Files
glpi-ai-agent-controller/internal/dockerctl/controller.go
jbergner fe32431d21
Some checks failed
release-tag / release-image (push) Failing after 55s
init
2026-08-01 23:54:45 +02:00

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
}