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
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 ` (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 ` 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
}