package dockerctl import ( "context" "encoding/json" "fmt" "os/exec" "sort" "strings" "time" ) type Runner interface { Run(ctx context.Context, name string, args ...string) ([]byte, error) } type ExecRunner struct{} func (ExecRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { cmd := exec.CommandContext(ctx, name, args...) out, err := cmd.CombinedOutput() if err != nil { return out, fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) } return out, nil } type Target struct { ContainerName string `json:"container_name"` DisplayName string `json:"display_name"` AllowedActions []string `json:"allowed_actions"` DefaultAction string `json:"default_action"` ApplyByDefault bool `json:"apply_by_default"` ProjectDir string `json:"project_dir,omitempty"` ComposeFiles []string `json:"compose_files,omitempty"` ComposeService string `json:"compose_service,omitempty"` ComposeProject string `json:"compose_project,omitempty"` EnvFile string `json:"env_file,omitempty"` } type Status struct { ContainerName string `json:"container_name"` State string `json:"state"` Health string `json:"health"` Image string `json:"image"` Error string `json:"error,omitempty"` } type Result struct { ContainerName string `json:"container_name"` Action string `json:"action"` Success bool `json:"success"` Output string `json:"output,omitempty"` Error string `json:"error,omitempty"` Duration time.Duration `json:"duration"` } type Controller struct { Runner Runner Timeout time.Duration } func (c Controller) Status(ctx context.Context, target Target) Status { ctx, cancel := context.WithTimeout(ctx, c.timeout()) defer cancel() format := `{{json .}}` out, err := c.runner().Run(ctx, "docker", "inspect", "--format", format, target.ContainerName) if err != nil { return Status{ContainerName: target.ContainerName, Error: err.Error()} } var raw struct { Config struct { Image string `json:"Image"` } `json:"Config"` State struct { Status string `json:"Status"` Health *struct { Status string `json:"Status"` } `json:"Health"` } `json:"State"` } if err := json.Unmarshal(out, &raw); err != nil { return Status{ContainerName: target.ContainerName, Error: err.Error()} } health := "" if raw.State.Health != nil { health = raw.State.Health.Status } return Status{ContainerName: target.ContainerName, State: raw.State.Status, Health: health, Image: raw.Config.Image} } func (c Controller) Execute(ctx context.Context, target Target, action string) Result { start := time.Now() result := Result{ContainerName: target.ContainerName, Action: action} if !allowed(target.AllowedActions, action) { result.Error = "action is not allowlisted for this container" result.Duration = time.Since(start) return result } ctx, cancel := context.WithTimeout(ctx, c.timeout()) defer cancel() var out []byte var err error switch action { case "restart": out, err = c.runner().Run(ctx, "docker", "restart", target.ContainerName) case "recreate": out, err = c.recreate(ctx, target) default: err = fmt.Errorf("unsupported action %q", action) } result.Output = strings.TrimSpace(string(out)) if err != nil { result.Error = err.Error() } else { result.Success = true } result.Duration = time.Since(start) return result } func (c Controller) recreate(ctx context.Context, target Target) ([]byte, error) { meta, err := c.composeMetadata(ctx, target) if err != nil { return nil, err } args := []string{"compose"} if meta.EnvFile != "" { args = append(args, "--env-file", meta.EnvFile) } if meta.ProjectDir != "" { args = append(args, "--project-directory", meta.ProjectDir) } if meta.Project != "" { args = append(args, "-p", meta.Project) } for _, file := range meta.Files { args = append(args, "-f", file) } args = append(args, "up", "-d", "--no-deps", "--force-recreate", meta.Service) return c.runner().Run(ctx, "docker", args...) } type composeMeta struct { ProjectDir string Files []string Service, Project, EnvFile string } func (c Controller) composeMetadata(ctx context.Context, target Target) (composeMeta, error) { meta := composeMeta{ProjectDir: target.ProjectDir, Files: append([]string(nil), target.ComposeFiles...), Service: target.ComposeService, Project: target.ComposeProject, EnvFile: target.EnvFile} if meta.ProjectDir != "" && len(meta.Files) > 0 && meta.Service != "" { return meta, nil } out, err := c.runner().Run(ctx, "docker", "inspect", "--format", `{{json .Config.Labels}}`, target.ContainerName) if err != nil { return meta, fmt.Errorf("inspect compose metadata: %w", err) } labels := map[string]string{} if err := json.Unmarshal(out, &labels); err != nil { return meta, fmt.Errorf("decode compose labels: %w", err) } if meta.ProjectDir == "" { meta.ProjectDir = labels["com.docker.compose.project.working_dir"] } if meta.Service == "" { meta.Service = labels["com.docker.compose.service"] } if meta.Project == "" { meta.Project = labels["com.docker.compose.project"] } if len(meta.Files) == 0 { for _, f := range strings.Split(labels["com.docker.compose.project.config_files"], ",") { if f = strings.TrimSpace(f); f != "" { meta.Files = append(meta.Files, f) } } } if meta.ProjectDir == "" || meta.Service == "" || len(meta.Files) == 0 { return meta, fmt.Errorf("container %q has incomplete Compose metadata; configure project_dir, compose_files and compose_service explicitly", target.ContainerName) } return meta, nil } func (c Controller) runner() Runner { if c.Runner != nil { return c.Runner } return ExecRunner{} } func (c Controller) timeout() time.Duration { if c.Timeout > 0 { return c.Timeout } return 3 * time.Minute } func allowed(values []string, needle string) bool { for _, v := range values { if v == needle { return true } } return false } func ValidateTargets(targets []Target) error { seen := map[string]struct{}{} for i := range targets { target := &targets[i] if target.ContainerName == "" { return fmt.Errorf("target container_name is required") } if _, ok := seen[target.ContainerName]; ok { return fmt.Errorf("duplicate target %q", target.ContainerName) } seen[target.ContainerName] = struct{}{} if len(target.AllowedActions) == 0 { return fmt.Errorf("target %q has no allowed_actions", target.ContainerName) } for _, action := range target.AllowedActions { if action != "restart" && action != "recreate" { return fmt.Errorf("target %q has invalid action %q", target.ContainerName, action) } } if target.DefaultAction == "" { target.DefaultAction = target.AllowedActions[0] } if !allowed(target.AllowedActions, target.DefaultAction) { return fmt.Errorf("target %q default action is not allowed", target.ContainerName) } } return nil } func SortedTargets(targets []Target) []Target { out := append([]Target(nil), targets...) sort.SliceStable(out, func(i, j int) bool { return out[i].DisplayName < out[j].DisplayName }) return out }