package compose import ( "context" "encoding/json" "fmt" "strings" ) // psItem is one NDJSON object from `docker ps --format json`. Only the // fields discovery consumes are decoded; the rest are ignored. Field // names match Docker's template JSON exactly (ID, Names, Image, State, // Status, HealthStatus, Labels, Ports). type psItem struct { ID string `json:"ID"` Names string `json:"Names"` Image string `json:"Image"` State string `json:"State"` Status string `json:"Status"` HealthStatus string `json:"HealthStatus"` Labels string `json:"Labels"` Ports string `json:"Ports"` } // Discover collects the current Compose project snapshot. It is the // single entry point for the refresh loop and the operator console. // // The flow is: // 1. `docker compose ls --all --format json` seeds the project map so // exited/stopped projects (which have no running containers) still // appear with their status and compose file. // 2. `docker ps --format json` lists every running container; those // carrying `com.docker.compose.project` are inspected and grouped. // 3. Project-level metadata from container labels refines (and usually // matches) the `compose ls` data. // // Docker being unavailable is reported as a non-empty Errors slice with // an empty Projects map, so the caller renders a banner rather than a // blank page. Per-container inspect failures are skipped silently (the // container may have been removed mid-scan) and never abort the scan. func Discover(ctx context.Context) (*DiscoveryResult, error) { res := &DiscoveryResult{Projects: map[string]Project{}} if !Available(ctx) { res.Errors = append(res.Errors, "docker daemon is not available") return res, nil } ls := composeLS(ctx) for name, item := range ls { res.Projects[name] = Project{ Name: name, Status: item.Status, ConfigFiles: item.ConfigFiles, Services: map[string]Service{}, } } items, err := psItems(ctx) if err != nil { res.Errors = append(res.Errors, err.Error()) return res, nil } containers, err := enrichContainers(ctx, items) if err != nil { // enrichContainers never returns a hard error today; keep the // signature so future enrichment steps can report partial loss. res.Errors = append(res.Errors, err.Error()) } groupContainers(res, containers) return res, nil } // psItems runs `docker ps --format json` and decodes the NDJSON stream. // Each line is one container; malformed lines are skipped. func psItems(ctx context.Context) ([]psItem, error) { out, err := runDocker(ctx, "ps", "--format", "json") if err != nil { return nil, fmt.Errorf("docker ps: %w", err) } var items []psItem for _, line := range strings.Split(strings.TrimSpace(out), "\n") { line = strings.TrimSpace(line) if line == "" { continue } var it psItem if err := json.Unmarshal([]byte(line), &it); err != nil { continue } items = append(items, it) } return items, nil } // enrichContainers maps `docker ps` rows into enriched Container values. // Containers without a Compose project label are skipped. Each retained // container is `docker inspect`-ed for its host PID, mounts, published // ports, and full label set; inspect failures downgrade to the `ps` data // so a transient inspect error never drops a known container. func enrichContainers(ctx context.Context, items []psItem) ([]Container, error) { containers := make([]Container, 0, len(items)) for _, it := range items { labels := parseLabels(it.Labels) if labels[LabelComposeProject] == "" { continue } c := Container{ ID: it.ID, Name: strings.TrimPrefix(it.Names, "/"), Image: it.Image, State: it.State, Status: it.Status, Health: it.HealthStatus, Labels: labels, } if data, err := inspectContainer(ctx, it.ID); err == nil && data != nil { c.PID = data.State.Pid c.Ports = extractPorts(data) c.Mounts = extractMounts(data) for k, v := range data.Config.Labels { c.Labels[k] = v } } containers = append(containers, c) } return containers, nil } // groupContainers folds enriched containers into the project map. The // project and service come from the Compose labels; the working dir and // config file paths are lifted from any container in the project (they // are identical across containers of one project) and override the // `compose ls` values when present, since labels carry the exact paths // the project was deployed with. // // Pure (no I/O) so it can be unit-tested with fixture containers. func groupContainers(res *DiscoveryResult, containers []Container) { for _, c := range containers { projectName := c.Labels[LabelComposeProject] if projectName == "" { continue } serviceName := c.Labels[LabelComposeService] if serviceName == "" { serviceName = c.Name } c.Service = serviceName project, ok := res.Projects[projectName] if !ok { project = Project{Name: projectName, Services: map[string]Service{}} } // Container labels carry the authoritative working dir / config // file; prefer them over the `compose ls` row when present. if wd := c.Labels[LabelComposeWorkingDir]; wd != "" { project.WorkingDir = wd } if cf := c.Labels[LabelComposeConfigFiles]; cf != "" { project.ConfigFiles = cf } service := project.Services[serviceName] service.Name = serviceName service.Containers = append(service.Containers, c) project.Services[serviceName] = service res.Projects[projectName] = project } } // Snapshot is a sorted, JSON-friendly view of a DiscoveryResult for the // operator console: projects ordered by name. It is the shape rendered // on /compose and returned by GET /web/api/compose. type Snapshot struct { Projects []ProjectSummary `json:"projects"` Errors []string `json:"errors,omitempty"` } // Summarize builds the sorted Snapshot from a raw DiscoveryResult. func Summarize(res *DiscoveryResult) Snapshot { snap := Snapshot{Errors: append([]string(nil), res.Errors...)} snap.Projects = BuildAllProjectSummaries(res) return snap }