package compose import ( "context" "encoding/json" "fmt" "os/exec" "strings" "time" ) // dockerBin is the Docker CLI binary name. Overridable in tests via // SetDockerBin so the management/discovery code never hard-codes a path. var dockerBin = "docker" // SetDockerBin overrides the Docker CLI binary used by discovery and // management. Pass an empty value to restore the default ("docker"). // Tests use it to point at a stub binary; production leaves it alone. func SetDockerBin(name string) { if name == "" { dockerBin = "docker" return } dockerBin = name } // commandTimeout is the deadline applied to every Docker CLI call so a // hung dockerd cannot wedge the inventory refresh loop or a management // request. 30s is well above `docker ps` / `docker inspect` on a busy // host but short enough that the operator notices. const commandTimeout = 30 * time.Second // Available reports whether the Docker CLI can reach the daemon. Used // by the refresh loop to no-op (instead of erroring) on hosts without // Docker, and by the UI to show a "Docker not available" banner. func Available(ctx context.Context) bool { c, cancel := context.WithTimeout(ctx, commandTimeout) defer cancel() if err := exec.CommandContext(c, dockerBin, "version").Run(); err != nil { return false } // `docker version` succeeds against the client even when the daemon // is down on some builds; `docker ps` requires the daemon, so it is // the authoritative liveness probe. pc, pcancel := context.WithTimeout(ctx, commandTimeout) defer pcancel() return exec.CommandContext(pc, dockerBin, "ps", "--format", "{{.ID}}").Run() == nil } // composeLSItem is one row of `docker compose ls --all --format json`. type composeLSItem struct { Name string `json:"Name"` Status string `json:"Status"` ConfigFiles string `json:"ConfigFiles"` } // composeLS runs `docker compose ls --all --format json` and returns // the projects keyed by name. Unlike `docker ps --format json` (NDJSON), // `docker compose ls` emits a single JSON array, so the whole stdout is // unmarshalled at once. Non-fatal: returns an empty map on any error. func composeLS(ctx context.Context) map[string]composeLSItem { out, err := runDocker(ctx, "compose", "ls", "--all", "--format", "json") if err != nil { return nil } trimmed := strings.TrimSpace(out) if trimmed == "" { return nil } var items []composeLSItem if err := json.Unmarshal([]byte(trimmed), &items); err != nil { return nil } m := make(map[string]composeLSItem, len(items)) for _, it := range items { m[it.Name] = it } return m } // runDocker executes the Docker CLI with the given args and returns // stdout. The context deadline is enforced by Go (CommandContext); a // non-zero exit yields an error that carries the daemon's stderr so the // caller can surface the daemon's diagnostic instead of a bare exit code. func runDocker(ctx context.Context, args ...string) (string, error) { c, cancel := context.WithTimeout(ctx, commandTimeout) defer cancel() cmd := exec.CommandContext(c, dockerBin, args...) out, err := cmd.Output() if err != nil { if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 { return "", fmt.Errorf("%s: %s", strings.Join(args, " "), strings.TrimSpace(string(ee.Stderr))) } return "", err } return string(out), nil } // dockerCombined runs the Docker CLI with a working directory and // returns combined stdout+stderr. Used by management operations, which // must run inside the project's working directory so Compose resolves // the compose file and `.env` exactly as the operator would. Unlike // runDocker, stderr is merged into the returned string because compose // progress output goes to stderr and is useful to the operator. func dockerCombined(ctx context.Context, dir string, args ...string) (string, error) { c, cancel := context.WithTimeout(ctx, commandTimeout) defer cancel() cmd := exec.CommandContext(c, dockerBin, args...) cmd.Dir = dir out, err := cmd.CombinedOutput() return string(out), err } // parseLabels parses the comma-separated `key=value` label string that // `docker ps --format json` emits in the Labels field. Tolerates empty // input and values containing '=' (only the first '=' splits). func parseLabels(labels string) map[string]string { out := make(map[string]string) if labels == "" { return out } for _, pair := range strings.Split(labels, ",") { k, v, ok := strings.Cut(pair, "=") if !ok { continue } out[strings.TrimSpace(k)] = strings.TrimSpace(v) } return out }