feat(worker): add Docker Compose discovery and management
Некоторые проверки не удались
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.
Этот коммит содержится в:
root
2026-07-29 21:31:52 +03:00
родитель e987f24903
Коммит ff0d2f088f
19 изменённых файлов: 2115 добавлений и 0 удалений

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
}

144
internal/compose/discover_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,144 @@
package compose
import (
"context"
"testing"
)
func TestParseLabels(t *testing.T) {
got := parseLabels("com.docker.compose.project=rsmon,com.docker.compose.service=web,foo=a=b")
if got[LabelComposeProject] != "rsmon" {
t.Fatalf("project = %q", got[LabelComposeProject])
}
if got[LabelComposeService] != "web" {
t.Fatalf("service = %q", got[LabelComposeService])
}
if got["foo"] != "a=b" { // value may contain '='
t.Fatalf("foo = %q", got["foo"])
}
if len(parseLabels("")) != 0 {
t.Fatal("empty labels should yield empty map")
}
}
func TestGroupContainers(t *testing.T) {
res := &DiscoveryResult{Projects: map[string]Project{
"rsmon": {Name: "rsmon", Status: "running(2)", ConfigFiles: "/compose/rsmon/docker-compose.yml", Services: map[string]Service{}},
}}
containers := []Container{
{
ID: "a",
Name: "rsmon-web",
Image: "nginx",
State: "running",
Labels: map[string]string{LabelComposeProject: "rsmon", LabelComposeService: "web", LabelComposeWorkingDir: "/compose/rsmon", LabelComposeConfigFiles: "/compose/rsmon/docker-compose.yml"},
},
{
ID: "b",
Name: "rsmon-backend",
Image: "rsmon",
State: "running",
Labels: map[string]string{LabelComposeProject: "rsmon", LabelComposeService: "backend"},
},
{
ID: "c",
Name: "loose",
State: "running",
Labels: map[string]string{}, // not a compose container
},
}
groupContainers(res, containers)
proj, ok := res.Projects["rsmon"]
if !ok {
t.Fatal("rsmon project missing")
}
if proj.WorkingDir != "/compose/rsmon" {
t.Fatalf("working dir = %q", proj.WorkingDir)
}
if len(proj.Services) != 2 {
t.Fatalf("service count = %d", len(proj.Services))
}
if got := proj.Services["web"].Containers[0].Service; got != "web" {
t.Fatalf("web service tag = %q", got)
}
if _, ok := res.Projects["loose"]; ok {
t.Fatal("non-compose container should not create a project")
}
}
func TestParseTraefikLabels(t *testing.T) {
routes := ParseTraefikLabels(map[string]string{
"traefik.http.routers.rsmon.rule": "Host(`rsmon.rscx.ru`)",
"traefik.http.routers.rsmon-api.rule": "PathPrefix(`/api`)",
"traefik.http.routers.rsmon.service": "rsmon", // non-rule label, ignored
"traefik.http.middlewares.redirect.rule": "not a router", // wrong prefix path
})
if len(routes) != 2 {
t.Fatalf("route count = %d", len(routes))
}
var hosts, prefixes []string
for _, r := range routes {
hosts = append(hosts, r.Hostnames...)
prefixes = append(prefixes, r.PathPrefixes...)
}
if !contains(hosts, "rsmon.rscx.ru") {
t.Fatalf("hosts = %v", hosts)
}
if !contains(prefixes, "/api") {
t.Fatalf("prefixes = %v", prefixes)
}
}
func contains(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
func TestBuildProjectSummary(t *testing.T) {
res := &DiscoveryResult{Projects: map[string]Project{
"p": {
Name: "p",
Services: map[string]Service{
"web": {Name: "web", Containers: []Container{
{Name: "web-1", State: "running", Image: "nginx", Ports: []PortBinding{{HostPort: "80"}}},
}},
"db": {Name: "db", Containers: []Container{
{Name: "db-1", State: "exited", Image: "postgres"},
}},
},
},
}}
snap := Summarize(res)
if len(snap.Projects) != 1 {
t.Fatalf("project count = %d", len(snap.Projects))
}
p := snap.Projects[0]
if p.ContainerCount != 2 || p.RunningCount != 1 || p.ServiceCount != 2 {
t.Fatalf("counts = svc=%d c=%d run=%d", p.ServiceCount, p.ContainerCount, p.RunningCount)
}
if p.Services[0].Name != "db" { // sorted alphabetically
t.Fatalf("first service = %q", p.Services[0].Name)
}
}
func TestValidProjectAction(t *testing.T) {
for _, a := range []string{"up", "down", "stop", "restart", "pull"} {
if _, ok := ValidProjectAction(a); !ok {
t.Errorf("%q should be valid", a)
}
}
if _, ok := ValidProjectAction("rm"); ok {
t.Error("rm should be rejected")
}
}
func TestManageProjectRejectsUnknownAction(t *testing.T) {
if _, err := ManageProject(context.Background(), ProjectRefs{Name: "x", WorkingDir: "/tmp"}, "bogus"); err == nil {
t.Fatal("expected error for unknown action")
}
}

130
internal/compose/docker.go Обычный файл
Просмотреть файл

@@ -0,0 +1,130 @@
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
}

94
internal/compose/inspect.go Обычный файл
Просмотреть файл

@@ -0,0 +1,94 @@
package compose
import (
"context"
"encoding/json"
"fmt"
"strings"
)
// inspectData mirrors the subset of `docker inspect <id>` output that
// discovery consumes. Env is intentionally absent: the worker must not
// collect container environment (which carries secrets). Only Config
// labels, runtime State (for the host PID), Mounts, and published Ports
// are read.
type inspectData struct {
ID string `json:"Id"`
State inspectState `json:"State"`
Config inspectConfig `json:"Config"`
Mounts []inspectMount `json:"Mounts"`
NetworkSettings inspectNetworks `json:"NetworkSettings"`
}
type inspectState struct {
Status string `json:"Status"`
Running bool `json:"Running"`
Pid int `json:"Pid"`
ExitCode int `json:"ExitCode"`
}
type inspectConfig struct {
Labels map[string]string `json:"Labels"`
}
type inspectMount struct {
Type string `json:"Type"`
Source string `json:"Source"`
Destination string `json:"Destination"`
}
type inspectNetworks struct {
Ports portMap `json:"Ports"`
}
// portMap mirrors the Docker inspect "Ports" object: the key is the
// container port ("80/tcp") and the value is the list of host bindings
// (nil when the port is exposed but not published).
type portMap map[string][]portBinding
type portBinding struct {
HostIP string `json:"HostIp"`
HostPort string `json:"HostPort"`
}
// inspectContainer runs `docker inspect <id>` and decodes the first
// element. A missing container (race with `docker rm`) is reported as
// an error so the caller can skip it without aborting the whole scan.
func inspectContainer(ctx context.Context, id string) (*inspectData, error) {
out, err := runDocker(ctx, "inspect", id)
if err != nil {
return nil, err
}
var data []inspectData
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &data); err != nil {
return nil, err
}
if len(data) == 0 {
return nil, fmt.Errorf("no inspect data for %s", id)
}
return &data[0], nil
}
// extractPorts flattens the inspect Ports map into a slice of host
// bindings. Container ports that are exposed but not published (nil
// binding list) are skipped because they have no host-side footprint.
func extractPorts(data *inspectData) []PortBinding {
var ports []PortBinding
for _, bindings := range data.NetworkSettings.Ports {
for _, b := range bindings {
ports = append(ports, PortBinding{HostIP: b.HostIP, HostPort: b.HostPort})
}
}
return ports
}
// extractMounts copies the inspect Mounts into the wire Mount shape,
// dropping Docker-internal fields (mode, propagation, rw) the operator
// console does not render.
func extractMounts(data *inspectData) []Mount {
mounts := make([]Mount, 0, len(data.Mounts))
for _, m := range data.Mounts {
mounts = append(mounts, Mount{Source: m.Source, Destination: m.Destination, Type: m.Type})
}
return mounts
}

124
internal/compose/manage.go Обычный файл
Просмотреть файл

@@ -0,0 +1,124 @@
package compose
import (
"context"
"fmt"
"strings"
)
// Action is a management operation the operator console can run against
// a project. The string values are the suffix used in the management
// API path (/compose/{project}/{action}) and the audit log target.
type Action string
const (
ActionUp Action = "up"
ActionDown Action = "down"
ActionStop Action = "stop"
ActionRestart Action = "restart"
ActionPull Action = "pull"
ActionLogs Action = "logs"
)
// projectActions is the allowlist of actions a project-level POST
// accepts. It maps the action to the compose subcommand(s). The map is
// the single source of truth so the route handler and the executor
// agree on what is permitted.
var projectActions = map[Action][]string{
ActionUp: {"up", "-d", "--remove-orphans"},
ActionDown: {"down", "--remove-orphans"},
ActionStop: {"stop"},
ActionRestart: {"restart"},
ActionPull: {"pull"},
}
// serviceActions is the allowlist for service-level operations
// (start/stop/restart a single service inside a project).
var serviceActions = map[Action][]string{
ActionUp: {"up", "-d", "--no-deps"},
ActionStop: {"stop"},
ActionRestart: {"restart"},
}
// ValidProjectAction reports whether name is an accepted project action.
func ValidProjectAction(name string) (Action, bool) {
a := Action(name)
_, ok := projectActions[a]
if !ok && a == ActionLogs {
return a, true
}
return a, ok
}
// ValidServiceAction reports whether name is an accepted service action.
func ValidServiceAction(name string) (Action, bool) {
a := Action(name)
_, ok := serviceActions[a]
return a, ok
}
// ManagementResult is returned by every management operation. Output is
// the combined compose stdout/stderr; OK is false when the command
// exited non-zero. The handler serializes it as JSON for the API path
// and renders Output in a <pre> block on the HTML path.
type ManagementResult struct {
OK bool `json:"ok"`
Action string `json:"action"`
Output string `json:"output"`
}
// ManageProject runs an action against a whole project. refs supplies
// the working directory (so Compose auto-discovers the compose file and
// .env) and the project name (pinned with -p). A missing working dir is
// a hard error: without it Compose cannot locate the project files.
func ManageProject(ctx context.Context, refs ProjectRefs, action Action) (ManagementResult, error) {
args, ok := projectActions[action]
if !ok {
return ManagementResult{}, fmt.Errorf("compose: unknown project action %q", action)
}
return runCompose(ctx, refs, args...)
}
// ManageService runs an action against a single service in a project.
// --no-deps on `up` ensures starting one service does not implicitly
// recreate its dependencies (matching dockge's service-start behavior).
func ManageService(ctx context.Context, refs ProjectRefs, service string, action Action) (ManagementResult, error) {
args, ok := serviceActions[action]
if !ok {
return ManagementResult{}, fmt.Errorf("compose: unknown service action %q", action)
}
if service == "" {
return ManagementResult{}, fmt.Errorf("compose: service is required")
}
full := append(append([]string{}, args...), service)
return runCompose(ctx, refs, full...)
}
// Logs returns the recent log output for a project as a single string.
// It runs `docker compose logs --no-color --tail <n>` (no -f) so the
// operator console gets a finite snapshot; streaming tails are a later
// enhancement (dockge uses a PTY + websocket, out of scope here).
func Logs(ctx context.Context, refs ProjectRefs, tail int) (ManagementResult, error) {
if tail <= 0 {
tail = 200
}
return runCompose(ctx, refs, "logs", "--no-color", "--tail", fmt.Sprintf("%d", tail))
}
// runCompose executes `docker compose -p <name> <args...>` with the
// working directory set to refs.WorkingDir. The working dir makes
// Compose resolve the compose file and `.env` exactly as the operator
// would from the shell; -p pins the project name so the command cannot
// accidentally target a different project that shares the directory.
func runCompose(ctx context.Context, refs ProjectRefs, args ...string) (ManagementResult, error) {
if refs.WorkingDir == "" {
return ManagementResult{OK: false, Output: "compose project working directory is unknown; cannot manage"}, fmt.Errorf("compose: empty working dir")
}
full := append([]string{"compose", "-p", refs.Name}, args...)
out, err := dockerCombined(ctx, refs.WorkingDir, full...)
res := ManagementResult{OK: err == nil, Action: strings.Join(args, " "), Output: out}
if err != nil {
return res, err
}
return res, nil
}

199
internal/compose/summary.go Обычный файл
Просмотреть файл

@@ -0,0 +1,199 @@
package compose
import (
"fmt"
"sort"
"strings"
)
// ProjectSummary is the operator-facing view of one Compose project:
// counts, per-service containers, grouped mounts, and Traefik routes.
// It is the shape rendered on /compose and returned by the JSON API.
type ProjectSummary struct {
Name string `json:"name"`
Status string `json:"status"`
ConfigFiles string `json:"config_files"`
WorkingDir string `json:"working_dir"`
ServiceCount int `json:"service_count"`
ContainerCount int `json:"container_count"`
RunningCount int `json:"running_count"`
Services []ServiceSummary `json:"services"`
GroupedMounts []ProjectMountInfo `json:"grouped_mounts"`
AllTraefikRoutes []ProjectTraefikRoute `json:"traefik_routes"`
}
// ServiceSummary is a condensed view of a Compose service.
type ServiceSummary struct {
Name string `json:"name"`
Containers []ContainerSummary `json:"containers"`
}
// ContainerSummary holds the key container fields the detail page shows.
type ContainerSummary struct {
Name string `json:"name"`
ID string `json:"id"`
Image string `json:"image"`
State string `json:"state"`
Status string `json:"status"`
Health string `json:"health"`
PID int `json:"pid"`
Ports []PortBinding `json:"ports"`
}
// ProjectMountInfo represents a mount that may be shared across the
// services/containers of a project.
type ProjectMountInfo struct {
Source string `json:"source"`
Destination string `json:"destination"`
Type string `json:"type"`
UsedBy []MountUsage `json:"used_by"`
}
// MountUsage identifies which service/container uses a mount.
type MountUsage struct {
Service string `json:"service"`
Container string `json:"container"`
}
// ProjectTraefikRoute is a Traefik route attributed to the project.
type ProjectTraefikRoute struct {
RouterName string `json:"router_name"`
Service string `json:"service"`
Container string `json:"container"`
Hostnames []string `json:"hostnames"`
PathPrefixes []string `json:"path_prefixes"`
Paths []string `json:"paths"`
Rule string `json:"rule"`
}
// BuildProjectSummary creates the operator-facing summary from a raw
// Project. Services, mounts, and routes are sorted for stable display.
func BuildProjectSummary(p Project) ProjectSummary {
s := ProjectSummary{
Name: p.Name,
Status: p.Status,
ConfigFiles: p.ConfigFiles,
WorkingDir: p.WorkingDir,
Services: make([]ServiceSummary, 0, len(p.Services)),
GroupedMounts: make([]ProjectMountInfo, 0),
AllTraefikRoutes: make([]ProjectTraefikRoute, 0),
}
mountGroups := map[string]*ProjectMountInfo{}
for svcName, svc := range p.Services {
s.ServiceCount++
svcSummary := ServiceSummary{Name: svcName, Containers: make([]ContainerSummary, 0, len(svc.Containers))}
for _, c := range svc.Containers {
s.ContainerCount++
if c.State == "running" {
s.RunningCount++
}
svcSummary.Containers = append(svcSummary.Containers, ContainerSummary{
Name: c.Name,
ID: c.ID,
Image: c.Image,
State: c.State,
Status: c.Status,
Health: c.Health,
PID: c.PID,
Ports: c.Ports,
})
groupMount(mountGroups, svcName, c)
for _, r := range ExtractTraefikRoutes(c) {
s.AllTraefikRoutes = append(s.AllTraefikRoutes, ProjectTraefikRoute{
RouterName: r.RouterName,
Service: svcName,
Container: c.Name,
Hostnames: r.Hostnames,
PathPrefixes: r.PathPrefixes,
Paths: r.Paths,
Rule: r.Rule,
})
}
}
s.Services = append(s.Services, svcSummary)
}
for _, mi := range mountGroups {
s.GroupedMounts = append(s.GroupedMounts, *mi)
}
sort.Slice(s.Services, func(i, j int) bool { return s.Services[i].Name < s.Services[j].Name })
sort.Slice(s.GroupedMounts, func(i, j int) bool { return s.GroupedMounts[i].Source < s.GroupedMounts[j].Source })
sort.Slice(s.AllTraefikRoutes, func(i, j int) bool {
return s.AllTraefikRoutes[i].RouterName < s.AllTraefikRoutes[j].RouterName
})
return s
}
func groupMount(groups map[string]*ProjectMountInfo, svc string, c Container) {
for _, m := range c.Mounts {
key := fmt.Sprintf("%s|%s|%s", m.Source, m.Destination, m.Type)
usage := MountUsage{Service: svc, Container: c.Name}
if existing := groups[key]; existing != nil {
for _, u := range existing.UsedBy {
if u == usage {
goto next
}
}
existing.UsedBy = append(existing.UsedBy, usage)
next:
continue
}
groups[key] = &ProjectMountInfo{
Source: m.Source, Destination: m.Destination, Type: m.Type, UsedBy: []MountUsage{usage},
}
}
}
// BuildAllProjectSummaries returns every project summary sorted by name.
func BuildAllProjectSummaries(res *DiscoveryResult) []ProjectSummary {
out := make([]ProjectSummary, 0, len(res.Projects))
for _, p := range res.Projects {
out = append(out, BuildProjectSummary(p))
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// FormatMountSourceShort shortens a long mount source for table display.
func FormatMountSourceShort(source string, maxLen int) string {
if len(source) <= maxLen || maxLen < 10 {
return source
}
keep := (maxLen - 3) / 2
return source[:keep] + "..." + source[len(source)-keep:]
}
// SharedMountSummary returns a short label describing who uses a mount.
func (m *ProjectMountInfo) SharedMountSummary() string {
if len(m.UsedBy) == 0 {
return "unused"
}
if len(m.UsedBy) == 1 {
return fmt.Sprintf("%s/%s", m.UsedBy[0].Service, m.UsedBy[0].Container)
}
counts := map[string]int{}
for _, u := range m.UsedBy {
counts[u.Service]++
}
var parts []string
for svc, n := range counts {
if n == 1 {
parts = append(parts, svc)
} else {
parts = append(parts, fmt.Sprintf("%s(%d)", svc, n))
}
}
sort.Strings(parts)
return "shared: " + strings.Join(parts, ", ")
}
// FindProject returns the summary for the named project, or nil.
func (s Snapshot) FindProject(name string) *ProjectSummary {
for i := range s.Projects {
if s.Projects[i].Name == name {
return &s.Projects[i]
}
}
return nil
}

87
internal/compose/traefik.go Обычный файл
Просмотреть файл

@@ -0,0 +1,87 @@
package compose
import (
"regexp"
"strings"
)
// Traefik label parsing. Compose projects fronted by Traefik encode
// their router rules as `traefik.http.routers.<name>.rule=Host(...)`.
// We surface hostnames/paths so the operator console can show which
// domains a project serves without reading raw labels.
const (
traefikLabelPrefix = "traefik.http.routers."
traefikRuleSuffix = ".rule"
)
var (
// Host(`example.com`) or Host("example.com")
hostRegex = regexp.MustCompile(`Host\s*\(\s*[` + "`" + `"]([^` + "`" + `"]+)[` + "`" + `"]\s*\)`)
// Path(`/api`) or Path("/api")
pathRegex = regexp.MustCompile(`Path\s*\(\s*[` + "`" + `"]([^` + "`" + `"]+)[` + "`" + `"]\s*\)`)
// PathPrefix(`/api`) or PathPrefix("/api")
pathPrefixRegex = regexp.MustCompile(`PathPrefix\s*\(\s*[` + "`" + `"]([^` + "`" + `"]+)[` + "`" + `"]\s*\)`)
)
// TraefikRoute is one decoded Traefik router derived from labels.
type TraefikRoute struct {
RouterName string `json:"router_name"`
Hostnames []string `json:"hostnames"`
Paths []string `json:"paths"`
PathPrefixes []string `json:"path_prefixes"`
Rule string `json:"rule"`
}
// ParseTraefikLabels scans a label map for `traefik.http.routers.<n>.rule`
// entries and returns one TraefikRoute per router, with the rule's
// Host/Path/PathPrefix operands extracted. Pure function: no I/O.
func ParseTraefikLabels(labels map[string]string) []TraefikRoute {
routes := map[string]*TraefikRoute{}
for key, value := range labels {
if !strings.HasPrefix(key, traefikLabelPrefix) {
continue
}
suffix := strings.TrimPrefix(key, traefikLabelPrefix)
idx := strings.Index(suffix, ".")
if idx == -1 {
continue
}
routerName := suffix[:idx]
property := suffix[idx:]
if property != traefikRuleSuffix {
continue
}
route := routes[routerName]
if route == nil {
route = &TraefikRoute{RouterName: routerName}
}
route.Rule = value
route.Hostnames = extractAll(hostRegex, value)
route.Paths = extractAll(pathRegex, value)
route.PathPrefixes = extractAll(pathPrefixRegex, value)
routes[routerName] = route
}
out := make([]TraefikRoute, 0, len(routes))
for _, r := range routes {
out = append(out, *r)
}
return out
}
func extractAll(re *regexp.Regexp, rule string) []string {
matches := re.FindAllStringSubmatch(rule, -1)
out := make([]string, 0, len(matches))
for _, m := range matches {
if len(m) > 1 && m[1] != "" {
out = append(out, m[1])
}
}
return out
}
// ExtractTraefikRoutes returns the Traefik routes declared by a
// container's labels.
func ExtractTraefikRoutes(c Container) []TraefikRoute {
return ParseTraefikLabels(c.Labels)
}

90
internal/compose/types.go Обычный файл
Просмотреть файл

@@ -0,0 +1,90 @@
// Package compose implements Docker Compose project discovery and
// management for the worker operator console.
//
// It talks to Docker exclusively through the `docker` / `docker compose`
// CLI over os/exec. There is no dependency on the Docker Engine SDK or
// compose-go: the CLI is the same surface deploymentd and dockge use, it
// needs no extra libraries, and it is already present on every host the
// worker manages. Discovery groups running containers by the
// `com.docker.compose.project` / `com.docker.compose.service` labels and
// merges the project-level metadata (status, compose file, working dir)
// reported by `docker compose ls`. Management runs `docker compose`
// against the project's working directory.
package compose
// Label constants written by Docker Compose onto every container it
// creates. They are the stable identity keys that map a raw container
// back to its Compose project and service.
const (
LabelComposeProject = "com.docker.compose.project"
LabelComposeService = "com.docker.compose.service"
LabelComposeWorkingDir = "com.docker.compose.project.working_dir"
LabelComposeConfigFiles = "com.docker.compose.project.config_files"
)
// PortBinding is a single host-side port mapping for a container.
type PortBinding struct {
HostIP string `json:"host_ip"`
HostPort string `json:"host_port"`
}
// Mount is a single bind/volume/tmpfs mount attached to a container.
type Mount struct {
Source string `json:"source"`
Destination string `json:"destination"`
Type string `json:"type"`
}
// Container is the per-container view discovery collects. It is the
// union of `docker ps` (name/image/state/status/health) and
// `docker inspect` (pid/ports/mounts/full labels).
type Container struct {
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
State string `json:"state"`
Status string `json:"status"`
Health string `json:"health"`
PID int `json:"pid"`
Labels map[string]string `json:"labels"`
Ports []PortBinding `json:"ports"`
Mounts []Mount `json:"mounts"`
Service string `json:"service"`
}
// Service groups the containers that belong to one Compose service.
type Service struct {
Name string `json:"name"`
Containers []Container `json:"containers"`
}
// Project is a single Compose project. Status / ConfigFiles / WorkingDir
// come from `docker compose ls` and are refined from container labels
// (which carry the exact absolute paths the project was deployed with).
type Project struct {
Name string `json:"name"`
Status string `json:"status"`
ConfigFiles string `json:"config_files"`
WorkingDir string `json:"working_dir"`
Services map[string]Service `json:"services"`
}
// DiscoveryResult is the full snapshot returned by Discover. Projects
// is keyed by project name. Errors carries non-fatal section failures
// (e.g. docker unavailable) so the UI can render a banner instead of a
// blank page.
type DiscoveryResult struct {
Projects map[string]Project `json:"projects"`
Errors []string `json:"errors,omitempty"`
}
// ProjectRefs resolves the Compose CLI arguments a management operation
// needs for a project: the working directory (so Compose auto-discovers
// the compose file and `.env`) and the project name (pinned with -p so
// the operation targets exactly this project even when the directory
// basename differs).
type ProjectRefs struct {
Name string
WorkingDir string
ConfigFiles string
}