@@ -0,0 +1,239 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user