240 lines
7.1 KiB
Go
240 lines
7.1 KiB
Go
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
|
|
}
|