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
130 changes: 130 additions & 0 deletions cli/cmd/release_extract_images.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package cmd

import (
"context"
"errors"
"fmt"
"strings"

"github.com/replicatedhq/replicated/cli/print"
"github.com/replicatedhq/replicated/pkg/imageextract"
"github.com/spf13/cobra"
)

func (r *runners) InitReleaseExtractImages(parent *cobra.Command) {
cmd := &cobra.Command{
Use: "extract-images --yaml-dir DIRECTORY | --chart CHART_PATH",
Short: "Extract container image references from Kubernetes manifests or Helm charts",
Long: `Extract all container image references from Kubernetes manifests or Helm charts locally.

This command extracts image reference strings (like "nginx:1.19", "postgres:14")
from YAML files without making any network calls or downloading images.`,
Example: ` # Extract from manifest directory
replicated release extract-images --yaml-dir ./manifests

# Extract from Helm chart with custom values
replicated release extract-images --chart ./mychart.tgz --values prod-values.yaml

# JSON output for scripting
replicated release extract-images --yaml-dir ./manifests -o json

# Simple list for piping
replicated release extract-images --yaml-dir ./manifests -o list`,
}

cmd.Flags().StringVar(&r.args.extractImagesYamlDir, "yaml-dir", "", "Directory containing Kubernetes manifests")
cmd.Flags().StringVar(&r.args.extractImagesChart, "chart", "", "Helm chart file (.tgz) or directory")
cmd.Flags().StringSliceVar(&r.args.extractImagesValues, "values", nil, "Values files for Helm rendering (can specify multiple)")
cmd.Flags().StringSliceVar(&r.args.extractImagesSet, "set", nil, "Set values on command line (can specify multiple, format: key=value)")
cmd.Flags().StringVarP(&r.outputFormat, "output", "o", "table", "Output format: table, json, or list")
cmd.Flags().BoolVar(&r.args.extractImagesShowDuplicates, "show-duplicates", false, "Show all occurrences instead of unique images only")
cmd.Flags().BoolVar(&r.args.extractImagesNoWarnings, "no-warnings", false, "Suppress warnings about image references")
cmd.Flags().StringVar(&r.args.extractImagesNamespace, "namespace", "default", "Default namespace for Helm rendering")

cmd.RunE = r.releaseExtractImages
parent.AddCommand(cmd)
}

func (r *runners) releaseExtractImages(cmd *cobra.Command, args []string) error {
// Validate inputs
if r.args.extractImagesYamlDir == "" && r.args.extractImagesChart == "" {
return errors.New("either --yaml-dir or --chart must be specified")
}

if r.args.extractImagesYamlDir != "" && r.args.extractImagesChart != "" {
return errors.New("cannot specify both --yaml-dir and --chart")
}

// Validate output format
validFormats := map[string]bool{"table": true, "json": true, "list": true}
if !validFormats[r.outputFormat] {
return fmt.Errorf("invalid output format %q, must be one of: table, json, list", r.outputFormat)
}

// Prepare options
opts := imageextract.Options{
HelmValuesFiles: r.args.extractImagesValues,
HelmValues: parseSetValues(r.args.extractImagesSet),
Namespace: r.args.extractImagesNamespace,
IncludeDuplicates: r.args.extractImagesShowDuplicates,
NoWarnings: r.args.extractImagesNoWarnings,
}

// Create extractor
extractor := imageextract.NewExtractor()
ctx := context.Background()

// Extract images
var result *imageextract.Result
var err error

if r.args.extractImagesYamlDir != "" {
result, err = extractor.ExtractFromDirectory(ctx, r.args.extractImagesYamlDir, opts)
} else {
result, err = extractor.ExtractFromChart(ctx, r.args.extractImagesChart, opts)
}

if err != nil {
return fmt.Errorf("extraction failed: %w", err)
}

// Print results
return print.Images(r.outputFormat, r.w, result)
}

// parseSetValues parses --set flags into a map
// Format: key=value or key.nested=value
func parseSetValues(setValues []string) map[string]interface{} {
result := make(map[string]interface{})

for _, kv := range setValues {
parts := strings.SplitN(kv, "=", 2)
if len(parts) != 2 {
continue
}

key := parts[0]
value := parts[1]

// Handle nested keys (e.g., image.repository=nginx)
keys := strings.Split(key, ".")
current := result

for i, k := range keys {
if i == len(keys)-1 {
// Last key - set the value
current[k] = value
} else {
// Intermediate key - create nested map
if _, ok := current[k]; !ok {
current[k] = make(map[string]interface{})
}
if nested, ok := current[k].(map[string]interface{}); ok {
current = nested
}
}
}
}

return result
}
195 changes: 195 additions & 0 deletions cli/cmd/release_extract_images_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package cmd

import (
"bytes"
"testing"
"text/tabwriter"
)

func TestParseSetValues(t *testing.T) {
tests := []struct {
name string
input []string
expected map[string]interface{}
}{
{
name: "simple key-value",
input: []string{"image=nginx"},
expected: map[string]interface{}{
"image": "nginx",
},
},
{
name: "nested key-value",
input: []string{"image.repository=nginx", "image.tag=1.19"},
expected: map[string]interface{}{
"image": map[string]interface{}{
"repository": "nginx",
"tag": "1.19",
},
},
},
{
name: "deeply nested",
input: []string{"a.b.c=value"},
expected: map[string]interface{}{
"a": map[string]interface{}{
"b": map[string]interface{}{
"c": "value",
},
},
},
},
{
name: "empty input",
input: []string{},
expected: map[string]interface{}{},
},
{
name: "invalid format (no equals)",
input: []string{"invalid"},
expected: map[string]interface{}{},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseSetValues(tt.input)

// Simple comparison for string values
if len(result) != len(tt.expected) {
t.Errorf("expected %d keys, got %d", len(tt.expected), len(result))
}

// For nested values, just check if keys exist
for key := range tt.expected {
if _, ok := result[key]; !ok {
t.Errorf("expected key %q not found in result", key)
}
}
})
}
}

func TestReleaseExtractImages_Validation(t *testing.T) {
tests := []struct {
name string
yamlDir string
chart string
expectError bool
errorMsg string
}{
{
name: "no input specified",
yamlDir: "",
chart: "",
expectError: true,
errorMsg: "either --yaml-dir or --chart must be specified",
},
{
name: "both inputs specified",
yamlDir: "./manifests",
chart: "./chart.tgz",
expectError: true,
errorMsg: "cannot specify both --yaml-dir and --chart",
},
{
name: "valid yaml-dir",
yamlDir: "../../pkg/imageextract/testdata/simple-deployment",
chart: "",
expectError: false,
},
{
name: "valid chart",
yamlDir: "",
chart: "../../pkg/imageextract/testdata/helm-chart",
expectError: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buf := new(bytes.Buffer)
w := tabwriter.NewWriter(buf, 0, 8, 4, ' ', 0)

r := &runners{
w: w,
outputFormat: "list",
args: runnerArgs{
extractImagesYamlDir: tt.yamlDir,
extractImagesChart: tt.chart,
},
}

err := r.releaseExtractImages(nil, nil)

if tt.expectError {
if err == nil {
t.Error("expected error but got none")
} else if tt.errorMsg != "" && err.Error() != tt.errorMsg {
t.Errorf("expected error %q, got %q", tt.errorMsg, err.Error())
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
})
}
}

func TestReleaseExtractImages_OutputFormat(t *testing.T) {
tests := []struct {
name string
outputFormat string
expectError bool
}{
{
name: "valid table format",
outputFormat: "table",
expectError: false,
},
{
name: "valid json format",
outputFormat: "json",
expectError: false,
},
{
name: "valid list format",
outputFormat: "list",
expectError: false,
},
{
name: "invalid format",
outputFormat: "xml",
expectError: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buf := new(bytes.Buffer)
w := tabwriter.NewWriter(buf, 0, 8, 4, ' ', 0)

r := &runners{
w: w,
outputFormat: tt.outputFormat,
args: runnerArgs{
extractImagesYamlDir: "../../pkg/imageextract/testdata/simple-deployment",
},
}

err := r.releaseExtractImages(nil, nil)

if tt.expectError {
if err == nil {
t.Error("expected error for invalid format")
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
})
}
}
1 change: 1 addition & 0 deletions cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ func Execute(rootCmd *cobra.Command, stdin io.Reader, stdout io.Writer, stderr i
runCmds.InitReleaseTest(releaseCmd)
runCmds.InitReleaseCompatibility(releaseCmd)
runCmds.InitReleaseImageLS(releaseCmd)
runCmds.InitReleaseExtractImages(releaseCmd)

collectorsCmd := runCmds.InitCollectorsCommand(runCmds.rootCmd)
runCmds.InitCollectorList(collectorsCmd)
Expand Down
8 changes: 8 additions & 0 deletions cli/cmd/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ type runnerArgs struct {
releaseImageLSVersion string
releaseImageLSKeepProxy bool

extractImagesYamlDir string
extractImagesChart string
extractImagesValues []string
extractImagesSet []string
extractImagesShowDuplicates bool
extractImagesNoWarnings bool
extractImagesNamespace string

createCollectorName string
createCollectorYaml string
createCollectorYamlFile string
Expand Down
Loading