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 удалений

Просмотреть файл

@@ -16,6 +16,7 @@ test:
./internal/distworker \ ./internal/distworker \
./internal/installer \ ./internal/installer \
./internal/webapp \ ./internal/webapp \
./internal/compose \
./internal/workercluster \ ./internal/workercluster \
./internal/wire \ ./internal/wire \
./internal/checkexec \ ./internal/checkexec \

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
}

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

@@ -0,0 +1,176 @@
package webapp
import (
"context"
"log"
"os"
"sync"
"time"
"rocketgit.ru/rsmon/worker/internal/compose"
)
// composeRefreshInterval is the cadence the Compose project snapshot is
// rebuilt. 60s matches the process-inventory loop so the two collectors
// stay in step; management operations are synchronous POSTs that do not
// wait for the next tick.
const composeRefreshInterval = 60 * time.Second
// ComposeRefresher owns the periodic `docker compose` discovery loop and
// exposes the latest snapshot for the operator console. It is the
// Compose counterpart of Inventory: a self-contained refresher that is
// constructed in New and started in Start.
//
// When Docker is unavailable (or compose is disabled) the refresher
// stays idle and reports an empty snapshot with an "unavailable" error,
// so /compose renders a banner instead of a half-built table.
type ComposeRefresher struct {
enabled bool
log *log.Logger
disco composeDiscover
mu sync.RWMutex
snap compose.Snapshot
lastAt time.Time
stopCh chan struct{}
stopWG sync.WaitGroup
started bool
}
// composeDiscover is the subset of the compose package the refresher
// calls. A function field so tests can inject a fixture discovery
// without exec'ing Docker.
type composeDiscover func(ctx context.Context) (*compose.DiscoveryResult, error)
// NewComposeRefresher constructs a refresher. When enabled is false the
// refresher never starts its loop and Snapshot returns an empty result;
// the page still renders (with the disabled notice). The discover hook
// defaults to compose.Discover so production needs no wiring.
func NewComposeRefresher(enabled bool, logger *log.Logger) *ComposeRefresher {
if logger == nil {
logger = log.New(os.Stderr, "webapp-compose: ", log.LstdFlags)
}
return &ComposeRefresher{
enabled: enabled,
log: logger,
disco: compose.Discover,
stopCh: make(chan struct{}),
}
}
// SetDiscovery injects the discovery function. Used by tests; production
// leaves the compose.Discover default set by NewComposeRefresher.
func (c *ComposeRefresher) SetDiscovery(f composeDiscover) {
if f == nil {
c.disco = compose.Discover
return
}
c.disco = f
}
// Enabled reports whether compose management is turned on.
func (c *ComposeRefresher) Enabled() bool { return c != nil && c.enabled }
// Start launches the background refresh loop. The first refresh runs
// immediately so /compose has data on the first request. Idempotent.
func (c *ComposeRefresher) Start(ctx context.Context) {
if c == nil || !c.enabled {
return
}
c.mu.Lock()
if c.started {
c.mu.Unlock()
return
}
c.started = true
c.mu.Unlock()
c.stopWG.Add(1)
go c.loop(ctx)
}
// Stop cancels the refresh loop and waits for it to exit.
func (c *ComposeRefresher) Stop() {
if c == nil {
return
}
c.mu.Lock()
if !c.started {
c.mu.Unlock()
return
}
select {
case <-c.stopCh:
default:
close(c.stopCh)
}
c.mu.Unlock()
c.stopWG.Wait()
}
// Snapshot returns the most recent discovery snapshot. Always safe to
// call; returns a zero-value snapshot (with the unavailable notice when
// disabled) before the first refresh completes.
func (c *ComposeRefresher) Snapshot() compose.Snapshot {
if c == nil {
return compose.Snapshot{Errors: []string{"compose management is disabled"}}
}
c.mu.RLock()
defer c.mu.RUnlock()
if !c.enabled {
return compose.Snapshot{Errors: []string{"compose management is disabled"}}
}
out := c.snap
out.Projects = append([]compose.ProjectSummary(nil), c.snap.Projects...)
return out
}
// LastRefreshAt returns the time of the last successful refresh.
func (c *ComposeRefresher) LastRefreshAt() time.Time {
c.mu.RLock()
defer c.mu.RUnlock()
return c.lastAt
}
// Refs resolves the Compose CLI arguments for a project from the current
// snapshot. Returns ok=false when the project is unknown or its working
// directory was not discovered (Compose cannot be driven without it).
func (c *ComposeRefresher) Refs(project string) (compose.ProjectRefs, bool) {
p := c.Snapshot().FindProject(project)
if p == nil || p.WorkingDir == "" {
return compose.ProjectRefs{}, false
}
return compose.ProjectRefs{Name: p.Name, WorkingDir: p.WorkingDir, ConfigFiles: p.ConfigFiles}, true
}
func (c *ComposeRefresher) loop(ctx context.Context) {
defer c.stopWG.Done()
c.refresh(ctx)
t := time.NewTicker(composeRefreshInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-c.stopCh:
return
case <-t.C:
c.refresh(ctx)
}
}
}
func (c *ComposeRefresher) refresh(ctx context.Context) {
res, err := c.disco(ctx)
if err != nil || res == nil {
if err != nil {
c.log.Printf("compose refresh: %v", err)
}
return
}
snap := compose.Summarize(res)
c.mu.Lock()
c.snap = snap
c.lastAt = time.Now().UTC()
c.mu.Unlock()
}

Просмотреть файл

@@ -30,6 +30,7 @@ const (
envWorkerLogin = "WORKER_LOGIN" envWorkerLogin = "WORKER_LOGIN"
envWorkerPassword = "WORKER_PASSWORD" envWorkerPassword = "WORKER_PASSWORD"
envClusterEnabled = "WORKER_CLUSTER_ENABLED" envClusterEnabled = "WORKER_CLUSTER_ENABLED"
envComposeEnabled = "WORKER_COMPOSE_ENABLED"
envReleaseURL = "WORKER_RELEASE_URL" envReleaseURL = "WORKER_RELEASE_URL"
) )

280
internal/webapp/handlers_compose.go Обычный файл
Просмотреть файл

@@ -0,0 +1,280 @@
package webapp
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"rocketgit.ru/rsmon/worker/internal/compose"
)
// Compose management endpoints. The browser UI uses session+CSRF POSTs
// under /compose; scripted callers use HTTP basic auth under
// /web/api/compose (the /web/api/* prefix is the only one the auth
// middleware accepts basic credentials on). Read endpoints (list,
// detail, logs) are GET; lifecycle endpoints (up/down/stop/restart/pull)
// are POST. Every mutation is audited via the shared webapp audit log.
// handleComposeList renders the project list page.
func (s *Server) handleComposeList(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
sess, _ := sessionFromContext(r.Context())
data := composeListPageData{
basePageData: s.newBasePage(r, "Compose projects", sess),
Snapshot: s.compose.Snapshot(),
LastAt: s.compose.LastRefreshAt(),
Enabled: s.compose.Enabled(),
}
if err := s.templates.Execute(w, "compose.html", data); err != nil {
s.deps.Logger.Printf("render compose: %v", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
// handleComposeDetail renders one project with its services, containers,
// mounts, and Traefik routes, plus the management action buttons.
func (s *Server) handleComposeDetail(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
sess, _ := sessionFromContext(r.Context())
snap := s.compose.Snapshot()
p := snap.FindProject(r.PathValue("project"))
if p == nil {
http.NotFound(w, r)
return
}
data := composeDetailPageData{
basePageData: s.newBasePage(r, "Compose: "+p.Name, sess),
Project: p,
LastAt: s.compose.LastRefreshAt(),
ActionOK: r.URL.Query().Get("ok") == "1",
ActionMessage: r.URL.Query().Get("msg"),
}
if err := s.templates.Execute(w, "compose_detail.html", data); err != nil {
s.deps.Logger.Printf("render compose detail: %v", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
// handleComposeLogsPage renders the recent log tail for a project in a
// <pre> block so an operator can read it from the browser without curl.
func (s *Server) handleComposeLogsPage(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
sess, _ := sessionFromContext(r.Context())
project := r.PathValue("project")
res, status := s.composeLogsResult(r.Context(), project)
if status != http.StatusOK {
http.Error(w, res.Output, status)
return
}
data := composeLogsPageData{
basePageData: s.newBasePage(r, "Compose logs: "+project, sess),
Project: project,
Output: res.Output,
}
if err := s.templates.Execute(w, "compose_logs.html", data); err != nil {
s.deps.Logger.Printf("render compose logs: %v", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
// handleComposeProjectAction runs a lifecycle action against a project
// from the HTML form (session+CSRF) or the API (basic auth). The
// response is a redirect for the browser path and JSON for the API path.
func (s *Server) handleComposeProjectAction(w http.ResponseWriter, r *http.Request) {
if !s.compose.Enabled() {
http.Error(w, "compose management is disabled", http.StatusServiceUnavailable)
return
}
if !validProjectPostAction(r.PathValue("action")) {
http.NotFound(w, r)
return
}
if !s.composeMutationAuthorized(w, r) {
return
}
project := r.PathValue("project")
action := r.PathValue("action")
refs, ok := s.compose.Refs(project)
if !ok {
http.Error(w, "compose project working directory is unknown", http.StatusNotFound)
return
}
res, _ := compose.ManageProject(r.Context(), refs, compose.Action(action))
s.auditCompose(r, action, project, "")
s.respondComposeAction(w, r, project, res)
}
// handleComposeServiceAction runs a lifecycle action against a single
// service inside a project.
func (s *Server) handleComposeServiceAction(w http.ResponseWriter, r *http.Request) {
if !s.compose.Enabled() {
http.Error(w, "compose management is disabled", http.StatusServiceUnavailable)
return
}
if _, ok := compose.ValidServiceAction(r.PathValue("action")); !ok {
http.NotFound(w, r)
return
}
if !s.composeMutationAuthorized(w, r) {
return
}
project := r.PathValue("project")
service := r.PathValue("service")
action := r.PathValue("action")
refs, ok := s.compose.Refs(project)
if !ok {
http.Error(w, "compose project working directory is unknown", http.StatusNotFound)
return
}
res, _ := compose.ManageService(r.Context(), refs, service, compose.Action(action))
s.auditCompose(r, action, project, service)
s.respondComposeAction(w, r, project, res)
}
// handleComposeAPIList returns the full discovery snapshot as JSON. The
// shape matches the /compose HTML page (a Snapshot: projects + errors).
func (s *Server) handleComposeAPIList(w http.ResponseWriter, r *http.Request) {
writeComposeJSON(w, s.compose.Snapshot())
}
// handleComposeAPIDetail returns one project summary as JSON.
func (s *Server) handleComposeAPIDetail(w http.ResponseWriter, r *http.Request) {
p := s.compose.Snapshot().FindProject(r.PathValue("project"))
if p == nil {
http.Error(w, "compose project not found", http.StatusNotFound)
return
}
writeComposeJSON(w, p)
}
// handleComposeLogsAPI returns the recent log tail for a project as JSON.
func (s *Server) handleComposeLogsAPI(w http.ResponseWriter, r *http.Request) {
res, status := s.composeLogsResult(r.Context(), r.PathValue("project"))
if status != http.StatusOK {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(res)
return
}
writeComposeJSON(w, res)
}
// composeLogsResult resolves the log tail for a project and the HTTP
// status the caller should write. A 404 means the project (or its
// working directory) is unknown; 502 means Docker refused the call.
func (s *Server) composeLogsResult(ctx context.Context, project string) (compose.ManagementResult, int) {
refs, ok := s.compose.Refs(project)
if !ok {
return compose.ManagementResult{OK: false, Action: "logs", Output: "compose project working directory is unknown"}, http.StatusNotFound
}
res, err := compose.Logs(ctx, refs, 300)
if err != nil && res.Output == "" {
return res, http.StatusBadGateway
}
return res, http.StatusOK
}
// composeMutationAuthorized enforces CSRF for browser (session) callers
// while letting basic-auth API callers through without a token (the
// middleware already validated their credentials). Returns true when the
// request may proceed; writes a 403 and returns false otherwise.
func (s *Server) composeMutationAuthorized(w http.ResponseWriter, r *http.Request) bool {
sess, hasSess := sessionFromContext(r.Context())
if !hasSess {
return true // basic-auth API path; middleware authenticated it
}
if !s.requireCSRF(sess, r) {
http.Error(w, "csrf token required", http.StatusForbidden)
return false
}
return true
}
// validProjectPostAction reports whether name is a POST-able project
// action. It reuses compose.ValidProjectAction but excludes "logs",
// which is served by a dedicated GET route.
func validProjectPostAction(name string) bool {
if name == string(compose.ActionLogs) {
return false
}
_, ok := compose.ValidProjectAction(name)
return ok
}
// respondComposeAction sends the action result to the right consumer:
// JSON for /web/api/* (scripting), a redirect with a flash banner for
// the browser path. The action's combined output is URL-encoded into the
// banner so the operator sees what Compose printed without a separate
// request; very large output is truncated to keep the URL bounded.
func (s *Server) respondComposeAction(w http.ResponseWriter, r *http.Request, project string, res compose.ManagementResult) {
if strings.HasPrefix(r.URL.Path, "/web/api/") {
writeComposeJSON(w, res)
return
}
msg := strings.TrimSpace(res.Output)
if len(msg) > 500 {
msg = msg[:500] + "…"
}
if !res.OK && msg == "" {
msg = res.Action + " failed"
}
loc := fmt.Sprintf("/compose/%s?ok=%t&msg=%s", project, res.OK, url.QueryEscape(msg))
http.Redirect(w, r, loc, http.StatusSeeOther)
}
// writeComposeJSON serializes a value as no-store JSON. Used by every
// /web/api/compose endpoint so scripted callers get one consistent shape.
func writeComposeJSON(w http.ResponseWriter, v interface{}) {
writeNoStore(w)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(v)
}
// auditCompose records a management action in the worker audit log. The
// target encodes project and service so the audit trail reconstructs the
// exact scope (e.g. "compose:rsmon/web"); failures are still audited so
// an operator can trace a stopped service back to the action.
func (s *Server) auditCompose(r *http.Request, action, project, service string) {
target := "compose:" + project
if service != "" {
target += "/" + service
}
authMode := auditAuthModeBasic
if _, hasSess := sessionFromContext(r.Context()); hasSess {
authMode = auditAuthModeLocal
}
_ = s.store.WriteAudit(r.Context(), &AuditEntry{
Actor: auditActorLocal,
Role: auditRoleAdmin,
AuthMode: authMode,
IP: clientIP(r),
UA: r.UserAgent(),
Action: "compose_" + action,
Target: target,
})
}
type composeListPageData struct {
basePageData
Snapshot compose.Snapshot
LastAt time.Time
Enabled bool
}
type composeDetailPageData struct {
basePageData
Project *compose.ProjectSummary
LastAt time.Time
ActionOK bool
ActionMessage string
}
type composeLogsPageData struct {
basePageData
Project string
Output string
}

417
internal/webapp/handlers_compose_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,417 @@
package webapp
import (
"context"
"encoding/json"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"rocketgit.ru/rsmon/worker/internal/compose"
)
// newComposeTestServer builds a session-auth server with the Compose
// subsystem enabled. The default newTestServer leaves ComposeEnabled
// false (zero-value Config), so the handlers would short-circuit to the
// disabled banner; flipping the refresher flag on exercises the real
// code paths without starting the background loop.
func newComposeTestServer(t *testing.T, runner WorkerView) *Server {
t.Helper()
srv := newTestServer(t, runner)
srv.compose.enabled = true
return srv
}
// newComposeTestServerBasicAuth is the basic-auth variant used by the
// /web/api/compose/* tests: those routes accept HTTP basic credentials
// in lieu of a session cookie.
func newComposeTestServerBasicAuth(t *testing.T, runner WorkerView, login, password string) *Server {
t.Helper()
srv := newTestServerWithBasicAuth(t, runner, login, password)
srv.compose.enabled = true
return srv
}
// injectComposeSnapshot seeds the refresher with a single "rsmon"
// project so the list/detail/logs handlers have something to render
// without exec'ing Docker. The project's WorkingDir is a real temp dir
// so management operations (which chdir into it) succeed under a stub
// Docker binary.
func injectComposeSnapshot(t *testing.T, srv *Server, workDir string) {
t.Helper()
res := &compose.DiscoveryResult{
Projects: map[string]compose.Project{
"rsmon": {
Name: "rsmon",
Status: "running(1)",
WorkingDir: workDir,
ConfigFiles: filepath.Join(workDir, "docker-compose.yml"),
Services: map[string]compose.Service{
"web": {Name: "web", Containers: []compose.Container{{
ID: "c1",
Name: "rsmon-web-1",
Image: "nginx:latest",
State: "running",
Status: "Up 5 minutes",
Health: "healthy",
PID: 4242,
Ports: []compose.PortBinding{{HostIP: "0.0.0.0", HostPort: "8080"}},
Mounts: []compose.Mount{{Source: filepath.Join(workDir, "data"), Destination: "/data", Type: "bind"}},
Labels: map[string]string{
compose.LabelComposeProject: "rsmon",
compose.LabelComposeService: "web",
},
}}},
},
},
},
}
snap := compose.Summarize(res)
srv.compose.mu.Lock()
srv.compose.snap = snap
srv.compose.lastAt = time.Now().UTC()
srv.compose.mu.Unlock()
}
// stubDockerOK points the compose package at an executable stub that
// echoes its arguments and exits 0, so management/logs handlers run the
// full exec path without a real Docker daemon. Restored on cleanup.
func stubDockerOK(t *testing.T) {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "stub-docker")
script := "#!/bin/sh\necho \"stub: $*\"\nexit 0\n"
require.NoError(t, os.WriteFile(p, []byte(script), 0o755))
compose.SetDockerBin(p)
t.Cleanup(func() { compose.SetDockerBin("") })
}
// stubDockerFail points at a stub that exits non-zero with a stderr
// message, so the !OK management path is observable.
func stubDockerFail(t *testing.T) {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "stub-docker")
script := "#!/bin/sh\necho \"compose boom\" >&2\nexit 1\n"
require.NoError(t, os.WriteFile(p, []byte(script), 0o755))
compose.SetDockerBin(p)
t.Cleanup(func() { compose.SetDockerBin("") })
}
// auditHasAction reports whether the audit log contains a row matching
// action and a target substring (e.g. "compose_restart" / "rsmon").
func auditHasAction(t *testing.T, srv *Server, action, targetSub string) bool {
t.Helper()
rows, err := srv.store.RecentAudit(context.Background(), 10)
require.NoError(t, err)
for _, r := range rows {
if r.Action == action && strings.Contains(r.Target, targetSub) {
return true
}
}
return false
}
func TestComposeList_RendersProjects(t *testing.T) {
srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
injectComposeSnapshot(t, srv, t.TempDir())
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
resp, err := c.Get(ts.URL + "/compose")
require.NoError(t, err)
body := mustBody(t, resp)
assert.Contains(t, body, "Docker Compose projects")
assert.Contains(t, body, "rsmon")
assert.Contains(t, body, "details") // link to the detail page
}
func TestComposeList_DisabledBanner(t *testing.T) {
// Default server leaves Compose disabled.
srv := newTestServer(t, &stubRunner{id: "w-1"})
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
resp, err := c.Get(ts.URL + "/compose")
require.NoError(t, err)
body := mustBody(t, resp)
assert.Contains(t, body, "Compose management is disabled")
}
func TestComposeDetail_FoundAndNotFound(t *testing.T) {
srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
injectComposeSnapshot(t, srv, t.TempDir())
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
resp, err := c.Get(ts.URL + "/compose/rsmon")
require.NoError(t, err)
body := mustBody(t, resp)
assert.Contains(t, body, "Project actions")
assert.Contains(t, body, "rsmon-web-1")
resp2, err := c.Get(ts.URL + "/compose/missing")
require.NoError(t, err)
defer resp2.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusNotFound, resp2.StatusCode)
}
func TestComposeAPIList_JSON(t *testing.T) {
srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
injectComposeSnapshot(t, srv, t.TempDir())
ts := newHTTPTestServer(t, srv)
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/compose", nil)
req.SetBasicAuth("alice", "s3cret")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
var snap compose.Snapshot
require.NoError(t, json.NewDecoder(resp.Body).Decode(&snap))
require.Len(t, snap.Projects, 1)
assert.Equal(t, "rsmon", snap.Projects[0].Name)
}
func TestComposeAPIDetail_JSONAndNotFound(t *testing.T) {
srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
injectComposeSnapshot(t, srv, t.TempDir())
ts := newHTTPTestServer(t, srv)
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/compose/rsmon", nil)
req.SetBasicAuth("alice", "s3cret")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
var p compose.ProjectSummary
require.NoError(t, json.NewDecoder(resp.Body).Decode(&p))
assert.Equal(t, "rsmon", p.Name)
req2, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/compose/missing", nil)
req2.SetBasicAuth("alice", "s3cret")
resp2, err := http.DefaultClient.Do(req2)
require.NoError(t, err)
defer resp2.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusNotFound, resp2.StatusCode)
}
func TestComposeProjectAction_RequiresCSRF(t *testing.T) {
srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
injectComposeSnapshot(t, srv, t.TempDir())
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
form := url.Values{}
form.Set("csrf_token", "") // missing token
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/restart", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusForbidden, resp.StatusCode, "POST without CSRF must be 403")
}
func TestComposeProjectAction_HTMLRedirectAndAudit(t *testing.T) {
srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
injectComposeSnapshot(t, srv, t.TempDir())
stubDockerOK(t)
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
// Pull the CSRF token out of the detail page's action forms.
detail, err := c.Get(ts.URL + "/compose/rsmon")
require.NoError(t, err)
csrf := extractCSRFToken(t, mustBody(t, detail))
form := url.Values{}
form.Set("csrf_token", csrf)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/restart", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
loc := resp.Header.Get("Location")
assert.True(t, strings.HasPrefix(loc, "/compose/rsmon?ok=true"), "redirect location=%q", loc)
assert.True(t, auditHasAction(t, srv, "compose_restart", "compose:rsmon"),
"audit row for project restart expected")
}
func TestComposeProjectAction_APIBasicAuthAndAudit(t *testing.T) {
srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
injectComposeSnapshot(t, srv, t.TempDir())
stubDockerOK(t)
ts := newHTTPTestServer(t, srv)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/api/compose/rsmon/restart", nil)
req.SetBasicAuth("alice", "s3cret")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
var res compose.ManagementResult
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
assert.True(t, res.OK)
assert.Contains(t, res.Output, "stub:")
assert.True(t, auditHasAction(t, srv, "compose_restart", "compose:rsmon"),
"audit row for API restart expected")
}
func TestComposeProjectAction_FailureReportsNotOK(t *testing.T) {
srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
injectComposeSnapshot(t, srv, t.TempDir())
stubDockerFail(t)
ts := newHTTPTestServer(t, srv)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/api/compose/rsmon/stop", nil)
req.SetBasicAuth("alice", "s3cret")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
var res compose.ManagementResult
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
assert.False(t, res.OK)
assert.Contains(t, res.Output, "compose boom")
}
func TestComposeProjectAction_UnknownActionRejected(t *testing.T) {
srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
injectComposeSnapshot(t, srv, t.TempDir())
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
for _, action := range []string{"bogus", "logs"} { // logs is GET-only
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/"+action, strings.NewReader(""))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, resp.StatusCode, "action %q should 404", action)
resp.Body.Close() //nolint:errcheck
}
}
func TestComposeProjectAction_DisabledReturns503(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"}) // compose disabled
injectComposeSnapshot(t, srv, t.TempDir())
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/restart", strings.NewReader(""))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
}
func TestComposeServiceAction_HTMLRedirectAndAudit(t *testing.T) {
srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
injectComposeSnapshot(t, srv, t.TempDir())
stubDockerOK(t)
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
detail, err := c.Get(ts.URL + "/compose/rsmon")
require.NoError(t, err)
csrf := extractCSRFToken(t, mustBody(t, detail))
form := url.Values{}
form.Set("csrf_token", csrf)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/service/web/restart", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
assert.True(t, strings.HasPrefix(resp.Header.Get("Location"), "/compose/rsmon"))
assert.True(t, auditHasAction(t, srv, "compose_restart", "compose:rsmon/web"),
"audit row for service restart expected")
}
func TestComposeServiceAction_UnknownActionRejected(t *testing.T) {
srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
injectComposeSnapshot(t, srv, t.TempDir())
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/service/web/bogus", strings.NewReader(""))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}
func TestComposeLogs_HTMLPage(t *testing.T) {
srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
injectComposeSnapshot(t, srv, t.TempDir())
stubDockerOK(t)
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
resp, err := c.Get(ts.URL + "/compose/rsmon/logs")
require.NoError(t, err)
body := mustBody(t, resp)
assert.Contains(t, body, "Compose logs: rsmon")
assert.Contains(t, body, "stub:")
}
func TestComposeLogs_API_JSONAndNotFound(t *testing.T) {
srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
injectComposeSnapshot(t, srv, t.TempDir())
stubDockerOK(t)
ts := newHTTPTestServer(t, srv)
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/compose/rsmon/logs", nil)
req.SetBasicAuth("alice", "s3cret")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
var res compose.ManagementResult
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
assert.Contains(t, res.Output, "stub:")
req2, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/compose/missing/logs", nil)
req2.SetBasicAuth("alice", "s3cret")
resp2, err := http.DefaultClient.Do(req2)
require.NoError(t, err)
defer resp2.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusNotFound, resp2.StatusCode)
}
func TestValidProjectPostAction(t *testing.T) {
for _, a := range []string{"up", "down", "stop", "restart", "pull"} {
assert.True(t, validProjectPostAction(a), "%q should be POST-able", a)
}
assert.False(t, validProjectPostAction("logs"), "logs is GET-only")
assert.False(t, validProjectPostAction("bogus"), "bogus is invalid")
}
func TestComposeAPIList_RequiresAuth(t *testing.T) {
srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
injectComposeSnapshot(t, srv, t.TempDir())
ts := newHTTPTestServer(t, srv)
// No credentials: the basic-auth fast path returns 401.
resp, err := http.Get(ts.URL + "/web/api/compose")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}

Просмотреть файл

@@ -51,6 +51,23 @@ func (s *Server) routes() {
s.mux.Handle("POST /settings/rotate-token", s.requireSession(s.handleRotateToken)) s.mux.Handle("POST /settings/rotate-token", s.requireSession(s.handleRotateToken))
s.mux.Handle("GET /updates", s.requireSession(s.handleUpdates)) s.mux.Handle("GET /updates", s.requireSession(s.handleUpdates))
// Docker Compose discovery + management. The list/detail/logs pages
// are session-protected HTML; the lifecycle endpoints accept either a
// session (browser, CSRF-checked in the handler) or HTTP basic auth
// (scripting, on the /web/api/compose/* prefix). Compose is disabled
// per host via WORKER_COMPOSE_ENABLED=false; when off, the management
// handlers return 503 and the list page renders a banner.
s.mux.Handle("GET /compose", s.requireSession(s.handleComposeList))
s.mux.Handle("GET /compose/{project}", s.requireSession(s.handleComposeDetail))
s.mux.Handle("GET /compose/{project}/logs", s.requireSession(s.handleComposeLogsPage))
s.mux.Handle("POST /compose/{project}/{action}", s.requireSession(s.handleComposeProjectAction))
s.mux.Handle("POST /compose/{project}/service/{service}/{action}", s.requireSession(s.handleComposeServiceAction))
s.mux.Handle("GET /web/api/compose", s.requireSession(s.handleComposeAPIList))
s.mux.Handle("GET /web/api/compose/{project}", s.requireSession(s.handleComposeAPIDetail))
s.mux.Handle("GET /web/api/compose/{project}/logs", s.requireSession(s.handleComposeLogsAPI))
s.mux.Handle("POST /web/api/compose/{project}/{action}", s.requireSession(s.handleComposeProjectAction))
s.mux.Handle("POST /web/api/compose/{project}/service/{service}/{action}", s.requireSession(s.handleComposeServiceAction))
// Health endpoint for the cmd health subcommand and for the // Health endpoint for the cmd health subcommand and for the
// operator to confirm the listener is up without going through the // operator to confirm the listener is up without going through the
// login form. Returns 200 with a tiny body. // login form. Returns 200 with a tiny body.

Просмотреть файл

@@ -87,6 +87,14 @@ type Config struct {
// "tag_name" field (GitHub release JSON is the canonical // "tag_name" field (GitHub release JSON is the canonical
// shape). WORKER_RELEASE_URL sets this. // shape). WORKER_RELEASE_URL sets this.
ReleaseURL string ReleaseURL string
// ComposeEnabled turns the Docker Compose discovery + management
// subsystem on. Defaults to true (WORKER_COMPOSE_ENABLED=false to
// disable): the refresher no-ops when the Docker daemon is absent,
// so leaving it on is safe on non-Docker hosts. When enabled, the
// /compose page lists every Compose project on the host and the
// /web/api/compose/* endpoints drive up/down/stop/restart/pull.
ComposeEnabled bool
} }
// ValidateBasicAuth enforces that WORKER_LOGIN and WORKER_PASSWORD // ValidateBasicAuth enforces that WORKER_LOGIN and WORKER_PASSWORD
@@ -177,9 +185,23 @@ func ConfigFromEnv(env map[string]string, defaultDataDir string) (Config, error)
cfg.StorePath = v cfg.StorePath = v
} }
cfg.ReleaseURL = strings.TrimSpace(env[envReleaseURL]) cfg.ReleaseURL = strings.TrimSpace(env[envReleaseURL])
// Compose discovery defaults to on; only an explicit false disables it.
cfg.ComposeEnabled = !parseBoolFalseDefault(env[envComposeEnabled])
return cfg, nil return cfg, nil
} }
// parseBoolFalseDefault returns true for any value that is not an
// explicit falsy literal. Used by ComposeEnabled so that the default
// (unset env var) keeps the feature on, unlike parseBool where the
// default is false.
func parseBoolFalseDefault(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "false", "0", "no", "off", "":
return false
}
return true
}
// parseBool returns true for the strings "true", "1", "yes" (any // parseBool returns true for the strings "true", "1", "yes" (any
// case, trimmed). Anything else is false. Used for opt-in feature // case, trimmed). Anything else is false. Used for opt-in feature
// flags wired through env vars without dragging in a config-package // flags wired through env vars without dragging in a config-package
@@ -201,6 +223,7 @@ func ConfigFromEnvOrDefault() Config {
envWorkerHost, envWorkerPort, envWorkerURL, envWorkerLogin, envWorkerPassword, envWorkerHost, envWorkerPort, envWorkerURL, envWorkerLogin, envWorkerPassword,
"RSMON_WEBAPP_DATA_DIR", "RSMON_WEBAPP_STORE_PATH", "RSMON_WEBAPP_DATA_DIR", "RSMON_WEBAPP_STORE_PATH",
"WORKER_CLUSTER_ENABLED", "WORKER_CLUSTER_ENABLED",
envComposeEnabled,
envReleaseURL, envReleaseURL,
} { } {
if v := os.Getenv(k); v != "" { if v := os.Getenv(k); v != "" {
@@ -336,6 +359,7 @@ type Server struct {
templates *Templates templates *Templates
logBuffer *LogBuffer logBuffer *LogBuffer
inventory *Inventory inventory *Inventory
compose *ComposeRefresher
metrics *Metrics metrics *Metrics
cluster ClusterView cluster ClusterView
pruneStop chan struct{} pruneStop chan struct{}
@@ -402,6 +426,7 @@ func New(cfg Config, deps *Deps) (*Server, error) { //nolint:gocritic // Config
templates: tmpl, templates: tmpl,
logBuffer: NewLogBuffer(5000), logBuffer: NewLogBuffer(5000),
inventory: NewInventory(store, deps.Logger), inventory: NewInventory(store, deps.Logger),
compose: NewComposeRefresher(cfg.ComposeEnabled, deps.Logger),
metrics: NewMetrics(), metrics: NewMetrics(),
cluster: deps.Cluster, cluster: deps.Cluster,
pruneStop: make(chan struct{}), pruneStop: make(chan struct{}),
@@ -460,6 +485,10 @@ func (s *Server) SetCluster(c ClusterView) { s.cluster = c }
// Cluster returns the attached cluster subsystem (or nil). // Cluster returns the attached cluster subsystem (or nil).
func (s *Server) Cluster() ClusterView { return s.cluster } func (s *Server) Cluster() ClusterView { return s.cluster }
// Compose returns the Compose discovery/management refresher so tests
// and the cmd binary can drive it (e.g. inject a fixture discovery).
func (s *Server) Compose() *ComposeRefresher { return s.compose }
// Close shuts down the HTTP listener, the prune goroutine, and the // Close shuts down the HTTP listener, the prune goroutine, and the
// embedded store. Safe to call multiple times. // embedded store. Safe to call multiple times.
func (s *Server) Close(ctx context.Context) error { func (s *Server) Close(ctx context.Context) error {
@@ -472,6 +501,9 @@ func (s *Server) Close(ctx context.Context) error {
close(s.pruneStop) close(s.pruneStop)
} }
s.pruneWG.Wait() s.pruneWG.Wait()
if s.compose != nil {
s.compose.Stop()
}
if s.httpServer != nil { if s.httpServer != nil {
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() defer cancel()
@@ -493,6 +525,12 @@ func (s *Server) Start(ctx context.Context) error {
s.pruneWG.Add(1) s.pruneWG.Add(1)
go s.pruneLoop(ctx) go s.pruneLoop(ctx)
// Start the Compose discovery loop alongside the listener. It no-ops
// when disabled or when Docker is absent, so it is always safe to
// start. The first refresh runs immediately so /compose has data on
// the first request.
s.compose.Start(ctx)
// Run ListenAndServe in a goroutine so we can race it against ctx. // Run ListenAndServe in a goroutine so we can race it against ctx.
errCh := make(chan error, 1) errCh := make(chan error, 1)
go func() { go func() {

44
internal/webapp/templates/compose.html Обычный файл
Просмотреть файл

@@ -0,0 +1,44 @@
{{define "body"}}<section class="card">
<h1>Docker Compose projects</h1>
{{if not .Enabled}}
<p class="muted">Compose management is disabled (WORKER_COMPOSE_ENABLED=false). Re-enable it and restart the worker to see projects here.</p>
{{else}}
<p class="muted">Discovered from <code>docker compose ls</code> and <code>docker ps</code> labels, refreshed every 60s. Lifecycle actions (up/down/stop/restart/pull) run <code>docker compose</code> in each project's working directory.</p>
{{if .LastAt.IsZero}}
<p class="muted">No refresh yet — the first scan runs within 60s of start.</p>
{{else}}
<p class="muted">Last refresh: {{fmtTime .LastAt}}</p>
{{end}}
{{range .Snapshot.Errors}}
<p class="muted">⚠ {{.}}</p>
{{end}}
<table>
<thead>
<tr>
<th>Project</th>
<th>Status</th>
<th>Services</th>
<th>Containers</th>
<th>Running</th>
<th>Compose file</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Snapshot.Projects}}
<tr>
<td>{{.Name}}</td>
<td>{{.Status}}</td>
<td>{{.ServiceCount}}</td>
<td>{{.ContainerCount}}</td>
<td>{{.RunningCount}}</td>
<td>{{.ConfigFiles}}</td>
<td><a href="/compose/{{.Name}}">details</a></td>
</tr>
{{else}}
<tr><td colspan="7" class="muted">No Compose projects discovered.</td></tr>
{{end}}
</tbody>
</table>
{{end}}
</section>{{end}}

Просмотреть файл

@@ -0,0 +1,81 @@
{{define "body"}}<section class="card">
<h1>{{.Project.Name}}</h1>
<p class="muted">Working dir: <code>{{.Project.WorkingDir}}</code>{{if .Project.ConfigFiles}} · compose file: <code>{{.Project.ConfigFiles}}</code>{{end}} · last refresh: {{fmtTime .LastAt}}</p>
{{if .ActionMessage}}
<p class="{{if .ActionOK}}muted{{else}}muted{{end}}">{{if .ActionOK}}✓{{else}}⚠{{end}} {{.ActionMessage}}</p>
{{end}}
<h2>Project actions</h2>
<div class="cards">
<form action="/compose/{{.Project.Name}}/up" method="post"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">Up</button></form>
<form action="/compose/{{.Project.Name}}/restart" method="post"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">Restart</button></form>
<form action="/compose/{{.Project.Name}}/pull" method="post"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">Pull</button></form>
<form action="/compose/{{.Project.Name}}/stop" method="post"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">Stop</button></form>
<form action="/compose/{{.Project.Name}}/down" method="post"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">Down</button></form>
<a href="/compose/{{.Project.Name}}/logs"><button type="button">Logs</button></a>
</div>
<h2>Services ({{.Project.ServiceCount}})</h2>
<table>
<thead><tr><th>Service</th><th>Container</th><th>Image</th><th>State</th><th>Status</th><th>Health</th><th>PID</th><th>Ports</th><th>Actions</th></tr></thead>
<tbody>
{{$root := .}}
{{range .Project.Services}}
<tr>
<td>{{.Name}}</td>
<td>{{range .Containers}}{{.Name}}<br>{{end}}</td>
<td>{{range .Containers}}{{.Image}}<br>{{end}}</td>
<td>{{range .Containers}}{{.State}}<br>{{end}}</td>
<td>{{range .Containers}}{{.Status}}<br>{{end}}</td>
<td>{{range .Containers}}{{.Health}}<br>{{end}}</td>
<td>{{range .Containers}}{{.PID}}<br>{{end}}</td>
<td>{{range .Containers}}{{range .Ports}}{{.HostPort}}<br>{{end}}{{end}}</td>
<td>
<form action="/compose/{{$root.Project.Name}}/service/{{.Name}}/up" method="post" style="display:inline"><input type="hidden" name="csrf_token" value="{{$root.CSRFToken}}"><button type="submit">up</button></form>
<form action="/compose/{{$root.Project.Name}}/service/{{.Name}}/restart" method="post" style="display:inline"><input type="hidden" name="csrf_token" value="{{$root.CSRFToken}}"><button type="submit">restart</button></form>
<form action="/compose/{{$root.Project.Name}}/service/{{.Name}}/stop" method="post" style="display:inline"><input type="hidden" name="csrf_token" value="{{$root.CSRFToken}}"><button type="submit">stop</button></form>
</td>
</tr>
{{else}}
<tr><td colspan="9" class="muted">No services discovered for this project.</td></tr>
{{end}}
</tbody>
</table>
{{if .Project.GroupedMounts}}
<h2>Mounts</h2>
<table>
<thead><tr><th>Source</th><th>Destination</th><th>Type</th><th>Used by</th></tr></thead>
<tbody>
{{range .Project.GroupedMounts}}
<tr>
<td>{{.Source}}</td>
<td>{{.Destination}}</td>
<td>{{.Type}}</td>
<td>{{.SharedMountSummary}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{if .Project.AllTraefikRoutes}}
<h2>Traefik routes</h2>
<table>
<thead><tr><th>Router</th><th>Service</th><th>Container</th><th>Hostnames</th><th>Prefixes</th><th>Rule</th></tr></thead>
<tbody>
{{range .Project.AllTraefikRoutes}}
<tr>
<td>{{.RouterName}}</td>
<td>{{.Service}}</td>
<td>{{.Container}}</td>
<td>{{range .Hostnames}}{{.}}<br>{{end}}</td>
<td>{{range .PathPrefixes}}{{.}}<br>{{end}}</td>
<td><code>{{.Rule}}</code></td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</section>{{end}}

Просмотреть файл

@@ -0,0 +1,6 @@
{{define "body"}}<section class="card">
<h1>Compose logs: {{.Project}}</h1>
<p class="muted">Last 300 lines from <code>docker compose logs --no-color --tail 300</code>. Streaming tails are not supported in the web UI yet.</p>
<pre class="logs">{{if .Output}}{{.Output}}{{else}}<span class="muted">(no output)</span>{{end}}</pre>
<p><a href="/compose/{{.Project}}">← back to {{.Project}}</a></p>
</section>{{end}}

Просмотреть файл

@@ -14,6 +14,7 @@
<nav> <nav>
<a href="/overview">Overview</a> <a href="/overview">Overview</a>
<a href="/apps">Apps</a> <a href="/apps">Apps</a>
<a href="/compose">Compose</a>
<a href="/checks">Checks</a> <a href="/checks">Checks</a>
<a href="/notifications">Notifications</a> <a href="/notifications">Notifications</a>
<a href="/logs">Logs</a> <a href="/logs">Logs</a>