feat(worker): add Docker Compose discovery and management
Некоторые проверки не удались
CI / test (push) Failing after 6s
Docker / Build and publish worker image (push) Failing after 8s
Некоторые проверки не удались
CI / test (push) Failing after 6s
Docker / Build and publish worker image (push) Failing after 8s
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.
Этот коммит содержится в:
185
internal/compose/discover.go
Обычный файл
185
internal/compose/discover.go
Обычный файл
@@ -0,0 +1,185 @@
|
||||
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
|
||||
}
|
||||
Ссылка в новой задаче
Block a user