mydev/internal/checks/compose.go
Victor Abrell 7140397a13 Initial commit: MyDev dev-environment TUI
CLI + live dashboard wrapping aws/docker/git/awsmfa for local dev:
AWS MFA auth status + expiry, dev compose stack control, container
drift vs the dev repo, git repo status, prod-data and DB-access tools.
Config-driven (~/.config/mydev/config.yaml). Dashboard runs commands
via a command palette with captured/interactive modes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:31:58 +02:00

179 lines
5.2 KiB
Go

package checks
import (
"context"
"encoding/json"
"os/exec"
"strings"
"time"
)
// ServiceDrift reports whether one compose service's running container still
// matches the dev repo: same compose config and same image as defined now.
type ServiceDrift struct {
Service string
State string // running, exited, created, ...
Missing bool // no container created for this service yet
ConfigChanged bool // docker-compose.yml changed since container start
ImageRebuilt bool // image tag now points to a different (newer) build
}
// UpToDate is true when the running container reflects the current dev repo.
func (d ServiceDrift) UpToDate() bool {
return !d.Missing && !d.ConfigChanged && !d.ImageRebuilt
}
// Reason is a short human explanation of the drift state.
func (d ServiceDrift) Reason() string {
switch {
case d.Missing:
return "not created"
case d.ConfigChanged && d.ImageRebuilt:
return "config changed + image rebuilt"
case d.ConfigChanged:
return "compose config changed"
case d.ImageRebuilt:
return "image rebuilt — recreate to pick up"
default:
return "up to date"
}
}
// ComposeDrift compares every service in the compose file against its running
// container. composeFile is the path to docker-compose.yml.
func ComposeDrift(ctx context.Context, composeFile string) ([]ServiceDrift, string) {
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
// 1. Current per-service config hashes from the compose file.
wantHash, errMsg := composeHashes(ctx, composeFile)
if errMsg != "" {
return nil, errMsg
}
// 2. Existing containers for the project, keyed by service name.
conts, errMsg := composeContainers(ctx, composeFile)
if errMsg != "" {
return nil, errMsg
}
// 3. Cache of "current image ID for this ref" to avoid duplicate inspects.
curImageID := map[string]string{}
var out []ServiceDrift
for service := range wantHash {
d := ServiceDrift{Service: service}
c, ok := conts[service]
if !ok {
d.Missing = true
out = append(out, d)
continue
}
d.State = c.State
d.ConfigChanged = c.ConfigHash != "" && c.ConfigHash != wantHash[service]
if c.Ref != "" {
id, seen := curImageID[c.Ref]
if !seen {
id = imageID(ctx, c.Ref) // "" if the tag no longer exists
curImageID[c.Ref] = id
}
// Different (or vanished) current image vs what the container pinned.
d.ImageRebuilt = id != c.PinnedImage
}
out = append(out, d)
}
return out, ""
}
// composeHashes runs `docker compose config --hash '*'` → map[service]hash.
func composeHashes(ctx context.Context, composeFile string) (map[string]string, string) {
out, err := exec.CommandContext(ctx, "docker", "compose", "--file", composeFile, "config", "--hash", "*").Output()
if err != nil {
return nil, dockerErr(err)
}
m := map[string]string{}
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
fields := strings.Fields(line)
if len(fields) == 2 {
m[fields[0]] = fields[1]
}
}
return m, ""
}
type containerInfo struct {
Service string
State string
ConfigHash string
PinnedImage string // image ID the container actually runs
Ref string // image tag/ref, e.g. mystore5-local-dev:latest
}
// composeContainers lists project containers and inspects their compose labels.
func composeContainers(ctx context.Context, composeFile string) (map[string]containerInfo, string) {
psOut, err := exec.CommandContext(ctx, "docker", "compose", "--file", composeFile, "ps", "-a", "--format", "json").Output()
if err != nil {
return nil, dockerErr(err)
}
var names []string
for _, line := range strings.Split(strings.TrimSpace(string(psOut)), "\n") {
if line == "" {
continue
}
var row struct {
Name string `json:"Name"`
}
if json.Unmarshal([]byte(line), &row) == nil && row.Name != "" {
names = append(names, row.Name)
}
}
if len(names) == 0 {
return map[string]containerInfo{}, ""
}
// One inspect call, one pipe-delimited line per container. A pipe can't
// appear in service names, hashes, or image IDs, so it's a safe separator.
const f = `{{index .Config.Labels "com.docker.compose.service"}}` + "|" +
`{{.State.Status}}` + "|" +
`{{index .Config.Labels "com.docker.compose.config-hash"}}` + "|" +
`{{.Image}}` + "|" + `{{.Config.Image}}`
args := append([]string{"inspect", "--format", f}, names...)
insOut, err := exec.CommandContext(ctx, "docker", args...).Output()
if err != nil {
return nil, dockerErr(err)
}
m := map[string]containerInfo{}
for _, line := range strings.Split(strings.TrimSpace(string(insOut)), "\n") {
p := strings.Split(line, "|")
if len(p) != 5 || p[0] == "" {
continue
}
m[p[0]] = containerInfo{
Service: p[0],
State: p[1],
ConfigHash: p[2],
PinnedImage: p[3],
Ref: p[4],
}
}
return m, ""
}
func imageID(ctx context.Context, ref string) string {
out, err := exec.CommandContext(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", ref).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func dockerErr(err error) string {
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
return strings.TrimSpace(string(ee.Stderr))
}
return err.Error()
}