Files
worker/internal/compose/docker.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

131 строка
4.4 KiB
Go

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
}