From 90a62f3a5b24670ae884294e4c17efff84780bf4 Mon Sep 17 00:00:00 2001 From: Gustavo Castro Date: Fri, 17 Apr 2026 18:27:02 +0200 Subject: [PATCH 1/2] add images pull subcommand; compare running container digest vs remote --- internal/cli/imagescmd/check.go | 133 +++++++++++++----- internal/cli/imagescmd/new.go | 1 + internal/cli/imagescmd/pull.go | 224 ++++++++++++++++++++++++++++++ internal/cli/imagescmd/upgrade.go | 10 +- internal/dockercli/compose.go | 11 ++ internal/dockercli/info.go | 66 +++++++++ internal/images/check.go | 2 +- internal/images/check_test.go | 6 +- internal/images/types.go | 7 +- 9 files changed, 417 insertions(+), 43 deletions(-) create mode 100644 internal/cli/imagescmd/pull.go diff --git a/internal/cli/imagescmd/check.go b/internal/cli/imagescmd/check.go index c44c9c1..b7ca477 100644 --- a/internal/cli/imagescmd/check.go +++ b/internal/cli/imagescmd/check.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "path/filepath" "sort" "strings" @@ -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 }) @@ -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 } } @@ -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 { diff --git a/internal/cli/imagescmd/new.go b/internal/cli/imagescmd/new.go index 804c1d1..c91a3a0 100644 --- a/internal/cli/imagescmd/new.go +++ b/internal/cli/imagescmd/new.go @@ -10,5 +10,6 @@ func New() *cobra.Command { } cmd.AddCommand(newCheckCmd()) cmd.AddCommand(newUpgradeCmd()) + cmd.AddCommand(newPullCmd()) return cmd } diff --git a/internal/cli/imagescmd/pull.go b/internal/cli/imagescmd/pull.go new file mode 100644 index 0000000..3a94294 --- /dev/null +++ b/internal/cli/imagescmd/pull.go @@ -0,0 +1,224 @@ +package imagescmd + +import ( + "context" + "sort" + + "github.com/charmbracelet/lipgloss" + "github.com/gcstr/dockform/internal/cli/common" + "github.com/gcstr/dockform/internal/dockercli" + "github.com/gcstr/dockform/internal/images" + "github.com/gcstr/dockform/internal/manifest" + "github.com/gcstr/dockform/internal/registry" + "github.com/gcstr/dockform/internal/ui" + "github.com/spf13/cobra" +) + +func newPullCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "pull", + Short: "Pull images whose remote digest has changed (same tag, new content)", + Long: `Pull images where the remote digest differs from the local copy. + +This updates images on the remote Docker daemon without modifying compose files. +Use --recreate to also restart affected containers so they run the new image.`, + RunE: runPull, + } + + cmd.Flags().Bool("recreate", false, "Recreate containers after pulling to apply the new image") + cmd.Flags().Bool("dry-run", false, "Show what would be pulled without making any changes") + + common.AddTargetFlags(cmd) + + return cmd +} + +func runPull(cmd *cobra.Command, _ []string) error { + pr := ui.StdPrinter{Out: cmd.OutOrStdout(), Err: cmd.ErrOrStderr()} + + cfg, err := common.LoadConfigWithWarnings(cmd, pr) + if err != nil { + return err + } + + opts := common.ReadTargetOptions(cmd) + if !opts.IsEmpty() { + cfg, err = common.ResolveTargets(cfg, opts) + if err != nil { + return err + } + } + + common.DisplayDaemonInfo(pr, cfg) + + factory := common.CreateClientFactory() + reg := registry.NewOCIClient(nil) + + inputs, err := buildCheckInputs(cmd.Context(), cfg, factory) + if err != nil { + return err + } + + if len(inputs) == 0 { + pr.Plain("\nNo stacks with images found.") + return nil + } + + var results []images.ImageStatus + err = common.SpinnerOperation(pr, "Checking images...", func() error { + 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 + }) + if err != nil { + return err + } + + // Only care about same-tag digest drift: DigestStale, no newer tags, no error. + var stale []images.ImageStatus + for _, r := range results { + if r.DigestStale && len(r.NewerTags) == 0 && r.Error == "" { + stale = append(stale, r) + } + } + + if len(stale) == 0 { + pr.Plain("\n%s All images are current — no digest drift detected.", ui.GreenText("✓")) + return nil + } + + dryRun, _ := cmd.Flags().GetBool("dry-run") + recreate, _ := cmd.Flags().GetBool("recreate") + + if dryRun { + renderPullDryRun(pr, stale, recreate) + return nil + } + + allStacks := cfg.GetAllStacks() + + err = common.SpinnerOperation(pr, "Pulling images...", func() error { + return executePull(cmd.Context(), stale, allStacks, factory, cfg, recreate) + }) + if err != nil { + return err + } + + renderPullTerminal(pr, stale, recreate) + return nil +} + +// stackPullGroup aggregates stale images that belong to the same stack. +type stackPullGroup struct { + stackKey string + stack manifest.Stack + services []string + statuses []images.ImageStatus +} + +func groupByStackForPull(stale []images.ImageStatus, allStacks map[string]manifest.Stack) []stackPullGroup { + index := make(map[string]*stackPullGroup) + var order []string + + for _, r := range stale { + if _, ok := index[r.Stack]; !ok { + stack := allStacks[r.Stack] + index[r.Stack] = &stackPullGroup{ + stackKey: r.Stack, + stack: stack, + } + order = append(order, r.Stack) + } + g := index[r.Stack] + g.services = append(g.services, r.Service) + g.statuses = append(g.statuses, r) + } + + sort.Strings(order) + groups := make([]stackPullGroup, 0, len(order)) + for _, k := range order { + groups = append(groups, *index[k]) + } + return groups +} + +func executePull(ctx context.Context, stale []images.ImageStatus, allStacks map[string]manifest.Stack, factory *dockercli.DefaultClientFactory, cfg *manifest.Config, recreate bool) error { + groups := groupByStackForPull(stale, allStacks) + + for _, g := range groups { + ctxName, _, err := manifest.ParseStackKey(g.stackKey) + if err != nil { + return err + } + client := factory.GetClientForContext(ctxName, cfg) + + projName := "" + if g.stack.Project != nil { + projName = g.stack.Project.Name + } + + if _, err := client.ComposePull(ctx, g.stack.RootAbs, g.stack.Files, g.stack.Profiles, g.stack.EnvFile, projName, g.services, g.stack.EnvInline); err != nil { + return err + } + + if recreate { + if _, err := client.ComposeUp(ctx, g.stack.RootAbs, g.stack.Files, g.stack.Profiles, g.stack.EnvFile, projName, g.stack.EnvInline); err != nil { + return err + } + } + } + + return nil +} + +func renderPullDryRun(pr ui.Printer, stale []images.ImageStatus, recreate bool) { + pr.Plain("\n%s %d image(s) with digest drift (dry run)\n", ui.YellowText("⚠"), len(stale)) + + boldStyle := lipgloss.NewStyle().Bold(true) + lastStack := "" + + for _, r := range stale { + if r.Stack != lastStack { + if lastStack != "" { + pr.Plain("") + } + pr.Plain("%s", boldStyle.Render(r.Stack)) + lastStack = r.Stack + } + pr.Plain(" %s %s", ui.YellowText("→"), r.Image) + } + + if recreate { + pr.Plain("\nContainers would be recreated after pull.") + } else { + pr.Plain("\nPass --recreate to restart containers with the new images.") + } +} + +func renderPullTerminal(pr ui.Printer, stale []images.ImageStatus, recreate bool) { + pr.Plain("") + + boldStyle := lipgloss.NewStyle().Bold(true) + lastStack := "" + + for _, r := range stale { + if r.Stack != lastStack { + if lastStack != "" { + pr.Plain("") + } + pr.Plain("%s", boldStyle.Render(r.Stack)) + lastStack = r.Stack + } + pr.Plain(" %s %s", ui.GreenText("✓"), r.Image) + } + + pr.Plain("") + if recreate { + pr.Plain("%s %d image(s) pulled and containers recreated.", ui.GreenText("✓"), len(stale)) + } else { + pr.Plain("%s %d image(s) pulled.", ui.GreenText("✓"), len(stale)) + pr.Plain("%s Pass --recreate to restart containers with the new images.", ui.YellowText("→")) + } +} diff --git a/internal/cli/imagescmd/upgrade.go b/internal/cli/imagescmd/upgrade.go index 9c9e230..c8b565f 100644 --- a/internal/cli/imagescmd/upgrade.go +++ b/internal/cli/imagescmd/upgrade.go @@ -66,14 +66,12 @@ func runUpgrade(cmd *cobra.Command, _ []string) error { return nil } - // Pre-fetch local digests sequentially before parallel registry checks. - localDigests := prefetchLocalDigests(cmd.Context(), inputs, makeLocalDigestFunc(cfg, factory)) - - // Run the check. + // Run the check inside the spinner so the user sees feedback immediately. 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 + 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 }) diff --git a/internal/dockercli/compose.go b/internal/dockercli/compose.go index 4efb007..1b24283 100644 --- a/internal/dockercli/compose.go +++ b/internal/dockercli/compose.go @@ -39,6 +39,17 @@ func (c *Client) ComposeUp(ctx context.Context, workingDir string, files, profil return c.runInDirOptionalEnv(ctx, workingDir, inlineEnv, args...) } +// ComposePull runs `docker compose pull [services...]` using the given compose +// configuration. When services is empty, compose pulls images for every +// service in the project. The returned string is the raw stdout of the +// command (typically empty on success for modern compose versions). +func (c *Client) ComposePull(ctx context.Context, workingDir string, files, profiles, envFiles []string, projectName string, services []string, inlineEnv []string) (string, error) { + args := c.composeBaseArgs(files, profiles, envFiles, projectName) + args = append(args, "pull") + args = append(args, services...) + return c.runInDirOptionalEnv(ctx, workingDir, inlineEnv, args...) +} + // ComposeConfigServices returns the list of service names that would be part of the project. func (c *Client) ComposeConfigServices(ctx context.Context, workingDir string, files, profiles, envFiles []string, inlineEnv []string) ([]string, error) { args := c.composeBaseArgs(files, profiles, envFiles, "") diff --git a/internal/dockercli/info.go b/internal/dockercli/info.go index 7eaed7e..abfb8b8 100644 --- a/internal/dockercli/info.go +++ b/internal/dockercli/info.go @@ -69,6 +69,72 @@ func (c *Client) ImageInspectRepoDigests(ctx context.Context, imageRef string) ( return digests, nil } +// ComposeContainerImageMap returns a map of "project|service" → full image ID +// (sha256:…) for every running compose container on the daemon. +// A single docker ps call is used so cost is constant regardless of container count. +// Best-effort: returns nil on failure. +func (c *Client) ComposeContainerImageMap(ctx context.Context) (map[string]string, error) { + out, err := c.exec.Run(ctx, + "ps", + "--no-trunc", + "--filter", "label=com.docker.compose.service", + "--format", `{{.Label "com.docker.compose.project"}}|{{.Label "com.docker.compose.service"}}|{{.ImageID}}`, + ) + if err != nil { + return nil, err + } + result := make(map[string]string) + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, "|", 3) + if len(parts) != 3 || parts[0] == "" || parts[1] == "" { + continue + } + result[parts[0]+"|"+parts[1]] = parts[2] + } + return result, nil +} + +// ImageRepoDigestMap returns a map of image ID → repo digest (sha256:…) for +// each of the given image IDs. A single docker image inspect call is issued +// for all IDs. Images not found locally are silently omitted. +func (c *Client) ImageRepoDigestMap(ctx context.Context, imageIDs []string) (map[string]string, error) { + if len(imageIDs) == 0 { + return make(map[string]string), nil + } + args := append([]string{"image", "inspect", "--format", `{{.Id}}|{{json .RepoDigests}}`}, imageIDs...) + out, err := c.exec.Run(ctx, args...) + if err != nil { + return nil, err + } + result := make(map[string]string, len(imageIDs)) + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + sep := strings.Index(line, "|") + if sep < 0 { + continue + } + id := line[:sep] + var digests []string + if jsonErr := json.Unmarshal([]byte(line[sep+1:]), &digests); jsonErr != nil { + continue + } + for _, rd := range digests { + if idx := strings.LastIndex(rd, "@"); idx >= 0 { + result[id] = rd[idx+1:] + break + } + } + } + return result, nil +} + // ImageExists returns true if the given image is present locally in the configured context. func (c *Client) ImageExists(ctx context.Context, imageRef string) (bool, error) { if strings.TrimSpace(imageRef) == "" { diff --git a/internal/images/check.go b/internal/images/check.go index 2d1084e..1c188cf 100644 --- a/internal/images/check.go +++ b/internal/images/check.go @@ -76,7 +76,7 @@ func checkImage( return status } - localDigest, err := localDigestFn(ctx, stackKey, imageStr) + localDigest, err := localDigestFn(ctx, stackKey, svcName, imageStr) if err != nil { status.Error = err.Error() return status diff --git a/internal/images/check_test.go b/internal/images/check_test.go index 4c56a8b..05815f5 100644 --- a/internal/images/check_test.go +++ b/internal/images/check_test.go @@ -54,9 +54,9 @@ func (m *mockRegistry) setDigest(fullName, tag, digest string) { m.digests[fullName][tag] = digest } -// mockLocalDigest returns a LocalDigestFunc backed by a simple map. +// mockLocalDigest returns a LocalDigestFunc backed by a simple map keyed by imageRef. func mockLocalDigest(digests map[string]string) LocalDigestFunc { - return func(_ context.Context, _ string, imageRef string) (string, error) { + return func(_ context.Context, _, _ string, imageRef string) (string, error) { if d, ok := digests[imageRef]; ok { return d, nil } @@ -385,7 +385,7 @@ func TestCheck_LocalDigestError(t *testing.T) { reg := newMockRegistry() reg.setDigest("library/nginx", "1.25", "sha256:remote") - localFn := func(_ context.Context, _ string, _ string) (string, error) { + localFn := func(_ context.Context, _, _, _ string) (string, error) { return "", fmt.Errorf("image not pulled locally") } diff --git a/internal/images/types.go b/internal/images/types.go index cac1342..da8e441 100644 --- a/internal/images/types.go +++ b/internal/images/types.go @@ -21,9 +21,12 @@ type CheckInput struct { } // LocalDigestFunc returns the local digest for an image reference on the -// Docker daemon associated with the given stack. +// Docker daemon associated with the given stack and service. +// Implementations should prefer the digest of the running container (so that +// a pulled-but-not-recreated container still appears stale), falling back to +// the stored image digest when no container is running. // This is injected to avoid coupling to the docker CLI directly. -type LocalDigestFunc func(ctx context.Context, stackKey, imageRef string) (string, error) +type LocalDigestFunc func(ctx context.Context, stackKey, service, imageRef string) (string, error) // FileChange represents a tag rewrite in a compose file. type FileChange struct { From e505a01e41ca9bde6100ea48fd0030e19fe68479 Mon Sep 17 00:00:00 2001 From: Gustavo Castro Date: Fri, 17 Apr 2026 19:14:30 +0200 Subject: [PATCH 2/2] split images check status into UPGRADE and DIGEST columns --- internal/cli/imagescmd/check.go | 115 +++++++++++++++++++---- internal/cli/imagescmd/imagescmd_test.go | 16 ++-- internal/images/check.go | 7 +- internal/images/types.go | 15 +-- 4 files changed, 118 insertions(+), 35 deletions(-) diff --git a/internal/cli/imagescmd/check.go b/internal/cli/imagescmd/check.go index b7ca477..cfb84b5 100644 --- a/internal/cli/imagescmd/check.go +++ b/internal/cli/imagescmd/check.go @@ -337,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 @@ -352,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")), @@ -363,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 { @@ -379,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..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)")) @@ -398,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") -} diff --git a/internal/cli/imagescmd/imagescmd_test.go b/internal/cli/imagescmd/imagescmd_test.go index 977acb6..28a4f6a 100644 --- a/internal/cli/imagescmd/imagescmd_test.go +++ b/internal/cli/imagescmd/imagescmd_test.go @@ -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) } } @@ -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) } } diff --git a/internal/images/check.go b/internal/images/check.go index 1c188cf..4c35d54 100644 --- a/internal/images/check.go +++ b/internal/images/check.go @@ -57,9 +57,10 @@ func checkImage( localDigestFn LocalDigestFunc, ) ImageStatus { status := ImageStatus{ - Stack: stackKey, - Service: svcName, - Image: imageStr, + Stack: stackKey, + Service: svcName, + Image: imageStr, + HasTagPattern: tagPattern != "", } ref, err := registry.ParseImageRef(imageStr) diff --git a/internal/images/types.go b/internal/images/types.go index da8e441..3bc93fd 100644 --- a/internal/images/types.go +++ b/internal/images/types.go @@ -4,13 +4,14 @@ import "context" // ImageStatus represents the check result for a single image. type ImageStatus struct { - Stack string // Stack key (e.g., "hetzner/traefik") - Service string // Service name within the compose file - Image string // Full image reference as written in compose - CurrentTag string // Current tag - DigestStale bool // True if remote digest differs from local - NewerTags []string // Newer semver tags (empty if no tag_pattern or no newer tags) - Error string // Non-empty if check failed for this image + Stack string // Stack key (e.g., "hetzner/traefik") + Service string // Service name within the compose file + Image string // Full image reference as written in compose + CurrentTag string // Current tag + DigestStale bool // True if remote digest differs from local + NewerTags []string // Newer semver tags (empty if no tag_pattern or no newer tags) + HasTagPattern bool // True if a tag_pattern is configured for the stack + Error string // Non-empty if check failed for this image } // CheckInput bundles everything needed to check images for a stack.