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>
179 lines
5.2 KiB
Go
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()
|
|
}
|