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
|
||||
}
|
||||
144
internal/compose/discover_test.go
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
|
||||
}
|
||||
Ссылка в новой задаче
Block a user