Некоторые проверки не удались
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.
186 строки
6.0 KiB
Go
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
|
|
}
|