diff --git a/cmd/nomos/bugreport/bugreport.go b/cmd/nomos/bugreport/bugreport.go deleted file mode 100644 index aec3a39e6c..0000000000 --- a/cmd/nomos/bugreport/bugreport.go +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright 2022 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package bugreport - -import ( - "fmt" - - "github.com/spf13/cobra" - "k8s.io/client-go/kubernetes" - "k8s.io/klog/v2" - "kpt.dev/configsync/cmd/nomos/flags" - "kpt.dev/configsync/pkg/api/configmanagement" - "kpt.dev/configsync/pkg/bugreport" - "kpt.dev/configsync/pkg/client/restconfig" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -func init() { - Cmd.Flags().DurationVar(&flags.ClientTimeout, "timeout", restconfig.DefaultTimeout, "Timeout for connecting to the cluster") -} - -// Cmd retrieves readers for all relevant nomos container logs and cluster state commands and writes them to a zip file -var Cmd = &cobra.Command{ - Use: "bugreport", - Short: fmt.Sprintf("Generates a zip file of relevant %v debug information.", configmanagement.CLIName), - Long: "Generates a zip file in your current directory containing an aggregate of the logs and cluster state for debugging purposes.", - RunE: func(cmd *cobra.Command, _ []string) error { - // Don't show usage on error, as argument validation passed. - cmd.SilenceUsage = true - - // Send all logs to STDERR. - if err := cmd.InheritedFlags().Lookup("stderrthreshold").Value.Set("0"); err != nil { - klog.Errorf("failed to increase logging STDERR threshold: %v", err) - } - - cfg, err := restconfig.NewRestConfig(flags.ClientTimeout) - if err != nil { - return fmt.Errorf("failed to create rest config: %w", err) - } - cs, err := kubernetes.NewForConfig(cfg) - if err != nil { - return fmt.Errorf("failed to create kubernetes client set: %w", err) - } - c, err := client.New(cfg, client.Options{}) - if err != nil { - return fmt.Errorf("failed to create kubernetes client: %w", err) - } - - report, err := bugreport.New(cmd.Context(), c, cs) - if err != nil { - return fmt.Errorf("failed to initialize bug reporter: %w", err) - } - - if err = report.Open(); err != nil { - return err - } - - report.WriteRawInZip(report.FetchLogSources(cmd.Context())) - report.WriteRawInZip(report.FetchResources(cmd.Context())) - report.WriteRawInZip(report.FetchCMSystemPods(cmd.Context())) - report.AddNomosStatusToZip(cmd.Context()) - report.AddNomosVersionToZip(cmd.Context()) - - report.Close() - return nil - }, -} diff --git a/cmd/nomos/bugreport/cmd.go b/cmd/nomos/bugreport/cmd.go new file mode 100644 index 0000000000..25db0a42d0 --- /dev/null +++ b/cmd/nomos/bugreport/cmd.go @@ -0,0 +1,48 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bugreport + +import ( + "fmt" + + "github.com/spf13/cobra" + "kpt.dev/configsync/cmd/nomos/flags" + "kpt.dev/configsync/pkg/api/configmanagement" +) + +// Cmd retrieves readers for all relevant nomos container logs and cluster state commands and writes them to a zip file +var Cmd = &cobra.Command{ + Use: "bugreport", + Short: fmt.Sprintf("Generates a zip file of relevant %v debug information.", configmanagement.CLIName), + Long: "Generates a zip file in your current directory containing an aggregate of the logs and cluster state for debugging purposes.", + RunE: func(cmd *cobra.Command, _ []string) error { + // Don't show usage on error, as argument validation passed. + cmd.SilenceUsage = true + + // Create execution parameters from parsed flags + params := ExecParams{ + ClientTimeout: flags.ClientTimeout, + } + + // Execute the bugreport command logic + return ExecuteBugreport(cmd.Context(), params) + }, +} + +func init() { + // Initialize flags for the bugreport command + // This separation keeps flag definitions isolated from command execution logic + flags.AddClientTimeout(Cmd) +} diff --git a/cmd/nomos/bugreport/exec.go b/cmd/nomos/bugreport/exec.go new file mode 100644 index 0000000000..25ceadf1f6 --- /dev/null +++ b/cmd/nomos/bugreport/exec.go @@ -0,0 +1,68 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bugreport + +import ( + "context" + "fmt" + "time" + + "k8s.io/client-go/kubernetes" + "kpt.dev/configsync/pkg/bugreport" + "kpt.dev/configsync/pkg/client/restconfig" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ExecParams contains all parameters needed to execute the bugreport command +// This struct is completely independent of cobra command structures +type ExecParams struct { + ClientTimeout time.Duration +} + +// ExecuteBugreport executes the core bugreport command logic without any cobra dependencies +// This function encapsulates all the business logic for the bugreport command +func ExecuteBugreport(ctx context.Context, params ExecParams) error { + + cfg, err := restconfig.NewRestConfig(params.ClientTimeout) + if err != nil { + return fmt.Errorf("failed to create rest config: %w", err) + } + cs, err := kubernetes.NewForConfig(cfg) + if err != nil { + return fmt.Errorf("failed to create kubernetes client set: %w", err) + } + c, err := client.New(cfg, client.Options{}) + if err != nil { + return fmt.Errorf("failed to create kubernetes client: %w", err) + } + + report, err := bugreport.New(ctx, c, cs) + if err != nil { + return fmt.Errorf("failed to initialize bug reporter: %w", err) + } + + if err = report.Open(); err != nil { + return err + } + + report.WriteRawInZip(report.FetchLogSources(ctx)) + report.WriteRawInZip(report.FetchResources(ctx)) + report.WriteRawInZip(report.FetchCMSystemPods(ctx)) + report.AddNomosStatusToZip(ctx) + report.AddNomosVersionToZip(ctx) + + report.Close() + return nil +} diff --git a/cmd/nomos/flags/flags.go b/cmd/nomos/flags/flags.go index 6261d4fc85..144e102fa8 100644 --- a/cmd/nomos/flags/flags.go +++ b/cmd/nomos/flags/flags.go @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +// Package flags contains flags used by several CLI commands. +// The local flags will be found in the command folder in the flags.go file. package flags import ( @@ -119,6 +121,12 @@ func AddOutputFormat(cmd *cobra.Command) { `Output format. Accepts 'yaml' and 'json'.`) } +// AddClientTimeout adds the --timeout flag +func AddClientTimeout(cmd *cobra.Command) { + cmd.Flags().DurationVar(&ClientTimeout, "timeout", restconfig.DefaultTimeout, + fmt.Sprintf("Timeout for client connections; defaults to %s", restconfig.DefaultTimeout)) +} + // AddAPIServerTimeout adds the --api-server-timeout flag func AddAPIServerTimeout(cmd *cobra.Command) { cmd.Flags().DurationVar(&APIServerTimeout, "api-server-timeout", restconfig.DefaultTimeout, fmt.Sprintf("Client-side timeout for talking to the API server; defaults to %s", restconfig.DefaultTimeout)) diff --git a/cmd/nomos/hydrate/cmd.go b/cmd/nomos/hydrate/cmd.go new file mode 100644 index 0000000000..9987718b5d --- /dev/null +++ b/cmd/nomos/hydrate/cmd.go @@ -0,0 +1,61 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hydrate + +import ( + "github.com/spf13/cobra" + "kpt.dev/configsync/cmd/nomos/flags" + "kpt.dev/configsync/pkg/api/configsync" +) + +// localFlags holds the hydrate command flags +var localFlags = NewFlags() + +func init() { + // Initialize flags for the hydrate command + // This separation keeps flag definitions isolated from command execution logic + localFlags.AddFlags(Cmd) +} + +// Cmd is the Cobra object representing the hydrate command. +var Cmd = &cobra.Command{ + Use: "hydrate", + Short: "Compiles the local repository to the exact form that would be sent to the APIServer.", + Long: `Compiles the local repository to the exact form that would be sent to the APIServer. + +The output directory consists of one directory per declared Cluster, and defaultcluster/ for +clusters without declarations. Each directory holds the full set of configs for a single cluster, +which you could kubectl apply -fR to the cluster, or have Config Sync sync to the cluster.`, + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, _ []string) error { + // Don't show usage on error, as argument validation passed. + cmd.SilenceUsage = true + + // Create execution parameters from parsed flags + params := ExecParams{ + Clusters: flags.Clusters, + Path: flags.Path, + SkipAPIServer: flags.SkipAPIServer, + SourceFormat: configsync.SourceFormat(flags.SourceFormat), + OutputFormat: flags.OutputFormat, + APIServerTimeout: flags.APIServerTimeout, + Flat: localFlags.Flat, + OutPath: localFlags.OutPath, + } + + // Execute the hydrate command logic + return ExecuteHydrate(cmd.Context(), params) + }, +} diff --git a/cmd/nomos/hydrate/exec.go b/cmd/nomos/hydrate/exec.go new file mode 100644 index 0000000000..5d7df95f68 --- /dev/null +++ b/cmd/nomos/hydrate/exec.go @@ -0,0 +1,157 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hydrate + +import ( + "context" + "fmt" + "os" + "time" + + nomosparse "kpt.dev/configsync/cmd/nomos/parse" + "kpt.dev/configsync/cmd/nomos/util" + "kpt.dev/configsync/pkg/api/configsync" + "kpt.dev/configsync/pkg/declared" + "kpt.dev/configsync/pkg/hydrate" + "kpt.dev/configsync/pkg/importer/analyzer/ast" + "kpt.dev/configsync/pkg/importer/filesystem" + "kpt.dev/configsync/pkg/importer/filesystem/cmpath" + "kpt.dev/configsync/pkg/importer/reader" + "kpt.dev/configsync/pkg/status" +) + +// ExecParams contains all parameters needed to execute the hydrate command +// This struct is completely independent of cobra command structures +type ExecParams struct { + Clusters []string + Path string + SkipAPIServer bool + SourceFormat configsync.SourceFormat + OutputFormat string + APIServerTimeout time.Duration + Flat bool + OutPath string +} + +// ExecuteHydrate executes the core hydrate command logic without any cobra dependencies +// This function encapsulates all the business logic for the hydrate command +func ExecuteHydrate(ctx context.Context, params ExecParams) error { + sourceFormat := params.SourceFormat + if sourceFormat == "" { + sourceFormat = configsync.SourceFormatHierarchy + } + rootDir, needsHydrate, err := hydrate.ValidateHydrateFlags(sourceFormat) + if err != nil { + return err + } + + if needsHydrate { + // update rootDir to point to the hydrated output for further processing. + if rootDir, err = hydrate.ValidateAndRunKustomize(rootDir.OSPath()); err != nil { + return err + } + // delete the hydrated output directory in the end. + defer func() { + _ = os.RemoveAll(rootDir.OSPath()) + }() + } + + files, err := nomosparse.FindFiles(rootDir) + if err != nil { + return err + } + + parser := filesystem.NewParser(&reader.File{}) + + validateOpts, err := hydrate.ValidateOptions(ctx, rootDir, params.APIServerTimeout) + if err != nil { + return err + } + validateOpts.FieldManager = util.FieldManager + + if sourceFormat == configsync.SourceFormatHierarchy { + files = filesystem.FilterHierarchyFiles(rootDir, files) + } else { + // hydrate as a root repository to preview all the hydrated configs + validateOpts.Scope = declared.RootScope + } + + filePaths := reader.FilePaths{ + RootDir: rootDir, + PolicyDir: cmpath.RelativeOS(rootDir.OSPath()), + Files: files, + } + + parseOpts := hydrate.ParseOptions{ + Parser: parser, + SourceFormat: sourceFormat, + FilePaths: filePaths, + } + + var allObjects []ast.FileObject + encounteredError := false + numClusters := 0 + clusterFilterFunc := func(clusterName string, fileObjects []ast.FileObject, err status.MultiError) { + clusterEnabled := allClusters(params.Clusters) + for _, cluster := range params.Clusters { + if clusterName == cluster { + clusterEnabled = true + } + } + if !clusterEnabled { + return + } + numClusters++ + + if err != nil { + if clusterName == "" { + clusterName = nomosparse.UnregisteredCluster + } + util.PrintErrOrDie(fmt.Errorf("errors for Cluster %q: %w", clusterName, err)) + + encounteredError = true + + if status.HasBlockingErrors(err) { + return + } + } + + allObjects = append(allObjects, fileObjects...) + } + hydrate.ForEachCluster(ctx, parseOpts, validateOpts, clusterFilterFunc) + + multiCluster := numClusters > 1 + fileObjects := hydrate.GenerateFileObjects(multiCluster, allObjects...) + if params.Flat { + err = hydrate.PrintFlatOutput(params.OutPath, params.OutputFormat, fileObjects) + } else { + err = hydrate.PrintDirectoryOutput(params.OutPath, params.OutputFormat, fileObjects) + } + if err != nil { + return err + } + + if encounteredError { + os.Exit(1) + } + + return nil +} + +// allClusters returns true if all clusters should be processed. +// This is extracted from the flags package to avoid dependency +func allClusters(clusters []string) bool { + return len(clusters) == 0 +} diff --git a/cmd/nomos/hydrate/flags.go b/cmd/nomos/hydrate/flags.go new file mode 100644 index 0000000000..9e5f352453 --- /dev/null +++ b/cmd/nomos/hydrate/flags.go @@ -0,0 +1,63 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hydrate + +import ( + "github.com/spf13/cobra" + "kpt.dev/configsync/cmd/nomos/flags" +) + +// Flags holds all the flags specific to the hydrate command +type Flags struct { + // Flat determines whether to print all output to a single file + Flat bool + // OutPath specifies the location to write hydrated configuration to + OutPath string +} + +// NewFlags creates a new instance of HydrateFlags with default values +func NewFlags() *Flags { + return &Flags{ + Flat: false, + OutPath: flags.DefaultHydrationOutput, + } +} + +// AddFlags adds all hydrate-specific flags to the command +// This function centralizes flag definitions and keeps them separate from command logic +func (hf *Flags) AddFlags(cmd *cobra.Command) { + // Add shared flags from the global flags package + flags.AddClusters(cmd) + flags.AddPath(cmd) + flags.AddSkipAPIServerCheck(cmd) + flags.AddSourceFormat(cmd) + flags.AddOutputFormat(cmd) + flags.AddAPIServerTimeout(cmd) + + // Add hydrate-specific flags + cmd.Flags().BoolVar(&hf.Flat, "flat", hf.Flat, + `If enabled, print all output to a single file`) + cmd.Flags().StringVar(&hf.OutPath, "output", hf.OutPath, + `Location to write hydrated configuration to. + +If --flat is not enabled, writes each resource manifest as a +separate file. You may run "kubectl apply -fR" on the result to apply +the configuration to a cluster. If the repository declares any Cluster +resources, contains a subdirectory for each Cluster. + +If --flat is enabled, writes to the, writes a single file holding all +resource manifests. You may run "kubectl apply -f" on the result to +apply the configuration to a cluster.`) +} diff --git a/cmd/nomos/hydrate/hydrate.go b/cmd/nomos/hydrate/hydrate.go deleted file mode 100644 index 9130710457..0000000000 --- a/cmd/nomos/hydrate/hydrate.go +++ /dev/null @@ -1,177 +0,0 @@ -// Copyright 2022 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package hydrate - -import ( - "fmt" - "os" - - "github.com/spf13/cobra" - "kpt.dev/configsync/cmd/nomos/flags" - nomosparse "kpt.dev/configsync/cmd/nomos/parse" - "kpt.dev/configsync/cmd/nomos/util" - "kpt.dev/configsync/pkg/api/configsync" - "kpt.dev/configsync/pkg/declared" - "kpt.dev/configsync/pkg/hydrate" - "kpt.dev/configsync/pkg/importer/analyzer/ast" - "kpt.dev/configsync/pkg/importer/filesystem" - "kpt.dev/configsync/pkg/importer/filesystem/cmpath" - "kpt.dev/configsync/pkg/importer/reader" - "kpt.dev/configsync/pkg/status" -) - -var ( - flat bool - outPath string -) - -func init() { - flags.AddClusters(Cmd) - flags.AddPath(Cmd) - flags.AddSkipAPIServerCheck(Cmd) - flags.AddSourceFormat(Cmd) - flags.AddOutputFormat(Cmd) - flags.AddAPIServerTimeout(Cmd) - Cmd.Flags().BoolVar(&flat, "flat", false, - `If enabled, print all output to a single file`) - Cmd.Flags().StringVar(&outPath, "output", flags.DefaultHydrationOutput, - `Location to write hydrated configuration to. - -If --flat is not enabled, writes each resource manifest as a -separate file. You may run "kubectl apply -fR" on the result to apply -the configuration to a cluster. If the repository declares any Cluster -resources, contains a subdirectory for each Cluster. - -If --flat is enabled, writes to the, writes a single file holding all -resource manifests. You may run "kubectl apply -f" on the result to -apply the configuration to a cluster.`) -} - -// Cmd is the Cobra object representing the hydrate command. -var Cmd = &cobra.Command{ - Use: "hydrate", - Short: "Compiles the local repository to the exact form that would be sent to the APIServer.", - Long: `Compiles the local repository to the exact form that would be sent to the APIServer. - -The output directory consists of one directory per declared Cluster, and defaultcluster/ for -clusters without declarations. Each directory holds the full set of configs for a single cluster, -which you could kubectl apply -fR to the cluster, or have Config Sync sync to the cluster.`, - Args: cobra.ExactArgs(0), - RunE: func(cmd *cobra.Command, _ []string) error { - // Don't show usage on error, as argument validation passed. - cmd.SilenceUsage = true - - sourceFormat := configsync.SourceFormat(flags.SourceFormat) - if sourceFormat == "" { - sourceFormat = configsync.SourceFormatHierarchy - } - rootDir, needsHydrate, err := hydrate.ValidateHydrateFlags(sourceFormat) - if err != nil { - return err - } - - if needsHydrate { - // update rootDir to point to the hydrated output for further processing. - if rootDir, err = hydrate.ValidateAndRunKustomize(rootDir.OSPath()); err != nil { - return err - } - // delete the hydrated output directory in the end. - defer func() { - _ = os.RemoveAll(rootDir.OSPath()) - }() - } - - files, err := nomosparse.FindFiles(rootDir) - if err != nil { - return err - } - - parser := filesystem.NewParser(&reader.File{}) - - validateOpts, err := hydrate.ValidateOptions(cmd.Context(), rootDir, flags.APIServerTimeout) - if err != nil { - return err - } - validateOpts.FieldManager = util.FieldManager - - if sourceFormat == configsync.SourceFormatHierarchy { - files = filesystem.FilterHierarchyFiles(rootDir, files) - } else { - // hydrate as a root repository to preview all the hydrated configs - validateOpts.Scope = declared.RootScope - } - - filePaths := reader.FilePaths{ - RootDir: rootDir, - PolicyDir: cmpath.RelativeOS(rootDir.OSPath()), - Files: files, - } - - parseOpts := hydrate.ParseOptions{ - Parser: parser, - SourceFormat: sourceFormat, - FilePaths: filePaths, - } - - var allObjects []ast.FileObject - encounteredError := false - numClusters := 0 - clusterFilterFunc := func(clusterName string, fileObjects []ast.FileObject, err status.MultiError) { - clusterEnabled := flags.AllClusters() - for _, cluster := range flags.Clusters { - if clusterName == cluster { - clusterEnabled = true - } - } - if !clusterEnabled { - return - } - numClusters++ - - if err != nil { - if clusterName == "" { - clusterName = nomosparse.UnregisteredCluster - } - util.PrintErrOrDie(fmt.Errorf("errors for Cluster %q: %w", clusterName, err)) - - encounteredError = true - - if status.HasBlockingErrors(err) { - return - } - } - - allObjects = append(allObjects, fileObjects...) - } - hydrate.ForEachCluster(cmd.Context(), parseOpts, validateOpts, clusterFilterFunc) - - multiCluster := numClusters > 1 - fileObjects := hydrate.GenerateFileObjects(multiCluster, allObjects...) - if flat { - err = hydrate.PrintFlatOutput(outPath, flags.OutputFormat, fileObjects) - } else { - err = hydrate.PrintDirectoryOutput(outPath, flags.OutputFormat, fileObjects) - } - if err != nil { - return err - } - - if encounteredError { - os.Exit(1) - } - - return nil - }, -} diff --git a/cmd/nomos/initialize/init.go b/cmd/nomos/initialize/cmd.go similarity index 90% rename from cmd/nomos/initialize/init.go rename to cmd/nomos/initialize/cmd.go index 75334277f0..41eeb98f33 100644 --- a/cmd/nomos/initialize/init.go +++ b/cmd/nomos/initialize/cmd.go @@ -29,12 +29,13 @@ import ( "kpt.dev/configsync/pkg/status" ) -var forceValue bool +// localFlags holds the initialize command flags +var localFlags = NewFlags() func init() { - flags.AddPath(Cmd) - Cmd.Flags().BoolVar(&forceValue, "force", false, - "write to directory even if nonempty, overwriting conflicting files") + // Initialize flags for the initialize command + // This separation keeps flag definitions isolated from command execution logic + localFlags.AddFlags(Cmd) } // Cmd is the Cobra object representing the nomos init command @@ -56,11 +57,14 @@ initialize nonempty directories.`, // Don't show usage on error, as argument validation passed. cmd.SilenceUsage = true - return Initialize(flags.Path, forceValue) - }, - PostRunE: func(_ *cobra.Command, _ []string) error { - _, err := fmt.Fprintf(os.Stdout, "Done!\n") - return err + // Create execution parameters from parsed flags + params := ExecParams{ + Path: flags.Path, + Force: localFlags.Force, + } + + // Execute the initialize command logic + return ExecuteInitialize(params) }, } diff --git a/cmd/nomos/initialize/exec.go b/cmd/nomos/initialize/exec.go new file mode 100644 index 0000000000..01da4df304 --- /dev/null +++ b/cmd/nomos/initialize/exec.go @@ -0,0 +1,40 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package initialize + +import ( + "fmt" + "os" +) + +// ExecParams contains all parameters needed to execute the initialize command +// This struct is completely independent of cobra command structures +type ExecParams struct { + Path string + Force bool +} + +// ExecuteInitialize executes the core initialize command logic without any cobra dependencies +// This function encapsulates all the business logic for the initialize command +func ExecuteInitialize(params ExecParams) error { + err := Initialize(params.Path, params.Force) + if err != nil { + return err + } + + // Print success message (equivalent to PostRunE in the original command) + _, err = fmt.Fprintf(os.Stdout, "Done!\n") + return err +} diff --git a/cmd/nomos/initialize/flags.go b/cmd/nomos/initialize/flags.go new file mode 100644 index 0000000000..248e5ed2b7 --- /dev/null +++ b/cmd/nomos/initialize/flags.go @@ -0,0 +1,44 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package initialize + +import ( + "github.com/spf13/cobra" + "kpt.dev/configsync/cmd/nomos/flags" +) + +// Flags holds all the flags specific to the initialize command +type Flags struct { + // Force determines whether to write to directory even if nonempty, overwriting conflicting files + Force bool +} + +// NewFlags creates a new instance of Flags with default values +func NewFlags() *Flags { + return &Flags{ + Force: false, + } +} + +// AddFlags adds all initialize-specific flags to the command +// This function centralizes flag definitions and keeps them separate from command logic +func (inf *Flags) AddFlags(cmd *cobra.Command) { + // Add shared flags from the global flags package + flags.AddPath(cmd) + + // Add initialize-specific flags + cmd.Flags().BoolVar(&inf.Force, "force", inf.Force, + "write to directory even if nonempty, overwriting conflicting files") +} diff --git a/cmd/nomos/initialize/init_test.go b/cmd/nomos/initialize/init_test.go index 69c8a59cb6..b0ee925270 100644 --- a/cmd/nomos/initialize/init_test.go +++ b/cmd/nomos/initialize/init_test.go @@ -32,7 +32,7 @@ func resetFlags() { // independent. flags.Path = flags.PathDefault flags.SkipAPIServer = true - forceValue = false + localFlags.Force = false } type testCase struct { diff --git a/cmd/nomos/status/cluster_state.go b/cmd/nomos/status/cluster_state.go index d5105b3689..2de9431759 100644 --- a/cmd/nomos/status/cluster_state.go +++ b/cmd/nomos/status/cluster_state.go @@ -47,7 +47,8 @@ type ClusterState struct { isMulti *bool } -func (c *ClusterState) printRows(writer io.Writer) { +// printRows prints cluster state information, filtering by name if provided +func (c *ClusterState) printRows(writer io.Writer, nameFilter string, showResourceStatus bool) { util.MustFprintf(writer, "\n") util.MustFprintf(writer, "%s\n", c.Ref) if c.status != "" || c.Error != "" { @@ -55,9 +56,9 @@ func (c *ClusterState) printRows(writer io.Writer) { util.MustFprintf(writer, "%s%s\t%s\n", util.Indent, c.status, c.Error) } for _, repo := range c.repos { - if name == "" || name == repo.syncName { + if nameFilter == "" || nameFilter == repo.syncName { util.MustFprintf(writer, "%s%s\n", util.Indent, util.Separator) - repo.printRows(writer) + repo.printRows(writer, showResourceStatus) } } } @@ -89,7 +90,8 @@ type RepoState struct { resources []resourceState } -func (r *RepoState) printRows(writer io.Writer) { +// printRows prints repo state information, showing resource status if enabled +func (r *RepoState) printRows(writer io.Writer, showResourceStatus bool) { util.MustFprintf(writer, "%s%s:%s\t%s\t\n", util.Indent, r.scope, r.syncName, sourceString(r.sourceType, r.git, r.oci, r.helm)) if r.status == syncedMsg { util.MustFprintf(writer, "%s%s @ %v\t%s\t\n", util.Indent, r.status, r.lastSyncTimestamp, r.commit) @@ -110,7 +112,7 @@ func (r *RepoState) printRows(writer io.Writer) { util.MustFprintf(writer, "%sError:\t%s\t\n", util.Indent, err) } - if resourceStatus && len(r.resources) > 0 { + if showResourceStatus && len(r.resources) > 0 { sort.Sort(byNamespaceAndType(r.resources)) util.MustFprintf(writer, "%sManaged resources:\n", util.Indent) hasSourceHash := r.resources[0].SourceHash != "" diff --git a/cmd/nomos/status/cluster_state_test.go b/cmd/nomos/status/cluster_state_test.go index b7703ef5a8..86ee3228d9 100644 --- a/cmd/nomos/status/cluster_state_test.go +++ b/cmd/nomos/status/cluster_state_test.go @@ -350,7 +350,7 @@ func TestRepoState_PrintRows(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { var buffer bytes.Buffer - tc.repo.printRows(&buffer) + tc.repo.printRows(&buffer, true) // Enable resource status for tests got := buffer.String() if got != tc.want { t.Errorf("got:\n%s\nwant:\n%s", got, tc.want) @@ -3007,7 +3007,7 @@ gke_sample-project_europe-west1-b_cluster-2 for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { var buffer bytes.Buffer - tc.cluster.printRows(&buffer) + tc.cluster.printRows(&buffer, "", true) // No name filter, enable resource status got := buffer.String() if got != tc.want { t.Errorf("got:\n%s\nwant:\n%s", got, tc.want) @@ -3072,8 +3072,7 @@ gke_sample-project_europe-west1-b_cluster-2 for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { var buffer bytes.Buffer - name = "root-sync-2" - tc.cluster.printRows(&buffer) + tc.cluster.printRows(&buffer, "root-sync-2", true) // Filter by name, enable resource status got := buffer.String() if got != tc.want { t.Errorf("got:\n%s\nwant:\n%s", got, tc.want) diff --git a/cmd/nomos/status/cmd.go b/cmd/nomos/status/cmd.go new file mode 100644 index 0000000000..cee692551e --- /dev/null +++ b/cmd/nomos/status/cmd.go @@ -0,0 +1,54 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package status + +import ( + "github.com/spf13/cobra" + "kpt.dev/configsync/cmd/nomos/flags" +) + +// localFlags holds the status command flags +var localFlags = NewFlags() + +// Cmd runs a loop that fetches ACM objects from all available clusters and prints a summary of the +// status of Config Management for each cluster. +var Cmd = &cobra.Command{ + Use: "status", + // TODO: make Configuration Management a constant (for product renaming) + Short: `Prints the status of all clusters with Configuration Management installed.`, + RunE: func(cmd *cobra.Command, _ []string) error { + // Don't show usage on error, as argument validation passed. + cmd.SilenceUsage = true + + // Create execution parameters from parsed flags + params := ExecutionParams{ + Contexts: flags.Contexts, + ClientTimeout: flags.ClientTimeout, + PollingInterval: localFlags.PollingInterval, + Namespace: localFlags.Namespace, + ResourceStatus: localFlags.ResourceStatus, + Name: localFlags.Name, + } + + // Execute the status command logic + return ExecuteStatus(cmd.Context(), params) + }, +} + +func init() { + // Initialize flags for the status command + // This separation keeps flag definitions isolated from command execution logic + localFlags.AddFlags(Cmd) +} diff --git a/cmd/nomos/status/status.go b/cmd/nomos/status/exec.go similarity index 60% rename from cmd/nomos/status/status.go rename to cmd/nomos/status/exec.go index f1c97132ec..457e862783 100644 --- a/cmd/nomos/status/status.go +++ b/cmd/nomos/status/exec.go @@ -26,9 +26,7 @@ import ( "text/tabwriter" "time" - "github.com/spf13/cobra" "k8s.io/klog/v2" - "kpt.dev/configsync/cmd/nomos/flags" "kpt.dev/configsync/cmd/nomos/util" "kpt.dev/configsync/pkg/client/restconfig" ) @@ -40,20 +38,46 @@ const ( reconcilingMsg = "RECONCILING" ) -var ( - pollingInterval time.Duration - namespace string - resourceStatus bool - name string -) +// ExecutionParams contains all parameters needed to execute the status command +// This struct is completely independent of cobra command structures +type ExecutionParams struct { + Contexts []string + ClientTimeout time.Duration + PollingInterval time.Duration + Namespace string + ResourceStatus bool + Name string +} -func init() { - flags.AddContexts(Cmd) - Cmd.Flags().DurationVar(&flags.ClientTimeout, "timeout", restconfig.DefaultTimeout, "Sets the timeout for connecting to each cluster. Defaults to 15 seconds. Example: --timeout=30s") - Cmd.Flags().DurationVar(&pollingInterval, "poll", 0*time.Second, "Continuously polls for status updates at the specified interval. If not provided, the command runs only once. Example: --poll=30s for polling every 30 seconds") - Cmd.Flags().StringVar(&namespace, "namespace", "", "Filters the status output by the specified RootSync or RepoSync namespace. If not provided, displays status for all RootSync and RepoSync objects.") - Cmd.Flags().BoolVar(&resourceStatus, "resources", true, "Displays detailed status for individual resources managed by RootSync or RepoSync objects. Defaults to true.") - Cmd.Flags().StringVar(&name, "name", "", "Filters the status output by the specified RootSync or RepoSync name.") +// ExecuteStatus executes the core status command logic without any cobra dependencies +// This function encapsulates all the business logic for the status command +func ExecuteStatus(ctx context.Context, params ExecutionParams) error { + fmt.Println("Connecting to clusters...") + + clientMap, err := ClusterClients(ctx, params.Contexts) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to create client configs: %w", err) + } + klog.Fatalf("Failed to get clients: %v", err) + } + if len(clientMap) == 0 { + return errors.New("no clusters found") + } + + // Use a sorted order of names to avoid shuffling in the output. + names := clusterNames(clientMap) + + writer := util.NewWriter(os.Stdout) + if params.PollingInterval > 0 { + for { + printStatusWithParams(ctx, writer, clientMap, names, params) + time.Sleep(params.PollingInterval) + } + } else { + printStatusWithParams(ctx, writer, clientMap, names, params) + } + return nil } // SaveToTempFile writes the `nomos status` output into a temporary file, and @@ -74,7 +98,16 @@ func SaveToTempFile(ctx context.Context, contexts []string) (*os.File, error) { } names := clusterNames(clientMap) - printStatus(ctx, writer, clientMap, names) + // Create default execution parameters for temp file generation + params := ExecutionParams{ + Contexts: contexts, + Namespace: "", + ResourceStatus: true, + Name: "", + PollingInterval: 0, + } + + printStatusWithParams(ctx, writer, clientMap, names, params) err = tmpFile.Close() if err != nil { return tmpFile, fmt.Errorf("failed to close status file writer with error: %w", err) @@ -88,46 +121,6 @@ func SaveToTempFile(ctx context.Context, contexts []string) (*os.File, error) { return f, nil } -// Cmd runs a loop that fetches ACM objects from all available clusters and prints a summary of the -// status of Config Management for each cluster. -var Cmd = &cobra.Command{ - Use: "status", - // TODO: make Configuration Management a constant (for product renaming) - Short: `Prints the status of all clusters with Configuration Management installed.`, - RunE: func(cmd *cobra.Command, _ []string) error { - // Don't show usage on error, as argument validation passed. - cmd.SilenceUsage = true - - fmt.Println("Connecting to clusters...") - - clientMap, err := ClusterClients(cmd.Context(), flags.Contexts) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("failed to create client configs: %w", err) - } - - klog.Fatalf("Failed to get clients: %v", err) - } - if len(clientMap) == 0 { - return errors.New("no clusters found") - } - - // Use a sorted order of names to avoid shuffling in the output. - names := clusterNames(clientMap) - - writer := util.NewWriter(os.Stdout) - if pollingInterval > 0 { - for { - printStatus(cmd.Context(), writer, clientMap, names) - time.Sleep(pollingInterval) - } - } else { - printStatus(cmd.Context(), writer, clientMap, names) - } - return nil - }, -} - // clusterNames returns a sorted list of names from the given clientMap. func clusterNames(clientMap map[string]*ClusterClient) []string { var names []string @@ -140,7 +133,7 @@ func clusterNames(clientMap map[string]*ClusterClient) []string { // clusterStates returns a map of clusterStates calculated from the given map of // clients, and a list of clusters running in the mono-repo mode. -func clusterStates(ctx context.Context, clientMap map[string]*ClusterClient) (map[string]*ClusterState, []string) { +func clusterStates(ctx context.Context, clientMap map[string]*ClusterClient, namespace string) (map[string]*ClusterState, []string) { stateMap := make(map[string]*ClusterState) var monoRepoClusters []string for name, client := range clientMap { @@ -157,13 +150,14 @@ func clusterStates(ctx context.Context, clientMap map[string]*ClusterClient) (ma return stateMap, monoRepoClusters } -// printStatus fetches ConfigManagementStatus and/or RepoStatus from each cluster in the given map +// printStatusWithParams fetches ConfigManagementStatus and/or RepoStatus from each cluster in the given map // and then prints a formatted status row for each one. If there are any errors reported by either // object, those are printed in a second table under the status table. +// This function accepts execution parameters instead of relying on global variables // nolint:errcheck -func printStatus(ctx context.Context, writer *tabwriter.Writer, clientMap map[string]*ClusterClient, names []string) { +func printStatusWithParams(ctx context.Context, writer *tabwriter.Writer, clientMap map[string]*ClusterClient, names []string, params ExecutionParams) { // First build up a map of all the states to display. - stateMap, monoRepoClusters := clusterStates(ctx, clientMap) + stateMap, monoRepoClusters := clusterStates(ctx, clientMap, params.Namespace) // Log a notice for the detected clusters that are running in the mono-repo mode. util.MonoRepoNotice(writer, monoRepoClusters...) @@ -175,7 +169,7 @@ func printStatus(ctx context.Context, writer *tabwriter.Writer, clientMap map[st // Now we write everything at once. Processing and then printing helps avoid screen strobe. - if pollingInterval > 0 { + if params.PollingInterval > 0 { // Clear previous output and flush it to avoid messing up column widths. clearTerminal(writer) writer.Flush() @@ -188,7 +182,7 @@ func printStatus(ctx context.Context, writer *tabwriter.Writer, clientMap map[st // Prepend an asterisk for the users' current context state.Ref = "*" + name } - state.printRows(writer) + state.printRows(writer, params.Name, params.ResourceStatus) } writer.Flush() diff --git a/cmd/nomos/status/flags.go b/cmd/nomos/status/flags.go new file mode 100644 index 0000000000..d40a280fd5 --- /dev/null +++ b/cmd/nomos/status/flags.go @@ -0,0 +1,64 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package status + +import ( + "time" + + "github.com/spf13/cobra" + "kpt.dev/configsync/cmd/nomos/flags" + "kpt.dev/configsync/pkg/client/restconfig" +) + +// Flags holds all the flags specific to the status command +type Flags struct { + // PollingInterval is the interval for continuous polling + PollingInterval time.Duration + // Namespace filters the output by specified namespace + Namespace string + // ResourceStatus determines whether to show detailed resource status + ResourceStatus bool + // Name filters the output by specified RootSync or RepoSync name + Name string +} + +// NewFlags creates a new instance of Flags with default values +func NewFlags() *Flags { + return &Flags{ + PollingInterval: 0 * time.Second, + Namespace: "", + ResourceStatus: true, + Name: "", + } +} + +// AddFlags adds all status-specific flags to the command +// This function centralizes flag definitions and keeps them separate from command logic +func (sf *Flags) AddFlags(cmd *cobra.Command) { + // Add shared flags from the global flags package + flags.AddContexts(cmd) + + // Add status-specific flags + cmd.Flags().DurationVar(&flags.ClientTimeout, "timeout", restconfig.DefaultTimeout, + "Sets the timeout for connecting to each cluster. Defaults to 15 seconds. Example: --timeout=30s") + cmd.Flags().DurationVar(&sf.PollingInterval, "poll", sf.PollingInterval, + "Continuously polls for status updates at the specified interval. If not provided, the command runs only once. Example: --poll=30s for polling every 30 seconds") + cmd.Flags().StringVar(&sf.Namespace, "namespace", sf.Namespace, + "Filters the status output by the specified RootSync or RepoSync namespace. If not provided, displays status for all RootSync and RepoSync objects.") + cmd.Flags().BoolVar(&sf.ResourceStatus, "resources", sf.ResourceStatus, + "Displays detailed status for individual resources managed by RootSync or RepoSync objects. Defaults to true.") + cmd.Flags().StringVar(&sf.Name, "name", sf.Name, + "Filters the status output by the specified RootSync or RepoSync name.") +} diff --git a/cmd/nomos/version/cmd.go b/cmd/nomos/version/cmd.go new file mode 100644 index 0000000000..6d67635a3e --- /dev/null +++ b/cmd/nomos/version/cmd.go @@ -0,0 +1,48 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package version + +import ( + "github.com/spf13/cobra" + "kpt.dev/configsync/cmd/nomos/flags" +) + +func init() { + // Initialize flags for the version command + // This separation keeps flag definitions isolated from command execution logic + flags.AddClientTimeout(Cmd) +} + +// Cmd is the Cobra object representing the nomos version command. +var Cmd = &cobra.Command{ + Use: "version", + Short: "Prints the version of ACM for each cluster as well this CLI", + Long: `Prints the version of Configuration Management installed on each cluster and the version +of the "nomos" client binary for debugging purposes.`, + Example: ` nomos version`, + RunE: func(cmd *cobra.Command, _ []string) error { + // Don't show usage on error, as argument validation passed. + cmd.SilenceUsage = true + + // Create execution parameters from parsed flags + params := ExecParams{ + Contexts: flags.Contexts, + ClientTimeout: flags.ClientTimeout, + } + + // Execute the version command logic + return ExecuteVersion(cmd.Context(), params) + }, +} diff --git a/cmd/nomos/version/exec.go b/cmd/nomos/version/exec.go new file mode 100644 index 0000000000..54a3c0d315 --- /dev/null +++ b/cmd/nomos/version/exec.go @@ -0,0 +1,61 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package version + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + "k8s.io/client-go/rest" + "kpt.dev/configsync/pkg/client/restconfig" +) + +// ExecParams contains all parameters needed to execute the version command +// This struct is completely independent of cobra command structures +type ExecParams struct { + Contexts []string + ClientTimeout time.Duration +} + +// ExecuteVersion executes the core version command logic without any cobra dependencies +// This function encapsulates all the business logic for the version command +func ExecuteVersion(ctx context.Context, params ExecParams) error { + allCfgs, err := getAllKubectlConfigs(params.Contexts, params.ClientTimeout) + versionInternal(ctx, allCfgs, os.Stdout) + + if err != nil { + return fmt.Errorf("unable to parse kubectl config: %w", err) + } + return nil +} + +// getAllKubectlConfigs gets all kubectl configs, with error handling +// This function is extracted to remove dependency on global flags +func getAllKubectlConfigs(contexts []string, clientTimeout time.Duration) (map[string]*rest.Config, error) { + allCfgs, err := restconfig.AllKubectlConfigs(clientTimeout, contexts) + if err != nil { + var pathErr *os.PathError + if errors.As(err, &pathErr) { + err = pathErr + } + + fmt.Printf("failed to create client configs: %v\n", err) + } + + return allCfgs, err +} diff --git a/cmd/nomos/version/version.go b/cmd/nomos/version/version.go index cc60bf81e5..689841b17e 100644 --- a/cmd/nomos/version/version.go +++ b/cmd/nomos/version/version.go @@ -16,7 +16,6 @@ package version import ( "context" - "errors" "fmt" "io" "os" @@ -24,7 +23,6 @@ import ( "strings" "sync" - "github.com/spf13/cobra" v1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -41,11 +39,6 @@ import ( ctrl "sigs.k8s.io/controller-runtime/pkg/client" ) -func init() { - flags.AddContexts(Cmd) - Cmd.Flags().DurationVar(&flags.ClientTimeout, "timeout", restconfig.DefaultTimeout, "Timeout for connecting to each cluster") -} - // GetVersionReadCloser returns a ReadCloser with the output produced by running the "nomos version" command as a string func GetVersionReadCloser(ctx context.Context) (io.ReadCloser, error) { r, w, _ := os.Pipe() @@ -64,47 +57,14 @@ func GetVersionReadCloser(ctx context.Context) (io.ReadCloser, error) { return io.NopCloser(r), nil } -var ( - // clientVersion is a function that obtains the local client version. - clientVersion = func() string { - return version.VERSION - } - - // Cmd is the Cobra object representing the nomos version command. - Cmd = &cobra.Command{ - Use: "version", - Short: "Prints the version of ACM for each cluster as well this CLI", - Long: `Prints the version of Configuration Management installed on each cluster and the version -of the "nomos" client binary for debugging purposes.`, - Example: ` nomos version`, - RunE: func(cmd *cobra.Command, _ []string) error { - // Don't show usage on error, as argument validation passed. - cmd.SilenceUsage = true - - allCfgs, err := allKubectlConfigs() - versionInternal(cmd.Context(), allCfgs, os.Stdout) - - if err != nil { - return fmt.Errorf("unable to parse kubectl config: %w", err) - } - return nil - }, - } -) +var clientVersion = func() string { + return version.VERSION +} // allKubectlConfigs gets all kubectl configs, with error handling +// This function is maintained for backward compatibility with existing code func allKubectlConfigs() (map[string]*rest.Config, error) { - allCfgs, err := restconfig.AllKubectlConfigs(flags.ClientTimeout, flags.Contexts) - if err != nil { - var pathErr *os.PathError - if errors.As(err, &pathErr) { - err = pathErr - } - - fmt.Printf("failed to create client configs: %v\n", err) - } - - return allCfgs, err + return getAllKubectlConfigs(flags.Contexts, flags.ClientTimeout) } // versionInternal allows stubbing out the config for tests. diff --git a/cmd/nomos/vet/cmd.go b/cmd/nomos/vet/cmd.go new file mode 100644 index 0000000000..cbee1aabf7 --- /dev/null +++ b/cmd/nomos/vet/cmd.go @@ -0,0 +1,66 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package vet + +import ( + "github.com/spf13/cobra" + "kpt.dev/configsync/cmd/nomos/flags" + "kpt.dev/configsync/pkg/api/configsync" +) + +// localFlags holds the vet command flags +var localFlags = NewFlags() + +// Cmd is the Cobra object representing the nomos vet command. +var Cmd = &cobra.Command{ + Use: "vet", + Short: "Validate an Anthos Configuration Management directory", + Long: `Validate an Anthos Configuration Management directory +Checks for semantic and syntactic errors in an Anthos Configuration Management directory +that will interfere with applying resources. Prints found errors to STDERR and +returns a non-zero error code if any issues are found. +`, + Example: ` nomos vet + nomos vet --path=my/directory + nomos vet --path=/path/to/my/directory`, + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, _ []string) error { + // Don't show usage on error, as argument validation passed. + cmd.SilenceUsage = true + + // Create execution parameters from parsed flags + params := ExecParams{ + Clusters: flags.Clusters, + Path: flags.Path, + SkipAPIServer: flags.SkipAPIServer, + SourceFormat: configsync.SourceFormat(flags.SourceFormat), + OutputFormat: flags.OutputFormat, + APIServerTimeout: flags.APIServerTimeout, + Namespace: localFlags.NamespaceValue, + KeepOutput: localFlags.KeepOutput, + MaxObjectCount: localFlags.Threshold, + OutPath: localFlags.OutPath, + } + + // Execute the vet command logic + return ExecuteVet(cmd.Context(), cmd.OutOrStderr(), params) + }, +} + +func init() { + // Initialize flags for the vet command + // This separation keeps flag definitions isolated from command execution logic + localFlags.AddFlags(Cmd) +} diff --git a/cmd/nomos/vet/vet_impl.go b/cmd/nomos/vet/exec.go similarity index 79% rename from cmd/nomos/vet/vet_impl.go rename to cmd/nomos/vet/exec.go index a940826c13..e06882c666 100644 --- a/cmd/nomos/vet/vet_impl.go +++ b/cmd/nomos/vet/exec.go @@ -38,11 +38,48 @@ import ( "kpt.dev/configsync/pkg/status" ) +// ExecParams contains all parameters needed to execute the vet command +// This struct is completely independent of cobra command structures +type ExecParams struct { + Clusters []string + Path string + SkipAPIServer bool + SourceFormat configsync.SourceFormat + OutputFormat string + APIServerTimeout time.Duration + Namespace string + KeepOutput bool + MaxObjectCount int + OutPath string +} + +// ExecuteVet executes the core vet command logic without any cobra dependencies +// This function encapsulates all the business logic for the vet command +func ExecuteVet(ctx context.Context, out io.Writer, params ExecParams) error { + // Create vet options from execution parameters + // This separates the cobra command structure from the actual business logic + opts := vetOptions{ + Namespace: params.Namespace, + SourceFormat: params.SourceFormat, + APIServerTimeout: params.APIServerTimeout, + MaxObjectCount: params.MaxObjectCount, + KeepOutput: params.KeepOutput, + OutPath: params.OutPath, + OutputFormat: params.OutputFormat, + } + + // Execute the actual vet logic + return runVet(ctx, out, opts) +} + type vetOptions struct { Namespace string SourceFormat configsync.SourceFormat APIServerTimeout time.Duration MaxObjectCount int + KeepOutput bool + OutPath string + OutputFormat string } // vet runs nomos vet with the specified options. @@ -161,15 +198,15 @@ func runVet(ctx context.Context, out io.Writer, opts vetOptions) error { }.Error()) } - if keepOutput { + if opts.KeepOutput { allObjects = append(allObjects, fileObjects...) } } hydrate.ForEachCluster(ctx, parseOpts, validateOpts, clusterFilterFunc) - if keepOutput { + if opts.KeepOutput { multiCluster := numClusters > 1 fileObjects := hydrate.GenerateFileObjects(multiCluster, allObjects...) - if err := hydrate.PrintDirectoryOutput(outPath, flags.OutputFormat, fileObjects); err != nil { + if err := hydrate.PrintDirectoryOutput(opts.OutPath, opts.OutputFormat, fileObjects); err != nil { _ = util.PrintErr(err) } } diff --git a/cmd/nomos/vet/vet.go b/cmd/nomos/vet/flags.go similarity index 55% rename from cmd/nomos/vet/vet.go rename to cmd/nomos/vet/flags.go index ae5abeb8a0..edd80a4b5d 100644 --- a/cmd/nomos/vet/vet.go +++ b/cmd/nomos/vet/flags.go @@ -24,66 +24,60 @@ import ( "kpt.dev/configsync/pkg/importer/analyzer/validation/system" ) -var ( - namespaceValue string - keepOutput bool - threshold int - outPath string -) +// Flags holds all the flags specific to the vet command +type Flags struct { + // NamespaceValue specifies the namespace for validation + NamespaceValue string + // KeepOutput determines whether to keep hydrated output + KeepOutput bool + // Threshold sets the maximum object count + Threshold int + // OutPath specifies the output location for hydrated output + OutPath string +} + +// NewFlags creates a new instance of Flags with default values +func NewFlags() *Flags { + return &Flags{ + NamespaceValue: "", + KeepOutput: false, + Threshold: system.DefaultMaxObjectCountDisabled, + OutPath: flags.DefaultHydrationOutput, + } +} -func init() { - flags.AddClusters(Cmd) - flags.AddPath(Cmd) - flags.AddSkipAPIServerCheck(Cmd) - flags.AddSourceFormat(Cmd) - flags.AddOutputFormat(Cmd) - flags.AddAPIServerTimeout(Cmd) - Cmd.Flags().StringVar(&namespaceValue, "namespace", "", +// AddFlags adds all vet-specific flags to the command +// This function centralizes flag definitions and keeps them separate from command logic +func (vf *Flags) AddFlags(cmd *cobra.Command) { + // Add shared flags from the global flags package + flags.AddClusters(cmd) + flags.AddPath(cmd) + flags.AddSkipAPIServerCheck(cmd) + flags.AddSourceFormat(cmd) + flags.AddOutputFormat(cmd) + flags.AddAPIServerTimeout(cmd) + + // Add vet-specific flags + cmd.Flags().StringVar(&vf.NamespaceValue, "namespace", vf.NamespaceValue, fmt.Sprintf( "If set, validate the repository as a Namespace Repo with the provided name. Automatically sets --source-format=%s", configsync.SourceFormatUnstructured)) - Cmd.Flags().BoolVar(&keepOutput, "keep-output", false, + cmd.Flags().BoolVar(&vf.KeepOutput, "keep-output", vf.KeepOutput, `If enabled, keep the hydrated output`) // The --threshold flag has three modes: // 1. When `--threshold` is not specified, the validation is disabled. // 2. When `--threshold` is specified with no value, the validation is enabled, using the default value, same as `--threshold=1000`. // 3. When `--threshold=1000` is specified with a value, the validation is enabled, using the specified maximum. - Cmd.Flags().IntVar(&threshold, "threshold", system.DefaultMaxObjectCountDisabled, + cmd.Flags().IntVar(&vf.Threshold, "threshold", vf.Threshold, fmt.Sprintf(`Maximum objects allowed per repository; errors if exceeded. Omit or set to %d to disable. `, system.DefaultMaxObjectCountDisabled)+ fmt.Sprintf(`Provide flag without value for default (%d), or use --threshold=N for a specific limit.`, system.DefaultMaxObjectCount)) // Using NoOptDefVal allows the flag to be specified without a value, but // changes flag parsing such that the key and value must be in the same // argument, like `--threshold=1000`, instead of `--threshold 1000`. - Cmd.Flags().Lookup("threshold").NoOptDefVal = strconv.Itoa(system.DefaultMaxObjectCount) + cmd.Flags().Lookup("threshold").NoOptDefVal = strconv.Itoa(system.DefaultMaxObjectCount) - Cmd.Flags().StringVar(&outPath, "output", flags.DefaultHydrationOutput, + cmd.Flags().StringVar(&vf.OutPath, "output", vf.OutPath, `Location of the hydrated output`) } - -// Cmd is the Cobra object representing the nomos vet command. -var Cmd = &cobra.Command{ - Use: "vet", - Short: "Validate an Anthos Configuration Management directory", - Long: `Validate an Anthos Configuration Management directory -Checks for semantic and syntactic errors in an Anthos Configuration Management directory -that will interfere with applying resources. Prints found errors to STDERR and -returns a non-zero error code if any issues are found. -`, - Example: ` nomos vet - nomos vet --path=my/directory - nomos vet --path=/path/to/my/directory`, - Args: cobra.ExactArgs(0), - RunE: func(cmd *cobra.Command, _ []string) error { - // Don't show usage on error, as argument validation passed. - cmd.SilenceUsage = true - - return runVet(cmd.Context(), cmd.OutOrStderr(), vetOptions{ - Namespace: namespaceValue, - SourceFormat: configsync.SourceFormat(flags.SourceFormat), - APIServerTimeout: flags.APIServerTimeout, - MaxObjectCount: threshold, - }) - }, -} diff --git a/cmd/nomos/vet/vet_test.go b/cmd/nomos/vet/vet_test.go index 4ac0165e38..bce3042e9e 100644 --- a/cmd/nomos/vet/vet_test.go +++ b/cmd/nomos/vet/vet_test.go @@ -46,9 +46,9 @@ func resetFlags() { flags.Path = flags.PathDefault flags.SkipAPIServer = true flags.SourceFormat = string(configsync.SourceFormatHierarchy) - namespaceValue = "" - keepOutput = false - outPath = flags.DefaultHydrationOutput + localFlags.NamespaceValue = "" + localFlags.KeepOutput = false + localFlags.OutPath = flags.DefaultHydrationOutput flags.OutputFormat = flags.OutputYAML }