Files
worker/internal/compose/manage.go
root ff0d2f088f
Некоторые проверки не удались
CI / test (push) Failing after 6s
Docker / Build and publish worker image (push) Failing after 8s
feat(worker): add Docker Compose discovery and management
Add an internal/compose package that discovers Compose projects via
`docker compose ls` + `docker ps` labels (grouped by
com.docker.compose.project/service) and enriches each container with
`docker inspect` ports/mounts and Traefik router labels. Management
runs `docker compose` in each project's working directory for
up/down/stop/restart/pull plus per-service variants and log tails.

Wire it into the webapp: a 60s ComposeRefresher (constructed in New,
started in Start, stopped in Close), a /compose list + detail + logs
HTML surface, and /web/api/compose/* JSON endpoints (list, detail,
logs, project/service lifecycle). Browser lifecycle POSTs are
session+CSRF protected; the /web/api/* variants accept HTTP basic auth.
WORKER_COMPOSE_ENABLED defaults on (false to disable).

Tests cover discovery parsing/grouping/traefik/summary, the action
allowlists, and the full handler surface (list/detail/logs HTML+API,
CSRF enforcement, disabled/unknown-action rejection, audit writes,
success+failure exec paths) via a stub Docker binary.
2026-07-29 21:31:52 +03:00

125 строки
4.5 KiB
Go

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
}