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() }