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
4 changes: 4 additions & 0 deletions internal/cli/common/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}

Expand Down
95 changes: 56 additions & 39 deletions internal/cli/imagescmd/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -136,27 +153,25 @@ 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],
}
}

if len(services) == 0 {
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
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.<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)"))
}
}

// 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.
Expand Down
101 changes: 101 additions & 0 deletions internal/cli/imagescmd/filter.go
Original file line number Diff line number Diff line change
@@ -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()
}
Loading
Loading