diff --git a/.gitignore b/.gitignore index 356959ca..680856e7 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,6 @@ vendor/ crane -.DS_Store \ No newline at end of file +.DS_Store + +audit/ diff --git a/cmd/apply/apply.go b/cmd/apply/apply.go index c99dbc78..1e59b521 100644 --- a/cmd/apply/apply.go +++ b/cmd/apply/apply.go @@ -5,11 +5,11 @@ import ( "os" "path/filepath" - "github.com/sirupsen/logrus" "github.com/konveyor/crane/internal/apply" "github.com/konveyor/crane/internal/flags" "github.com/konveyor/crane/internal/kustomize" internalTransform "github.com/konveyor/crane/internal/transform" + "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -45,12 +45,14 @@ type Flags struct { func (o *Options) Complete(c *cobra.Command, args []string) error { // Store positional arguments as requested stages o.RequestedStages = args + o.globalFlags.SetCmdName("apply") o.log = o.globalFlags.GetLoggerOrDefault() + return nil } func (o *Options) Validate() error { - log := o.globalFlags.GetLoggerOrDefault() + log := o.log info, err := os.Stat(o.TransformDir) if err != nil { if os.IsNotExist(err) { @@ -74,6 +76,7 @@ func (o *Options) Run() error { func NewApplyCommand(f *flags.GlobalFlags) *cobra.Command { o := &Options{ cobraGlobalFlags: f, + log: logrus.StandardLogger(), } cmd := &cobra.Command{ Use: "apply [stage...]", @@ -135,7 +138,7 @@ func addFlagsForOptions(o *Flags, cmd *cobra.Command) { } func (o *Options) run() error { - log := o.globalFlags.GetLoggerOrDefault() + log := o.log log.Infof("Starting apply...") transformDir, err := filepath.Abs(o.TransformDir) diff --git a/cmd/apply/apply_test.go b/cmd/apply/apply_test.go index 752e441a..9ced825a 100644 --- a/cmd/apply/apply_test.go +++ b/cmd/apply/apply_test.go @@ -147,7 +147,8 @@ func TestComplete(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - o := &Options{} + o := &Options{ + log: logrus.StandardLogger()} cmd := &cobra.Command{} err := o.Complete(cmd, tt.args) @@ -237,6 +238,7 @@ func TestRun_UnresolvedStagesError(t *testing.T) { o := &Options{ cobraGlobalFlags: globalFlags, globalFlags: globalFlags, + log: logrus.StandardLogger(), Flags: Flags{ TransformDir: transformDir, OutputDir: outputDir, @@ -302,6 +304,7 @@ func TestValidate_TransformDir(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { o := &Options{ + log: logrus.StandardLogger(), Flags: Flags{ TransformDir: tt.transformDir, }, @@ -331,6 +334,7 @@ func TestValidate_MissingTransformDir_DoesNotCreateOutputDir(t *testing.T) { outputDir := filepath.Join(tmpDir, "output") o := &Options{ + log: logrus.StandardLogger(), Flags: Flags{ TransformDir: missingTransformDir, OutputDir: outputDir, diff --git a/cmd/convert/convert.go b/cmd/convert/convert.go index dd95cec3..da43ffb6 100644 --- a/cmd/convert/convert.go +++ b/cmd/convert/convert.go @@ -2,6 +2,7 @@ package convert import ( "github.com/konveyor/crane-lib/convert" + "github.com/konveyor/crane/internal/flags" "github.com/sirupsen/logrus" "github.com/spf13/cobra" "k8s.io/cli-runtime/pkg/genericclioptions" @@ -16,6 +17,7 @@ import ( type ConvertOptions struct { configFlags *genericclioptions.ConfigFlags genericclioptions.IOStreams + globalFlags *flags.GlobalFlags SourceContext string Namespace string Logger logrus.FieldLogger @@ -27,15 +29,11 @@ type ConvertOptions struct { debug bool } -func NewConvertOptions(streams genericclioptions.IOStreams) *cobra.Command { - logger := logrus.New() - logger.SetOutput(streams.Out) - logger.SetFormatter(&logrus.TextFormatter{}) - +func NewConvertOptions(streams genericclioptions.IOStreams, f *flags.GlobalFlags) *cobra.Command { t := &ConvertOptions{ configFlags: genericclioptions.NewConfigFlags(false), IOStreams: streams, - Logger: logger, + globalFlags: f, } cmd := &cobra.Command{ @@ -72,11 +70,11 @@ func addFlagsForConvertOptions(t *ConvertOptions, cmd *cobra.Command) { } func (t *ConvertOptions) Complete(c *cobra.Command, args []string) error { + t.globalFlags.SetCmdName("convert") if t.debug { - if logger, ok := t.Logger.(*logrus.Logger); ok { - logger.SetLevel(logrus.DebugLevel) - } + t.globalFlags.Debug = true } + t.Logger = t.globalFlags.GetLoggerOrDefault() return nil } diff --git a/cmd/export/export.go b/cmd/export/export.go index 0569e62b..ffdfd097 100644 --- a/cmd/export/export.go +++ b/cmd/export/export.go @@ -22,7 +22,6 @@ import ( "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/clientcmd/api" - ) // ExportOptions holds CLI flags and runtime state for a single export run. @@ -56,6 +55,7 @@ type ExportOptions struct { // Complete loads kubeconfig context, namespace, and parses --as-extras into o.extras. func (o *ExportOptions) Complete(c *cobra.Command, args []string) error { var err error + o.globalFlags.SetCmdName("export") o.log = o.globalFlags.GetLoggerOrDefault() log := o.log @@ -108,6 +108,7 @@ func (o *ExportOptions) Complete(c *cobra.Command, args []string) error { // Users can override by explicitly using --include-gk Event or --exclude-gk if len(o.includeGK) == 0 && len(o.excludeGK) == 0 { o.excludeGK = []string{"Event"} + log.Debugf("No GK filters specified; applying default exclusion: Event") } return nil @@ -115,8 +116,7 @@ func (o *ExportOptions) Complete(c *cobra.Command, args []string) error { // Validate checks flag combinations (e.g. --as-extras requires impersonation). func (o *ExportOptions) Validate() error { - log := o.globalFlags.GetLoggerOrDefault() - + log := o.log if o.configFlags.Context != nil && *o.configFlags.Context != "" { for _, f := range []struct { flag string @@ -156,7 +156,7 @@ func (o *ExportOptions) Validate() error { } } if _, err := NewGKFilter(o.includeGK, o.excludeGK); err != nil { - log.Debugf("Invalid GK filter: %v", err) + log.Errorf("Invalid GK filter: %v", err) return err } return nil @@ -218,7 +218,7 @@ func mergeImpersonationExtras(dest, src map[string][]string) map[string][]string func (o *ExportOptions) Run() error { var err error - log := o.globalFlags.GetLoggerOrDefault() + log := o.log log.Infof("Starting export for namespace %q", o.userSpecifiedNamespace) restConfig, err := o.configFlags.ToRESTConfig() @@ -358,10 +358,10 @@ func (o *ExportOptions) Run() error { // NewExportCommand builds the cobra export command with flags and viper wiring. func NewExportCommand(streams genericclioptions.IOStreams, f *flags.GlobalFlags) *cobra.Command { o := &ExportOptions{ - configFlags: genericclioptions.NewConfigFlags(true), - + configFlags: genericclioptions.NewConfigFlags(true), IOStreams: streams, cobraGlobalFlags: f, + log: logrus.StandardLogger(), } cmd := &cobra.Command{ Use: "export", diff --git a/cmd/export/export_test.go b/cmd/export/export_test.go index 4a66ef90..81152661 100644 --- a/cmd/export/export_test.go +++ b/cmd/export/export_test.go @@ -85,6 +85,7 @@ func TestComplete_AsExtras(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { o := &ExportOptions{ + log: logrus.StandardLogger(), configFlags: genericclioptions.NewConfigFlags(true), asExtras: tt.asExtras, } @@ -180,6 +181,7 @@ func TestValidate(t *testing.T) { t.Run(tt.name, func(t *testing.T) { o := &ExportOptions{ configFlags: genericclioptions.NewConfigFlags(true), + log: logrus.StandardLogger(), asExtras: tt.asExtras, labelSelector: tt.labelSelector, } @@ -261,6 +263,7 @@ func TestValidate_ContextConflicts(t *testing.T) { t.Run(tt.name, func(t *testing.T) { o := &ExportOptions{ configFlags: genericclioptions.NewConfigFlags(true), + log: logrus.StandardLogger(), } if tt.context != nil { o.configFlags.Context = tt.context @@ -335,6 +338,7 @@ func TestValidate_CRDGroupConflict(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { o := &ExportOptions{ + log: logrus.StandardLogger(), configFlags: genericclioptions.NewConfigFlags(true), crdSkipGroups: tt.crdSkipGroups, crdIncludeGroups: tt.crdIncludeGroups, @@ -666,7 +670,8 @@ func TestValidate_GKFilter(t *testing.T) { t.Run(tt.name, func(t *testing.T) { o := &ExportOptions{ configFlags: genericclioptions.NewConfigFlags(true), - globalFlags: nil, // GetLoggerOrDefault handles nil + globalFlags: nil, + log: logrus.StandardLogger(), includeGK: tt.includeGK, excludeGK: tt.excludeGK, } @@ -684,33 +689,33 @@ func TestValidate_GKFilter(t *testing.T) { func TestComplete_DefaultExcludeEvent(t *testing.T) { tests := []struct { - name string - includeGK []string - excludeGK []string + name string + includeGK []string + excludeGK []string wantExclude []string }{ { - name: "no GK filters - defaults to exclude Event", - includeGK: nil, - excludeGK: nil, + name: "no GK filters - defaults to exclude Event", + includeGK: nil, + excludeGK: nil, wantExclude: []string{"Event"}, }, { - name: "explicit include - no default", - includeGK: []string{"Deployment"}, - excludeGK: nil, + name: "explicit include - no default", + includeGK: []string{"Deployment"}, + excludeGK: nil, wantExclude: nil, }, { - name: "explicit exclude - no default", - includeGK: nil, - excludeGK: []string{"Secret"}, + name: "explicit exclude - no default", + includeGK: nil, + excludeGK: []string{"Secret"}, wantExclude: []string{"Secret"}, }, { - name: "empty slices - defaults to exclude Event", - includeGK: []string{}, - excludeGK: []string{}, + name: "empty slices - defaults to exclude Event", + includeGK: []string{}, + excludeGK: []string{}, wantExclude: []string{"Event"}, }, } diff --git a/cmd/plugin-manager/add/add.go b/cmd/plugin-manager/add/add.go index 8c708fd1..34a1912f 100644 --- a/cmd/plugin-manager/add/add.go +++ b/cmd/plugin-manager/add/add.go @@ -26,6 +26,7 @@ type Options struct { cobraGlobalFlags *flags.GlobalFlags globalFlags *flags.GlobalFlags + log *logrus.Logger // Two Flags struct fields are needed // 1. cobraFlags for explicit CLI args parsed by cobra // 2. Flags for the args merged with values from the viper config file @@ -42,6 +43,8 @@ type Flags struct { func (o *Options) Complete(c *cobra.Command, args []string) error { // TODO: @jgabani + o.globalFlags.SetCmdName("plugin-manager add") + o.log = o.globalFlags.GetLoggerOrDefault() return nil } @@ -96,7 +99,6 @@ func NewAddCommand(f *flags.GlobalFlags) *cobra.Command { o := &Options{ globalFlags: f, } - log := o.globalFlags.GetLogger() cmd := &cobra.Command{ Use: "add ", Short: "installs the desired plugin", @@ -105,7 +107,7 @@ func NewAddCommand(f *flags.GlobalFlags) *cobra.Command { return err } if err := o.Validate(args); err != nil { - log.Errorf("%s", err.Error()) + o.log.Errorf("%s", err.Error()) return nil } if err := o.Run(args); err != nil { @@ -130,7 +132,7 @@ func addFlagsForOptions(o *Flags, cmd *cobra.Command) { } func (o *Options) run(args []string) error { - log := o.globalFlags.GetLogger() + log := o.log manifestMap, err := plugin.BuildManifestMap(log, args[0], o.Repo) if err != nil { diff --git a/cmd/plugin-manager/list/list.go b/cmd/plugin-manager/list/list.go index c98500d6..281126fc 100644 --- a/cmd/plugin-manager/list/list.go +++ b/cmd/plugin-manager/list/list.go @@ -10,6 +10,7 @@ import ( "github.com/konveyor/crane/internal/flags" "github.com/konveyor/crane/internal/plugin" "github.com/olekukonko/tablewriter" + "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -20,6 +21,7 @@ type Options struct { // 2. globalFlags for the args merged with values from the viper config file cobraGlobalFlags *flags.GlobalFlags globalFlags *flags.GlobalFlags + log *logrus.Logger // Two Flags struct fields are needed // 1. cobraFlags for explicit CLI args parsed by cobra // 2. Flags for the args merged with values from the viper config file @@ -45,6 +47,8 @@ type AvailablePlugins struct { func (o *Options) Complete(c *cobra.Command, args []string) error { // TODO: @jgabani + o.globalFlags.SetCmdName("plugin-manager list") + o.log = o.globalFlags.GetLoggerOrDefault() return nil } @@ -96,7 +100,7 @@ func addFlagsForOptions(o *Flags, cmd *cobra.Command) { } func (o *Options) run() error { - log := o.globalFlags.GetLogger() + log := o.log if o.Installed { // retrieve list of all the plugins that are installed within plugin dir // TODO: differentiate between multiple repos @@ -163,7 +167,7 @@ func (o *Options) run() error { return nil } -//TODO: this can be merged with printParamsInformation +// TODO: this can be merged with printParamsInformation func printInstalledInformation(plugins []transform2.Plugin) { for _, thisPlugin := range plugins { printTable([][]string{ diff --git a/cmd/plugin-manager/remove/remove.go b/cmd/plugin-manager/remove/remove.go index 56ffd6d4..5f645863 100644 --- a/cmd/plugin-manager/remove/remove.go +++ b/cmd/plugin-manager/remove/remove.go @@ -8,6 +8,7 @@ import ( "github.com/konveyor/crane/internal/flags" "github.com/konveyor/crane/internal/plugin" + "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -18,6 +19,7 @@ type Options struct { // 2. globalFlags for the args merged with values from the viper config file cobraGlobalFlags *flags.GlobalFlags globalFlags *flags.GlobalFlags + log *logrus.Logger // Two Flags struct fields are needed // 1. cobraFlags for explicit CLI args parsed by cobra // 2. Flags for the args merged with values from the viper config file @@ -32,6 +34,8 @@ type Flags struct { func (o *Options) Complete(c *cobra.Command, args []string) error { // TODO: @jgabani + o.globalFlags.SetCmdName("plugin-manager remove") + o.log = o.globalFlags.GetLoggerOrDefault() return nil } @@ -75,7 +79,7 @@ func NewRemoveCommand(f *flags.GlobalFlags) *cobra.Command { } func (o *Options) run(args []string) error { - log := o.globalFlags.GetLogger() + log := o.log pluginDir, err := filepath.Abs(fmt.Sprintf("%v/%v", o.PluginDir, o.Repo)) if err != nil { return err diff --git a/cmd/skopeo-sync-gen/skopeo-sync-gen.go b/cmd/skopeo-sync-gen/skopeo-sync-gen.go index a6fe6c7b..adbc1a37 100644 --- a/cmd/skopeo-sync-gen/skopeo-sync-gen.go +++ b/cmd/skopeo-sync-gen/skopeo-sync-gen.go @@ -59,6 +59,7 @@ type registrySyncConfig struct { type sourceConfig map[string]registrySyncConfig func (o *Options) Complete(c *cobra.Command, args []string) error { + o.globalFlags.SetCmdName("skopeo-sync-gen") return nil } @@ -119,7 +120,7 @@ func (o *Options) Run() error { return err } - files, err := file.ReadFiles(context.TODO(), exportDir) + files, err := file.ReadFilesWithLogger(context.TODO(), exportDir, o.globalFlags.GetLoggerOrDefault()) if err != nil { return err } diff --git a/cmd/transfer-pvc/indirect.go b/cmd/transfer-pvc/indirect.go index 03fa8f81..1a50a44e 100644 --- a/cmd/transfer-pvc/indirect.go +++ b/cmd/transfer-pvc/indirect.go @@ -26,7 +26,7 @@ import ( ) func (t *TransferPVCCommand) runIndirect() error { - log := t.globalFlags.GetLoggerOrDefault() + log := t.log log.Infof("Starting indirect PVC transfer: %s/%s -> %s/%s", t.PVC.Namespace.source, t.PVC.Name.source, t.PVC.Namespace.destination, t.PVC.Name.destination) fmt.Fprintf(os.Stderr, "\ncrane transfer-pvc (indirect via cloud storage)\n") @@ -355,7 +355,7 @@ func checkRclonePartialSuccess(output, podName, namespace string, log *logrus.Lo } func (t *TransferPVCCommand) createTempRcloneSecretFromData(c client.Client, namespace string, configData []byte, labelPVCName string) (string, error) { - log := t.globalFlags.GetLoggerOrDefault() + log := t.log secretName := fmt.Sprintf("crane-rclone-config-%s", getValidatedResourceName(t.PVC.Name.source)) secret := &corev1.Secret{ diff --git a/cmd/transfer-pvc/transfer-pvc.go b/cmd/transfer-pvc/transfer-pvc.go index 93d0ba55..0fba7a8e 100644 --- a/cmd/transfer-pvc/transfer-pvc.go +++ b/cmd/transfer-pvc/transfer-pvc.go @@ -61,7 +61,6 @@ type TransferPVCCommand struct { genericclioptions.IOStreams globalFlags *flags.GlobalFlags log *logrus.Logger - logger logrus.FieldLogger sourceContext *clientcmdapi.Context destinationContext *clientcmdapi.Context @@ -154,9 +153,8 @@ func NewTransferPVCCommand(streams genericclioptions.IOStreams, f *flags.GlobalF StorageRequests: quantityVar{}, }, }, - IOStreams: streams, + IOStreams: streams, globalFlags: f, - logger: logrus.New(), } cmd := &cobra.Command{ @@ -207,6 +205,7 @@ func addFlagsToTransferPVCCommand(c *Flags, cmd *cobra.Command) { } func (t *TransferPVCCommand) Complete(c *cobra.Command, args []string) error { + t.globalFlags.SetCmdName("transfer-pvc") t.log = t.globalFlags.GetLoggerOrDefault() config := t.configFlags.ToRawKubeConfigLoader() rawConfig, err := config.RawConfig() @@ -245,7 +244,10 @@ func (t *TransferPVCCommand) Complete(c *cobra.Command, args []string) error { } func (t *TransferPVCCommand) Validate() error { - log := t.globalFlags.GetLoggerOrDefault() + log := t.log + if log == nil { + log = t.globalFlags.GetLoggerOrDefault() + } cloudStorage := t.CloudStorage if t.Encrypt && cloudStorage == "" { @@ -353,11 +355,10 @@ func (t *TransferPVCCommand) getRestConfigFromContext(ctx string) (*rest.Config, } func (t *TransferPVCCommand) run() (retErr error) { - log := t.globalFlags.GetLoggerOrDefault() + log := t.log log.Infof("Starting PVC transfer: %s/%s -> %s/%s", t.PVC.Namespace.source, t.PVC.Name.source, t.PVC.Namespace.destination, t.PVC.Name.destination) - logrusLog := logrus.New() - logrusLog.SetFormatter(&logrus.JSONFormatter{}) - logger := logrusr.New(logrusLog).WithName("transfer-pvc") + ctrlLogger := logrus.New() + logger := logrusr.New(ctrlLogger).WithName("transfer-pvc") totalPhases := 7 if t.isIntraClusterSameNamespace() { diff --git a/cmd/transform/listplugins/listplugins.go b/cmd/transform/listplugins/listplugins.go index 057910c4..a664ad0c 100644 --- a/cmd/transform/listplugins/listplugins.go +++ b/cmd/transform/listplugins/listplugins.go @@ -4,9 +4,9 @@ import ( "fmt" "path/filepath" + cranelib "github.com/konveyor/crane-lib/transform" "github.com/konveyor/crane/internal/flags" "github.com/konveyor/crane/internal/plugin" - cranelib "github.com/konveyor/crane-lib/transform" "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -33,6 +33,7 @@ type Flags struct { func (o *Options) Complete(c *cobra.Command, args []string) error { // TODO: @sseago + o.globalFlags.SetCmdName("transform listplugins") o.log = o.globalFlags.GetLoggerOrDefault() return nil } @@ -106,7 +107,7 @@ func getFilteredPlugins(pluginDir string, skipPlugins []string, log *logrus.Logg } func (o *Options) run() error { - log := o.globalFlags.GetLoggerOrDefault() + log := o.log plugins, err := getFilteredPlugins(o.PluginDir, o.SkipPlugins, log) if err != nil { diff --git a/cmd/transform/optionals/optionals.go b/cmd/transform/optionals/optionals.go index b3d52184..4490393e 100644 --- a/cmd/transform/optionals/optionals.go +++ b/cmd/transform/optionals/optionals.go @@ -32,6 +32,7 @@ type Flags struct { func (o *Options) Complete(c *cobra.Command, args []string) error { // TODO: @sseago + o.globalFlags.SetCmdName("transform optionals") o.log = o.globalFlags.GetLoggerOrDefault() return nil } @@ -78,7 +79,7 @@ func NewOptionalsCommand(f *flags.GlobalFlags) *cobra.Command { } func (o *Options) run() error { - log := o.globalFlags.GetLoggerOrDefault() + log := o.log pluginDir, err := filepath.Abs(o.PluginDir) if err != nil { diff --git a/cmd/transform/transform.go b/cmd/transform/transform.go index 7be947b1..95080bcd 100644 --- a/cmd/transform/transform.go +++ b/cmd/transform/transform.go @@ -41,13 +41,13 @@ type Options struct { } type Flags struct { - ExportDir string `mapstructure:"export-dir"` - PluginDir string `mapstructure:"plugin-dir"` - TransformDir string `mapstructure:"transform-dir"` - SkipPlugins []string `mapstructure:"skip-plugins"` - OptionalFlags string `mapstructure:"optional-flags"` - StageOptionals []string `mapstructure:"stage-optionals"` - Overwrite bool `mapstructure:"overwrite"` + ExportDir string `mapstructure:"export-dir"` + PluginDir string `mapstructure:"plugin-dir"` + TransformDir string `mapstructure:"transform-dir"` + SkipPlugins []string `mapstructure:"skip-plugins"` + OptionalFlags string `mapstructure:"optional-flags"` + StageOptionals []string `mapstructure:"stage-optionals"` + Overwrite bool `mapstructure:"overwrite"` // Kustomize arguments KustomizeArgs string `mapstructure:"kustomize-args"` // Instructions file @@ -57,12 +57,13 @@ type Flags struct { func (o *Options) Complete(c *cobra.Command, args []string) error { // Store positional arguments as requested stages o.RequestedStages = args + o.globalFlags.SetCmdName("transform") o.log = o.globalFlags.GetLoggerOrDefault() return nil } func (o *Options) Validate() error { - log := o.globalFlags.GetLoggerOrDefault() + log := o.log exportDir, err := filepath.Abs(o.ExportDir) if err != nil { @@ -106,7 +107,8 @@ func getPluginCompletions(f *flags.GlobalFlags) func(cmd *cobra.Command, args [] } // Get plugin names using shared function - log := f.GetLogger() + f.SetCmdName("transform") + log := f.GetLoggerOrDefault() pluginNames, err := listplugins.GetPluginNames(pluginDir, skipPlugins, log) if err != nil { return nil, cobra.ShellCompDirectiveError @@ -119,6 +121,7 @@ func getPluginCompletions(f *flags.GlobalFlags) func(cmd *cobra.Command, args [] func NewTransformCommand(f *flags.GlobalFlags) *cobra.Command { o := &Options{ cobraGlobalFlags: f, + log: logrus.StandardLogger(), } cmd := &cobra.Command{ Use: "transform [stage...]", @@ -181,7 +184,7 @@ func addFlagsForOptions(o *Flags, cmd *cobra.Command) { } func (o *Options) run() error { - log := o.globalFlags.GetLoggerOrDefault() + log := o.log log.Infof("Starting transform...") exportDir, err := filepath.Abs(o.ExportDir) @@ -203,7 +206,7 @@ func (o *Options) run() error { } if o.InstructionsFile != "" && len(o.RequestedStages) > 0 { - log.Debugf("Cannot use --instructions-file together with positional stage arguments") + log.Debugf("Cannot use --instructions-file together with positional stage arguments") return fmt.Errorf("use either --instructions-file or positional stage arguments, not both") } if o.InstructionsFile != "" && len(o.StageOptionals) > 0 { diff --git a/cmd/transform/transform_test.go b/cmd/transform/transform_test.go index c225d858..9e5bef98 100644 --- a/cmd/transform/transform_test.go +++ b/cmd/transform/transform_test.go @@ -476,6 +476,7 @@ func TestReconcileInstructionStages_Overwrite(t *testing.T) { func TestRun_InstructionsFileAndPositionalArgsConflict(t *testing.T) { o := &Options{ globalFlags: &flags.GlobalFlags{}, + log: logrus.StandardLogger(), RequestedStages: []string{"10_KubernetesPlugin"}, Flags: Flags{ InstructionsFile: "sample-transform-instructor-file.yaml", @@ -551,11 +552,11 @@ func TestResolveAndValidateStages_CustomStageCreation(t *testing.T) { expectError: false, }, { - name: "invalid custom stage name returns wrapped error", - requestedStage: "invalid stage name!", - shouldCreate: false, - expectError: true, - }, + name: "invalid custom stage name returns wrapped error", + requestedStage: "invalid stage name!", + shouldCreate: false, + expectError: true, + }, } for _, tt := range tests { @@ -597,24 +598,24 @@ func TestResolveAndValidateStages_CustomStageCreation(t *testing.T) { ) if tt.expectError { - if err == nil { - t.Fatalf("expected error but got none") - } - if !strings.Contains(err.Error(), "invalid custom stage name") || strings.Contains(err.Error(), "") { - t.Errorf("expected error to contain validation context, but got: %v", err) - } - - stageDir := filepath.Join(subTransformDir, tt.requestedStage) - if _, statErr := os.Stat(stageDir); statErr == nil { - t.Errorf("expected directory %s NOT to be created for invalid stage name", stageDir) - } - - return - } else { - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - } + if err == nil { + t.Fatalf("expected error but got none") + } + if !strings.Contains(err.Error(), "invalid custom stage name") || strings.Contains(err.Error(), "") { + t.Errorf("expected error to contain validation context, but got: %v", err) + } + + stageDir := filepath.Join(subTransformDir, tt.requestedStage) + if _, statErr := os.Stat(stageDir); statErr == nil { + t.Errorf("expected directory %s NOT to be created for invalid stage name", stageDir) + } + + return + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } if len(resolved) != 1 { t.Fatalf("expected 1 resolved stage, got %d", len(resolved)) @@ -680,6 +681,7 @@ func TestResolveAndValidateStages_MultipleCustomStages(t *testing.T) { log.SetOutput(os.Stderr) o := &Options{ + log: logrus.StandardLogger(), Flags: Flags{ SkipPlugins: []string{}, }, @@ -823,6 +825,7 @@ func TestResolveAndValidateStages_CustomStageWithPreviousStageOutput(t *testing. log.SetOutput(os.Stderr) o := &Options{ + log: logrus.StandardLogger(), Flags: Flags{ SkipPlugins: []string{}, }, @@ -1060,6 +1063,7 @@ func TestValidate_ExportDir(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { o := &Options{ + log: logrus.StandardLogger(), Flags: Flags{ ExportDir: tt.exportDir, PluginDir: filepath.Join(tmpDir, "plugins"), @@ -1090,11 +1094,11 @@ func TestValidate_ExportDir(t *testing.T) { func TestParseStageOptionals(t *testing.T) { tests := []struct { - name string - values []string - wantErr bool - errMsg string - expected map[string]map[string]string + name string + values []string + wantErr bool + errMsg string + expected map[string]map[string]string }{ { name: "single stage", @@ -1233,6 +1237,7 @@ func TestParseStageOptionals_MultiFieldJSON(t *testing.T) { func TestRun_InstructionsFileAndStageOptionalsConflict(t *testing.T) { o := &Options{ globalFlags: &flags.GlobalFlags{}, + log: logrus.StandardLogger(), Flags: Flags{ InstructionsFile: "instructions.yaml", StageOptionals: []string{`KubernetesPlugin={"key": "val"}`}, @@ -1253,6 +1258,7 @@ func TestValidate_MissingExportDir_FailsBeforeRun(t *testing.T) { transformDir := filepath.Join(tmpDir, "transform") o := &Options{ + log: logrus.StandardLogger(), Flags: Flags{ ExportDir: filepath.Join(tmpDir, "missing-export"), TransformDir: transformDir, diff --git a/cmd/tunnel-api/tunnel-api.go b/cmd/tunnel-api/tunnel-api.go index 2d449433..547c8773 100644 --- a/cmd/tunnel-api/tunnel-api.go +++ b/cmd/tunnel-api/tunnel-api.go @@ -2,9 +2,9 @@ package tunnel_api import ( "fmt" - "log" "github.com/konveyor/crane-lib/connect/tunnel_api" + "github.com/konveyor/crane/internal/flags" "github.com/sirupsen/logrus" "github.com/spf13/cobra" "k8s.io/cli-runtime/pkg/genericclioptions" @@ -17,8 +17,9 @@ import ( type TunnelAPIOptions struct { configFlags *genericclioptions.ConfigFlags genericclioptions.IOStreams + globalFlags *flags.GlobalFlags + logger logrus.FieldLogger - logger logrus.FieldLogger SourceContext string DestinationContext string Namespace string @@ -32,12 +33,11 @@ type TunnelAPIOptions struct { destinationContext *clientcmdapi.Context } -func NewTunnelAPIOptions(streams genericclioptions.IOStreams) *cobra.Command { +func NewTunnelAPIOptions(streams genericclioptions.IOStreams, f *flags.GlobalFlags) *cobra.Command { t := &TunnelAPIOptions{ configFlags: genericclioptions.NewConfigFlags(false), - - IOStreams: streams, - logger: logrus.New(), + IOStreams: streams, + globalFlags: f, } cmd := &cobra.Command{ @@ -75,6 +75,8 @@ func addFlagsForTunnelAPIOptions(t *TunnelAPIOptions, cmd *cobra.Command) { } func (t *TunnelAPIOptions) Complete(c *cobra.Command, args []string) error { + t.globalFlags.SetCmdName("tunnel-api") + t.logger = t.globalFlags.GetLoggerOrDefault() config := t.configFlags.ToRawKubeConfigLoader() rawConfig, err := config.RawConfig() if err != nil { @@ -98,15 +100,20 @@ func (t *TunnelAPIOptions) Complete(c *cobra.Command, args []string) error { } func (t *TunnelAPIOptions) Validate() error { + log := t.logger + if t.sourceContext == nil { + log.Debugf("Source context %q not found in kubeconfig", t.SourceContext) return fmt.Errorf("cannot evaluate source context") } if t.destinationContext == nil { + log.Debugf("Destination context %q not found in kubeconfig", t.DestinationContext) return fmt.Errorf("cannot evaluate destination context") } if t.sourceContext.Cluster == t.destinationContext.Cluster { + log.Debugf("Source and destination cluster are the same: %q", t.sourceContext.Cluster) return fmt.Errorf("both source and destination cluster are same, this is not supported") } @@ -134,6 +141,7 @@ func (t *TunnelAPIOptions) getRestConfigFromContext(ctx string) (*rest.Config, e } func (t *TunnelAPIOptions) run() error { + log := t.logger tunnel := tunnel_api.Tunnel{} fmt.Println("Generating SSL certificates. This may take several minutes.") @@ -151,21 +159,25 @@ func (t *TunnelAPIOptions) run() error { srcConfig, err := t.getRestConfigFromContext(t.SourceContext) if err != nil { - log.Fatal(err, "unable to get source config") + log.Debugf("Unable to get source config: %v", err) + return fmt.Errorf("unable to get source config: %w", err) } dstConfig, err := t.getRestConfigFromContext(t.DestinationContext) if err != nil { - log.Fatal(err, "unable to get destination config") + log.Debugf("Unable to get destination config: %v", err) + return fmt.Errorf("unable to get destination config: %w", err) } _, err = t.getClientFromContext(t.SourceContext) if err != nil { - log.Fatal(err, "unable to get source client") + log.Debugf("Unable to get source client: %v", err) + return fmt.Errorf("unable to get source client: %w", err) } _, err = t.getClientFromContext(t.DestinationContext) if err != nil { - log.Fatal(err, "unable to get destination client") + log.Debugf("Unable to get destination client: %v", err) + return fmt.Errorf("unable to get destination client: %w", err) } tunnel.SrcConfig = srcConfig @@ -180,7 +192,8 @@ func (t *TunnelAPIOptions) run() error { err = tunnel_api.Openvpn(tunnel) if err != nil { - log.Fatal(err, "Unable to create Tunnel") + log.Debugf("Unable to create tunnel: %v", err) + return fmt.Errorf("unable to create tunnel: %w", err) } return nil diff --git a/cmd/validate/validate.go b/cmd/validate/validate.go index e03b5dbb..5135e9ee 100644 --- a/cmd/validate/validate.go +++ b/cmd/validate/validate.go @@ -37,6 +37,7 @@ type ValidateOptions struct { // detected later in Run() when discovery is queried. // Skipped in offline mode (--api-resources). func (o *ValidateOptions) Complete(c *cobra.Command, args []string) error { + o.globalFlags.SetCmdName("validate") o.log = o.globalFlags.GetLoggerOrDefault() kubeconfigFlag := c.Flags().Lookup("kubeconfig") @@ -87,7 +88,7 @@ func determineClusterContext(cf *genericclioptions.ConfigFlags) (string, error) // Validate checks that flags have valid values. func (o *ValidateOptions) Validate(cmd *cobra.Command) error { - log := o.globalFlags.GetLoggerOrDefault() + log := o.log info, err := os.Stat(o.inputDir) if err != nil { @@ -144,7 +145,7 @@ func (o *ValidateOptions) Validate(cmd *cobra.Command) error { // Run performs the scan, match, and report steps. func (o *ValidateOptions) Run() error { - log := o.globalFlags.GetLoggerOrDefault() + log := o.log log.Infof("Starting validate") log.Debugf("Input directory: %s", o.inputDir) @@ -265,6 +266,7 @@ func NewValidateCommand(streams genericclioptions.IOStreams, f *flags.GlobalFlag configFlags: genericclioptions.NewConfigFlags(true), IOStreams: streams, cobraGlobalFlags: f, + log: logrus.StandardLogger(), } cmd := &cobra.Command{ Use: "validate", diff --git a/cmd/validate/validate_test.go b/cmd/validate/validate_test.go index a598fbd0..a3f88aa2 100644 --- a/cmd/validate/validate_test.go +++ b/cmd/validate/validate_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/konveyor/crane/internal/flags" + "github.com/sirupsen/logrus" "github.com/spf13/cobra" "k8s.io/cli-runtime/pkg/genericclioptions" ) @@ -42,17 +43,18 @@ func TestNewValidateCommand(t *testing.T) { func TestValidate_Flags(t *testing.T) { tests := []struct { - name string - setup func(t *testing.T) *ValidateOptions - wantErr bool - errMatch string - setMutualExclusionFlags bool // If true, explicitly mark kubeconfig-related flags as changed + name string + setup func(t *testing.T) *ValidateOptions + wantErr bool + errMatch string + setMutualExclusionFlags bool // If true, explicitly mark kubeconfig-related flags as changed }{ { name: "missing input-dir", setup: func(t *testing.T) *ValidateOptions { missingDir := filepath.Join(t.TempDir(), "missing") return &ValidateOptions{ + log: logrus.StandardLogger(), inputDir: missingDir, outputFormat: "yaml", } @@ -69,6 +71,7 @@ func TestValidate_Flags(t *testing.T) { t.Fatal(err) } return &ValidateOptions{ + log: logrus.StandardLogger(), inputDir: f, outputFormat: "yaml", } @@ -80,6 +83,7 @@ func TestValidate_Flags(t *testing.T) { name: "invalid output format", setup: func(t *testing.T) *ValidateOptions { return &ValidateOptions{ + log: logrus.StandardLogger(), inputDir: t.TempDir(), outputFormat: "xml", } @@ -91,6 +95,7 @@ func TestValidate_Flags(t *testing.T) { name: "valid yaml format", setup: func(t *testing.T) *ValidateOptions { return &ValidateOptions{ + log: logrus.StandardLogger(), inputDir: t.TempDir(), outputFormat: "yaml", } @@ -101,6 +106,7 @@ func TestValidate_Flags(t *testing.T) { name: "valid json format", setup: func(t *testing.T) *ValidateOptions { return &ValidateOptions{ + log: logrus.StandardLogger(), inputDir: t.TempDir(), outputFormat: "json", } @@ -111,6 +117,7 @@ func TestValidate_Flags(t *testing.T) { name: "uppercase JSON accepted", setup: func(t *testing.T) *ValidateOptions { return &ValidateOptions{ + log: logrus.StandardLogger(), inputDir: t.TempDir(), outputFormat: "JSON", } @@ -121,6 +128,7 @@ func TestValidate_Flags(t *testing.T) { name: "uppercase YAML accepted", setup: func(t *testing.T) *ValidateOptions { return &ValidateOptions{ + log: logrus.StandardLogger(), inputDir: t.TempDir(), outputFormat: "YAML", } @@ -131,6 +139,7 @@ func TestValidate_Flags(t *testing.T) { name: "mixed case Json accepted", setup: func(t *testing.T) *ValidateOptions { return &ValidateOptions{ + log: logrus.StandardLogger(), inputDir: t.TempDir(), outputFormat: "Json", } @@ -141,6 +150,7 @@ func TestValidate_Flags(t *testing.T) { name: "api-resources file not found", setup: func(t *testing.T) *ValidateOptions { return &ValidateOptions{ + log: logrus.StandardLogger(), configFlags: genericclioptions.NewConfigFlags(true), inputDir: t.TempDir(), outputFormat: "json", @@ -162,6 +172,7 @@ func TestValidate_Flags(t *testing.T) { cf := genericclioptions.NewConfigFlags(true) cf.Context = &ctx return &ValidateOptions{ + log: logrus.StandardLogger(), configFlags: cf, inputDir: dir, outputFormat: "json", @@ -184,6 +195,7 @@ func TestValidate_Flags(t *testing.T) { cf := genericclioptions.NewConfigFlags(true) cf.KubeConfig = &kc return &ValidateOptions{ + log: logrus.StandardLogger(), configFlags: cf, inputDir: dir, outputFormat: "json", @@ -206,6 +218,7 @@ func TestValidate_Flags(t *testing.T) { cf := genericclioptions.NewConfigFlags(true) cf.APIServer = &server return &ValidateOptions{ + log: logrus.StandardLogger(), configFlags: cf, inputDir: dir, outputFormat: "json", @@ -228,6 +241,7 @@ func TestValidate_Flags(t *testing.T) { cf := genericclioptions.NewConfigFlags(true) cf.BearerToken = &token return &ValidateOptions{ + log: logrus.StandardLogger(), configFlags: cf, inputDir: dir, outputFormat: "json", @@ -250,6 +264,7 @@ func TestValidate_Flags(t *testing.T) { cf := genericclioptions.NewConfigFlags(true) cf.ClusterName = &cluster return &ValidateOptions{ + log: logrus.StandardLogger(), configFlags: cf, inputDir: dir, outputFormat: "json", @@ -272,6 +287,7 @@ func TestValidate_Flags(t *testing.T) { cf := genericclioptions.NewConfigFlags(true) cf.AuthInfoName = &user return &ValidateOptions{ + log: logrus.StandardLogger(), configFlags: cf, inputDir: dir, outputFormat: "json", @@ -291,6 +307,7 @@ func TestValidate_Flags(t *testing.T) { t.Fatal(err) } return &ValidateOptions{ + log: logrus.StandardLogger(), configFlags: genericclioptions.NewConfigFlags(true), inputDir: dir, outputFormat: "json", @@ -378,6 +395,7 @@ func TestRun_EmptyInputDirReturnsError(t *testing.T) { } o := &ValidateOptions{ + log: logrus.StandardLogger(), configFlags: genericclioptions.NewConfigFlags(true), IOStreams: genericclioptions.NewTestIOStreamsDiscard(), inputDir: emptyDir, @@ -484,6 +502,7 @@ current-context: existing-context cf.KubeConfig = &kc o := &ValidateOptions{ + log: logrus.StandardLogger(), configFlags: cf, globalFlags: &flags.GlobalFlags{}, inputDir: t.TempDir(), @@ -506,6 +525,7 @@ current-context: existing-context func TestComplete_SkippedInOfflineMode(t *testing.T) { o := &ValidateOptions{ + log: logrus.StandardLogger(), configFlags: genericclioptions.NewConfigFlags(true), globalFlags: &flags.GlobalFlags{}, inputDir: t.TempDir(), diff --git a/internal/audit/audit_logger.go b/internal/audit/audit_logger.go new file mode 100644 index 00000000..498ab36c --- /dev/null +++ b/internal/audit/audit_logger.go @@ -0,0 +1,122 @@ +package audit + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/sirupsen/logrus" +) + +// FileHook writes every log entry as a JSON line to a file. +type FileHook struct { + file *os.File + cmd *string + formatter logrus.Formatter +} + +// NewFileHook opens (or creates) the file at path in append mode and returns a hook. +// cmd is a pointer to a string that is read on every Fire() call, so it can be set after the hook is created. +func NewFileHook(path string, cmd *string) (*FileHook, error) { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return nil, err + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return nil, err + } + if err := f.Chmod(0600); err != nil { + _ = f.Close() + return nil, err + } + return &FileHook{ + file: f, + cmd: cmd, + formatter: &logrus.JSONFormatter{}, + }, nil +} + +// Levels returns all log levels down to Debug. Trace is excluded because the +// configured logger is set to DebugLevel and will not emit Trace entries. +func (h *FileHook) Levels() []logrus.Level { + return []logrus.Level{ + logrus.PanicLevel, + logrus.FatalLevel, + logrus.ErrorLevel, + logrus.WarnLevel, + logrus.InfoLevel, + logrus.DebugLevel, + } +} + +// Fire is called by logrus for every log entry. It formats the entry as JSON and writes it to the file. +func (h *FileHook) Fire(entry *logrus.Entry) error { + e := entry + if h.cmd != nil && *h.cmd != "" { + data := make(logrus.Fields, len(entry.Data)+1) + for k, v := range entry.Data { + data[k] = v + } + data["cmd"] = *h.cmd + clone := *entry + clone.Data = data + e = &clone + } + line, err := h.formatter.Format(e) + if err != nil { + return fmt.Errorf("audit file hook: format entry: %w", err) + } + if _, err := h.file.Write(line); err != nil { + return fmt.Errorf("audit file hook: write to %s: %w", h.file.Name(), err) + } + return nil +} + +// Close closes the underlying file. Call this when the program exits. +func (h *FileHook) Close() error { + return h.file.Close() +} + +// ConsoleHook writes log entries to stderr, filtered by the levels it is given. +// This replaces logrus's default output so the level list can be controlled independently of the file hook. +type ConsoleHook struct { + writer io.Writer + levels []logrus.Level + formatter logrus.Formatter +} + +// NewConsoleHook returns a hook that writes to stderr. +// If debug is true, all levels are shown; otherwise only Info and above. +func NewConsoleHook(debug bool) *ConsoleHook { + levels := []logrus.Level{ + logrus.PanicLevel, + logrus.FatalLevel, + logrus.ErrorLevel, + logrus.WarnLevel, + logrus.InfoLevel, + } + if debug { + levels = logrus.AllLevels + } + return &ConsoleHook{ + writer: os.Stderr, + levels: levels, + formatter: &logrus.TextFormatter{}, + } +} + +func (h *ConsoleHook) Levels() []logrus.Level { + return h.levels +} + +func (h *ConsoleHook) Fire(entry *logrus.Entry) error { + line, err := h.formatter.Format(entry) + if err != nil { + return fmt.Errorf("audit console hook: format entry: %w", err) + } + if _, err := h.writer.Write(line); err != nil { + return fmt.Errorf("audit console hook: write to stderr: %w", err) + } + return nil +} diff --git a/internal/audit/audit_logger_test.go b/internal/audit/audit_logger_test.go new file mode 100644 index 00000000..7029eadf --- /dev/null +++ b/internal/audit/audit_logger_test.go @@ -0,0 +1,300 @@ +package audit + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/sirupsen/logrus" +) + +// makeEntry builds a minimal logrus.Entry for use in Fire() calls. +func makeEntry(msg string, level logrus.Level) *logrus.Entry { + return &logrus.Entry{ + Logger: logrus.New(), + Message: msg, + Level: level, + Time: time.Now(), + Data: logrus.Fields{}, + } +} + +// --- FileHook tests --- + +func TestNewFileHook(t *testing.T) { + dir := t.TempDir() + + // Create a plain file so that MkdirAll fails when we try to use it as a directory. + notADir := filepath.Join(dir, "not-a-dir") + if err := os.WriteFile(notADir, []byte("x"), 0644); err != nil { + t.Fatalf("setup: %v", err) + } + + tests := []struct { + name string + path string + wantErr bool + }{ + { + name: "valid path creates file", + path: filepath.Join(dir, "audit", ".crane-audit.log"), + wantErr: false, + }, + { + name: "empty path returns error", + path: "", + wantErr: true, + }, + { + name: "bad path returns error when dir is a file", + path: filepath.Join(notADir, "audit.log"), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hook, err := NewFileHook(tt.path, nil) + if tt.wantErr && err == nil { + t.Fatal("expected error, got nil") + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if hook != nil { + if err := hook.Close(); err != nil { + t.Errorf("Close: %v", err) + } + } + if !tt.wantErr { + if _, statErr := os.Stat(tt.path); statErr != nil { + t.Fatalf("file was not created at %s: %v", tt.path, statErr) + } + } + }) + } +} + +func TestNewFileHook_EnforcesPermissionsOnExistingFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "existing.log") + // Create file with permissive 0644 permissions + if err := os.WriteFile(path, []byte("old content\n"), 0644); err != nil { + t.Fatalf("setup: %v", err) + } + hook, err := NewFileHook(path, nil) + if err != nil { + t.Fatalf("NewFileHook: %v", err) + } + if err := hook.Close(); err != nil { + t.Errorf("Close: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat: %v", err) + } + if got := info.Mode().Perm(); got != 0600 { + t.Errorf("permissions = %04o, want 0600", got) + } +} + +func TestFileHook_Fire_WritesJSON(t *testing.T) { + path := filepath.Join(t.TempDir(), "audit.log") + hook, err := NewFileHook(path, nil) + if err != nil { + t.Fatalf("NewFileHook: %v", err) + } + defer func() { + if err := hook.Close(); err != nil { + t.Errorf("Close: %v", err) + } + }() + + if err := hook.Fire(makeEntry("test message", logrus.InfoLevel)); err != nil { + t.Fatalf("Fire: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if len(data) == 0 { + t.Fatal("expected file to contain data, got empty file") + } + + var parsed map[string]interface{} + if err := json.Unmarshal(data[:len(data)-1], &parsed); err != nil { + t.Fatalf("file content is not valid JSON: %v\ncontent: %s", err, data) + } + if parsed["msg"] != "test message" { + t.Errorf("msg = %q, want %q", parsed["msg"], "test message") + } + if parsed["level"] != "info" { + t.Errorf("level = %q, want %q", parsed["level"], "info") + } +} + +func TestFileHook_AppendMode(t *testing.T) { + path := filepath.Join(t.TempDir(), "audit.log") + + for i, msg := range []string{"first", "second"} { + hook, err := NewFileHook(path, nil) + if err != nil { + t.Fatalf("run %d NewFileHook: %v", i, err) + } + if err := hook.Fire(makeEntry(msg, logrus.InfoLevel)); err != nil { + t.Fatalf("run %d Fire: %v", i, err) + } + if err := hook.Close(); err != nil { + t.Fatalf("run %d Close: %v", i, err) + } + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + lines := bytes.Split(bytes.TrimRight(data, "\n"), []byte("\n")) + if len(lines) != 2 { + t.Fatalf("expected 2 lines (append mode), got %d:\n%s", len(lines), data) + } +} + +func TestFileHook_Close_PreventsFurtherWrites(t *testing.T) { + path := filepath.Join(t.TempDir(), "audit.log") + hook, err := NewFileHook(path, nil) + if err != nil { + t.Fatalf("NewFileHook: %v", err) + } + + if err := hook.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + if err := hook.Fire(makeEntry("after close", logrus.InfoLevel)); err == nil { + t.Fatal("expected error when firing after Close, got nil") + } +} + +func TestFileHook_Levels_IncludesDebugExcludesTrace(t *testing.T) { + path := filepath.Join(t.TempDir(), "audit.log") + hook, err := NewFileHook(path, nil) + if err != nil { + t.Fatalf("NewFileHook: %v", err) + } + defer func() { + if err := hook.Close(); err != nil { + t.Errorf("Close: %v", err) + } + }() + + levels := hook.Levels() + hasDebug, hasTrace := false, false + for _, l := range levels { + if l == logrus.DebugLevel { + hasDebug = true + } + if l == logrus.TraceLevel { + hasTrace = true + } + } + if !hasDebug { + t.Error("Levels() should include DebugLevel") + } + if hasTrace { + t.Error("Levels() should not include TraceLevel (logger is set to DebugLevel)") + } +} + +func TestFileHook_Fire_InjectsCmdField(t *testing.T) { + path := filepath.Join(t.TempDir(), "audit.log") + cmd := "export" + hook, err := NewFileHook(path, &cmd) + if err != nil { + t.Fatalf("NewFileHook: %v", err) + } + defer func() { + if err := hook.Close(); err != nil { + t.Errorf("Close: %v", err) + } + }() + + entry := makeEntry("test", logrus.InfoLevel) + if err := hook.Fire(entry); err != nil { + t.Fatalf("Fire: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + + var parsed map[string]interface{} + if err := json.Unmarshal(data[:len(data)-1], &parsed); err != nil { + t.Fatalf("not valid JSON: %v", err) + } + if parsed["cmd"] != "export" { + t.Errorf("cmd = %q, want %q", parsed["cmd"], "export") + } +} + +func TestNewFileHook_CustomPath(t *testing.T) { + dir := t.TempDir() + customPath := filepath.Join(dir, "custom", "crane.jsonl") + cmd := "export" + hook, err := NewFileHook(customPath, &cmd) + if err != nil { + t.Fatalf("NewFileHook with custom path: %v", err) + } + defer func() { + if err := hook.Close(); err != nil { + t.Errorf("Close: %v", err) + } + }() + + if err := hook.Fire(makeEntry("hello", logrus.InfoLevel)); err != nil { + t.Fatalf("Fire: %v", err) + } + if _, err := os.Stat(customPath); err != nil { + t.Fatalf("file not created at custom path %s: %v", customPath, err) + } +} + +// --- ConsoleHook tests --- + +func TestNewConsoleHook_LevelsWithoutDebug(t *testing.T) { + hook := NewConsoleHook(false) + for _, l := range hook.Levels() { + if l == logrus.DebugLevel || l == logrus.TraceLevel { + t.Errorf("ConsoleHook without debug should not include level %s", l) + } + } +} + +func TestNewConsoleHook_LevelsWithDebug(t *testing.T) { + hook := NewConsoleHook(true) + if len(hook.Levels()) != len(logrus.AllLevels) { + t.Errorf("ConsoleHook with debug: got %d levels, want %d", len(hook.Levels()), len(logrus.AllLevels)) + } +} + +func TestConsoleHook_Fire_WritesToWriter(t *testing.T) { + var buf bytes.Buffer + hook := &ConsoleHook{ + writer: &buf, + levels: logrus.AllLevels, + formatter: &logrus.TextFormatter{DisableColors: true}, + } + + if err := hook.Fire(makeEntry("hello console", logrus.InfoLevel)); err != nil { + t.Fatalf("Fire: %v", err) + } + if buf.Len() == 0 { + t.Fatal("expected output in writer, got nothing") + } + if !bytes.Contains(buf.Bytes(), []byte("hello console")) { + t.Errorf("output does not contain message: %s", buf.Bytes()) + } +} diff --git a/internal/file/file_helper.go b/internal/file/file_helper.go index 8b220c26..df24df32 100644 --- a/internal/file/file_helper.go +++ b/internal/file/file_helper.go @@ -21,8 +21,15 @@ type File struct { } func ReadFiles(ctx context.Context, dir string) ([]File, error) { - log := logrus.StandardLogger() + return ReadFilesWithLogger(ctx, dir, logrus.StandardLogger()) +} +// ReadFilesWithLogger reads Kubernetes resource files from dir using the provided logger. +// Use this instead of ReadFiles when the caller has an audit-hooked logger. +func ReadFilesWithLogger(ctx context.Context, dir string, log *logrus.Logger) ([]File, error) { + if log == nil { + log = logrus.StandardLogger() + } files, err := ioutil.ReadDir(dir) if err != nil { return nil, fmt.Errorf("failed to read directory %q: %w", dir, err) diff --git a/internal/file/file_helper_test.go b/internal/file/file_helper_test.go index 052cb2df..e787cc74 100644 --- a/internal/file/file_helper_test.go +++ b/internal/file/file_helper_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/konveyor/crane/internal/file" + "github.com/sirupsen/logrus" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" ) @@ -32,6 +33,40 @@ func writeFile(t *testing.T, path, content string) { } } +func TestReadFilesWithLogger(t *testing.T) { + const validYAML = `apiVersion: v1 +kind: ConfigMap +metadata: + name: logger-test-cm + namespace: default +` + tests := []struct { + name string + logger *logrus.Logger + }{ + {name: "nil logger does not panic", logger: nil}, + {name: "non-nil logger", logger: logrus.New()}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := createTestDir(t) + writeFile(t, filepath.Join(dir, "cm.yaml"), validYAML) + + files, err := file.ReadFilesWithLogger(context.TODO(), dir, tt.logger) + if err != nil { + t.Fatalf("ReadFilesWithLogger: %v", err) + } + if len(files) != 1 { + t.Fatalf("expected 1 file, got %d", len(files)) + } + if files[0].Unstructured.GetName() != "logger-test-cm" { + t.Errorf("name = %q, want %q", files[0].Unstructured.GetName(), "logger-test-cm") + } + }) + } +} + func TestReadFilesValidResource(t *testing.T) { dir := createTestDir(t) validYAML := `apiVersion: v1 diff --git a/internal/flags/global_flags.go b/internal/flags/global_flags.go index 353a54e5..04f17752 100644 --- a/internal/flags/global_flags.go +++ b/internal/flags/global_flags.go @@ -1,24 +1,40 @@ package flags import ( + "fmt" + "io" + "os" + + "github.com/konveyor/crane/internal/audit" "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) type GlobalFlags struct { - ConfigFile string - Debug bool - logger *logrus.Logger + ConfigFile string + Debug bool + AuditLogPath string `mapstructure:"audit-log"` + cmdName string + logger *logrus.Logger + fileHook *audit.FileHook } func (g *GlobalFlags) ApplyFlags(cmd *cobra.Command) { cobra.OnInitialize(g.initConfig) cmd.PersistentFlags().BoolVar(&g.Debug, "debug", false, "Debug the command by printing more information") cmd.PersistentFlags().StringVarP(&g.ConfigFile, "flags-file", "f", "", "Path to input file which contains a yaml representation of cli flags. Explicit flags take precedence over input file values.") + cmd.PersistentFlags().StringVar(&g.AuditLogPath, "audit-log", "audit/.crane-audit.log", "Path to the audit log file") viper.BindPFlags(cmd.PersistentFlags()) } +// SetCmdName sets the command name used in audit log entries. Safe to call on a nil GlobalFlags. +func (g *GlobalFlags) SetCmdName(name string) { + if g != nil { + g.cmdName = name + } +} + // GetLoggerOrDefault returns the configured logger, or logrus.StandardLogger() if GlobalFlags is nil. func (g *GlobalFlags) GetLoggerOrDefault() *logrus.Logger { if g == nil { @@ -27,16 +43,40 @@ func (g *GlobalFlags) GetLoggerOrDefault() *logrus.Logger { return g.GetLogger() } +// isCompletionMode returns true when cobra is running a shell completion request. +// In that case we skip the audit file hook to avoid creating audit/ during tab-completion. +func isCompletionMode() bool { + return len(os.Args) > 1 && (os.Args[1] == "__complete" || os.Args[1] == "__completeNoDesc") +} + func (g *GlobalFlags) GetLogger() *logrus.Logger { if g.logger == nil { g.logger = logrus.New() - if g.Debug { - g.logger.SetLevel(logrus.DebugLevel) + g.logger.SetLevel(logrus.DebugLevel) + g.logger.SetOutput(io.Discard) + consoleHook := audit.NewConsoleHook(g.Debug) + g.logger.AddHook(consoleHook) + if !isCompletionMode() { + fileHook, err := audit.NewFileHook(g.AuditLogPath, &g.cmdName) + if err == nil { + g.fileHook = fileHook + g.logger.AddHook(fileHook) + } else { + g.logger.Warnf("Failed to open audit log file %s: %v", g.AuditLogPath, err) + } } } return g.logger } +// Close releases the audit log file. Call this when the program exits. +func (g *GlobalFlags) Close() error { + if g.fileHook != nil { + return g.fileHook.Close() + } + return nil +} + func (g *GlobalFlags) initConfig() { if g.ConfigFile != "" { viper.SetConfigFile(g.ConfigFile) @@ -44,6 +84,9 @@ func (g *GlobalFlags) initConfig() { viper.AutomaticEnv() if err := viper.ReadInConfig(); err == nil { + if err := viper.UnmarshalKey("audit-log", &g.AuditLogPath); err != nil { + fmt.Fprintf(os.Stderr, "warning: invalid audit-log value in %s: %v\n", viper.ConfigFileUsed(), err) + } g.GetLogger().Infof("Using config file: %v", viper.ConfigFileUsed()) } } diff --git a/internal/transform/orchestrator.go b/internal/transform/orchestrator.go index 6e8d74fe..9271d5f5 100644 --- a/internal/transform/orchestrator.go +++ b/internal/transform/orchestrator.go @@ -471,7 +471,7 @@ func (o *Orchestrator) applyStageTransforms(stageDir string) ([]unstructured.Uns // loadResourcesFromDirectory loads all Kubernetes resources from a directory func (o *Orchestrator) loadResourcesFromDirectory(dir string) ([]unstructured.Unstructured, error) { - files, err := file.ReadFiles(context.TODO(), dir) + files, err := file.ReadFilesWithLogger(context.TODO(), dir, o.Log) if err != nil { o.Log.Debugf("Failed to read directory %s: %v", dir, err) return nil, fmt.Errorf("failed to read directory %s: %w", dir, err) diff --git a/main.go b/main.go index c39e0a46..8624cf37 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "os" "path/filepath" @@ -20,15 +21,24 @@ import ( ) func main() { + os.Exit(run()) +} + +func run() int { f := &flags.GlobalFlags{} + defer func() { + if err := f.Close(); err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to close audit log: %v\n", err) + } + }() root := cobra.Command{ Use: filepath.Base(os.Args[0]), } f.ApplyFlags(&root) root.AddCommand(export.NewExportCommand(genericclioptions.IOStreams{In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr}, f)) root.AddCommand(transfer_pvc.NewTransferPVCCommand(genericclioptions.IOStreams{In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr}, f)) - root.AddCommand(tunnel_api.NewTunnelAPIOptions(genericclioptions.IOStreams{In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr})) - root.AddCommand(convert.NewConvertOptions(genericclioptions.IOStreams{In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr})) + root.AddCommand(tunnel_api.NewTunnelAPIOptions(genericclioptions.IOStreams{In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr}, f)) + root.AddCommand(convert.NewConvertOptions(genericclioptions.IOStreams{In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr}, f)) root.AddCommand(transform.NewTransformCommand(f)) root.AddCommand(skopeo_sync_gen.NewSkopeoSyncGenCommand(f)) root.AddCommand(apply.NewApplyCommand(f)) @@ -36,6 +46,7 @@ func main() { root.AddCommand(version.NewVersionCommand(f)) root.AddCommand(validate.NewValidateCommand(genericclioptions.IOStreams{In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr}, f)) if err := root.Execute(); err != nil { - os.Exit(1) + return 1 } + return 0 }