diff --git a/internal/cli/common/common.go b/internal/cli/common/common.go index 3eec7a3..bb538f3 100644 --- a/internal/cli/common/common.go +++ b/internal/cli/common/common.go @@ -87,6 +87,10 @@ func DisplayDaemonInfo(pr ui.Printer, cfg *manifest.Config) { label := fmt.Sprintf("%-*s", labelWidth, contextLabel) lines = append(lines, fmt.Sprintf("│ %s %s", labelStyle.Render(label), ui.Italic(contextsValue))) + // Trailing blank line so callers don't have to manage spacing between the + // daemon block and whatever follows (table, error, message). + lines = append(lines, "") + pr.Plain("%s", strings.Join(lines, "\n")) } diff --git a/internal/cli/imagescmd/check.go b/internal/cli/imagescmd/check.go index cfb84b5..5b9245a 100644 --- a/internal/cli/imagescmd/check.go +++ b/internal/cli/imagescmd/check.go @@ -19,11 +19,23 @@ import ( "github.com/spf13/cobra" ) +// tagPatternLabel is the Docker Compose service label that configures tag +// matching for `dockform images check` / `upgrade`. When absent, the service +// is only checked for digest drift. +const tagPatternLabel = "dockform.tag_pattern" + func newCheckCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "check", + Use: "check [service...]", Short: "Check image freshness across compose stacks", - RunE: runCheck, + Long: `Check image freshness across compose stacks. + +With no positional arguments, every service in scope is checked. Pass service +names to narrow the check; combine with --stack to scope those names to a +single stack. A typo or unmatched name fails with an error listing the services +available in scope.`, + Args: cobra.ArbitraryArgs, + RunE: runCheck, } cmd.Flags().Bool("json", false, "Output results as JSON") @@ -35,7 +47,7 @@ func newCheckCmd() *cobra.Command { return cmd } -func runCheck(cmd *cobra.Command, _ []string) error { +func runCheck(cmd *cobra.Command, args []string) error { pr := ui.StdPrinter{Out: cmd.OutOrStdout(), Err: cmd.ErrOrStderr()} // Load configuration with warnings. @@ -68,10 +80,15 @@ func runCheck(cmd *cobra.Command, _ []string) error { } if len(inputs) == 0 { - pr.Plain("\nNo stacks with images found.") + pr.Plain("No stacks with images found.") return nil } + inputs, err = filterInputsByServices(inputs, args) + if err != nil { + return err + } + // 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. @@ -136,10 +153,14 @@ func buildCheckInputs(ctx context.Context, cfg *manifest.Config, factory *docker continue } - services := make(map[string]string, len(doc.Services)) + services := make(map[string]images.ServiceSpec, len(doc.Services)) for svcName, svc := range doc.Services { - if svc.Image != "" { - services[svcName] = svc.Image + if svc.Image == "" { + continue + } + services[svcName] = images.ServiceSpec{ + Image: svc.Image, + TagPattern: svc.Labels[tagPatternLabel], } } @@ -147,16 +168,10 @@ func buildCheckInputs(ctx context.Context, cfg *manifest.Config, factory *docker continue } - input := images.CheckInput{ + inputs = append(inputs, images.CheckInput{ StackKey: stackKey, Services: services, - } - - if stack.Images != nil { - input.TagPattern = stack.Images.TagPattern - } - - inputs = append(inputs, input) + }) } return inputs, nil @@ -271,12 +286,12 @@ func effectiveProjectName(stack manifest.Stack) string { func prefetchLocalDigests(ctx context.Context, inputs []images.CheckInput, fn images.LocalDigestFunc) map[string]string { out := make(map[string]string) for _, input := range inputs { - for svcName, imageRef := range input.Services { + for svcName, spec := range input.Services { key := input.StackKey + "|" + svcName if _, seen := out[key]; seen { continue } - digest, _ := fn(ctx, input.StackKey, svcName, imageRef) // best-effort: empty string on failure + digest, _ := fn(ctx, input.StackKey, svcName, spec.Image) // best-effort: empty string on failure out[key] = digest } } @@ -318,12 +333,10 @@ func renderJSON(cmd *cobra.Command, results []images.ImageStatus) error { func renderTerminal(pr ui.Printer, results []images.ImageStatus, showAll bool) { if len(results) == 0 { - pr.Plain("\nNo images found.") + pr.Plain("No images found.") return } - pr.Plain("") - // Split into attention-needed and ok. var attention, ok []images.ImageStatus for _, r := range results { @@ -451,30 +464,34 @@ 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)) 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)")) } } + + // Footer: explain the "no tag_pattern" badge whenever any image in scope + // is missing a tag_pattern — across both attention and ok tables. + hasMissingPattern := false + for _, r := range results { + if r.Error == "" && !r.HasTagPattern { + hasMissingPattern = true + break + } + } + if hasMissingPattern { + icon := lipgloss.NewStyle().Foreground(lipgloss.Color("69")).Render("ℹ") + badge := dimStyle.Render(`"no tag_pattern"`) + labelName := lipgloss.NewStyle().Italic(true). + Foreground(lipgloss.AdaptiveColor{Light: "#3478F6", Dark: "#4A9EFF"}). + Render(tagPatternLabel) + line1Prefix := dimStyle.Render("Rows marked ") + line1Suffix := dimStyle.Render(" are only checked for digest drift.") + line2Prefix := dimStyle.Render(" Add a ") + line2Suffix := dimStyle.Render(" label to the service to track newer tags.") + pr.Plain("\n%s %s%s%s", icon, line1Prefix, badge, line1Suffix) + pr.Plain("%s%s%s", line2Prefix, labelName, line2Suffix) + } } // imageNameWithoutTag strips the tag from an image reference. diff --git a/internal/cli/imagescmd/filter.go b/internal/cli/imagescmd/filter.go new file mode 100644 index 0000000..e827218 --- /dev/null +++ b/internal/cli/imagescmd/filter.go @@ -0,0 +1,101 @@ +package imagescmd + +import ( + "sort" + "strings" + + "github.com/gcstr/dockform/internal/apperr" + "github.com/gcstr/dockform/internal/images" +) + +// filterInputsByServices narrows the Services map of each CheckInput to only +// those whose names appear in serviceNames. If serviceNames is empty, inputs +// is returned unchanged (callers want everything in scope). +// +// An empty result triggers an apperr.InvalidInput error that lists the +// services available across the inputs — typos like "backp" should fail loud +// rather than silently no-op. +func filterInputsByServices(inputs []images.CheckInput, serviceNames []string) ([]images.CheckInput, error) { + if len(serviceNames) == 0 { + return inputs, nil + } + + wanted := make(map[string]struct{}, len(serviceNames)) + for _, n := range serviceNames { + wanted[n] = struct{}{} + } + + filtered := make([]images.CheckInput, 0, len(inputs)) + matched := 0 + for _, in := range inputs { + kept := make(map[string]images.ServiceSpec) + for svc, spec := range in.Services { + if _, ok := wanted[svc]; ok { + kept[svc] = spec + matched++ + } + } + if len(kept) == 0 { + continue + } + filtered = append(filtered, images.CheckInput{ + StackKey: in.StackKey, + Services: kept, + }) + } + + if matched == 0 { + return nil, apperr.New( + "imagescmd.filterInputsByServices", + apperr.InvalidInput, + "no services matched %s\n\navailable services:\n%s", + formatServiceNames(serviceNames), + formatAvailableServices(inputs), + ) + } + + return filtered, nil +} + +// formatServiceNames renders a list of requested service names as a +// human-readable, quoted, comma-separated list: `"app", "web"`. +func formatServiceNames(names []string) string { + quoted := make([]string, len(names)) + for i, n := range names { + quoted[i] = `"` + n + `"` + } + return strings.Join(quoted, ", ") +} + +// formatAvailableServices lists every service across every input, grouped by +// stack, sorted for deterministic output. +func formatAvailableServices(inputs []images.CheckInput) string { + if len(inputs) == 0 { + return " (none — scope is empty)" + } + + // Sort stacks first for stable output. + sorted := make([]images.CheckInput, len(inputs)) + copy(sorted, inputs) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].StackKey < sorted[j].StackKey + }) + + var b strings.Builder + for i, in := range sorted { + if i > 0 { + b.WriteString("\n") + } + b.WriteString(" ") + b.WriteString(in.StackKey) + b.WriteString(": ") + + names := make([]string, 0, len(in.Services)) + for svc := range in.Services { + names = append(names, svc) + } + sort.Strings(names) + b.WriteString(strings.Join(names, ", ")) + } + return b.String() +} diff --git a/internal/cli/imagescmd/filter_test.go b/internal/cli/imagescmd/filter_test.go new file mode 100644 index 0000000..95d8724 --- /dev/null +++ b/internal/cli/imagescmd/filter_test.go @@ -0,0 +1,144 @@ +package imagescmd + +import ( + "strings" + "testing" + + "github.com/gcstr/dockform/internal/apperr" + "github.com/gcstr/dockform/internal/images" +) + +func sampleInputs() []images.CheckInput { + return []images.CheckInput{ + { + StackKey: "hetzner-one/linkwarden", + Services: map[string]images.ServiceSpec{ + "app": {Image: "linkwarden/linkwarden:v2.10"}, + "postgres": {Image: "postgres:17"}, + "backup": {Image: "offen/docker-volume-backup:v2"}, + }, + }, + { + StackKey: "hetzner-two/bitwarden", + Services: map[string]images.ServiceSpec{ + "app": {Image: "bitwarden/lite:2026.4.0"}, + }, + }, + } +} + +func TestFilterInputsByServices_NoNamesReturnsInputsUnchanged(t *testing.T) { + in := sampleInputs() + out, err := filterInputsByServices(in, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(out) != len(in) { + t.Fatalf("expected %d inputs, got %d", len(in), len(out)) + } +} + +func TestFilterInputsByServices_SingleServiceOneStack(t *testing.T) { + out, err := filterInputsByServices(sampleInputs(), []string{"backup"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(out) != 1 { + t.Fatalf("expected 1 stack, got %d", len(out)) + } + if out[0].StackKey != "hetzner-one/linkwarden" { + t.Errorf("unexpected stack: %s", out[0].StackKey) + } + if _, ok := out[0].Services["backup"]; !ok { + t.Errorf("expected 'backup' in services, got %v", out[0].Services) + } + if len(out[0].Services) != 1 { + t.Errorf("expected exactly one service, got %d", len(out[0].Services)) + } +} + +func TestFilterInputsByServices_ServiceAcrossStacks(t *testing.T) { + out, err := filterInputsByServices(sampleInputs(), []string{"app"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(out) != 2 { + t.Fatalf("expected 2 stacks, got %d", len(out)) + } + for _, in := range out { + if len(in.Services) != 1 { + t.Errorf("stack %s: expected 1 service, got %d", in.StackKey, len(in.Services)) + } + if _, ok := in.Services["app"]; !ok { + t.Errorf("stack %s: expected 'app', got %v", in.StackKey, in.Services) + } + } +} + +func TestFilterInputsByServices_MultipleNamesOR(t *testing.T) { + out, err := filterInputsByServices(sampleInputs(), []string{"backup", "postgres"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(out) != 1 { + t.Fatalf("expected 1 stack (linkwarden), got %d", len(out)) + } + if len(out[0].Services) != 2 { + t.Errorf("expected 2 services, got %v", out[0].Services) + } +} + +func TestFilterInputsByServices_ZeroMatchErrorsWithAvailableList(t *testing.T) { + _, err := filterInputsByServices(sampleInputs(), []string{"backp"}) + if err == nil { + t.Fatal("expected error for zero matches, got nil") + } + if !apperr.IsKind(err, apperr.InvalidInput) { + t.Errorf("expected InvalidInput kind, got: %v", err) + } + msg := err.Error() + if !strings.Contains(msg, `"backp"`) { + t.Errorf("expected requested name in error, got: %s", msg) + } + if !strings.Contains(msg, "app") || !strings.Contains(msg, "backup") { + t.Errorf("expected available services listed, got: %s", msg) + } + if !strings.Contains(msg, "hetzner-one/linkwarden") { + t.Errorf("expected stack key listed, got: %s", msg) + } +} + +func TestFilterInputsByServices_ScopedStackEmpty(t *testing.T) { + // When --stack already narrowed inputs to bitwarden, asking for "backup" + // should fail even though backup exists in another stack (out of scope). + scoped := []images.CheckInput{ + { + StackKey: "hetzner-two/bitwarden", + Services: map[string]images.ServiceSpec{ + "app": {Image: "bitwarden/lite:2026.4.0"}, + }, + }, + } + _, err := filterInputsByServices(scoped, []string{"backup"}) + if err == nil { + t.Fatal("expected zero-match error, got nil") + } + if !strings.Contains(err.Error(), "hetzner-two/bitwarden") { + t.Errorf("expected only in-scope stack listed, got: %s", err.Error()) + } + if strings.Contains(err.Error(), "linkwarden") { + t.Errorf("out-of-scope stack should not appear, got: %s", err.Error()) + } +} + +func TestFilterInputsByServices_PartialMatchKeepsMatched(t *testing.T) { + // "app" matches both stacks; "unknownsvc" matches neither. Should succeed + // (not zero-match) and return only the matched services. + out, err := filterInputsByServices(sampleInputs(), []string{"app", "unknownsvc"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(out) != 2 { + t.Fatalf("expected 2 stacks, got %d", len(out)) + } +} diff --git a/internal/cli/imagescmd/pull.go b/internal/cli/imagescmd/pull.go index 3a94294..93643ef 100644 --- a/internal/cli/imagescmd/pull.go +++ b/internal/cli/imagescmd/pull.go @@ -16,12 +16,18 @@ import ( func newPullCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "pull", + Use: "pull [service...]", 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.`, +Use --recreate to also restart affected containers so they run the new image. + +With no positional arguments, every service in scope is considered. Pass +service names to narrow the pull; combine with --stack to scope those names to +a single stack. A typo or unmatched name fails with an error listing the +services available in scope.`, + Args: cobra.ArbitraryArgs, RunE: runPull, } @@ -33,7 +39,7 @@ Use --recreate to also restart affected containers so they run the new image.`, return cmd } -func runPull(cmd *cobra.Command, _ []string) error { +func runPull(cmd *cobra.Command, args []string) error { pr := ui.StdPrinter{Out: cmd.OutOrStdout(), Err: cmd.ErrOrStderr()} cfg, err := common.LoadConfigWithWarnings(cmd, pr) @@ -60,10 +66,15 @@ func runPull(cmd *cobra.Command, _ []string) error { } if len(inputs) == 0 { - pr.Plain("\nNo stacks with images found.") + pr.Plain("No stacks with images found.") return nil } + inputs, err = filterInputsByServices(inputs, args) + if err != nil { + return err + } + var results []images.ImageStatus err = common.SpinnerOperation(pr, "Checking images...", func() error { localDigests := prefetchLocalDigests(cmd.Context(), inputs, makeLocalDigestFunc(cfg, factory)) @@ -85,7 +96,7 @@ func runPull(cmd *cobra.Command, _ []string) error { } if len(stale) == 0 { - pr.Plain("\n%s All images are current — no digest drift detected.", ui.GreenText("✓")) + pr.Plain("%s All images are current — no digest drift detected.", ui.GreenText("✓")) return nil } @@ -174,7 +185,7 @@ func executePull(ctx context.Context, stale []images.ImageStatus, allStacks map[ } 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)) + pr.Plain("%s %d image(s) with digest drift (dry run)\n", ui.YellowText("⚠"), len(stale)) boldStyle := lipgloss.NewStyle().Bold(true) lastStack := "" @@ -198,8 +209,6 @@ func renderPullDryRun(pr ui.Printer, stale []images.ImageStatus, recreate bool) } func renderPullTerminal(pr ui.Printer, stale []images.ImageStatus, recreate bool) { - pr.Plain("") - boldStyle := lipgloss.NewStyle().Bold(true) lastStack := "" diff --git a/internal/cli/imagescmd/upgrade.go b/internal/cli/imagescmd/upgrade.go index c8b565f..370c1a5 100644 --- a/internal/cli/imagescmd/upgrade.go +++ b/internal/cli/imagescmd/upgrade.go @@ -17,9 +17,16 @@ import ( func newUpgradeCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "upgrade", + Use: "upgrade [service...]", Short: "Upgrade image tags in compose files to the newest available versions", - RunE: runUpgrade, + Long: `Upgrade image tags in compose files to the newest available versions. + +With no positional arguments, every service in scope is considered. Pass +service names to narrow the upgrade; combine with --stack to scope those names +to a single stack. A typo or unmatched name fails with an error listing the +services available in scope.`, + Args: cobra.ArbitraryArgs, + RunE: runUpgrade, } cmd.Flags().Bool("dry-run", false, "Preview changes without writing files") @@ -29,7 +36,7 @@ func newUpgradeCmd() *cobra.Command { return cmd } -func runUpgrade(cmd *cobra.Command, _ []string) error { +func runUpgrade(cmd *cobra.Command, args []string) error { pr := ui.StdPrinter{Out: cmd.OutOrStdout(), Err: cmd.ErrOrStderr()} // Load configuration with warnings. @@ -62,10 +69,15 @@ func runUpgrade(cmd *cobra.Command, _ []string) error { } if len(inputs) == 0 { - pr.Plain("\nNo stacks with images found.") + pr.Plain("No stacks with images found.") return nil } + inputs, err = filterInputsByServices(inputs, args) + if err != nil { + return err + } + // Run the check inside the spinner so the user sees feedback immediately. var results []images.ImageStatus err = common.SpinnerOperation(pr, "Checking images...", func() error { @@ -118,7 +130,7 @@ func buildStackFiles(cfg *manifest.Config) map[string][]string { func renderUpgradeTerminal(pr ui.Printer, results []images.ImageStatus, changes []images.FileChange, stackFiles map[string][]string, dryRun bool) { if len(results) == 0 { - pr.Plain("\nNo images found.") + pr.Plain("No images found.") return } @@ -207,4 +219,15 @@ func renderUpgradeTerminal(pr ui.Printer, results []images.ImageStatus, changes pr.Plain(" %-40s %s already latest", imageRef, ui.GreenText("✓")) } } + + // Footer: remind the user to apply the tag changes. + if len(changes) > 0 && !dryRun { + dim := lipgloss.NewStyle().Faint(true) + cmdStyle := lipgloss.NewStyle().Italic(true). + Foreground(lipgloss.AdaptiveColor{Light: "#3478F6", Dark: "#4A9EFF"}) + pr.Plain("\n%s%s%s", + dim.Render("Run "), + cmdStyle.Render("dockform apply"), + dim.Render(" to publish the changes.")) + } } diff --git a/internal/dockercli/types.go b/internal/dockercli/types.go index c1f853e..5e30323 100644 --- a/internal/dockercli/types.go +++ b/internal/dockercli/types.go @@ -22,6 +22,7 @@ type ComposeService struct { Ports []ComposePort `json:"ports" yaml:"ports"` Networks ComposeServiceNetworks `json:"networks" yaml:"networks"` Volumes []ComposeServiceVolume `json:"volumes" yaml:"volumes"` + Labels map[string]string `json:"labels" yaml:"labels"` } type ComposeServiceVolume struct { diff --git a/internal/images/check.go b/internal/images/check.go index 4c35d54..6a6ec57 100644 --- a/internal/images/check.go +++ b/internal/images/check.go @@ -42,7 +42,8 @@ func Check(ctx context.Context, inputs []CheckInput, reg registry.Registry, loca for i, j := range jobs { go func(idx int, j job) { defer wg.Done() - results[idx] = checkImage(ctx, j.input.StackKey, j.svcName, j.input.Services[j.svcName], j.input.TagPattern, reg, localDigestFn) + spec := j.input.Services[j.svcName] + results[idx] = checkImage(ctx, j.input.StackKey, j.svcName, spec.Image, spec.TagPattern, reg, localDigestFn) }(i, j) } wg.Wait() diff --git a/internal/images/check_test.go b/internal/images/check_test.go index 05815f5..1f65f6a 100644 --- a/internal/images/check_test.go +++ b/internal/images/check_test.go @@ -75,7 +75,7 @@ func TestCheck_DigestMatch(t *testing.T) { inputs := []CheckInput{ { StackKey: "ctx/web", - Services: map[string]string{"web": "nginx:1.25"}, + Services: map[string]ServiceSpec{"web": {Image: "nginx:1.25"}}, }, } @@ -110,7 +110,7 @@ func TestCheck_DigestMismatch(t *testing.T) { inputs := []CheckInput{ { StackKey: "ctx/web", - Services: map[string]string{"web": "nginx:1.25"}, + Services: map[string]ServiceSpec{"web": {Image: "nginx:1.25"}}, }, } @@ -136,9 +136,8 @@ func TestCheck_TagPatternNewerVersions(t *testing.T) { inputs := []CheckInput{ { - StackKey: "ctx/web", - TagPattern: `^\d+\.\d+\.\d+$`, - Services: map[string]string{"web": "nginx:1.25.0"}, + StackKey: "ctx/web", + Services: map[string]ServiceSpec{"web": {Image: "nginx:1.25.0", TagPattern: `^\d+\.\d+\.\d+$`}}, }, } @@ -174,9 +173,8 @@ func TestCheck_TagPatternNoNewerVersions(t *testing.T) { inputs := []CheckInput{ { - StackKey: "ctx/web", - TagPattern: `^\d+\.\d+\.\d+$`, - Services: map[string]string{"web": "nginx:2.0.0"}, + StackKey: "ctx/web", + Services: map[string]ServiceSpec{"web": {Image: "nginx:2.0.0", TagPattern: `^\d+\.\d+\.\d+$`}}, }, } @@ -205,9 +203,8 @@ func TestCheck_CurrentTagNotSemver(t *testing.T) { inputs := []CheckInput{ { - StackKey: "ctx/web", - TagPattern: `^\d+\.\d+\.\d+$`, - Services: map[string]string{"web": "nginx:latest"}, + StackKey: "ctx/web", + Services: map[string]ServiceSpec{"web": {Image: "nginx:latest", TagPattern: `^\d+\.\d+\.\d+$`}}, }, } @@ -244,9 +241,9 @@ func TestCheck_TagPatternFilters(t *testing.T) { inputs := []CheckInput{ { - StackKey: "ctx/web", - TagPattern: `^\d+\.\d+\.\d+$`, // Strict semver only, no suffixes - Services: map[string]string{"web": "nginx:1.25.0"}, + StackKey: "ctx/web", + // Strict semver only, no suffixes + Services: map[string]ServiceSpec{"web": {Image: "nginx:1.25.0", TagPattern: `^\d+\.\d+\.\d+$`}}, }, } @@ -271,6 +268,79 @@ func TestCheck_TagPatternFilters(t *testing.T) { } } +// TestCheck_PerServicePatternsSameStack verifies that two services in the same +// stack can have independent tag patterns (or no pattern at all). This is the +// core capability unlocked by moving tag_pattern from stack-level config to +// per-service `dockform.tag_pattern` labels. +func TestCheck_PerServicePatternsSameStack(t *testing.T) { + reg := newMockRegistry() + // App: semver tags available, newer version exists. + reg.setDigest("library/app", "1.0.0", "sha256:app-remote") + reg.tags["library/app"] = []string{"1.0.0", "1.0.1", "1.1.0"} + // Backup: v-prefixed tags, newer version exists. + reg.setDigest("library/backup", "v2.0.0", "sha256:backup-remote") + reg.tags["library/backup"] = []string{"v2.0.0", "v2.1.0"} + // DB: no pattern; digest still checked and matches. + reg.setDigest("library/db", "16-alpine", "sha256:db-remote") + + localFn := mockLocalDigest(map[string]string{ + "app:1.0.0": "sha256:app-remote", + "backup:v2.0.0": "sha256:backup-remote", + "db:16-alpine": "sha256:db-remote", + }) + + inputs := []CheckInput{ + { + StackKey: "default/stack", + Services: map[string]ServiceSpec{ + "app": {Image: "app:1.0.0", TagPattern: `^\d+\.\d+\.\d+$`}, + "backup": {Image: "backup:v2.0.0", TagPattern: `^v\d+\.\d+\.\d+$`}, + "db": {Image: "db:16-alpine"}, // no pattern + }, + }, + } + + results, err := Check(context.Background(), inputs, reg, localFn) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(results) != 3 { + t.Fatalf("expected 3 results, got %d", len(results)) + } + + byService := make(map[string]ImageStatus, len(results)) + for _, r := range results { + byService[r.Service] = r + } + + app := byService["app"] + if !app.HasTagPattern { + t.Error("app: expected HasTagPattern=true") + } + if len(app.NewerTags) == 0 || app.NewerTags[0] != "1.1.0" { + t.Errorf("app: expected newer tag 1.1.0, got %v", app.NewerTags) + } + + backup := byService["backup"] + if !backup.HasTagPattern { + t.Error("backup: expected HasTagPattern=true") + } + if len(backup.NewerTags) == 0 || backup.NewerTags[0] != "v2.1.0" { + t.Errorf("backup: expected newer tag v2.1.0, got %v", backup.NewerTags) + } + + db := byService["db"] + if db.HasTagPattern { + t.Error("db: expected HasTagPattern=false when no pattern configured") + } + if len(db.NewerTags) != 0 { + t.Errorf("db: expected no newer tags without pattern, got %v", db.NewerTags) + } + if db.DigestStale { + t.Error("db: expected DigestStale=false (digest matches)") + } +} + func TestCheck_ImageParseError(t *testing.T) { reg := newMockRegistry() localFn := mockLocalDigest(map[string]string{}) @@ -278,7 +348,7 @@ func TestCheck_ImageParseError(t *testing.T) { inputs := []CheckInput{ { StackKey: "ctx/web", - Services: map[string]string{"bad": ""}, + Services: map[string]ServiceSpec{"bad": {Image: ""}}, }, } @@ -304,9 +374,9 @@ func TestCheck_RegistryError(t *testing.T) { inputs := []CheckInput{ { StackKey: "ctx/web", - Services: map[string]string{ - "web": "nginx:1.25", - "proxy": "nginx:1.25", + Services: map[string]ServiceSpec{ + "web": {Image: "nginx:1.25"}, + "proxy": {Image: "nginx:1.25"}, }, }, } @@ -337,13 +407,12 @@ func TestCheck_MultipleStacksMixedResults(t *testing.T) { inputs := []CheckInput{ { - StackKey: "ctx1/web", - TagPattern: `^\d+\.\d+\.\d+$`, - Services: map[string]string{"web": "nginx:1.25.0"}, + StackKey: "ctx1/web", + Services: map[string]ServiceSpec{"web": {Image: "nginx:1.25.0", TagPattern: `^\d+\.\d+\.\d+$`}}, }, { StackKey: "ctx2/cache", - Services: map[string]string{"cache": "redis:7.0"}, + Services: map[string]ServiceSpec{"cache": {Image: "redis:7.0"}}, }, } @@ -392,7 +461,7 @@ func TestCheck_LocalDigestError(t *testing.T) { inputs := []CheckInput{ { StackKey: "ctx/web", - Services: map[string]string{"web": "nginx:1.25"}, + Services: map[string]ServiceSpec{"web": {Image: "nginx:1.25"}}, }, } @@ -421,9 +490,8 @@ func TestCheck_ListTagsError(t *testing.T) { inputs := []CheckInput{ { - StackKey: "ctx/web", - TagPattern: `^\d+\.\d+\.\d+$`, - Services: map[string]string{"web": "nginx:1.25.0"}, + StackKey: "ctx/web", + Services: map[string]ServiceSpec{"web": {Image: "nginx:1.25.0", TagPattern: `^\d+\.\d+\.\d+$`}}, }, } diff --git a/internal/images/types.go b/internal/images/types.go index 3bc93fd..ac68ee6 100644 --- a/internal/images/types.go +++ b/internal/images/types.go @@ -10,15 +10,22 @@ type ImageStatus struct { 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 + HasTagPattern bool // True if a dockform.tag_pattern label is set on this service Error string // Non-empty if check failed for this image } // CheckInput bundles everything needed to check images for a stack. type CheckInput struct { - StackKey string // Stack key (e.g., "hetzner/traefik") - TagPattern string // From stack.Images.TagPattern, empty means digest-only - Services map[string]string // service name -> image reference (from ComposeConfigFull) + StackKey string // Stack key (e.g., "hetzner/traefik") + Services map[string]ServiceSpec // service name -> image + per-service tag pattern +} + +// ServiceSpec holds the image reference and optional tag pattern for a single +// service within a stack. The tag pattern is read from the service's +// `dockform.tag_pattern` compose label; empty means digest-only. +type ServiceSpec struct { + Image string + TagPattern string } // LocalDigestFunc returns the local digest for an image reference on the diff --git a/internal/manifest/types.go b/internal/manifest/types.go index 2482174..73241b5 100644 --- a/internal/manifest/types.go +++ b/internal/manifest/types.go @@ -90,11 +90,6 @@ func (d DiscoveryConfig) GetVolumesDir() string { return d.VolumesDir } -// ImagesConfig configures image update checking for a stack. -type ImagesConfig struct { - TagPattern string `yaml:"tag_pattern"` -} - // Stack defines a Docker Compose stack to manage. // Stacks are discovered automatically from context directories. // The stacks: block can augment discovered stacks or define explicit stacks. @@ -110,7 +105,6 @@ type Stack struct { Secrets *Secrets `yaml:"secrets"` // Additional SOPS secrets Project *Project `yaml:"project"` // Compose project name override Filesets map[string]FilesetSpec `yaml:"filesets"` // Fileset overrides/declarations - Images *ImagesConfig `yaml:"images"` // Image update checking config // Computed fields Context string `yaml:"-"` // Which context this belongs to (from key prefix) @@ -308,9 +302,6 @@ func (c *Config) GetAllStacks() map[string]Stack { if v.Project != nil { merged.Project = v.Project } - if v.Images != nil { - merged.Images = v.Images - } result[k] = merged } else { // No discovered stack: use explicit stack as fallback diff --git a/internal/manifest/validation_test.go b/internal/manifest/validation_test.go index 547ec83..a96eb27 100644 --- a/internal/manifest/validation_test.go +++ b/internal/manifest/validation_test.go @@ -699,35 +699,6 @@ func TestGetAllStacks(t *testing.T) { } } -func TestGetAllStacks_ImagesMerged(t *testing.T) { - cfg := Config{ - Stacks: map[string]Stack{ - "default/web": { - Images: &ImagesConfig{TagPattern: `^\d+\.\d+\.\d+$`}, - }, - }, - DiscoveredStacks: map[string]Stack{ - "default/web": {Root: "/discovered/web"}, - }, - } - - all := cfg.GetAllStacks() - web, ok := all["default/web"] - if !ok { - t.Fatal("expected default/web in result") - } - if web.Images == nil { - t.Fatal("expected Images to be merged from explicit stack, got nil") - } - if web.Images.TagPattern != `^\d+\.\d+\.\d+$` { - t.Errorf("TagPattern: want %q, got %q", `^\d+\.\d+\.\d+$`, web.Images.TagPattern) - } - // Discovery still wins for core fields. - if web.Root != "/discovered/web" { - t.Errorf("Root: want /discovered/web, got %q", web.Root) - } -} - func TestGetStacksForDaemon(t *testing.T) { cfg := Config{ Stacks: map[string]Stack{