Files
worker/internal/compose/discover.go
root ff0d2f088f
Некоторые проверки не удались
CI / test (push) Failing after 6s
Docker / Build and publish worker image (push) Failing after 8s
feat(worker): add Docker Compose discovery and management
Add an internal/compose package that discovers Compose projects via
`docker compose ls` + `docker ps` labels (grouped by
com.docker.compose.project/service) and enriches each container with
`docker inspect` ports/mounts and Traefik router labels. Management
runs `docker compose` in each project's working directory for
up/down/stop/restart/pull plus per-service variants and log tails.

Wire it into the webapp: a 60s ComposeRefresher (constructed in New,
started in Start, stopped in Close), a /compose list + detail + logs
HTML surface, and /web/api/compose/* JSON endpoints (list, detail,
logs, project/service lifecycle). Browser lifecycle POSTs are
session+CSRF protected; the /web/api/* variants accept HTTP basic auth.
WORKER_COMPOSE_ENABLED defaults on (false to disable).

Tests cover discovery parsing/grouping/traefik/summary, the action
allowlists, and the full handler surface (list/detail/logs HTML+API,
CSRF enforcement, disabled/unknown-action rejection, audit writes,
success+failure exec paths) via a stub Docker binary.
2026-07-29 21:31:52 +03:00

186 строки
6.0 KiB
Go

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
}