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.
Этот коммит содержится в:
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
|
||||
}
|
||||
Ссылка в новой задаче
Block a user