From e8bf6435e4b67056b39b76d3a4b823ea162176ac Mon Sep 17 00:00:00 2001 From: Andrew Lavery Date: Thu, 12 Mar 2026 13:30:28 -0400 Subject: [PATCH] add a '--metadata' flag to support-bundle (#1993) * add a '--metadata' flag to support-bundle * test the metadata flag e2e --- cmd/troubleshoot/cli/root.go | 1 + cmd/troubleshoot/cli/run.go | 21 ++++++++ cmd/troubleshoot/cli/run_test.go | 51 +++++++++++++++++++ pkg/supportbundle/supportbundle.go | 14 +++++ .../support_bundle_metadata_e2e_test.go | 33 +++++++++--- 5 files changed, 114 insertions(+), 6 deletions(-) diff --git a/cmd/troubleshoot/cli/root.go b/cmd/troubleshoot/cli/root.go index 49520b05..27c70877 100644 --- a/cmd/troubleshoot/cli/root.go +++ b/cmd/troubleshoot/cli/root.go @@ -137,6 +137,7 @@ If no arguments are provided, specs are automatically loaded from the cluster by cmd.Flags().Bool("debug", false, "enable debug logging. This is equivalent to --v=0") cmd.Flags().Bool("dry-run", false, "print support bundle spec without collecting anything") cmd.Flags().Bool("auto-update", true, "enable automatic binary self-update check and install") + cmd.Flags().StringSlice("metadata", []string{}, "user-provided metadata key=value pairs to include in the bundle (can be specified multiple times)") // Upload flags cmd.Flags().Bool("auto-upload", false, "automatically upload resulting bundle to replicated.app") diff --git a/cmd/troubleshoot/cli/run.go b/cmd/troubleshoot/cli/run.go index c40f43b2..b806ef2d 100644 --- a/cmd/troubleshoot/cli/run.go +++ b/cmd/troubleshoot/cli/run.go @@ -200,6 +200,11 @@ func runTroubleshoot(v *viper.Viper, args []string) error { }() } + userMetadata, err := parseMetadataFlag(v.GetStringSlice("metadata")) + if err != nil { + return errors.Wrap(err, "invalid metadata flag") + } + createOpts := supportbundle.SupportBundleCreateOpts{ CollectorProgressCallback: collectorCB, CollectWithoutPermissions: v.GetBool("collect-without-permissions"), @@ -221,6 +226,7 @@ func runTroubleshoot(v *viper.Viper, args []string) error { VerifyTokenization: v.GetBool("verify-tokenization"), BundleID: v.GetString("bundle-id"), TokenizationStats: v.GetBool("tokenization-stats"), + UserMetadata: userMetadata, } nonInteractiveOutput := analysisOutput{} @@ -626,3 +632,18 @@ func VerifyTokenizationSetup(v *viper.Viper) error { return nil } + +func parseMetadataFlag(values []string) (map[string]string, error) { + if len(values) == 0 { + return nil, nil + } + metadata := make(map[string]string, len(values)) + for _, v := range values { + k, val, ok := strings.Cut(v, "=") + if !ok { + return nil, fmt.Errorf("invalid metadata format %q, expected key=value", v) + } + metadata[k] = val + } + return metadata, nil +} diff --git a/cmd/troubleshoot/cli/run_test.go b/cmd/troubleshoot/cli/run_test.go index 5d5a0bec..1f8e5916 100644 --- a/cmd/troubleshoot/cli/run_test.go +++ b/cmd/troubleshoot/cli/run_test.go @@ -464,3 +464,54 @@ func TestCollectTimeoutFlag(t *testing.T) { assert.Equal(t, 90, actualTimeout, "remote-host-collect-timeout should be 90 when --remote-host-collect-timeout=90 is passed") }) } + +func TestParseMetadataFlag(t *testing.T) { + tests := []struct { + name string + values []string + want map[string]string + wantErr bool + }{ + { + name: "nil input", + values: nil, + want: nil, + }, + { + name: "empty input", + values: []string{}, + want: nil, + }, + { + name: "single pair", + values: []string{"env=staging"}, + want: map[string]string{"env": "staging"}, + }, + { + name: "multiple pairs", + values: []string{"env=staging", "version=1.2.3"}, + want: map[string]string{"env": "staging", "version": "1.2.3"}, + }, + { + name: "value contains equals", + values: []string{"config=key=value"}, + want: map[string]string{"config": "key=value"}, + }, + { + name: "missing equals", + values: []string{"noequals"}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseMetadataFlag(tt.values) + if tt.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + }) + } +} diff --git a/pkg/supportbundle/supportbundle.go b/pkg/supportbundle/supportbundle.go index 20954390..d6d432ec 100644 --- a/pkg/supportbundle/supportbundle.go +++ b/pkg/supportbundle/supportbundle.go @@ -3,6 +3,7 @@ package supportbundle import ( "bytes" "context" + "encoding/json" "fmt" "net/http" "os" @@ -51,6 +52,8 @@ type SupportBundleCreateOpts struct { VerifyTokenization bool // Validation mode only BundleID string // Custom bundle identifier TokenizationStats bool // Include detailed tokenization statistics + + UserMetadata map[string]string // User-provided key=value metadata pairs } type SupportBundleResponse struct { @@ -182,6 +185,17 @@ func CollectSupportBundleFromSpec( return nil, errors.Wrap(err, "failed to write version") } + if len(opts.UserMetadata) > 0 { + metadataJSON, err := json.MarshalIndent(opts.UserMetadata, "", " ") + if err != nil { + return nil, errors.Wrap(err, "failed to marshal user metadata") + } + err = result.SaveResult(bundlePath, "metadata/user.json", bytes.NewBuffer(metadataJSON)) + if err != nil { + return nil, errors.Wrap(err, "failed to write user metadata") + } + } + // Run Analyzers analyzeResults, err := AnalyzeSupportBundle(ctx, spec, bundlePath) if err != nil { diff --git a/test/e2e/support-bundle/support_bundle_metadata_e2e_test.go b/test/e2e/support-bundle/support_bundle_metadata_e2e_test.go index e09db86c..fb5beb3d 100644 --- a/test/e2e/support-bundle/support_bundle_metadata_e2e_test.go +++ b/test/e2e/support-bundle/support_bundle_metadata_e2e_test.go @@ -64,7 +64,7 @@ func TestSupportBundleMetadata(t *testing.T) { } return ctx }). - Assess("check metadata/cluster.json contents", func(ctx context.Context, t *testing.T, c *envconf.Config) context.Context { + Assess("check metadata/cluster.json and metadata/user.json contents", func(ctx context.Context, t *testing.T, c *envconf.Config) context.Context { var out bytes.Buffer namespace := c.Namespace() @@ -75,8 +75,18 @@ func TestSupportBundleMetadata(t *testing.T) { err := os.WriteFile(specPath, []byte(spec), 0644) require.NoError(t, err) + expectedUserMetadata := map[string]string{ + "contactEmail": "support@example.com", + "ticketID": "ISSUE-42", + } + tarPath := fmt.Sprintf("%s.tar.gz", supportBundleName) - cmd := exec.CommandContext(ctx, sbBinary(), specPath, "--interactive=false", fmt.Sprintf("-o=%s", supportBundleName)) + cmd := exec.CommandContext(ctx, sbBinary(), specPath, + "--interactive=false", + fmt.Sprintf("-o=%s", supportBundleName), + "--metadata=contactEmail=support@example.com", + "--metadata=ticketID=ISSUE-42", + ) cmd.Stdout = &out err = cmd.Run() if err != nil { @@ -90,14 +100,25 @@ func TestSupportBundleMetadata(t *testing.T) { } }() - metadataJSON, err := readFileFromTar(tarPath, fmt.Sprintf("%s/metadata/cluster.json", supportBundleName)) + // Validate metadata/cluster.json from the secret + clusterJSON, err := readFileFromTar(tarPath, fmt.Sprintf("%s/metadata/cluster.json", supportBundleName)) require.NoError(t, err) - var result map[string]string - err = json.Unmarshal(metadataJSON, &result) + var clusterResult map[string]string + err = json.Unmarshal(clusterJSON, &clusterResult) require.NoError(t, err) - assert.Equal(t, expectedData, result) + assert.Equal(t, expectedData, clusterResult) + + // Validate metadata/user.json from the --metadata flag + userJSON, err := readFileFromTar(tarPath, fmt.Sprintf("%s/metadata/user.json", supportBundleName)) + require.NoError(t, err) + + var userResult map[string]string + err = json.Unmarshal(userJSON, &userResult) + require.NoError(t, err) + + assert.Equal(t, expectedUserMetadata, userResult) return ctx }).Feature()