Skip to content
Open
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
20 changes: 20 additions & 0 deletions docs/example/generate-config.yaml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
57 changes: 55 additions & 2 deletions pkg/compare/compare.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
`)

Expand All @@ -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://<IMAGE>:<TAG>:/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
`)
)

Expand Down Expand Up @@ -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
Expand All @@ -167,7 +183,7 @@ func NewCmd(f kcmdutil.Factory, streams genericiooptions.IOStreams) *cobra.Comma
}

cmd := &cobra.Command{
Use: "cluster-compare -r <Reference File>",
Use: "cluster-compare (-r <Reference File> | -g <Generate Config>)",
DisableFlagsInUseLine: true,
Short: i18n.T("Compare a reference configuration and a set of cluster configuration CRs."),
Long: compareLong,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.")
Expand All @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down
28 changes: 28 additions & 0 deletions pkg/compare/compare_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
98 changes: 98 additions & 0 deletions pkg/generate/config.go
Original file line number Diff line number Diff line change
@@ -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"`
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect that users will want to extend the list of ignored labels (defaults you have below) explicitly in the config file. This will allow them to capture any changes in the config file and re-generate after changes to the cluster, new versions, etc.


// 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
}
Loading