diff --git a/src/api.go b/src/api.go index 566c74b..9f4e22d 100644 --- a/src/api.go +++ b/src/api.go @@ -5,16 +5,19 @@ import ( "encoding/json" "errors" "fmt" + "io" "log/slog" log2 "mist/multilogger" "net/http" "os" "os/signal" + "strconv" "strings" "sync" "syscall" "time" + dockerClient "github.com/docker/docker/client" "github.com/redis/go-redis/v9" ) @@ -26,30 +29,45 @@ type App struct { wg sync.WaitGroup log *slog.Logger statusRegistry *StatusRegistry + dockerClient *dockerClient.Client + containerMgr *ContainerMgr } -func NewApp(redisAddr, gpuType string, log *slog.Logger) *App { - client := redis.NewClient(&redis.Options{Addr: redisAddr}) +func NewApp(redisAddr, gpuType string, log *slog.Logger) (*App, error) { + redisClient := redis.NewClient(&redis.Options{Addr: redisAddr}) scheduler := NewScheduler(redisAddr, log) statusRegistry := NewStatusRegistry(client, log) consumerID := fmt.Sprintf("worker_%d", os.Getpid()) supervisor := NewSupervisor(redisAddr, consumerID, gpuType, log) + // Initialize Docker client with explicit API version 1.41 for compatibility + // (Docker daemon supports up to 1.41, but client defaults to 1.50) + dockerClient, err := dockerClient.NewClientWithOpts(dockerClient.FromEnv, dockerClient.WithVersion("1.41")) + if err != nil { + return nil, fmt.Errorf("failed to create docker client: %w", err) + } + + // Initialize container manager with reasonable defaults + containerMgr := NewContainerMgr(dockerClient, 100, 50) + mux := http.NewServeMux() a := &App{ - redisClient: client, + redisClient: redisClient, scheduler: scheduler, supervisor: supervisor, httpServer: &http.Server{Addr: ":3000", Handler: mux}, log: log, statusRegistry: statusRegistry, + dockerClient: dockerClient, + containerMgr: containerMgr, } mux.HandleFunc("/auth/login", a.login) mux.HandleFunc("/auth/refresh", a.refresh) mux.HandleFunc("/jobs", a.handleJobs) mux.HandleFunc("/jobs/status", a.getJobStatus) + mux.HandleFunc("/containers/", a.handleContainerLogs) mux.HandleFunc("/supervisors/status", a.getSupervisorStatus) mux.HandleFunc("/supervisors/status/", a.getSupervisorStatusByID) mux.HandleFunc("/supervisors", a.getAllSupervisors) @@ -57,7 +75,7 @@ func NewApp(redisAddr, gpuType string, log *slog.Logger) *App { a.log.Info("new app initialized", "redis_address", redisAddr, "gpu_type", gpuType, "http_address", a.httpServer.Addr) - return a + return a, nil } func (a *App) Start() error { @@ -109,6 +127,14 @@ func (a *App) Shutdown(ctx context.Context) error { a.log.Info("redis client closed successfully") } + if a.dockerClient != nil { + if err := a.dockerClient.Close(); err != nil { + a.log.Error("error closing docker client", "err", err) + } else { + a.log.Info("docker client closed successfully") + } + } + a.log.Info("shutdown completed") return nil @@ -124,7 +150,11 @@ func main() { fmt.Fprintf(os.Stderr, "failed to create logger: %v\n", err) os.Exit(1) } - app := NewApp("localhost:6379", "AMD", log) + app, err := NewApp("localhost:6379", "AMD", log) + if err != nil { + log.Error("failed to create app", "err", err) + os.Exit(1) + } if err := app.Start(); err != nil { log.Error("failed to start app", "err", err) @@ -397,3 +427,140 @@ func (a *App) getAllSupervisors(w http.ResponseWriter, r *http.Request) { return } } + +// AssociateContainerWithUser stores the container-user association in Redis. +// This should be called when a container is created to track ownership for authorization. +func (a *App) AssociateContainerWithUser(ctx context.Context, containerID, userID string) error { + key := fmt.Sprintf("container:%s:owner", containerID) + return a.redisClient.Set(ctx, key, userID, 0).Err() +} + +// getContainerOwner retrieves the owner user ID for a container from Redis +func (a *App) getContainerOwner(ctx context.Context, containerID string) (string, error) { + key := fmt.Sprintf("container:%s:owner", containerID) + userID, err := a.redisClient.Get(ctx, key).Result() + if err != nil { + if errors.Is(err, redis.Nil) { + return "", fmt.Errorf("container not found or not associated with any user") + } + return "", fmt.Errorf("failed to get container owner: %w", err) + } + return userID, nil +} + +// getCurrentUser extracts the current user ID from the request +// This is a placeholder - in a real implementation, this would extract from JWT token, session, etc. +func (a *App) getCurrentUser(r *http.Request) (string, error) { + // For now, we'll use a simple Authorization header or user query parameter + // In a production system, this would validate JWT tokens, session cookies, etc. + authHeader := r.Header.Get("Authorization") + if authHeader != "" { + // Extract user from "Bearer " or similar + parts := strings.Split(authHeader, " ") + if len(parts) == 2 && parts[0] == "Bearer" { + // In a real implementation, decode and validate the token + // For now, we'll use the token as a simple user identifier + return parts[1], nil + } + } + + // Fallback: check for user query parameter (for testing) + userID := r.URL.Query().Get("user") + if userID != "" { + return userID, nil + } + + return "", fmt.Errorf("authentication required") +} + +// authorizeContainerAccess checks if the current user has access to the specified container +func (a *App) authorizeContainerAccess(ctx context.Context, containerID string, userID string) error { + ownerID, err := a.getContainerOwner(ctx, containerID) + if err != nil { + return err + } + + if ownerID != userID { + return fmt.Errorf("unauthorized: user %s does not have access to container %s", userID, containerID) + } + + return nil +} + +// handleContainerLogs handles requests to /containers/{containerID}/logs +func (a *App) handleContainerLogs(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Extract container ID from path + // Path format: /containers/{containerID}/logs + path := strings.TrimPrefix(r.URL.Path, "/containers/") + parts := strings.Split(path, "/") + if len(parts) < 2 || parts[1] != "logs" { + http.Error(w, "Invalid path. Expected /containers/{containerID}/logs", http.StatusBadRequest) + return + } + + containerID := parts[0] + if containerID == "" { + http.Error(w, "Container ID is required", http.StatusBadRequest) + return + } + + // Get current user + userID, err := a.getCurrentUser(r) + if err != nil { + a.log.Warn("authentication failed", "error", err, "remote_address", r.RemoteAddr) + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + // Authorize access to container + if err := a.authorizeContainerAccess(ctx, containerID, userID); err != nil { + a.log.Warn("authorization failed", "error", err, "user_id", userID, "container_id", containerID) + http.Error(w, "Unauthorized: "+err.Error(), http.StatusForbidden) + return + } + + // Parse query parameters for log options + tailStr := r.URL.Query().Get("tail") + tail := 0 + if tailStr != "" { + var err error + tail, err = strconv.Atoi(tailStr) + if err != nil || tail < 0 { + http.Error(w, "Invalid tail parameter. Must be a non-negative integer", http.StatusBadRequest) + return + } + } + + followStr := r.URL.Query().Get("follow") + follow := followStr == "true" || followStr == "1" + since := r.URL.Query().Get("since") + until := r.URL.Query().Get("until") + + // Fetch container logs + logsReader, err := a.containerMgr.GetContainerLogs(containerID, tail, follow, since, until) + if err != nil { + a.log.Error("failed to get container logs", "error", err, "container_id", containerID) + http.Error(w, fmt.Sprintf("Failed to fetch container logs: %v", err), http.StatusInternalServerError) + return + } + defer logsReader.Close() + + // Set appropriate headers for streaming logs + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + if follow { + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + } + + // Stream logs to response + _, err = io.Copy(w, logsReader) + if err != nil && !errors.Is(err, io.EOF) { + a.log.Error("error streaming logs", "error", err, "container_id", containerID) + // Don't send error to client if we've already started streaming + return + } + + a.log.Info("container logs retrieved", "container_id", containerID, "user_id", userID) +} diff --git a/src/docker.go b/src/docker.go new file mode 100644 index 0000000..a18323e --- /dev/null +++ b/src/docker.go @@ -0,0 +1,84 @@ +package main + +import ( + "context" + "fmt" + "io" + "log/slog" + "sync" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" +) + +// ContainerMgr manages Docker containers and volumes, enforces resource limits, and tracks active resources. +type ContainerMgr struct { + ctx context.Context + cli *client.Client + containerLimit int + volumeLimit int + containers map[string]struct{} + volumes map[string]struct{} + mu sync.Mutex +} + +// NewContainerMgr creates a new ContainerMgr with the specified Docker client and resource limits. +func NewContainerMgr(client *client.Client, containerLimit, volumeLimit int) *ContainerMgr { + return &ContainerMgr{ + ctx: context.Background(), + cli: client, + containerLimit: containerLimit, + volumeLimit: volumeLimit, + containers: make(map[string]struct{}), + volumes: make(map[string]struct{}), + } +} + +// GetContainerLogs fetches container logs from Docker for the specified container. +// Returns a ReadCloser that can be used to read the logs, or an error if the operation fails. +// Options: +// - tail: number of lines to return from the end of logs (0 = all) +// - follow: whether to follow log output (default: false) +// - since: return logs since this timestamp (RFC3339 format) +// - until: return logs before this timestamp (RFC3339 format) +func (mgr *ContainerMgr) GetContainerLogs(containerID string, tail int, follow bool, since, until string) (io.ReadCloser, error) { + ctx := mgr.ctx + cli := mgr.cli + + opts := container.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: follow, + Timestamps: false, // Don't include timestamps in output + } + + // Docker API: use "all" for all logs, or a number for tail + if tail > 0 { + opts.Tail = fmt.Sprintf("%d", tail) + } else { + opts.Tail = "all" + } + + if since != "" { + opts.Since = since + } + if until != "" { + opts.Until = until + } + + reader, err := cli.ContainerLogs(ctx, containerID, opts) + if err != nil { + return nil, fmt.Errorf("failed to get container logs: %w", err) + } + + return reader, nil +} + +// AssociateContainer associates a container ID with the ContainerMgr for tracking +func (mgr *ContainerMgr) AssociateContainer(containerID string) { + mgr.mu.Lock() + defer mgr.mu.Unlock() + mgr.containers[containerID] = struct{}{} + slog.Info("container associated", "container_id", containerID) +} + diff --git a/src/go.mod b/src/go.mod index 097afa1..6437aca 100644 --- a/src/go.mod +++ b/src/go.mod @@ -2,11 +2,41 @@ module mist go 1.24.3 -require github.com/redis/go-redis/v9 v9.10.0 +require ( + github.com/docker/docker v28.5.1+incompatible + github.com/redis/go-redis/v9 v9.10.0 + gopkg.in/yaml.v3 v3.0.1 +) require ( + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/time v0.14.0 // indirect + gotest.tools/v3 v3.5.2 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/src/go.sum b/src/go.sum index 0cdd507..851c58b 100644 --- a/src/go.sum +++ b/src/go.sum @@ -1,15 +1,118 @@ +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= +github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs= github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= +go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= +google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= +google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/src/images/serve_image.go b/src/images/serve_image.go index b264582..21cc40d 100644 --- a/src/images/serve_image.go +++ b/src/images/serve_image.go @@ -185,3 +185,37 @@ func (mgr *ContainerMgr) runContainer(imageName string, runtimeName string, volu io.Copy(os.Stdout, out) return resp.ID, nil } + +// GetContainerLogs fetches container logs from Docker for the specified container. +// Returns a ReadCloser that can be used to read the logs, or an error if the operation fails. +// Options: +// - tail: number of lines to return from the end of logs (0 = all) +// - follow: whether to follow log output (default: false) +// - since: return logs since this timestamp (RFC3339 format) +// - until: return logs before this timestamp (RFC3339 format) +func (mgr *ContainerMgr) GetContainerLogs(containerID string, tail int, follow bool, since, until string) (io.ReadCloser, error) { + ctx := mgr.ctx + cli := mgr.cli + + opts := container.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: follow, + Tail: fmt.Sprintf("%d", tail), + Timestamps: false, // Don't include timestamps in output + } + + if since != "" { + opts.Since = since + } + if until != "" { + opts.Until = until + } + + reader, err := cli.ContainerLogs(ctx, containerID, opts) + if err != nil { + return nil, fmt.Errorf("failed to get container logs: %w", err) + } + + return reader, nil +} diff --git a/src/images/serve_image_test.go b/src/images/serve_image_test.go index 9baa0f0..cc45ab9 100644 --- a/src/images/serve_image_test.go +++ b/src/images/serve_image_test.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "io" "testing" "github.com/docker/docker/api/types/volume" @@ -9,7 +10,7 @@ import ( ) func setupMgr(t *testing.T) *ContainerMgr { - cli, err := client.NewClientWithOpts(client.FromEnv) + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithVersion("1.41")) if err != nil { t.Fatalf("Failed to create Docker client: %v", err) } @@ -272,3 +273,70 @@ func TestContainerLimit(t *testing.T) { } }() } + +// query container logs +func TestQueryContainerLogs(t *testing.T) { + mgr := setupMgr(t) + imageName := "pytorch-cuda" + runtimeName := "nvidia" + volName := "test_query_container_logs" + _, err := mgr.createVolume(volName) + if err != nil { + t.Fatalf("Failed to create volume %s: %v", volName, err) + } + containerID, err := mgr.runContainer(imageName, runtimeName, volName) + if err != nil { + t.Fatalf("Failed to start container: %v", err) + } + defer func() { + // Cleanup: stop and remove container, then remove volume + if err := mgr.stopContainer(containerID); err != nil { + t.Logf("Cleanup: failed to stop container %s: %v", containerID, err) + } else { + t.Logf("Cleanup: stopped container %s successfully", containerID) + } + if err := mgr.removeContainer(containerID); err != nil { + t.Logf("Cleanup: failed to remove container %s: %v", containerID, err) + } else { + t.Logf("Cleanup: removed container %s successfully", containerID) + } + if err := mgr.removeVolume(volName, true); err != nil { + t.Logf("Cleanup: failed to remove volume %s: %v", volName, err) + } else { + t.Logf("Cleanup: removed volume %s successfully", volName) + } + }() + + rc, err := mgr.GetContainerLogs(containerID, 0, false, "", "") + if err != nil { + t.Fatalf("Failed to get container logs: %v", err) + } + defer rc.Close() + bytes, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("Failed to read container logs: %v", err) + } + + str := string(bytes) + fmt.Printf("Log output (length: %d): %q\n", len(str), str) + // Container runs "sleep 1000" which doesn't produce output + // Just verify we can successfully read logs (even if empty) + if err != nil { + t.Errorf("Failed to read logs: %v", err) + } + // Logs might be empty for a sleep container, that's expected + t.Logf("Successfully retrieved container logs (length: %d bytes)", len(bytes)) + + // readCloser.Read() + // readCloser.Read + // _, err = io.Copy(os.Stdout, reader) + // if err != nil && !errors.Is(err, io.EOF) { + // log.Fatal(err) + // } + // err = mgr.removeVolume(volName, true) // Should error: volume is in use by a running container + // if err == nil { + // t.Errorf("Expected error when removing volume in use, but no error") + // } else { + // t.Logf("Correctly got error when removing volume in use: %v", err) + // } +} diff --git a/src/mist b/src/mist new file mode 100755 index 0000000..fca6f45 Binary files /dev/null and b/src/mist differ