diff --git a/docs/example/generate-config.yaml b/docs/example/generate-config.yaml new file mode 100644 index 00000000..41a2c7ed --- /dev/null +++ b/docs/example/generate-config.yaml @@ -0,0 +1,20 @@ +# Example generate config for cluster-compare -g +# Use: kubectl cluster-compare -g docs/example/generate-config.yaml +# Or with must-gather: kubectl cluster-compare -g docs/example/generate-config.yaml -f ./must-gather.123456 +apiVersion: refgen/v1 +outputDir: ./generated-reference + +# Optional: extra metadata.annotations / metadata.labels keys to strip from captured +# YAML and to register in generated metadata.yaml fieldsToOmit (defaults still apply). +# omitAnnotations: +# - my.company/last-synced +# omitLabels: +# - ci-build-id + +resources: + - kind: Namespace + apiVersion: v1 + required: false + names: + - openshift-sriov-network-operator + - openshift-ptp diff --git a/go.mod b/go.mod index 528ae7ac..5a01795a 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/sergi/go-diff v1.4.0 github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 + gopkg.in/yaml.v3 v3.0.1 helm.sh/helm/v3 v3.18.4 k8s.io/api v0.34.0 k8s.io/apimachinery v0.34.0 @@ -91,7 +92,6 @@ require ( google.golang.org/protobuf v1.36.5 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.33.2 // indirect k8s.io/component-base v0.34.0 // indirect k8s.io/component-helpers v0.34.0 // indirect diff --git a/pkg/compare/compare.go b/pkg/compare/compare.go index 910ec387..4dcfcfa4 100644 --- a/pkg/compare/compare.go +++ b/pkg/compare/compare.go @@ -17,6 +17,7 @@ import ( jsonpatch "github.com/evanphx/json-patch" "github.com/gosimple/slug" + "github.com/openshift/kube-compare/pkg/generate" "github.com/sergi/go-diff/diffmatchpatch" "github.com/spf13/cobra" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -74,6 +75,11 @@ var ( Note: KUBECTL_EXTERNAL_DIFF, if used, is expected to follow that convention. + Generate mode: Use -g with a generate config file to create an initial reference from a live cluster + or must-gather directory. The config specifies which resource types to capture. Use -f with a single + path to the must-gather root directory to generate from disk; omit -f to use the live cluster. + Use --output-dir to override the output directory from the config. + Experimental: This command is under active development and may change without notice. `) @@ -92,6 +98,12 @@ var ( # Extract a reference configuration from a container image and compare with a local set of CRs: kubectl cluster-compare -r container://::/home/ztp/reference/metadata.yaml -f ./crsdir -R + + # Generate a reference configuration from a live cluster: + kubectl cluster-compare -g ./refgen-config.yaml + + # Generate a reference configuration from a must-gather directory: + kubectl cluster-compare -g ./refgen-config.yaml -f ./must-gather.123456 `) ) @@ -149,6 +161,10 @@ type Options struct { templatesToGenerateOverridesFor []string overrideReason string + // Generate mode (when -g is set) + generateConfig string + generateOutputDir string + TmpDir string diff *diff.DiffProgram @@ -167,7 +183,7 @@ func NewCmd(f kcmdutil.Factory, streams genericiooptions.IOStreams) *cobra.Comma } cmd := &cobra.Command{ - Use: "cluster-compare -r ", + Use: "cluster-compare (-r | -g )", DisableFlagsInUseLine: true, Short: i18n.T("Compare a reference configuration and a set of cluster configuration CRs."), Long: compareLong, @@ -198,6 +214,23 @@ func NewCmd(f kcmdutil.Factory, streams genericiooptions.IOStreams) *cobra.Comma defer os.RemoveAll(options.TmpDir) } kcmdutil.CheckDiffErr(options.Complete(f, cmd, args)) + // In generate mode, run generate and exit. + if options.generateConfig != "" { + var mustGatherDir string + if len(options.CRs.Filenames) > 0 { + mustGatherDir = options.CRs.Filenames[0] + } + genOpts := &generate.Options{ + GenerateConfig: options.generateConfig, + OutputDir: options.generateOutputDir, + MustGatherDir: mustGatherDir, + Verbose: options.verboseOutput, + Factory: f, + Streams: options.IOStreams, + } + kcmdutil.CheckErr(genOpts.Run(cmd.Context())) + return + } // `kubectl cluster-compare` propagates the error code from // `kubectl diff` that propagates the error code from // diff or `KUBECTL_EXTERNAL_DIFF`. Also, we @@ -227,7 +260,7 @@ func NewCmd(f kcmdutil.Factory, streams genericiooptions.IOStreams) *cobra.Comma cmd.Flags().IntVar(&options.Concurrency, "concurrency", 4, "Number of objects to process in parallel when diffing against the live version. Larger number = faster,"+ " but more memory, I/O and CPU over that shorter period of time.") - kcmdutil.AddFilenameOptionFlags(cmd, &options.CRs, "contains the configuration to diff") + kcmdutil.AddFilenameOptionFlags(cmd, &options.CRs, "contains the configuration to diff; with -g, optional single path to a must-gather root (omit for live cluster)") cmd.Flags().StringVarP(&options.diffConfigFileName, "diff-config", "c", "", "Path to the user config file") cmd.Flags().StringVarP(&options.ReferenceConfig, "reference", "r", "", "Path to reference config file.") cmd.Flags().BoolVar(&options.ShowManagedFields, "show-managed-fields", options.ShowManagedFields, "If true, include managed fields in the diff.") @@ -236,6 +269,8 @@ func NewCmd(f kcmdutil.Factory, streams genericiooptions.IOStreams) *cobra.Comma "In local mode will try to match all resources passed to the command") cmd.Flags().BoolVarP(&options.verboseOutput, "verbose", "v", options.verboseOutput, "Increases the verbosity of the tool") + cmd.Flags().StringVarP(&options.generateConfig, "generate-config", "g", "", "Path to generate config file. When set, generates reference from the live cluster or from a must-gather directory given by a single -f path instead of comparing.") + cmd.Flags().StringVar(&options.generateOutputDir, "output-dir", "", "Output directory for generated reference (overrides config file setting). Only used with -g.") cmd.Flags().StringVarP(&options.userOverridesPath, "overrides", "p", "", "Path to user overrides") cmd.Flags().StringSliceVar(&options.templatesToGenerateOverridesFor, "generate-override-for", []string{}, "Path for template file you wish to generate a override for") cmd.Flags().StringVar(&options.overrideReason, "override-reason", "", "Reason for generating the override") @@ -306,6 +341,24 @@ func (o *Options) GetRefFS() (fs.FS, error) { } func (o *Options) Complete(f kcmdutil.Factory, cmd *cobra.Command, args []string) error { var err error + + // Generate mode: -g and -r are mutually exclusive. + if o.generateConfig != "" { + if o.ReferenceConfig != "" { + return kcmdutil.UsageErrorf(cmd, "cannot use -r and -g together; use -r for compare or -g for generate") + } + if len(args) != 0 { + return kcmdutil.UsageErrorf(cmd, "Unexpected args: %v", args) + } + if o.CRs.Kustomize != "" { + return kcmdutil.UsageErrorf(cmd, "cannot use -k with -g; use -f with a must-gather directory path, or omit -f for a live cluster") + } + if len(o.CRs.Filenames) > 1 { + return kcmdutil.UsageErrorf(cmd, "with -g, specify at most one must-gather path with -f (or omit -f to use the live cluster)") + } + return nil + } + o.builder = f.NewBuilder() if o.OutputFormat == PatchYaml { diff --git a/pkg/compare/compare_test.go b/pkg/compare/compare_test.go index b7d3740f..031d2c17 100644 --- a/pkg/compare/compare_test.go +++ b/pkg/compare/compare_test.go @@ -352,6 +352,34 @@ func startWithCleanEnv() { } } +func TestOmitFieldsLabelPrefixRemovesKeyedEntries(t *testing.T) { + t.Parallel() + obj := map[string]any{ + "metadata": map[string]any{ + "labels": map[string]any{ + "app": "nginx", + "operators.coreos.com/subscription": "sub", + "pod-security.kubernetes.io/enforce": "restricted", + }, + }, + } + fields := []*ManifestPathV1{ + {PathToKey: `metadata.labels."operators.coreos.com/"`, IsPrefix: true}, + {PathToKey: `metadata.labels."pod-security.kubernetes.io/"`, IsPrefix: true}, + } + for _, f := range fields { + require.NoError(t, f.Process()) + } + omitFields(obj, fields) + md := obj["metadata"].(map[string]any) + lbl := md["labels"].(map[string]any) + require.Equal(t, "nginx", lbl["app"]) + _, hasOlm := lbl["operators.coreos.com/subscription"] + require.False(t, hasOlm) + _, hasPSA := lbl["pod-security.kubernetes.io/enforce"] + require.False(t, hasPSA) +} + // TestCompareRun ensures that Run command calls the right actions // and returns the expected error. func TestCompareRun(t *testing.T) { diff --git a/pkg/generate/config.go b/pkg/generate/config.go new file mode 100644 index 00000000..575c5f35 --- /dev/null +++ b/pkg/generate/config.go @@ -0,0 +1,98 @@ +// SPDX-License-Identifier:Apache-2.0 + +package generate + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "sigs.k8s.io/yaml" +) + +// validAPIVersions lists supported RefgenConfig.apiVersion values. +var validAPIVersions = []string{ + "refgen/v1", +} + +// RefgenConfig is the root configuration for reference generation. +type RefgenConfig struct { + APIVersion string `json:"apiVersion"` + OutputDir string `json:"outputDir"` + // OmitAnnotations lists metadata.annotation keys stripped from captured manifests + // and added to generated metadata.yaml fieldsToOmit (in addition to built-in defaults). + OmitAnnotations []string `json:"omitAnnotations,omitempty"` + // OmitLabels lists metadata.labels keys stripped from captured manifests and + // added to fieldsToOmit defaults (in addition to built-in defaults). + OmitLabels []string `json:"omitLabels,omitempty"` + Resources []ResourceSpec `json:"resources"` +} + +// ResourceSpec specifies a Kubernetes resource type to capture. +type ResourceSpec struct { + Kind string `json:"kind"` + APIVersion string `json:"apiVersion"` + Required bool `json:"required"` + Namespace string `json:"namespace,omitempty"` + Names []string `json:"names,omitempty"` +} + +// LoadConfig loads and validates a refgen configuration file. +func LoadConfig(configPath string) (*RefgenConfig, error) { + absPath, err := filepath.Abs(configPath) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path for %s: %w", configPath, err) + } + data, err := os.ReadFile(absPath) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("configuration file not found: %s", configPath) + } + return nil, fmt.Errorf("failed to read configuration: %w", err) + } + var config RefgenConfig + if err := yaml.UnmarshalStrict(data, &config); err != nil { + return nil, fmt.Errorf("invalid YAML in configuration file: %w", err) + } + if config.APIVersion == "" { + config.APIVersion = "refgen/v1" + } + allowedAPIVersion := false + for _, v := range validAPIVersions { + if config.APIVersion == v { + allowedAPIVersion = true + break + } + } + if !allowedAPIVersion { + return nil, fmt.Errorf("configuration apiVersion %q is invalid; must be one of: %s", config.APIVersion, strings.Join(validAPIVersions, ", ")) + } + if config.OutputDir == "" { + config.OutputDir = "./generated-reference" + } + if len(config.Resources) == 0 { + return nil, fmt.Errorf("configuration must specify at least one resource") + } + for i, k := range config.OmitAnnotations { + if err := validateOmitKey(k, "omitAnnotations", i); err != nil { + return nil, err + } + } + for i, k := range config.OmitLabels { + if err := validateOmitKey(k, "omitLabels", i); err != nil { + return nil, err + } + } + return &config, nil +} + +func validateOmitKey(key, field string, index int) error { + if key == "" { + return fmt.Errorf("%s[%d]: key must not be empty", field, index) + } + if strings.Contains(key, `"`) { + return fmt.Errorf("%s[%d]: key must not contain double quotes", field, index) + } + return nil +} diff --git a/pkg/generate/config_test.go b/pkg/generate/config_test.go new file mode 100644 index 00000000..be4c977f --- /dev/null +++ b/pkg/generate/config_test.go @@ -0,0 +1,179 @@ +// SPDX-License-Identifier:Apache-2.0 + +package generate + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadConfig(t *testing.T) { + t.Parallel() + + t.Run("valid minimal applies defaults", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + err := os.WriteFile(path, []byte(`resources: + - kind: Namespace + apiVersion: v1 + required: false +`), 0o600) + require.NoError(t, err) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.NotNil(t, cfg) + assert.Equal(t, "refgen/v1", cfg.APIVersion) + assert.Equal(t, "./generated-reference", cfg.OutputDir) + require.Len(t, cfg.Resources, 1) + assert.Equal(t, "Namespace", cfg.Resources[0].Kind) + assert.Equal(t, "v1", cfg.Resources[0].APIVersion) + assert.False(t, cfg.Resources[0].Required) + }) + + t.Run("valid with explicit apiVersion and outputDir", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + err := os.WriteFile(path, []byte(`apiVersion: refgen/v1 +outputDir: ./out +resources: + - kind: ConfigMap + apiVersion: v1 + required: true +`), 0o600) + require.NoError(t, err) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + assert.Equal(t, "refgen/v1", cfg.APIVersion) + assert.Equal(t, "./out", cfg.OutputDir) + require.Len(t, cfg.Resources, 1) + assert.True(t, cfg.Resources[0].Required) + }) + + t.Run("missing file", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "nope.yaml") + _, err := LoadConfig(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "configuration file not found") + assert.Contains(t, err.Error(), path) + }) + + t.Run("invalid YAML", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "bad.yaml") + require.NoError(t, os.WriteFile(path, []byte("resources: [\n"), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid YAML in configuration file") + }) + + t.Run("empty resources list", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "empty.yaml") + require.NoError(t, os.WriteFile(path, []byte(`apiVersion: refgen/v1 +resources: [] +`), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "at least one resource") + }) + + t.Run("resources key omitted", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "nor.yaml") + require.NoError(t, os.WriteFile(path, []byte(`apiVersion: refgen/v1 +`), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "at least one resource") + }) + + t.Run("unknown top-level field rejected", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "strict.yaml") + require.NoError(t, os.WriteFile(path, []byte(`apiVersion: refgen/v1 +unknownTopLevelKey: true +resources: + - kind: Namespace + apiVersion: v1 + required: false +`), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid YAML in configuration file") + }) + + t.Run("valid omitAnnotations and omitLabels", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "omit.yaml") + require.NoError(t, os.WriteFile(path, []byte(`apiVersion: refgen/v1 +omitAnnotations: + - my.operator/audit-id +omitLabels: + - batch.kubernetes.io/job-name +resources: + - kind: Namespace + apiVersion: v1 + required: false +`), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + assert.Equal(t, []string{"my.operator/audit-id"}, cfg.OmitAnnotations) + assert.Equal(t, []string{"batch.kubernetes.io/job-name"}, cfg.OmitLabels) + }) + + t.Run("empty omit annotation key rejected", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "badomit.yaml") + require.NoError(t, os.WriteFile(path, []byte(`apiVersion: refgen/v1 +omitAnnotations: + - "" +resources: + - kind: Namespace + apiVersion: v1 + required: false +`), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "omitAnnotations") + }) + + t.Run("omit key with double quote rejected", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "badquote.yaml") + require.NoError(t, os.WriteFile(path, []byte(`apiVersion: refgen/v1 +omitLabels: + - 'bad"key' +resources: + - kind: Namespace + apiVersion: v1 + required: false +`), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "omitLabels") + }) +} diff --git a/pkg/generate/fetcher.go b/pkg/generate/fetcher.go new file mode 100644 index 00000000..3e5a8b77 --- /dev/null +++ b/pkg/generate/fetcher.go @@ -0,0 +1,356 @@ +// SPDX-License-Identifier:Apache-2.0 + +package generate + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + yamlv3 "gopkg.in/yaml.v3" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/klog/v2" + kcmdutil "k8s.io/kubectl/pkg/cmd/util" +) + +// defaultListLimit is the page size for server-side List pagination against the API server. +const defaultListLimit int64 = 500 + +// Fetcher fetches resources from a cluster or must-gather directory. +type Fetcher interface { + FetchResources(ctx context.Context, spec *ResourceSpec) ([]*unstructured.Unstructured, error) +} + +// ClusterFetcher fetches resources from a live Kubernetes cluster. +type ClusterFetcher struct { + dynamicClient dynamic.Interface + mapper meta.RESTMapper +} + +// NewClusterFetcher creates a ClusterFetcher using the given factory. +func NewClusterFetcher(f kcmdutil.Factory) (*ClusterFetcher, error) { + dynamicClient, err := f.DynamicClient() + if err != nil { + return nil, fmt.Errorf("failed to create dynamic client: %w", err) + } + mapper, err := f.ToRESTMapper() + if err != nil { + return nil, fmt.Errorf("failed to create REST mapper: %w", err) + } + return &ClusterFetcher{ + dynamicClient: dynamicClient, + mapper: mapper, + }, nil +} + +// FetchResources fetches all resources matching the given specification from the cluster. +func (f *ClusterFetcher) FetchResources(ctx context.Context, spec *ResourceSpec) ([]*unstructured.Unstructured, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("fetch resources: %w", err) + } + gv, err := schema.ParseGroupVersion(spec.APIVersion) + if err != nil { + return nil, fmt.Errorf("invalid apiVersion %q: %w", spec.APIVersion, err) + } + gvk := gv.WithKind(spec.Kind) + + mapping, err := f.mapper.RESTMapping(schema.GroupKind{Group: gvk.Group, Kind: gvk.Kind}, gvk.Version) + if err != nil { + return nil, fmt.Errorf("failed to find API for %s (%s): %w", spec.Kind, spec.APIVersion, err) + } + + gvr := mapping.Resource + var ri dynamic.ResourceInterface + if spec.Namespace != "" { + ri = f.dynamicClient.Resource(gvr).Namespace(spec.Namespace) + } else { + ri = f.dynamicClient.Resource(gvr) + } + + var merged []unstructured.Unstructured + if len(spec.Names) == 0 { + merged, err = listClusterResourcePages(ctx, ri, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to fetch %s: %w", spec.Kind, err) + } + } else { + seen := make(map[string]struct{}, len(spec.Names)) + for _, name := range spec.Names { + if name == "" { + continue + } + if _, dup := seen[name]; dup { + continue + } + seen[name] = struct{}{} + base := metav1.ListOptions{ + FieldSelector: fields.OneTermEqualSelector(metav1.ObjectNameField, name).String(), + } + batch, err := listClusterResourcePages(ctx, ri, base) + if err != nil { + return nil, fmt.Errorf("failed to fetch %s %q: %w", spec.Kind, name, err) + } + merged = append(merged, batch...) + } + } + return unstructuredPtrSlice(merged), nil +} + +func listClusterResourcePages(ctx context.Context, ri dynamic.ResourceInterface, base metav1.ListOptions) ([]unstructured.Unstructured, error) { + opts := base + opts.Limit = defaultListLimit + var accumulated []unstructured.Unstructured + for { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("list cluster resources: %w", err) + } + list, err := ri.List(ctx, opts) + if err != nil { + return nil, fmt.Errorf("list cluster resources: %w", err) + } + accumulated = append(accumulated, list.Items...) + if list.GetContinue() == "" { + break + } + opts = metav1.ListOptions{ + Limit: defaultListLimit, + Continue: list.GetContinue(), + FieldSelector: base.FieldSelector, + LabelSelector: base.LabelSelector, + } + } + return accumulated, nil +} + +func unstructuredPtrSlice(items []unstructured.Unstructured) []*unstructured.Unstructured { + out := make([]*unstructured.Unstructured, len(items)) + for i := range items { + out[i] = &items[i] + } + return out +} + +// MustGatherFetcher fetches resources from a must-gather directory. +type MustGatherFetcher struct { + rootDir string + cache []*unstructured.Unstructured +} + +// NewMustGatherFetcher creates a MustGatherFetcher for the given directory. +func NewMustGatherFetcher(mustGatherDir string) (*MustGatherFetcher, error) { + absPath, err := filepath.Abs(mustGatherDir) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path: %w", err) + } + info, err := os.Stat(absPath) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("must-gather directory does not exist: %s", mustGatherDir) + } + return nil, fmt.Errorf("failed to stat must-gather directory: %w", err) + } + if !info.IsDir() { + return nil, fmt.Errorf("must-gather path is not a directory: %s", mustGatherDir) + } + return &MustGatherFetcher{rootDir: absPath}, nil +} + +// FetchResources fetches all resources matching the given specification from must-gather. +func (f *MustGatherFetcher) FetchResources(ctx context.Context, spec *ResourceSpec) ([]*unstructured.Unstructured, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("fetch from must-gather: %w", err) + } + resources, err := f.loadAllResources(ctx) + if err != nil { + return nil, err + } + var matched []*unstructured.Unstructured + for _, r := range resources { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("fetch from must-gather: %w", err) + } + if r.GetKind() != spec.Kind || r.GetAPIVersion() != spec.APIVersion { + continue + } + if spec.Namespace != "" && r.GetNamespace() != spec.Namespace { + continue + } + if len(spec.Names) > 0 { + found := false + for _, n := range spec.Names { + if r.GetName() == n { + found = true + break + } + } + if !found { + continue + } + } + matched = append(matched, r) + } + return matched, nil +} + +func (f *MustGatherFetcher) loadAllResources(ctx context.Context) ([]*unstructured.Unstructured, error) { + if f.cache != nil { + return f.cache, nil + } + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("load must-gather resources: %w", err) + } + roots, err := f.findDataRoots(ctx) + if err != nil { + return nil, err + } + if len(roots) == 0 { + return nil, fmt.Errorf("no must-gather data found under %s (expected cluster-scoped-resources/ or namespaces/)", f.rootDir) + } + seen := make(map[string]bool) + var loaded []*unstructured.Unstructured + for _, root := range roots { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("load must-gather resources: %w", err) + } + for _, subdir := range []string{"cluster-scoped-resources", "namespaces"} { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("load must-gather resources: %w", err) + } + base := filepath.Join(root, subdir) + if _, err := os.Stat(base); os.IsNotExist(err) { + continue + } + err := filepath.Walk(base, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("must-gather resource walk: %w", err) + } + if info.IsDir() || !strings.HasSuffix(path, ".yaml") { + return nil + } + objs, err := loadResourcesFromFile(ctx, path) + if err != nil { + if ctx.Err() != nil && errors.Is(err, ctx.Err()) { + return fmt.Errorf("must-gather resource walk: %w", err) + } + klog.V(2).Infof("Skipping %s: %v", path, err) + return nil + } + for _, obj := range objs { + if err := ctx.Err(); err != nil { + return fmt.Errorf("must-gather resource walk: %w", err) + } + key := fmt.Sprintf("%s/%s/%s/%s", obj.GetAPIVersion(), obj.GetKind(), obj.GetNamespace(), obj.GetName()) + if seen[key] { + continue + } + seen[key] = true + loaded = append(loaded, obj) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("failed to walk must-gather: %w", err) + } + } + } + if loaded == nil { + loaded = []*unstructured.Unstructured{} + } + f.cache = loaded + return loaded, nil +} + +func (f *MustGatherFetcher) findDataRoots(ctx context.Context) ([]string, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("walking must-gather root %q for data directories: %w", f.rootDir, err) + } + var roots []string + seen := make(map[string]bool) + err := filepath.Walk(f.rootDir, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("must-gather root discovery walk: %w", err) + } + if info.IsDir() && (filepath.Base(path) == "cluster-scoped-resources" || filepath.Base(path) == "namespaces") { + parent := filepath.Dir(path) + if !seen[parent] { + seen[parent] = true + roots = append(roots, parent) + } + return filepath.SkipDir + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("walking must-gather root %q for data directories: %w", f.rootDir, err) + } + return roots, nil +} + +func loadResourcesFromFile(ctx context.Context, path string) ([]*unstructured.Unstructured, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read file %s: %w", path, err) + } + dec := yamlv3.NewDecoder(bytes.NewReader(data)) + var result []*unstructured.Unstructured + for { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("yaml decode %s: %w", path, err) + } + var raw map[string]any + if err := dec.Decode(&raw); err != nil { + if errors.Is(err, io.EOF) { + break + } + return nil, fmt.Errorf("yaml decode %s: %w", path, err) + } + if raw == nil { + continue + } + if raw["items"] != nil { + kindStr, _ := raw["kind"].(string) + if kindStr == "List" || strings.HasSuffix(kindStr, "List") { + items, ok := raw["items"].([]any) + if !ok { + continue + } + for _, item := range items { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("yaml decode %s: %w", path, err) + } + itemMap, ok := item.(map[string]any) + if !ok { + continue + } + if itemMap["kind"] == nil || itemMap["apiVersion"] == nil { + continue + } + obj := &unstructured.Unstructured{Object: itemMap} + result = append(result, obj) + } + continue + } + } + if raw["kind"] != nil && raw["apiVersion"] != nil { + obj := &unstructured.Unstructured{Object: raw} + result = append(result, obj) + } + } + return result, nil +} diff --git a/pkg/generate/fetcher_test.go b/pkg/generate/fetcher_test.go new file mode 100644 index 00000000..29e61ae2 --- /dev/null +++ b/pkg/generate/fetcher_test.go @@ -0,0 +1,176 @@ +// SPDX-License-Identifier:Apache-2.0 + +package generate + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewMustGatherFetcher(t *testing.T) { + t.Parallel() + + t.Run("non-existent directory", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "missing-must-gather") + _, err := NewMustGatherFetcher(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not exist") + }) + + t.Run("path is a file", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + filePath := filepath.Join(dir, "notadir") + require.NoError(t, os.WriteFile(filePath, []byte("x"), 0o600)) + _, err := NewMustGatherFetcher(filePath) + require.Error(t, err) + assert.Contains(t, err.Error(), "not a directory") + }) +} + +func TestMustGatherFetcherFetchResources(t *testing.T) { + t.Parallel() + + writeClusterScopedYAML := func(t *testing.T, root, relPath, content string) { + t.Helper() + full := filepath.Join(root, relPath) + require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755)) + require.NoError(t, os.WriteFile(full, []byte(content), 0o600)) + } + + t.Run("single document matches spec", func(t *testing.T) { + t.Parallel() + mg := t.TempDir() + // Parent of cluster-scoped-resources is the data root discovered by findDataRoots. + writeClusterScopedYAML(t, mg, filepath.Join("bundle", "cluster-scoped-resources", "cm.yaml"), `apiVersion: v1 +kind: ConfigMap +metadata: + name: mycm +`) + + fetcher, err := NewMustGatherFetcher(mg) + require.NoError(t, err) + + spec := &ResourceSpec{Kind: "ConfigMap", APIVersion: "v1", Required: false} + objs, err := fetcher.FetchResources(context.Background(), spec) + require.NoError(t, err) + require.Len(t, objs, 1) + assert.Equal(t, "mycm", objs[0].GetName()) + assert.Equal(t, "ConfigMap", objs[0].GetKind()) + assert.Equal(t, "v1", objs[0].GetAPIVersion()) + }) + + t.Run("namespace filter", func(t *testing.T) { + t.Parallel() + mg := t.TempDir() + nsDir := filepath.Join(mg, "bundle", "namespaces", "app-ns") + require.NoError(t, os.MkdirAll(nsDir, 0o755)) + yamlContent := `apiVersion: v1 +kind: Secret +metadata: + name: s1 + namespace: app-ns +` + require.NoError(t, os.WriteFile(filepath.Join(nsDir, "secret.yaml"), []byte(yamlContent), 0o600)) + + fetcher, err := NewMustGatherFetcher(mg) + require.NoError(t, err) + + match := &ResourceSpec{Kind: "Secret", APIVersion: "v1", Namespace: "app-ns"} + objs, err := fetcher.FetchResources(context.Background(), match) + require.NoError(t, err) + require.Len(t, objs, 1) + assert.Equal(t, "s1", objs[0].GetName()) + + otherNS := &ResourceSpec{Kind: "Secret", APIVersion: "v1", Namespace: "other-ns"} + objs, err = fetcher.FetchResources(context.Background(), otherNS) + require.NoError(t, err) + assert.Len(t, objs, 0) + }) + + t.Run("list document expands items", func(t *testing.T) { + t.Parallel() + mg := t.TempDir() + writeClusterScopedYAML(t, mg, filepath.Join("q", "cluster-scoped-resources", "list.yaml"), `apiVersion: v1 +kind: List +items: + - apiVersion: v1 + kind: Namespace + metadata: + name: ns-from-list +`) + + fetcher, err := NewMustGatherFetcher(mg) + require.NoError(t, err) + + spec := &ResourceSpec{Kind: "Namespace", APIVersion: "v1", Required: true} + objs, err := fetcher.FetchResources(context.Background(), spec) + require.NoError(t, err) + require.Len(t, objs, 1) + assert.Equal(t, "ns-from-list", objs[0].GetName()) + }) + + t.Run("non-list kind with top-level items keeps outer object", func(t *testing.T) { + t.Parallel() + mg := t.TempDir() + writeClusterScopedYAML(t, mg, filepath.Join("q", "cluster-scoped-resources", "cm-items.yaml"), `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-with-items +items: + - not-a-full-kubernetes-object +`) + + fetcher, err := NewMustGatherFetcher(mg) + require.NoError(t, err) + + spec := &ResourceSpec{Kind: "ConfigMap", APIVersion: "v1", Required: true} + objs, err := fetcher.FetchResources(context.Background(), spec) + require.NoError(t, err) + require.Len(t, objs, 1) + assert.Equal(t, "cm-with-items", objs[0].GetName()) + assert.Equal(t, "ConfigMap", objs[0].GetKind()) + }) + + t.Run("typed list kind suffix expands items", func(t *testing.T) { + t.Parallel() + mg := t.TempDir() + writeClusterScopedYAML(t, mg, filepath.Join("q", "cluster-scoped-resources", "podlist.yaml"), `apiVersion: v1 +kind: PodList +items: + - apiVersion: v1 + kind: Pod + metadata: + name: p-from-podlist + namespace: default +`) + + fetcher, err := NewMustGatherFetcher(mg) + require.NoError(t, err) + + spec := &ResourceSpec{Kind: "Pod", APIVersion: "v1", Namespace: "default", Required: true} + objs, err := fetcher.FetchResources(context.Background(), spec) + require.NoError(t, err) + require.Len(t, objs, 1) + assert.Equal(t, "p-from-podlist", objs[0].GetName()) + }) + + t.Run("no must-gather data", func(t *testing.T) { + t.Parallel() + mg := t.TempDir() + fetcher, err := NewMustGatherFetcher(mg) + require.NoError(t, err) + + spec := &ResourceSpec{Kind: "ConfigMap", APIVersion: "v1"} + _, err = fetcher.FetchResources(context.Background(), spec) + require.Error(t, err) + assert.Contains(t, err.Error(), "no must-gather data found") + }) +} diff --git a/pkg/generate/generate.go b/pkg/generate/generate.go new file mode 100644 index 00000000..82fff03c --- /dev/null +++ b/pkg/generate/generate.go @@ -0,0 +1,113 @@ +// SPDX-License-Identifier:Apache-2.0 + +package generate + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/cli-runtime/pkg/genericiooptions" + "k8s.io/klog/v2" + kcmdutil "k8s.io/kubectl/pkg/cmd/util" +) + +// Options holds options for the generate command. +type Options struct { + GenerateConfig string + OutputDir string + MustGatherDir string + Verbose bool + + Factory kcmdutil.Factory + Streams genericiooptions.IOStreams +} + +// Run executes the generate command: fetches resources and writes reference files. +func (o *Options) Run(ctx context.Context) error { + config, err := LoadConfig(o.GenerateConfig) + if err != nil { + return err + } + klog.V(1).Infof("Loaded configuration from %s", o.GenerateConfig) + klog.V(1).Infof(" Resources to capture: %d", len(config.Resources)) + + outputDir := o.OutputDir + if outputDir == "" { + outputDir = config.OutputDir + } + + var fetcher Fetcher + if o.MustGatherDir != "" { + klog.V(1).Infof("Using must-gather directory: %s", o.MustGatherDir) + fetcher, err = NewMustGatherFetcher(o.MustGatherDir) + if err != nil { + return err + } + } else { + klog.V(1).Infof("Connected to Kubernetes cluster") + fetcher, err = NewClusterFetcher(o.Factory) + if err != nil { + return err + } + } + + resourcesBySpec := make(map[*ResourceSpec][]*unstructured.Unstructured) + var totalResources int + var missingSpecs []*ResourceSpec + + for i := range config.Resources { + spec := &config.Resources[i] + nsInfo := "" + if spec.Namespace != "" { + nsInfo = fmt.Sprintf(" in namespace %s", spec.Namespace) + } + klog.V(1).Infof("Fetching %s (%s)%s...", spec.Kind, spec.APIVersion, nsInfo) + resources, err := fetcher.FetchResources(ctx, spec) + if err != nil { + if !spec.Required { + klog.Warningf("failed to fetch optional resource %s (%s): %v", spec.Kind, spec.APIVersion, err) + resourcesBySpec[spec] = nil + missingSpecs = append(missingSpecs, spec) + continue + } + return fmt.Errorf("failed to fetch %s: %w", spec.Kind, err) + } + resourcesBySpec[spec] = resources + totalResources += len(resources) + if len(resources) == 0 { + missingSpecs = append(missingSpecs, spec) + } + klog.V(1).Infof(" Found %d resource(s)", len(resources)) + } + + generator := NewGenerator(config, outputDir) + outputPath, err := generator.Generate(resourcesBySpec) + if err != nil { + return err + } + + fmt.Fprintf(o.Streams.Out, "Generated reference at: %s\n", outputPath) + fmt.Fprintf(o.Streams.Out, " Total resources captured: %d\n", totalResources) + capturedTypes := 0 + for _, resources := range resourcesBySpec { + if len(resources) > 0 { + capturedTypes++ + } + } + fmt.Fprintf(o.Streams.Out, " Resource types: %d\n", capturedTypes) + if len(missingSpecs) > 0 { + fmt.Fprintf(o.Streams.ErrOut, "Warning: No resources found for:\n") + for _, spec := range missingSpecs { + details := fmt.Sprintf("%s (%s)", spec.Kind, spec.APIVersion) + if spec.Namespace != "" { + details = fmt.Sprintf("%s in namespace %s", details, spec.Namespace) + } + if len(spec.Names) > 0 { + details = fmt.Sprintf("%s with names %v", details, spec.Names) + } + fmt.Fprintf(o.Streams.ErrOut, " - %s\n", details) + } + } + return nil +} diff --git a/pkg/generate/generator.go b/pkg/generate/generator.go new file mode 100644 index 00000000..da82211b --- /dev/null +++ b/pkg/generate/generator.go @@ -0,0 +1,457 @@ +// SPDX-License-Identifier:Apache-2.0 + +package generate + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/yaml" +) + +var ( + sanitizePathChars = regexp.MustCompile(`[^\w\-.]`) + sanitizePathDashes = regexp.MustCompile(`-+`) +) + +// defaultFieldsToOmit returns the standard fieldsToOmit configuration for generated metadata. +func defaultFieldsToOmit() map[string]any { + return map[string]any{ + "defaultOmitRef": "all", + "items": map[string]any{ + "defaults": []map[string]any{ + {"pathToKey": "metadata.annotations.\"kubernetes.io/metadata.name\""}, + {"pathToKey": "metadata.annotations.\"openshift.io/sa.scc.uid-range\""}, + {"pathToKey": "metadata.annotations.\"openshift.io/sa.scc.mcs\""}, + {"pathToKey": "metadata.annotations.\"openshift.io/sa.scc.supplemental-groups\""}, + {"pathToKey": "metadata.annotations.\"machineconfiguration.openshift.io/mc-name-suffix\""}, + {"pathToKey": "metadata.annotations.\"kubectl.kubernetes.io/last-applied-configuration\""}, + {"pathToKey": "metadata.annotations.\"nmstate.io/webhook-mutating-timestamp\""}, + {"pathToKey": "metadata.annotations.\"ran.openshift.io/ztp-gitops-generated\""}, + {"pathToKey": "metadata.annotations.\"include.release.openshift.io/ibm-cloud-managed\""}, + {"pathToKey": "metadata.annotations.\"include.release.openshift.io/self-managed-high-availability\""}, + {"pathToKey": "metadata.annotations.\"include.release.openshift.io/single-node-developer\""}, + {"pathToKey": "metadata.annotations.\"release.openshift.io/create-only\""}, + {"pathToKey": "metadata.annotations.\"capability.openshift.io/name\""}, + {"pathToKey": "metadata.annotations.\"olm.providedAPIs\""}, + {"pathToKey": "metadata.annotations.\"operator.sriovnetwork.openshift.io/last-network-namespace\""}, + {"pathToKey": "metadata.annotations.\"k8s.v1.cni.cncf.io/resourceName\""}, + {"pathToKey": "metadata.annotations.\"security.openshift.io/MinimallySufficientPodSecurityStandard\""}, + {"pathToKey": "metadata.labels.\"kubernetes.io/metadata.name\""}, + // OLM and PSA inject multiple label keys under these prefixes; omit by prefix so + // metadata.yaml matches kube-compare ManifestPathV1 isPrefix semantics. + {"pathToKey": `metadata.labels."pod-security.kubernetes.io/"`, "isPrefix": true}, + {"pathToKey": `metadata.labels."operators.coreos.com/"`, "isPrefix": true}, + {"pathToKey": "metadata.labels.\"security.openshift.io/scc.podSecurityLabelSync\""}, + {"pathToKey": "metadata.labels.\"lca.openshift.io/target-ocp-version\""}, + {"pathToKey": "metadata.labels.\"olm.operatorgroup.uid\""}, + {"pathToKey": "metadata.resourceVersion"}, + {"pathToKey": "metadata.uid"}, + {"pathToKey": "metadata.creationTimestamp"}, + {"pathToKey": "metadata.generation"}, + {"pathToKey": "metadata.finalizers"}, + {"pathToKey": "metadata.ownerReferences"}, + {"pathToKey": "spec.finalizers"}, + {"pathToKey": "spec.ownerReferences"}, + {"pathToKey": "spec.clusterID"}, + {"pathToKey": "spec.filters"}, + }, + "all": []map[string]any{ + {"include": "defaults"}, + {"pathToKey": "status"}, + }, + }, + } +} + +const ( + annotationPathPrefix = `metadata.annotations."` + labelPathPrefix = `metadata.labels."` +) + +func pathToKeyForAnnotation(key string) string { + return annotationPathPrefix + key + `"` +} + +func pathToKeyForLabel(key string) string { + return labelPathPrefix + key + `"` +} + +// mergeFieldsToOmit returns fieldsToOmit metadata: built-in defaults plus any +// omitAnnotations / omitLabels from the refgen config. +func mergeFieldsToOmit(cfg *RefgenConfig) map[string]any { + fto := defaultFieldsToOmit() + if cfg == nil || (len(cfg.OmitAnnotations) == 0 && len(cfg.OmitLabels) == 0) { + return fto + } + itemsAny, itemsPresent := fto["items"] + if !itemsPresent { + panic(fmt.Sprintf( + "internal: defaultFieldsToOmit changed: missing top-level \"items\" (mergeFieldsToOmit RefgenConfig OmitAnnotations=%d OmitLabels=%d)", + len(cfg.OmitAnnotations), len(cfg.OmitLabels))) + } + items, itemsOK := itemsAny.(map[string]any) + if !itemsOK { + panic(fmt.Sprintf( + "internal: defaultFieldsToOmit changed: expected items to be map[string]any but was %T (mergeFieldsToOmit RefgenConfig OmitAnnotations=%d OmitLabels=%d)", + itemsAny, len(cfg.OmitAnnotations), len(cfg.OmitLabels))) + } + if items == nil { + panic(fmt.Sprintf( + "internal: defaultFieldsToOmit changed: items map is nil (mergeFieldsToOmit RefgenConfig OmitAnnotations=%d OmitLabels=%d)", + len(cfg.OmitAnnotations), len(cfg.OmitLabels))) + } + defaultsAny := items["defaults"] + orig, defaultsOK := defaultsAny.([]map[string]any) + if !defaultsOK { + panic(fmt.Sprintf( + "internal: defaultFieldsToOmit changed: expected items.defaults to be []map[string]any but was %T (mergeFieldsToOmit RefgenConfig OmitAnnotations=%d OmitLabels=%d)", + defaultsAny, len(cfg.OmitAnnotations), len(cfg.OmitLabels))) + } + merged := make([]map[string]any, 0, len(orig)+len(cfg.OmitAnnotations)+len(cfg.OmitLabels)) + merged = append(merged, orig...) + for _, k := range cfg.OmitAnnotations { + merged = append(merged, map[string]any{"pathToKey": pathToKeyForAnnotation(k)}) + } + for _, k := range cfg.OmitLabels { + merged = append(merged, map[string]any{"pathToKey": pathToKeyForLabel(k)}) + } + items["defaults"] = merged + return fto +} + +func defaultsEntryIsPrefix(m map[string]any) bool { + v, ok := m["isPrefix"] + if !ok { + return false + } + switch x := v.(type) { + case bool: + return x + case string: + return strings.EqualFold(x, "true") + default: + return false + } +} + +func forEachDefaultsEntry(defaultsAny any, fn func(pathToKey string, isPrefix bool)) { + switch defaults := defaultsAny.(type) { + case []map[string]any: + for _, m := range defaults { + p, _ := m["pathToKey"].(string) + if p == "" { + continue + } + fn(p, defaultsEntryIsPrefix(m)) + } + case []any: + for _, elem := range defaults { + m, ok := elem.(map[string]any) + if !ok { + continue + } + pv, ok := m["pathToKey"].(string) + if !ok || pv == "" { + continue + } + fn(pv, defaultsEntryIsPrefix(m)) + } + } +} + +// omitAnnotationAndLabelKeys returns exact and prefix keys for annotations and labels +// described by fieldsToOmit defaults (metadata.annotations."key" / metadata.labels."key", +// optional isPrefix for prefix removal on the underlying map keys). +func omitAnnotationAndLabelKeys(fto map[string]any) ( + annExact, annPrefix, lblExact, lblPrefix []string, +) { + items, _ := fto["items"].(map[string]any) + if items == nil { + return nil, nil, nil, nil + } + seenAnn := make(map[string]struct{}) + seenAnnPref := make(map[string]struct{}) + seenLbl := make(map[string]struct{}) + seenLblPref := make(map[string]struct{}) + + forEachDefaultsEntry(items["defaults"], func(p string, isPrefix bool) { + if strings.HasPrefix(p, annotationPathPrefix) && strings.HasSuffix(p, `"`) && len(p) > len(annotationPathPrefix)+1 { + k := p[len(annotationPathPrefix) : len(p)-1] + if isPrefix { + if _, ok := seenAnnPref[k]; !ok { + seenAnnPref[k] = struct{}{} + annPrefix = append(annPrefix, k) + } + return + } + if _, ok := seenAnn[k]; !ok { + seenAnn[k] = struct{}{} + annExact = append(annExact, k) + } + return + } + if strings.HasPrefix(p, labelPathPrefix) && strings.HasSuffix(p, `"`) && len(p) > len(labelPathPrefix)+1 { + k := p[len(labelPathPrefix) : len(p)-1] + if isPrefix { + if _, ok := seenLblPref[k]; !ok { + seenLblPref[k] = struct{}{} + lblPrefix = append(lblPrefix, k) + } + return + } + if _, ok := seenLbl[k]; !ok { + seenLbl[k] = struct{}{} + lblExact = append(lblExact, k) + } + } + }) + return annExact, annPrefix, lblExact, lblPrefix +} + +func deleteMapKeysByPrefix(m map[string]any, prefixes []string) { + if len(m) == 0 || len(prefixes) == 0 { + return + } + for k := range m { + for _, pref := range prefixes { + if strings.HasPrefix(k, pref) { + delete(m, k) + break + } + } + } +} + +// sanitizeFilename converts a resource name to a safe filename. +func sanitizeFilename(name string) string { + safe := sanitizePathChars.ReplaceAllString(name, "-") + safe = sanitizePathDashes.ReplaceAllString(safe, "-") + safe = strings.Trim(safe, "-.") + if safe == "" || safe == "." || safe == ".." || strings.Contains(safe, "..") { + return "unnamed" + } + return safe +} + +// sanitizePathSegment maps user-controlled strings (e.g. Kind) to a single directory name +// that cannot contain path separators or traverse outside the output directory when joined. +func sanitizePathSegment(s string) string { + s = strings.ReplaceAll(s, string(filepath.Separator), "-") + s = strings.ReplaceAll(s, "/", "-") + s = strings.ReplaceAll(s, `\`, "-") + safe := sanitizePathChars.ReplaceAllString(s, "-") + safe = sanitizePathDashes.ReplaceAllString(safe, "-") + safe = strings.Trim(safe, "-.") + if safe == "" || safe == "." || safe == ".." || strings.Contains(safe, "..") { + return "resource" + } + return safe +} + +// cleanResource returns a deep copy of the object (via DeepCopy) with runtime-managed fields removed. +// fto is the merged fieldsToOmit map (defaults entries drive annotation/label key removal). +func cleanResource(obj *unstructured.Unstructured, fto map[string]any) map[string]any { + result := obj.DeepCopy().Object + if metadata, ok := result["metadata"].(map[string]any); ok { + for _, key := range []string{"resourceVersion", "uid", "creationTimestamp", "generation", "managedFields", "selfLink"} { + delete(metadata, key) + } + annExact, annPrefix, lblExact, lblPrefix := omitAnnotationAndLabelKeys(fto) + if ann, ok := metadata["annotations"].(map[string]any); ok { + for _, k := range annExact { + delete(ann, k) + } + deleteMapKeysByPrefix(ann, annPrefix) + if len(ann) == 0 { + delete(metadata, "annotations") + } + } + if lbl, ok := metadata["labels"].(map[string]any); ok { + for _, k := range lblExact { + delete(lbl, k) + } + deleteMapKeysByPrefix(lbl, lblPrefix) + if len(lbl) == 0 { + delete(metadata, "labels") + } + } + } + delete(result, "status") + return result +} + +// Generator generates kube-compare reference files. +type Generator struct { + config *RefgenConfig + outputDir string // absolute, cleaned root after Generate begins + files map[int][]fileEntry // config.Resources index: one slice per ResourceSpec row (same Kind allowed) + fieldsToOmit map[string]any +} + +type fileEntry struct { + path string +} + +// NewGenerator creates a new Generator. +func NewGenerator(config *RefgenConfig, outputDir string) *Generator { + if outputDir == "" { + outputDir = config.OutputDir + } + return &Generator{ + config: config, + outputDir: outputDir, + files: make(map[int][]fileEntry), + fieldsToOmit: mergeFieldsToOmit(config), + } +} + +// Generate writes the reference directory with metadata.yaml and CR files. +// resourcesBySpec must use the same pointers as the configured rows, i.e. +// &config.Resources[i] for each index i (see Options.Run); each row gets its +// own metadata part even when Kind matches another row. +func (g *Generator) Generate(resourcesBySpec map[*ResourceSpec][]*unstructured.Unstructured) (string, error) { + outputAbs, err := filepath.Abs(filepath.Clean(g.outputDir)) + if err != nil { + return "", fmt.Errorf("failed to resolve output directory: %w", err) + } + g.outputDir = outputAbs + if err := os.MkdirAll(g.outputDir, 0o755); err != nil { + return "", fmt.Errorf("failed to create output directory: %w", err) + } + g.files = make(map[int][]fileEntry) + for i := range g.config.Resources { + spec := &g.config.Resources[i] + resources := resourcesBySpec[spec] + if len(resources) == 0 { + continue + } + if err := g.writeCRFiles(i, spec, resources); err != nil { + return "", err + } + } + + if err := g.writeMetadata(); err != nil { + return "", err + } + return g.outputDir, nil +} + +func (g *Generator) pathWithinOutput(path string) error { + abs, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("failed to resolve path: %w", err) + } + rel, err := filepath.Rel(g.outputDir, abs) + if err != nil { + return fmt.Errorf("invalid output path: %w", err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return fmt.Errorf("path escapes output directory: %s", path) + } + return nil +} + +func (g *Generator) writeCRFiles(specIndex int, spec *ResourceSpec, resources []*unstructured.Unstructured) error { + safeKind := sanitizePathSegment(spec.Kind) + kindDir := filepath.Join(g.outputDir, safeKind) + if err := g.pathWithinOutput(kindDir); err != nil { + return err + } + if err := os.MkdirAll(kindDir, 0o755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", kindDir, err) + } + for _, r := range resources { + clean := cleanResource(r, g.fieldsToOmit) + data, err := yaml.Marshal(clean) + if err != nil { + return fmt.Errorf("failed to marshal %s: %w", r.GetName(), err) + } + filename := sanitizeFilename(r.GetName()) + ".yaml" + crPath := filepath.Join(kindDir, filename) + counter := 1 + for { + if err := g.pathWithinOutput(crPath); err != nil { + return err + } + f, err := os.OpenFile(crPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err == nil { + if err := f.Close(); err != nil { + return fmt.Errorf("failed to close %s: %w", crPath, err) + } + break + } + if errors.Is(err, fs.ErrExist) { + filename = fmt.Sprintf("%s-%d.yaml", sanitizeFilename(r.GetName()), counter) + crPath = filepath.Join(kindDir, filename) + counter++ + continue + } + return fmt.Errorf("failed to reserve output path %s: %w", crPath, err) + } + if err := os.WriteFile(crPath, data, 0o600); err != nil { + return fmt.Errorf("failed to write %s: %w", crPath, err) + } + relativePath := safeKind + "/" + filepath.Base(crPath) + g.files[specIndex] = append(g.files[specIndex], fileEntry{path: relativePath}) + } + return nil +} + +func (g *Generator) writeMetadata() error { + metadata := map[string]any{ + "apiVersion": "v2", + "parts": []map[string]any{}, + "fieldsToOmit": g.fieldsToOmit, + } + parts := metadata["parts"].([]map[string]any) + for i := range g.config.Resources { + spec := &g.config.Resources[i] + entries := g.files[i] + if len(entries) == 0 { + continue + } + safeKind := sanitizePathSegment(spec.Kind) + paths := make([]map[string]string, 0, len(entries)) + for _, e := range entries { + paths = append(paths, map[string]string{"path": e.path}) + } + component := map[string]any{"name": strings.ToLower(safeKind)} + if spec.Required { + component["allOf"] = paths + } else { + component["anyOf"] = paths + } + reqStr := "optional" + reqTitle := "Optional" + if spec.Required { + reqStr = "required" + reqTitle = "Required" + } + part := map[string]any{ + "name": fmt.Sprintf("%s-%s", reqStr, strings.ToLower(safeKind)), + "description": fmt.Sprintf("%s %s resources", reqTitle, spec.Kind), + "components": []map[string]any{component}, + } + parts = append(parts, part) + } + metadata["parts"] = parts + data, err := yaml.Marshal(metadata) + if err != nil { + return fmt.Errorf("failed to marshal metadata: %w", err) + } + metadataPath := filepath.Join(g.outputDir, "metadata.yaml") + if err := g.pathWithinOutput(metadataPath); err != nil { + return err + } + if err := os.WriteFile(metadataPath, data, 0o600); err != nil { + return fmt.Errorf("failed to write metadata.yaml: %w", err) + } + return nil +} diff --git a/pkg/generate/generator_test.go b/pkg/generate/generator_test.go new file mode 100644 index 00000000..eb88eb08 --- /dev/null +++ b/pkg/generate/generator_test.go @@ -0,0 +1,568 @@ +// SPDX-License-Identifier:Apache-2.0 + +package generate + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/yaml" +) + +func TestSanitizeFilename(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + expected string + }{ + {name: "simple", input: "my-resource", expected: "my-resource"}, + {name: "empty", input: "", expected: "unnamed"}, + {name: "only special chars", input: "@#$", expected: "unnamed"}, + {name: "spaces and dots", input: "a b.c", expected: "a-b.c"}, + {name: "collapse dashes", input: "a---b", expected: "a-b"}, + {name: "trim dashes", input: "--x--", expected: "x"}, + {name: "dot only", input: ".", expected: "unnamed"}, + {name: "dot dot", input: "..", expected: "unnamed"}, + {name: "contains dot dot after sanitize", input: "a..b", expected: "unnamed"}, + {name: "trim dots and dashes", input: "..--x--..", expected: "x"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, sanitizeFilename(tt.input)) + }) + } +} + +func TestSanitizePathSegment(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + expected string + }{ + {name: "simple kind", input: "ConfigMap", expected: "ConfigMap"}, + {name: "empty", input: "", expected: "resource"}, + {name: "dot only", input: ".", expected: "resource"}, + {name: "dot dot", input: "..", expected: "resource"}, + {name: "contains dot dot after sanitize", input: "a..b", expected: "resource"}, + {name: "forward slash", input: "Foo/Bar", expected: "Foo-Bar"}, + {name: "backslash", input: `Foo\Bar`, expected: "Foo-Bar"}, + {name: "trim dots and dashes", input: "..--X--..", expected: "X"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, sanitizePathSegment(tt.input)) + }) + } +} + +func TestCleanResource(t *testing.T) { + t.Parallel() + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "cm1", + "namespace": "ns1", + "resourceVersion": "99", + "uid": "abc", + "creationTimestamp": map[string]any{ + "time": "now", + }, + "generation": int64(1), + "managedFields": []any{ + map[string]any{"manager": "kubectl"}, + }, + "selfLink": "/api/v1/...", + "annotations": map[string]any{}, + "labels": map[string]any{}, + }, + "data": map[string]any{ + "k": "v", + }, + "status": map[string]any{ + "phase": "active", + }, + }, + } + + out := cleanResource(obj, defaultFieldsToOmit()) + require.NotContains(t, out, "status") + md, ok := out["metadata"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "cm1", md["name"]) + assert.Equal(t, "ns1", md["namespace"]) + for _, removed := range []string{"resourceVersion", "uid", "creationTimestamp", "generation", "managedFields", "selfLink"} { + assert.NotContains(t, md, removed) + } + assert.NotContains(t, md, "annotations") + assert.NotContains(t, md, "labels") + data, ok := out["data"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "v", data["k"]) +} + +func TestCleanResourceKeepsUnlistedAnnotationsAndLabels(t *testing.T) { + t.Parallel() + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "cm1", + "annotations": map[string]any{ + "keep.me/custom": "val", + "kubectl.kubernetes.io/last-applied-configuration": "blob", + }, + "labels": map[string]any{ + "app": "nginx", + "kubernetes.io/metadata.name": "should-strip", + "security.openshift.io/scc.podSecurityLabelSync": "true", + }, + }, + }, + } + + out := cleanResource(obj, defaultFieldsToOmit()) + md := out["metadata"].(map[string]any) + ann := md["annotations"].(map[string]any) + assert.Equal(t, "val", ann["keep.me/custom"]) + assert.NotContains(t, ann, "kubectl.kubernetes.io/last-applied-configuration") + + lbl := md["labels"].(map[string]any) + assert.Equal(t, "nginx", lbl["app"]) + assert.NotContains(t, lbl, "kubernetes.io/metadata.name") + assert.NotContains(t, lbl, "security.openshift.io/scc.podSecurityLabelSync") +} + +func TestCleanResourceStripsLabelKeyPrefixesFromDefaults(t *testing.T) { + t.Parallel() + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "cm1", + "labels": map[string]any{ + "app": "nginx", + "operators.coreos.com/foo": "bar", + "operators.coreos.com/test": "v", + "pod-security.kubernetes.io/enforce": "restricted", + "pod-security.kubernetes.io/audit": "restricted", + "keep.me/should-stay": "yes", + }, + }, + }, + } + out := cleanResource(obj, defaultFieldsToOmit()) + md := out["metadata"].(map[string]any) + lbl := md["labels"].(map[string]any) + assert.Equal(t, "nginx", lbl["app"]) + assert.Equal(t, "yes", lbl["keep.me/should-stay"]) + assert.NotContains(t, lbl, "operators.coreos.com/foo") + assert.NotContains(t, lbl, "operators.coreos.com/test") + assert.NotContains(t, lbl, "pod-security.kubernetes.io/enforce") + assert.NotContains(t, lbl, "pod-security.kubernetes.io/audit") +} + +func TestGeneratorMetadataDefaultsIncludeLabelPrefixOmissions(t *testing.T) { + t.Parallel() + outDir := t.TempDir() + cfg := &RefgenConfig{ + APIVersion: "refgen/v1", + OutputDir: outDir, + Resources: []ResourceSpec{{Kind: "ConfigMap", APIVersion: "v1", Required: true}}, + } + cm := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{"name": "alpha"}, + }, + } + g := NewGenerator(cfg, outDir) + _, err := g.Generate(map[*ResourceSpec][]*unstructured.Unstructured{&cfg.Resources[0]: {cm}}) + require.NoError(t, err) + + metaRaw, err := os.ReadFile(filepath.Join(outDir, "metadata.yaml")) + require.NoError(t, err) + var meta map[string]any + require.NoError(t, yaml.Unmarshal(metaRaw, &meta)) + fto := meta["fieldsToOmit"].(map[string]any) + items := fto["items"].(map[string]any) + defaults := items["defaults"].([]any) + var psa, olm bool + for _, e := range defaults { + m := e.(map[string]any) + p, _ := m["pathToKey"].(string) + isP := false + if v, ok := m["isPrefix"]; ok { + switch x := v.(type) { + case bool: + isP = x + case string: + isP = strings.EqualFold(x, "true") + } + } + if p == `metadata.labels."pod-security.kubernetes.io/"` && isP { + psa = true + } + if p == `metadata.labels."operators.coreos.com/"` && isP { + olm = true + } + } + assert.True(t, psa, "defaults should include pod-security label prefix with isPrefix") + assert.True(t, olm, "defaults should include operators.coreos.com label prefix with isPrefix") +} + +func TestCleanResourceCustomOmitFromConfig(t *testing.T) { + t.Parallel() + fto := mergeFieldsToOmit(&RefgenConfig{ + OmitAnnotations: []string{"my.operator/strip-me"}, + OmitLabels: []string{"ephemeral.cluster/hash"}, + }) + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "cm1", + "annotations": map[string]any{ + "keep.me/custom": "val", + "my.operator/strip-me": "gone", + }, + "labels": map[string]any{ + "app": "nginx", + "ephemeral.cluster/hash": "abc", + "kubernetes.io/metadata.name": "strip-default", + }, + }, + }, + } + out := cleanResource(obj, fto) + md := out["metadata"].(map[string]any) + ann := md["annotations"].(map[string]any) + assert.Equal(t, "val", ann["keep.me/custom"]) + assert.NotContains(t, ann, "my.operator/strip-me") + + lbl := md["labels"].(map[string]any) + assert.Equal(t, "nginx", lbl["app"]) + assert.NotContains(t, lbl, "ephemeral.cluster/hash") + assert.NotContains(t, lbl, "kubernetes.io/metadata.name") +} + +func TestGeneratorGenerate(t *testing.T) { + t.Parallel() + + t.Run("required uses allOf in metadata", func(t *testing.T) { + t.Parallel() + outDir := t.TempDir() + cfg := &RefgenConfig{ + APIVersion: "refgen/v1", + OutputDir: outDir, + Resources: []ResourceSpec{{Kind: "ConfigMap", APIVersion: "v1", Required: true}}, + } + cm := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "alpha", + }, + }, + } + g := NewGenerator(cfg, outDir) + resourcesBySpec := map[*ResourceSpec][]*unstructured.Unstructured{ + &cfg.Resources[0]: {cm}, + } + absOut, err := g.Generate(resourcesBySpec) + require.NoError(t, err) + assert.True(t, filepath.IsAbs(absOut)) + + cmPath := filepath.Join(outDir, "ConfigMap", "alpha.yaml") + _, err = os.Stat(cmPath) + require.NoError(t, err) + + metaRaw, err := os.ReadFile(filepath.Join(outDir, "metadata.yaml")) + require.NoError(t, err) + var meta map[string]any + require.NoError(t, yaml.Unmarshal(metaRaw, &meta)) + assert.Equal(t, "v2", meta["apiVersion"]) + fto, ok := meta["fieldsToOmit"].(map[string]any) + require.True(t, ok) + assert.NotEmpty(t, fto) + + parts := meta["parts"].([]any) + require.Len(t, parts, 1) + part := parts[0].(map[string]any) + comps := part["components"].([]any) + require.Len(t, comps, 1) + comp := comps[0].(map[string]any) + _, hasAll := comp["allOf"] + _, hasAny := comp["anyOf"] + assert.True(t, hasAll) + assert.False(t, hasAny) + }) + + t.Run("optional uses anyOf in metadata", func(t *testing.T) { + t.Parallel() + outDir := t.TempDir() + cfg := &RefgenConfig{ + APIVersion: "refgen/v1", + OutputDir: outDir, + Resources: []ResourceSpec{{Kind: "Secret", APIVersion: "v1", Required: false}}, + } + sec := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]any{ + "name": "token", + }, + }, + } + g := NewGenerator(cfg, outDir) + _, err := g.Generate(map[*ResourceSpec][]*unstructured.Unstructured{&cfg.Resources[0]: {sec}}) + require.NoError(t, err) + + metaRaw, err := os.ReadFile(filepath.Join(outDir, "metadata.yaml")) + require.NoError(t, err) + var meta map[string]any + require.NoError(t, yaml.Unmarshal(metaRaw, &meta)) + parts := meta["parts"].([]any) + require.Len(t, parts, 1) + part := parts[0].(map[string]any) + comp := part["components"].([]any)[0].(map[string]any) + _, hasAll := comp["allOf"] + _, hasAny := comp["anyOf"] + assert.False(t, hasAll) + assert.True(t, hasAny) + }) + + t.Run("duplicate names get suffix", func(t *testing.T) { + t.Parallel() + outDir := t.TempDir() + cfg := &RefgenConfig{ + APIVersion: "refgen/v1", + OutputDir: outDir, + Resources: []ResourceSpec{{Kind: "ConfigMap", APIVersion: "v1", Required: true}}, + } + one := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{"name": "dup"}, + }, + } + two := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{"name": "dup"}, + }, + } + g := NewGenerator(cfg, outDir) + _, err := g.Generate(map[*ResourceSpec][]*unstructured.Unstructured{&cfg.Resources[0]: {one, two}}) + require.NoError(t, err) + + _, err = os.Stat(filepath.Join(outDir, "ConfigMap", "dup.yaml")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(outDir, "ConfigMap", "dup-1.yaml")) + require.NoError(t, err) + }) + + t.Run("same Kind different Required does not clobber prior spec files", func(t *testing.T) { + t.Parallel() + outDir := t.TempDir() + cfg := &RefgenConfig{ + APIVersion: "refgen/v1", + OutputDir: outDir, + Resources: []ResourceSpec{ + {Kind: "Namespace", APIVersion: "v1", Required: true}, + {Kind: "Namespace", APIVersion: "v1", Required: false}, + }, + } + nsOne := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": map[string]any{"name": "required-ns"}, + }, + } + nsTwo := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": map[string]any{"name": "optional-ns"}, + }, + } + g := NewGenerator(cfg, outDir) + _, err := g.Generate(map[*ResourceSpec][]*unstructured.Unstructured{ + &cfg.Resources[0]: {nsOne}, + &cfg.Resources[1]: {nsTwo}, + }) + require.NoError(t, err) + + _, err = os.Stat(filepath.Join(outDir, "Namespace", "required-ns.yaml")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(outDir, "Namespace", "optional-ns.yaml")) + require.NoError(t, err) + metaRaw, err := os.ReadFile(filepath.Join(outDir, "metadata.yaml")) + require.NoError(t, err) + var meta map[string]any + require.NoError(t, yaml.Unmarshal(metaRaw, &meta)) + parts := meta["parts"].([]any) + require.Len(t, parts, 2, "each ResourceSpec row must get its own metadata part even when Kind matches") + // Order follows refgen config: required first, then optional. + partReq := parts[0].(map[string]any) + compReq := partReq["components"].([]any)[0].(map[string]any) + _, hasAllReq := compReq["allOf"] + _, hasAnyReq := compReq["anyOf"] + assert.True(t, hasAllReq) + assert.False(t, hasAnyReq) + pathsReq := compReq["allOf"].([]any) + require.Len(t, pathsReq, 1) + assert.Equal(t, "Namespace/required-ns.yaml", pathsReq[0].(map[string]any)["path"]) + + partOpt := parts[1].(map[string]any) + compOpt := partOpt["components"].([]any)[0].(map[string]any) + _, hasAllOpt := compOpt["allOf"] + _, hasAnyOpt := compOpt["anyOf"] + assert.False(t, hasAllOpt) + assert.True(t, hasAnyOpt) + pathsOpt := compOpt["anyOf"].([]any) + require.Len(t, pathsOpt, 1) + assert.Equal(t, "Namespace/optional-ns.yaml", pathsOpt[0].(map[string]any)["path"]) + }) + + t.Run("skips empty resource list still writes metadata", func(t *testing.T) { + t.Parallel() + outDir := t.TempDir() + cfg := &RefgenConfig{ + APIVersion: "refgen/v1", + OutputDir: outDir, + Resources: []ResourceSpec{ + {Kind: "Pod", APIVersion: "v1", Required: false}, + {Kind: "Namespace", APIVersion: "v1", Required: true}, + }, + } + ns := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": map[string]any{"name": "openshift"}, + }, + } + g := NewGenerator(cfg, outDir) + _, err := g.Generate(map[*ResourceSpec][]*unstructured.Unstructured{ + &cfg.Resources[0]: {}, + &cfg.Resources[1]: {ns}, + }) + require.NoError(t, err) + + _, err = os.Stat(filepath.Join(outDir, "metadata.yaml")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(outDir, "Namespace", "openshift.yaml")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(outDir, "Pod")) + assert.True(t, os.IsNotExist(err)) + }) + + t.Run("sanitized kind directory", func(t *testing.T) { + t.Parallel() + outDir := t.TempDir() + cfg := &RefgenConfig{ + APIVersion: "refgen/v1", + OutputDir: outDir, + Resources: []ResourceSpec{{Kind: "Foo/Bar", APIVersion: "v1", Required: true}}, + } + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "Foo/Bar", + "metadata": map[string]any{"name": "x"}, + }, + } + g := NewGenerator(cfg, outDir) + _, err := g.Generate(map[*ResourceSpec][]*unstructured.Unstructured{&cfg.Resources[0]: {obj}}) + require.NoError(t, err) + + safeKind := sanitizePathSegment("Foo/Bar") + _, err = os.Stat(filepath.Join(outDir, safeKind, "x.yaml")) + require.NoError(t, err) + }) + + t.Run("custom omitAnnotations and omitLabels in metadata and CRs", func(t *testing.T) { + t.Parallel() + outDir := t.TempDir() + cfg := &RefgenConfig{ + APIVersion: "refgen/v1", + OutputDir: outDir, + OmitAnnotations: []string{"company.com/revision"}, + OmitLabels: []string{"rollout-id"}, + Resources: []ResourceSpec{{Kind: "ConfigMap", APIVersion: "v1", Required: true}}, + } + cm := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "app", + "annotations": map[string]any{ + "company.com/revision": "99", + "keep": "yes", + }, + "labels": map[string]any{ + "rollout-id": "r1", + "app": "web", + }, + }, + }, + } + g := NewGenerator(cfg, outDir) + _, err := g.Generate(map[*ResourceSpec][]*unstructured.Unstructured{&cfg.Resources[0]: {cm}}) + require.NoError(t, err) + + cmRaw, err := os.ReadFile(filepath.Join(outDir, "ConfigMap", "app.yaml")) + require.NoError(t, err) + var written map[string]any + require.NoError(t, yaml.Unmarshal(cmRaw, &written)) + md := written["metadata"].(map[string]any) + ann := md["annotations"].(map[string]any) + assert.Equal(t, "yes", ann["keep"]) + assert.NotContains(t, ann, "company.com/revision") + lbl := md["labels"].(map[string]any) + assert.Equal(t, "web", lbl["app"]) + assert.NotContains(t, lbl, "rollout-id") + + metaRaw, err := os.ReadFile(filepath.Join(outDir, "metadata.yaml")) + require.NoError(t, err) + var meta map[string]any + require.NoError(t, yaml.Unmarshal(metaRaw, &meta)) + fto := meta["fieldsToOmit"].(map[string]any) + items := fto["items"].(map[string]any) + defaults := items["defaults"].([]any) + var hasAnn, hasLbl bool + for _, e := range defaults { + m := e.(map[string]any) + p, _ := m["pathToKey"].(string) + if p == `metadata.annotations."company.com/revision"` { + hasAnn = true + } + if p == `metadata.labels."rollout-id"` { + hasLbl = true + } + } + assert.True(t, hasAnn, "custom annotation path in fieldsToOmit.defaults") + assert.True(t, hasLbl, "custom label path in fieldsToOmit.defaults") + }) +}