Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
248 changes: 198 additions & 50 deletions internal/cli/imagescmd/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"sort"
"strings"

Expand Down Expand Up @@ -71,15 +72,17 @@ func runCheck(cmd *cobra.Command, _ []string) error {
return nil
}

// Pre-fetch local digests sequentially before parallel registry checks.
// Running exec.Command concurrently (especially over SSH contexts) is unreliable.
localDigests := prefetchLocalDigests(cmd.Context(), inputs, makeLocalDigestFunc(cfg, factory))

// Run the check — registry HTTP calls run in parallel, local digests from cache.
// Run the check inside the spinner so the user sees feedback immediately.
// Local digest pre-fetching (sequential SSH calls) and remote registry checks
// (parallel HTTPS calls) both happen here.
var results []images.ImageStatus
err = common.SpinnerOperation(pr, "Checking images...", func() error {
results, err = images.Check(cmd.Context(), inputs, reg, func(ctx context.Context, stackKey, imageRef string) (string, error) {
return localDigests[stackKey+"|"+imageRef], nil
// Pre-fetch local digests sequentially — exec.Command over SSH contexts is
// unreliable when concurrent, so this must stay sequential.
localDigests := prefetchLocalDigests(cmd.Context(), inputs, makeLocalDigestFunc(cfg, factory))

results, err = images.Check(cmd.Context(), inputs, reg, func(_ context.Context, stackKey, service, _ string) (string, error) {
return localDigests[stackKey+"|"+service], nil
})
return err
})
Expand Down Expand Up @@ -159,55 +162,121 @@ func buildCheckInputs(ctx context.Context, cfg *manifest.Config, factory *docker
return inputs, nil
}

// makeLocalDigestFunc creates a LocalDigestFunc that uses docker image inspect
// to retrieve the local repo digest for an image on the Docker daemon
// associated with the image's stack. This is best-effort: if the image is not
// pulled or has no repo digests, it returns an empty string (which will cause
// the image to appear stale).
// makeLocalDigestFunc creates a LocalDigestFunc that returns the repo digest of
// the image currently running inside the container for a given service, falling
// back to the stored image digest when no container is found.
//
// Comparing the container's digest (rather than the stored image's digest)
// ensures that a service whose image has been pulled but not yet recreated
// still appears stale — the container is still running the old image.
//
// Performance: two calls are issued per Docker context (daemon), regardless of
// how many services or stacks share that context:
// 1. docker ps — maps every running compose container to its image ID
// 2. docker image inspect (batched) — maps each image ID to its repo digest
//
// Everything is cached in the closure; calls must be sequential (see
// prefetchLocalDigests). Failures are best-effort: an empty digest makes the
// image appear stale, which is safe.
func makeLocalDigestFunc(cfg *manifest.Config, factory *dockercli.DefaultClientFactory) images.LocalDigestFunc {
return func(ctx context.Context, stackKey, imageRef string) (string, error) {
type ctxCache struct {
containerImageID map[string]string // "project|service" → full image ID
imageDigest map[string]string // full image ID → repo digest (sha256:…)
}
cache := make(map[string]*ctxCache) // contextName → populated on first use

return func(ctx context.Context, stackKey, service, imageRef string) (string, error) {
ctxName, _, err := manifest.ParseStackKey(stackKey)
if err != nil {
return "", nil //nolint:nilerr // best-effort
}
client := factory.GetClientForContext(ctxName, cfg)
out, err := client.ImageInspectRepoDigests(ctx, imageRef)
if err != nil {
// Image not pulled or inspect failed — treat as stale.
return "", nil //nolint:nilerr // best-effort

// Populate cache for this context on first access.
cc, ok := cache[ctxName]
if !ok {
cc = &ctxCache{
containerImageID: make(map[string]string),
imageDigest: make(map[string]string),
}

// One docker ps call for all compose containers on this daemon.
containerMap, _ := client.ComposeContainerImageMap(ctx) //nolint:nilerr // best-effort
if containerMap != nil {
cc.containerImageID = containerMap

// Collect unique image IDs, then batch-fetch their repo digests.
seen := make(map[string]struct{}, len(containerMap))
imageIDs := make([]string, 0, len(containerMap))
for _, id := range containerMap {
if id == "" {
continue
}
if _, exists := seen[id]; !exists {
seen[id] = struct{}{}
imageIDs = append(imageIDs, id)
}
}
if len(imageIDs) > 0 {
digestMap, _ := client.ImageRepoDigestMap(ctx, imageIDs) //nolint:nilerr // best-effort
if digestMap != nil {
cc.imageDigest = digestMap
}
}
}
cache[ctxName] = cc
}

if len(out) == 0 {
return "", nil
// Look up the running container's digest for this (stack, service).
allStacks := cfg.GetAllStacks()
stack := allStacks[stackKey]
proj := effectiveProjectName(stack)

if imageID := cc.containerImageID[proj+"|"+service]; imageID != "" {
if digest := cc.imageDigest[imageID]; digest != "" {
return digest, nil
}
}

// Return the first repo digest. The format is "registry/name@sha256:abc...".
// We return just the digest portion for comparison with the remote digest.
// Fallback: stored image digest (for services with no running container).
out, err := client.ImageInspectRepoDigests(ctx, imageRef)
if err != nil || len(out) == 0 {
return "", nil //nolint:nilerr // best-effort
}
for _, rd := range out {
if idx := strings.LastIndex(rd, "@"); idx >= 0 {
return rd[idx+1:], nil
}
}

return "", nil
}
}

// prefetchLocalDigests calls localDigestFn sequentially for every (stack, image)
// pair across all inputs and returns a map keyed by "stackKey|imageRef".
// Keying by stack is required because different stacks may target different
// Docker daemons, so the same image ref can have different local digests
// (or be missing) depending on the daemon. Running exec.Command concurrently
// (especially over SSH contexts) is also unreliable, hence sequential.
// effectiveProjectName returns the Docker Compose project name for a stack.
// When no explicit override is set, Compose defaults to the lowercase basename
// of the working directory.
func effectiveProjectName(stack manifest.Stack) string {
if stack.Project != nil && stack.Project.Name != "" {
return strings.ToLower(stack.Project.Name)
}
return strings.ToLower(filepath.Base(stack.RootAbs))
}

// prefetchLocalDigests calls localDigestFn sequentially for every (stack, service)
// pair across all inputs and returns a map keyed by "stackKey|service".
// Keyed by service (not image ref) because different stacks may use different
// Docker daemons, and the local digest now reflects the running container rather
// than the stored image. Running exec.Command concurrently (especially over SSH
// contexts) is unreliable, hence sequential.
func prefetchLocalDigests(ctx context.Context, inputs []images.CheckInput, fn images.LocalDigestFunc) map[string]string {
out := make(map[string]string)
for _, input := range inputs {
for _, imageRef := range input.Services {
key := input.StackKey + "|" + imageRef
for svcName, imageRef := range input.Services {
key := input.StackKey + "|" + svcName
if _, seen := out[key]; seen {
continue
}
digest, _ := fn(ctx, input.StackKey, imageRef) // best-effort: empty string on failure
digest, _ := fn(ctx, input.StackKey, svcName, imageRef) // best-effort: empty string on failure
out[key] = digest
}
}
Expand Down Expand Up @@ -253,6 +322,8 @@ func renderTerminal(pr ui.Printer, results []images.ImageStatus, showAll bool) {
return
}

pr.Plain("")

// Split into attention-needed and ok.
var attention, ok []images.ImageStatus
for _, r := range results {
Expand All @@ -266,8 +337,8 @@ func renderTerminal(pr ui.Printer, results []images.ImageStatus, showAll bool) {
dimStyle := lipgloss.NewStyle().Faint(true)
headerStyle := lipgloss.NewStyle().Faint(true).Bold(true)

renderTable := func(rows []images.ImageStatus) {
// Compute column widths dynamically.
// computeBaseWidths computes widths for STACK, IMAGE, TAG given rows.
computeBaseWidths := func(rows []images.ImageStatus) (int, int, int) {
wStack, wImage, wTag := len("STACK"), len("IMAGE"), len("TAG")
for _, r := range rows {
stack, image, tag := r.Stack, imageNameWithoutTag(r.Image), r.CurrentTag
Expand All @@ -281,7 +352,75 @@ func renderTerminal(pr ui.Printer, results []images.ImageStatus, showAll bool) {
wTag = len(tag)
}
}
// Column header.
return wStack, wImage, wTag
}

// upgradeCell returns the raw text (without styling) for the UPGRADE column.
upgradeCellRaw := func(r images.ImageStatus) string {
if len(r.NewerTags) > 0 {
return r.NewerTags[0]
}
if !r.HasTagPattern {
return "unknown"
}
return "-"
}

// upgradeCellStyled returns the styled UPGRADE cell for rendering.
upgradeCellStyled := func(r images.ImageStatus, width int) string {
raw := upgradeCellRaw(r)
padded := fmt.Sprintf("%-*s", width, raw)
if len(r.NewerTags) > 0 {
return ui.YellowText(padded)
}
if !r.HasTagPattern {
return dimStyle.Render(padded)
}
return padded
}

renderAttentionTable := func(rows []images.ImageStatus) {
wStack, wImage, wTag := computeBaseWidths(rows)
wUpgrade := len("UPGRADE")
for _, r := range rows {
if r.Error != "" {
continue
}
if l := len(upgradeCellRaw(r)); l > wUpgrade {
wUpgrade = l
}
}
// Header.
pr.Plain(" %s %s %s %s %s",
headerStyle.Render(fmt.Sprintf("%-*s", wStack, "STACK")),
headerStyle.Render(fmt.Sprintf("%-*s", wImage, "IMAGE")),
headerStyle.Render(fmt.Sprintf("%-*s", wTag, "TAG")),
headerStyle.Render(fmt.Sprintf("%-*s", wUpgrade, "UPGRADE")),
headerStyle.Render("DIGEST"),
)
for _, r := range rows {
stack := fmt.Sprintf("%-*s", wStack, r.Stack)
image := fmt.Sprintf("%-*s", wImage, imageNameWithoutTag(r.Image))
tag := fmt.Sprintf("%-*s", wTag, r.CurrentTag)

if r.Error != "" {
pr.Plain(" %s %s %s %s", stack, image, tag, ui.YellowText("! "+r.Error))
continue
}

upgrade := upgradeCellStyled(r, wUpgrade)
var digest string
if r.DigestStale {
digest = ui.YellowText("changed")
} else {
digest = "-"
}
pr.Plain(" %s %s %s %s %s", stack, image, tag, upgrade, digest)
}
}

renderOkTable := func(rows []images.ImageStatus) {
wStack, wImage, wTag := computeBaseWidths(rows)
pr.Plain(" %s %s %s %s",
headerStyle.Render(fmt.Sprintf("%-*s", wStack, "STACK")),
headerStyle.Render(fmt.Sprintf("%-*s", wImage, "IMAGE")),
Expand All @@ -292,14 +431,17 @@ func renderTerminal(pr ui.Printer, results []images.ImageStatus, showAll bool) {
stack := fmt.Sprintf("%-*s", wStack, r.Stack)
image := fmt.Sprintf("%-*s", wImage, imageNameWithoutTag(r.Image))
tag := fmt.Sprintf("%-*s", wTag, r.CurrentTag)
status := statusText(r)
status := ui.GreenText("up to date")
if !r.HasTagPattern {
status += " " + dimStyle.Render("no tag_pattern")
}
pr.Plain(" %s %s %s %s", stack, image, tag, status)
}
}

if len(attention) > 0 {
pr.Plain("%s %d image(s) need attention\n", ui.YellowText("⚠"), len(attention))
renderTable(attention)
renderAttentionTable(attention)
}

if len(ok) > 0 {
Expand All @@ -308,7 +450,26 @@ func renderTerminal(pr ui.Printer, results []images.ImageStatus, showAll bool) {
}
if showAll {
pr.Plain("%s %d image(s) up to date\n", ui.GreenText("✓"), len(ok))
renderTable(ok)
renderOkTable(ok)

hasMissingPattern := false
for _, r := range ok {
if !r.HasTagPattern {
hasMissingPattern = true
break
}
}
if hasMissingPattern {
icon := lipgloss.NewStyle().Foreground(lipgloss.Color("69")).Render("ℹ")
badge := dimStyle.Render(`"no tag_pattern"`)
configPath := lipgloss.NewStyle().Italic(true).
Foreground(lipgloss.AdaptiveColor{Light: "#3478F6", Dark: "#4A9EFF"}).
Render("stacks.<name>.images.tag_pattern")
prefix := dimStyle.Render("Rows marked ")
middle := dimStyle.Render(" are only checked for digest drift. Set ")
suffix := dimStyle.Render(" to track newer tags.")
pr.Plain("\n%s %s%s%s%s%s", icon, prefix, badge, middle, configPath, suffix)
}
} else {
pr.Plain("%s %d image(s) up to date %s",
ui.GreenText("✓"), len(ok), dimStyle.Render("(--all to show)"))
Expand All @@ -327,16 +488,3 @@ func imageNameWithoutTag(image string) string {
return image
}

// statusText returns the human-readable status for an image result.
func statusText(r images.ImageStatus) string {
if r.Error != "" {
return ui.YellowText("! " + r.Error)
}
if len(r.NewerTags) > 0 {
return ui.YellowText("newer: " + strings.Join(r.NewerTags, ", "))
}
if r.DigestStale {
return ui.YellowText("updated upstream")
}
return ui.GreenText("up to date")
}
16 changes: 10 additions & 6 deletions internal/cli/imagescmd/imagescmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,12 @@ func TestRenderTerminal_NewerTagsAvailable(t *testing.T) {
renderTerminal(pr, results, false)

got := stripANSI(buf.String())
if !strings.Contains(got, "newer:") {
t.Errorf("expected 'newer:' in output, got: %q", got)
if !strings.Contains(got, "UPGRADE") {
t.Errorf("expected 'UPGRADE' column in output, got: %q", got)
}
if !strings.Contains(got, "1.26") || !strings.Contains(got, "1.27") {
t.Errorf("expected newer tags in output, got: %q", got)
// Newest tag (first in NewerTags) should appear in UPGRADE column.
if !strings.Contains(got, "1.26") {
t.Errorf("expected newest tag '1.26' in output, got: %q", got)
}
}

Expand All @@ -102,8 +103,11 @@ func TestRenderTerminal_DigestStaleOnly(t *testing.T) {
renderTerminal(pr, results, false)

got := stripANSI(buf.String())
if !strings.Contains(got, "updated upstream") {
t.Errorf("expected 'updated upstream' for digest-stale image, got: %q", got)
if !strings.Contains(got, "changed") {
t.Errorf("expected 'changed' in DIGEST column for digest-stale image, got: %q", got)
}
if !strings.Contains(got, "DIGEST") {
t.Errorf("expected 'DIGEST' column header, got: %q", got)
}
}

Expand Down
1 change: 1 addition & 0 deletions internal/cli/imagescmd/new.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ func New() *cobra.Command {
}
cmd.AddCommand(newCheckCmd())
cmd.AddCommand(newUpgradeCmd())
cmd.AddCommand(newPullCmd())
return cmd
}
Loading
Loading