diff --git a/tooling/image-updater/go.mod b/tooling/image-updater/go.mod index 457ca187d3..706ccf630a 100644 --- a/tooling/image-updater/go.mod +++ b/tooling/image-updater/go.mod @@ -10,6 +10,7 @@ require ( github.com/dusted-go/logging v1.3.0 github.com/go-logr/logr v1.4.3 github.com/google/go-containerregistry v0.20.6 + github.com/jedib0t/go-pretty/v6 v6.6.7 github.com/spf13/cobra v1.10.1 golang.org/x/mod v0.30.0 gopkg.in/yaml.v3 v3.0.1 @@ -31,12 +32,14 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/compress v1.18.1 // indirect github.com/kylelemons/godebug v1.1.0 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.10 // indirect diff --git a/tooling/image-updater/go.sum b/tooling/image-updater/go.sum index edfdc87e1d..9761c6c137 100644 --- a/tooling/image-updater/go.sum +++ b/tooling/image-updater/go.sum @@ -45,6 +45,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jedib0t/go-pretty/v6 v6.6.7 h1:m+LbHpm0aIAPLzLbMfn8dc3Ht8MW7lsSO4MPItz/Uuo= +github.com/jedib0t/go-pretty/v6 v6.6.7/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= @@ -55,6 +57,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -68,6 +72,9 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/tooling/image-updater/internal/options/options.go b/tooling/image-updater/internal/options/options.go index 6bc4b7461a..6f881ced29 100644 --- a/tooling/image-updater/internal/options/options.go +++ b/tooling/image-updater/internal/options/options.go @@ -34,6 +34,8 @@ type RawUpdateOptions struct { ForceUpdate bool Components string ExcludeComponents string + OutputFile string + OutputFormat string } // ValidatedUpdateOptions contains validated configuration and inputs @@ -48,7 +50,9 @@ type validatedUpdateOptions struct { // DefaultUpdateOptions returns a new RawUpdateOptions with defaults func DefaultUpdateOptions() *RawUpdateOptions { - return &RawUpdateOptions{} + return &RawUpdateOptions{ + OutputFormat: "table", + } } // BindUpdateOptions binds command-line flags to the raw options @@ -58,6 +62,8 @@ func BindUpdateOptions(opts *RawUpdateOptions, cmd *cobra.Command) error { cmd.Flags().BoolVar(&opts.ForceUpdate, "force", false, "Force update even if digests match (useful for regenerating version tag comments)") cmd.Flags().StringVar(&opts.Components, "components", "", "Update only specified components (comma-separated, e.g., 'maestro,arohcpfrontend'). If not specified, all components will be updated") cmd.Flags().StringVar(&opts.ExcludeComponents, "exclude-components", "", "Exclude specified components from update (comma-separated, e.g., 'arohcpfrontend,arohcpbackend'). Ignored if --components is specified") + cmd.Flags().StringVar(&opts.OutputFile, "output-file", "", "Write update results to specified file instead of stdout") + cmd.Flags().StringVar(&opts.OutputFormat, "output-format", "table", "Output format: table, markdown, or json (default: table)") if err := cmd.MarkFlagRequired("config"); err != nil { return err @@ -77,6 +83,24 @@ func (o *RawUpdateOptions) Validate(ctx context.Context) (*ValidatedUpdateOption return nil, fmt.Errorf("invalid configuration: %w", err) } + // Set default output format if not specified + if o.OutputFormat == "" { + o.OutputFormat = "table" + } + + // Validate output format + validFormats := []string{"table", "markdown", "json"} + isValidFormat := false + for _, format := range validFormats { + if o.OutputFormat == format { + isValidFormat = true + break + } + } + if !isValidFormat { + return nil, fmt.Errorf("invalid output format '%s': must be one of: %s", o.OutputFormat, strings.Join(validFormats, ", ")) + } + // --components takes precedence over --exclude-components if o.Components != "" { components := strings.Split(o.Components, ",") @@ -171,7 +195,7 @@ func (v *ValidatedUpdateOptions) Complete(ctx context.Context) (*updater.Updater } } - return updater.New(v.Config, v.DryRun, v.ForceUpdate, registryClients, yamlEditors), nil + return updater.New(v.Config, v.DryRun, v.ForceUpdate, registryClients, yamlEditors, v.OutputFile, v.OutputFormat), nil } // validateConfig ensures the configuration is complete and valid diff --git a/tooling/image-updater/internal/output/format_test.go b/tooling/image-updater/internal/output/format_test.go new file mode 100644 index 0000000000..f5d51b03c4 --- /dev/null +++ b/tooling/image-updater/internal/output/format_test.go @@ -0,0 +1,425 @@ +// Copyright 2025 Microsoft Corporation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package output + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/Azure/ARO-HCP/tooling/image-updater/internal/yaml" +) + +// TestFormatResults tests the main formatting function with various inputs and formats +func TestFormatResults(t *testing.T) { + tests := []struct { + name string + updates map[string][]yaml.Update + format string + dryRun bool + wantErr bool + wantEmpty bool + wantContains []string + }{ + { + name: "table format with updates", + updates: map[string][]yaml.Update{ + "config.yaml": { + { + Name: "frontend", + OldDigest: "sha256:abc123def456789", + NewDigest: "sha256:xyz789012345678", + Tag: "v1.2.3", + Date: "2025-12-17 10:30", + }, + }, + }, + format: "table", + dryRun: false, + wantErr: false, + wantEmpty: false, + wantContains: []string{ + "NAME", + "OLD DIGEST", + "NEW DIGEST", + "TAG", + "DATE", + "STATUS", + "frontend", + "abc123def456", + "xyz789012345", + "v1.2.3", + "2025-12-17 10:30", + "updated", + }, + }, + { + name: "markdown format with updates", + updates: map[string][]yaml.Update{ + "config.yaml": { + { + Name: "backend", + OldDigest: "sha256:old123456789", + NewDigest: "sha256:new987654321", + Tag: "v2.0.0", + Date: "2025-12-17 14:45", + }, + }, + }, + format: "markdown", + dryRun: false, + wantErr: false, + wantEmpty: false, + wantContains: []string{ + "| Name | Old Digest | New Digest | Tag | Date | Status |", + "| --- | --- | --- | --- | --- | --- |", + "| backend |", + "old123456789", + "new987654321", + "v2.0.0", + "2025-12-17 14:45", + "updated", + }, + }, + { + name: "json format with updates", + updates: map[string][]yaml.Update{ + "config.yaml": { + { + Name: "service", + OldDigest: "sha256:olddigest", + NewDigest: "sha256:newdigest", + Tag: "v3.0.0", + Date: "2025-12-17 16:00", + }, + }, + }, + format: "json", + dryRun: false, + wantErr: false, + wantEmpty: false, + wantContains: []string{ + `"name": "service"`, + `"old_digest": "sha256:olddigest"`, + `"new_digest": "sha256:newdigest"`, + `"tag": "v3.0.0"`, + `"date": "2025-12-17 16:00"`, + `"status": "updated"`, + }, + }, + { + name: "dry-run mode sets correct status", + updates: map[string][]yaml.Update{ + "config.yaml": { + { + Name: "dryrun-test", + OldDigest: "sha256:old", + NewDigest: "sha256:new", + Tag: "v1.0.0", + Date: "", + }, + }, + }, + format: "json", + dryRun: true, + wantErr: false, + wantEmpty: false, + wantContains: []string{ + `"status": "dry-run"`, + }, + }, + { + name: "empty updates returns empty string", + updates: map[string][]yaml.Update{}, + format: "table", + dryRun: false, + wantErr: false, + wantEmpty: true, + }, + { + name: "nil updates returns error", + updates: nil, + format: "table", + dryRun: false, + wantErr: true, + wantEmpty: false, + }, + { + name: "unsupported format returns error", + updates: map[string][]yaml.Update{ + "config.yaml": { + {Name: "test", OldDigest: "old", NewDigest: "new"}, + }, + }, + format: "xml", + dryRun: false, + wantErr: true, + }, + { + name: "multiple updates across files are deduplicated", + updates: map[string][]yaml.Update{ + "config1.yaml": { + { + Name: "frontend", + OldDigest: "sha256:old1", + NewDigest: "sha256:new1", + Tag: "v1.0.0", + }, + }, + "config2.yaml": { + { + Name: "frontend", // Duplicate name + OldDigest: "sha256:old2", + NewDigest: "sha256:new2", + Tag: "v1.0.1", + }, + { + Name: "backend", + OldDigest: "sha256:old3", + NewDigest: "sha256:new3", + Tag: "v2.0.0", + }, + }, + }, + format: "json", + dryRun: false, + wantErr: false, + wantEmpty: false, + // Should only have 2 results (frontend once, backend once) + wantContains: []string{ + `"name": "frontend"`, + `"name": "backend"`, + }, + }, + { + name: "empty tag and date show as dash in table", + updates: map[string][]yaml.Update{ + "config.yaml": { + { + Name: "no-metadata", + OldDigest: "sha256:old", + NewDigest: "sha256:new", + Tag: "", + Date: "", + }, + }, + }, + format: "table", + dryRun: false, + wantErr: false, + wantEmpty: false, + wantContains: []string{ + "no-metadata", + "-", // For empty tag and date + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := FormatResults(tt.updates, tt.format, tt.dryRun) + + // Check error expectation + if (err != nil) != tt.wantErr { + t.Errorf("FormatResults() error = %v, wantErr %v", err, tt.wantErr) + return + } + + // Check empty expectation + if tt.wantEmpty && result != "" { + t.Errorf("FormatResults() expected empty result, got: %s", result) + return + } + + // Check content expectations + for _, want := range tt.wantContains { + if !strings.Contains(result, want) { + t.Errorf("FormatResults() result missing expected string %q\nGot:\n%s", want, result) + } + } + + // For JSON format, validate it's valid JSON + if tt.format == "json" && !tt.wantErr && result != "" { + var parsed []UpdateResult + if err := json.Unmarshal([]byte(result), &parsed); err != nil { + t.Errorf("FormatResults() produced invalid JSON: %v\nJSON:\n%s", err, result) + } + } + }) + } +} + +// TestTruncateDigest tests digest truncation logic +func TestTruncateDigest(t *testing.T) { + tests := []struct { + name string + digest string + length int + want string + }{ + { + name: "digest with sha256 prefix longer than length", + digest: "sha256:abc123def456789", + length: 7, + want: "abc123d…", + }, + { + name: "digest without prefix longer than length", + digest: "xyz789012345678", + length: 10, + want: "xyz7890123…", + }, + { + name: "digest shorter than length", + digest: "sha256:short", + length: 20, + want: "short", + }, + { + name: "empty digest", + digest: "", + length: 10, + want: "", + }, + { + name: "digest exactly at length", + digest: "sha256:exact12", + length: 7, + want: "exact12", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateDigest(tt.digest, tt.length) + if got != tt.want { + t.Errorf("truncateDigest() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestValueOrDefault tests the helper function +func TestValueOrDefault(t *testing.T) { + tests := []struct { + name string + value string + defaultValue string + want string + }{ + { + name: "non-empty value returns value", + value: "test", + defaultValue: "default", + want: "test", + }, + { + name: "empty value returns default", + value: "", + defaultValue: "default", + want: "default", + }, + { + name: "whitespace is not empty", + value: " ", + defaultValue: "default", + want: " ", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := valueOrDefault(tt.value, tt.defaultValue) + if got != tt.want { + t.Errorf("valueOrDefault() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestConvertToResults tests the conversion and deduplication logic +func TestConvertToResults(t *testing.T) { + tests := []struct { + name string + updates map[string][]yaml.Update + dryRun bool + wantCount int + wantStatus string + }{ + { + name: "deduplicates by name", + updates: map[string][]yaml.Update{ + "file1": { + {Name: "frontend", OldDigest: "old1", NewDigest: "new1"}, + }, + "file2": { + {Name: "frontend", OldDigest: "old2", NewDigest: "new2"}, // Duplicate + {Name: "backend", OldDigest: "old3", NewDigest: "new3"}, + }, + }, + dryRun: false, + wantCount: 2, // Only frontend and backend, not duplicated frontend + }, + { + name: "sets dry-run status when dryRun is true and digests differ", + updates: map[string][]yaml.Update{ + "file": { + {Name: "service", OldDigest: "old", NewDigest: "new"}, + }, + }, + dryRun: true, + wantCount: 1, + wantStatus: "dry-run", + }, + { + name: "sets updated status when dryRun is false and digests differ", + updates: map[string][]yaml.Update{ + "file": { + {Name: "service", OldDigest: "old", NewDigest: "new"}, + }, + }, + dryRun: false, + wantCount: 1, + wantStatus: "updated", + }, + { + name: "sets unchanged status when digests match", + updates: map[string][]yaml.Update{ + "file": { + {Name: "service", OldDigest: "same", NewDigest: "same"}, + }, + }, + dryRun: false, + wantCount: 1, + wantStatus: "unchanged", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results := convertToResults(tt.updates, tt.dryRun) + + if len(results) != tt.wantCount { + t.Errorf("convertToResults() returned %d results, want %d", len(results), tt.wantCount) + } + + if tt.wantStatus != "" && len(results) > 0 { + if results[0].Status != tt.wantStatus { + t.Errorf("convertToResults() status = %v, want %v", results[0].Status, tt.wantStatus) + } + } + }) + } +} diff --git a/tooling/image-updater/internal/output/output.go b/tooling/image-updater/internal/output/output.go index 2e9b51fcc9..31a0f52d41 100644 --- a/tooling/image-updater/internal/output/output.go +++ b/tooling/image-updater/internal/output/output.go @@ -15,11 +15,12 @@ package output import ( + "encoding/json" "fmt" "strings" "text/template" - "k8s.io/apimachinery/pkg/util/sets" + "github.com/jedib0t/go-pretty/v6/table" "github.com/Azure/ARO-HCP/tooling/image-updater/internal/yaml" ) @@ -57,7 +58,8 @@ type commitMessageData struct { Updates []updateData } -// GenerateCommitMessage creates a markdown table commit message for the updated images +// GenerateCommitMessage creates a markdown table commit message for the updated images. +// Returns empty string if there are no updates to report. func GenerateCommitMessage(updates map[string][]yaml.Update) string { var allUpdates []yaml.Update for _, updates := range updates { @@ -69,56 +71,203 @@ func GenerateCommitMessage(updates map[string][]yaml.Update) string { } // Deduplicate updates by image name - seen := sets.NewString() + seen := make(map[string]bool) var uniqueUpdates []updateData for _, update := range allUpdates { - if !seen.Has(update.Name) { - seen.Insert(update.Name) + if seen[update.Name] { + continue + } + seen[update.Name] = true - // Strip sha256: prefix if present, then take first truncatedSHALength chars - oldSHA := strings.TrimPrefix(update.OldDigest, "sha256:") - if len(oldSHA) > truncatedSHALength { - oldSHA = oldSHA[:truncatedSHALength] + "…" - } + uniqueUpdates = append(uniqueUpdates, updateData{ + Name: update.Name, + OldSHA: truncateSHA(update.OldDigest, truncatedSHALength), + NewSHA: truncateSHA(update.NewDigest, truncatedSHALength), + Version: valueOrDefault(update.Tag, "-"), + Timestamp: valueOrDefault(update.Date, "-"), + }) + } - newSHA := strings.TrimPrefix(update.NewDigest, "sha256:") - if len(newSHA) > truncatedSHALength { - newSHA = newSHA[:truncatedSHALength] + "…" - } + tmpl, err := template.New("commitMessage").Parse(commitMessageTemplate) + if err != nil { + return fmt.Sprintf("error parsing commit message template: %v", err) + } + + var sb strings.Builder + if err := tmpl.Execute(&sb, commitMessageData{Updates: uniqueUpdates}); err != nil { + return fmt.Sprintf("error executing commit message template: %v", err) + } + + return sb.String() +} - version := update.Tag - if version == "" { - version = "-" +// UpdateResult represents the result of updating a single image. +// This structure is used for formatting output in various formats (table, markdown, JSON). +type UpdateResult struct { + Name string `json:"name"` // Component/image name + OldDigest string `json:"old_digest"` // Previous digest (may include sha256: prefix) + NewDigest string `json:"new_digest"` // New digest (may include sha256: prefix) + Tag string `json:"tag"` // Version tag (e.g., v1.2.3) + Date string `json:"date"` // Build/modification date (YYYY-MM-DD HH:MM format) + Status string `json:"status"` // "updated", "unchanged", or "dry-run" +} + +// FormatResults formats update results in the specified format. +// Supported formats: "table" (ASCII table), "markdown" (Markdown table), "json" (JSON array). +// For dry-run mode, status will be set to "dry-run" for changed images. +// Returns empty string if there are no results to format. +func FormatResults(updates map[string][]yaml.Update, format string, dryRun bool) (string, error) { + if updates == nil { + return "", fmt.Errorf("updates map is nil") + } + + results := convertToResults(updates, dryRun) + if len(results) == 0 { + return "", nil + } + + switch format { + case "table": + return formatTable(results), nil + case "markdown": + return formatMarkdown(results), nil + case "json": + return formatJSON(results) + default: + return "", fmt.Errorf("unsupported output format '%s': must be one of: table, markdown, json", format) + } +} + +// convertToResults converts yaml.Update map to UpdateResult slice. +// Deduplicates updates by image name (taking the first occurrence). +// Determines status based on whether digests changed and if it's a dry-run. +func convertToResults(updates map[string][]yaml.Update, dryRun bool) []UpdateResult { + seen := make(map[string]bool) + var results []UpdateResult + + for _, updateList := range updates { + for _, update := range updateList { + if seen[update.Name] { + continue } + seen[update.Name] = true - timestamp := update.Date - if timestamp == "" { - timestamp = "-" + // Determine status based on changes and mode + status := "unchanged" + if update.OldDigest != update.NewDigest { + if dryRun { + status = "dry-run" + } else { + status = "updated" + } } - uniqueUpdates = append(uniqueUpdates, updateData{ + results = append(results, UpdateResult{ Name: update.Name, - OldSHA: oldSHA, - NewSHA: newSHA, - Version: version, - Timestamp: timestamp, + OldDigest: update.OldDigest, + NewDigest: update.NewDigest, + Tag: update.Tag, + Date: update.Date, + Status: status, }) } } - tmpl, err := template.New("commitMessage").Parse(commitMessageTemplate) + return results +} + +// formatTable formats results as an ASCII table suitable for terminal output. +// Uses go-pretty/v6 for production-grade table rendering with proper alignment. +func formatTable(results []UpdateResult) string { + if len(results) == 0 { + return "" + } + + t := table.NewWriter() + t.SetStyle(table.StyleLight) + t.Style().Options.SeparateRows = false + t.Style().Options.DrawBorder = true + t.AppendHeader(table.Row{"Name", "Old Digest", "New Digest", "Tag", "Date", "Status"}) + + appendResultRows(t, results) + + return t.Render() +} + +// formatMarkdown formats results as a Markdown table. +// Uses go-pretty/v6 for production-grade markdown table rendering. +// Suitable for embedding in documentation, pull requests, or issue comments. +func formatMarkdown(results []UpdateResult) string { + if len(results) == 0 { + return "" + } + + t := table.NewWriter() + t.AppendHeader(table.Row{"Name", "Old Digest", "New Digest", "Tag", "Date", "Status"}) + + appendResultRows(t, results) + + return t.RenderMarkdown() +} + +// appendResultRows appends formatted result rows to the table writer. +// Centralizes the common logic for formatting and adding rows. +func appendResultRows(t table.Writer, results []UpdateResult) { + for _, result := range results { + t.AppendRow(table.Row{ + result.Name, + truncateDigest(result.OldDigest, 12), + truncateDigest(result.NewDigest, 12), + valueOrDefault(result.Tag, "-"), + valueOrDefault(result.Date, "-"), + result.Status, + }) + } +} + +// formatJSON formats results as a JSON array. +// Produces pretty-printed JSON with 2-space indentation for readability. +// Suitable for consumption by other tools and automation scripts. +func formatJSON(results []UpdateResult) (string, error) { + data, err := json.MarshalIndent(results, "", " ") if err != nil { - return fmt.Sprintf("error parsing commit message template: %v", err) + return "", fmt.Errorf("failed to marshal results to JSON: %w", err) } + return string(data), nil +} - data := commitMessageData{ - Updates: uniqueUpdates, +// truncateSHA truncates a SHA digest to the specified length with ellipsis. +// Strips the "sha256:" prefix if present. +func truncateSHA(digest string, length int) string { + digest = strings.TrimPrefix(digest, "sha256:") + if len(digest) > length { + return digest[:length] + "…" } + return digest +} - var sb strings.Builder - if err := tmpl.Execute(&sb, data); err != nil { - return fmt.Sprintf("error executing commit message template: %v", err) +// truncateDigest truncates a digest to the specified length for display. +// Strips the "sha256:" prefix if present, then truncates to length and adds ellipsis. +// Returns the original digest if it's already shorter than the specified length. +func truncateDigest(digest string, length int) string { + if digest == "" { + return "" } - return sb.String() + // Strip sha256: prefix if present + digest = strings.TrimPrefix(digest, "sha256:") + + if len(digest) > length { + return digest[:length] + "…" + } + return digest +} + +// valueOrDefault returns the value if non-empty, otherwise returns the default. +// Used for displaying "-" in place of empty values in tables. +func valueOrDefault(value, defaultValue string) string { + if value == "" { + return defaultValue + } + return value } diff --git a/tooling/image-updater/internal/updater/updater.go b/tooling/image-updater/internal/updater/updater.go index c223041f22..27ecb1dda7 100644 --- a/tooling/image-updater/internal/updater/updater.go +++ b/tooling/image-updater/internal/updater/updater.go @@ -17,6 +17,7 @@ package updater import ( "context" "fmt" + "os" "strings" "github.com/go-logr/logr" @@ -39,10 +40,12 @@ type Updater struct { RegistryClients map[string]clients.RegistryClient YAMLEditors map[string]*yaml.Editor Updates map[string][]yaml.Update + OutputFile string + OutputFormat string } // New creates a new Updater with all necessary resources pre-initialized -func New(cfg *config.Config, dryRun bool, forceUpdate bool, registryClients map[string]clients.RegistryClient, yamlEditors map[string]*yaml.Editor) *Updater { +func New(cfg *config.Config, dryRun bool, forceUpdate bool, registryClients map[string]clients.RegistryClient, yamlEditors map[string]*yaml.Editor, outputFile, outputFormat string) *Updater { return &Updater{ Config: cfg, DryRun: dryRun, @@ -50,6 +53,8 @@ func New(cfg *config.Config, dryRun bool, forceUpdate bool, registryClients map[ RegistryClients: registryClients, YAMLEditors: yamlEditors, Updates: make(map[string][]yaml.Update), + OutputFile: outputFile, + OutputFormat: outputFormat, } } @@ -98,11 +103,55 @@ func (u *Updater) UpdateImages(ctx context.Context) error { return fmt.Errorf("failed to apply updates to %s: %w", filePath, err) } } + } + + // Generate and output results + if err := u.outputResults(ctx); err != nil { + return fmt.Errorf("failed to output results: %w", err) + } + + return nil +} + +// outputResults formats and writes the update results +func (u *Updater) outputResults(ctx context.Context) error { + logger, err := logr.FromContext(ctx) + if err != nil { + return fmt.Errorf("logger not found in context: %w", err) + } - commitMsg := output.GenerateCommitMessage(u.Updates) - if commitMsg != "" { - fmt.Println(commitMsg) + // Check if there were any updates to report + if len(u.Updates) == 0 { + logger.V(1).Info("No updates to report") + if u.OutputFile != "" { + logger.V(1).Info("Skipping output file creation - no updates", "file", u.OutputFile) } + return nil + } + + // Format the results + logger.V(2).Info("Formatting results", "format", u.OutputFormat, "updateCount", len(u.Updates)) + formattedOutput, err := output.FormatResults(u.Updates, u.OutputFormat, u.DryRun) + if err != nil { + return fmt.Errorf("failed to format results as %s: %w", u.OutputFormat, err) + } + + if formattedOutput == "" { + logger.V(1).Info("Formatted output is empty, skipping write") + return nil + } + + // Write to file or stdout + if u.OutputFile != "" { + logger.V(1).Info("Writing results to file", "file", u.OutputFile, "format", u.OutputFormat, "size", len(formattedOutput)) + if err := os.WriteFile(u.OutputFile, []byte(formattedOutput), 0644); err != nil { + return fmt.Errorf("failed to write output file %s: %w", u.OutputFile, err) + } + logger.Info("Results written successfully", "file", u.OutputFile, "format", u.OutputFormat) + fmt.Printf("Results written to %s\n", u.OutputFile) + } else { + logger.V(2).Info("Writing results to stdout", "format", u.OutputFormat) + fmt.Print(formattedOutput) } return nil @@ -176,24 +225,13 @@ func (u *Updater) ProcessImageUpdates(ctx context.Context, name string, tag *cli logger.V(2).Info("Update needed", "name", name, "from", currentDigest, "to", newDigest) } - if u.DryRun { - logger.V(2).Info("DRY RUN: Would update image", - "name", name, - "jsonPath", target.JsonPath, - "filePath", target.FilePath, - "line", line, - "from", currentDigest, - "to", newDigest, - "tag", tag.Name) - return true, nil - } - // Format the date as YYYY-MM-DD HH:MM if available dateStr := "" if !tag.LastModified.IsZero() { dateStr = tag.LastModified.Format("2006-01-02 15:04") } + // Record the update for reporting purposes (both dry-run and real runs) u.Updates[target.FilePath] = append(u.Updates[target.FilePath], yaml.Update{ Name: name, NewDigest: newDigest, @@ -205,5 +243,16 @@ func (u *Updater) ProcessImageUpdates(ctx context.Context, name string, tag *cli Line: line, }) + if u.DryRun { + logger.V(2).Info("DRY RUN: Would update image", + "name", name, + "jsonPath", target.JsonPath, + "filePath", target.FilePath, + "line", line, + "from", currentDigest, + "to", newDigest, + "tag", tag.Name) + } + return true, nil } diff --git a/tooling/image-updater/internal/updater/updater_test.go b/tooling/image-updater/internal/updater/updater_test.go index f390ac0079..d449d4266c 100644 --- a/tooling/image-updater/internal/updater/updater_test.go +++ b/tooling/image-updater/internal/updater/updater_test.go @@ -90,7 +90,7 @@ func TestUpdater_UpdateImages(t *testing.T) { wantUpdateNames: []string{"test-image"}, }, { - name: "dry run mode does not update", + name: "dry run mode does not update files but tracks changes", config: &config.Config{ Images: map[string]config.ImageConfig{ "test-image": { @@ -109,7 +109,7 @@ func TestUpdater_UpdateImages(t *testing.T) { registryDigest: "sha256:newdigest", dryRun: true, wantErr: false, - wantUpdateNames: []string{}, + wantUpdateNames: []string{"test-image"}, // Changed: dry-run now tracks updates for reporting }, { name: "registry fetch error", @@ -227,6 +227,7 @@ image: RegistryClients: registryClients, YAMLEditors: yamlEditors, Updates: make(map[string][]yaml.Update), + OutputFormat: "table", } err = u.UpdateImages(ctx) @@ -413,6 +414,7 @@ image: RegistryClients: registryClients, YAMLEditors: yamlEditors, Updates: make(map[string][]yaml.Update), + OutputFormat: "table", } _, err := u.ProcessImageUpdates(ctx, "test-image", &clients.Tag{Digest: "sha256:newdigest", Name: "v1.0.0"}, tt.target) @@ -551,10 +553,11 @@ image: // Create updater u := &Updater{ - Config: &config.Config{}, - DryRun: false, - YAMLEditors: yamlEditors, - Updates: make(map[string][]yaml.Update), + Config: &config.Config{}, + DryRun: false, + YAMLEditors: yamlEditors, + Updates: make(map[string][]yaml.Update), + OutputFormat: "table", } // Process update @@ -673,15 +676,17 @@ config: YAMLEditors: map[string]*yaml.Editor{ yamlPath: editor, }, - Updates: make(map[string][]yaml.Update), + Updates: make(map[string][]yaml.Update), + OutputFormat: "table", } // Run update - if err := u.UpdateImages(ctx); err != nil { + err = u.UpdateImages(ctx) + if err != nil { t.Fatalf("UpdateImages() failed: %v", err) } - // Verify the file was updated correctly + // Read updated file to verify changes newEditor, err := yaml.NewEditor(yamlPath) if err != nil { t.Fatalf("failed to read updated file: %v", err)