diff --git a/cmd/transform/listplugins/listplugins_test.go b/cmd/transform/listplugins/listplugins_test.go new file mode 100644 index 00000000..a34f4077 --- /dev/null +++ b/cmd/transform/listplugins/listplugins_test.go @@ -0,0 +1,105 @@ +package listplugins + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/konveyor/crane/internal/flags" +) + +func TestNewListPluginsCommand_command_structure(t *testing.T) { + tests := []struct { + name string + globalFlags *flags.GlobalFlags + wantUse string + wantShortContain string + }{ + { + name: "command has correct Use and Short", + globalFlags: &flags.GlobalFlags{}, + wantUse: "list-plugins", + wantShortContain: "list", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := NewListPluginsCommand(tt.globalFlags) + + if cmd.Use != tt.wantUse { + t.Errorf("cmd.Use = %q, want %q", cmd.Use, tt.wantUse) + } + + if !strings.Contains(strings.ToLower(cmd.Short), tt.wantShortContain) { + t.Errorf("cmd.Short = %q, want it to contain %q", cmd.Short, tt.wantShortContain) + } + + if cmd.RunE == nil { + t.Error("cmd.RunE is nil, want it to be set") + } + + if cmd.PreRun == nil { + t.Error("cmd.PreRun is nil, want it to be set") + } + }) + } +} + +func TestListPluginsRun(t *testing.T) { + tests := []struct { + name string + setupDir func(t *testing.T) string + wantErr bool + errContains string + }{ + { + name: "non-existent plugin directory returns no error", + setupDir: func(t *testing.T) string { + // Return a path that doesn't exist + // Note: GetFilteredPlugins handles non-existent dirs gracefully + return filepath.Join(os.TempDir(), "crane-test-nonexistent-"+t.Name()) + }, + wantErr: false, + }, + { + name: "empty plugin directory returns no error", + setupDir: func(t *testing.T) string { + dir := t.TempDir() + return dir + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pluginDir := tt.setupDir(t) + + o := &Options{ + globalFlags: &flags.GlobalFlags{}, + Flags: Flags{ + PluginDir: pluginDir, + SkipPlugins: []string{}, + }, + } + + err := o.run() + + if tt.wantErr { + if err == nil { + t.Errorf("run() error = nil, wantErr %v", tt.wantErr) + return + } + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("run() error = %v, want error containing %q", err, tt.errContains) + } + } else { + if err != nil { + t.Errorf("run() unexpected error = %v", err) + } + } + }) + } +} diff --git a/cmd/transform/optionals/optionals_test.go b/cmd/transform/optionals/optionals_test.go new file mode 100644 index 00000000..d451d4a7 --- /dev/null +++ b/cmd/transform/optionals/optionals_test.go @@ -0,0 +1,119 @@ +package optionals + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/konveyor/crane/internal/flags" +) + +func TestNewOptionalsCommand_command_structure(t *testing.T) { + tests := []struct { + name string + globalFlags *flags.GlobalFlags + wantUse string + wantShortContain string + }{ + { + name: "command has correct Use and Short", + globalFlags: &flags.GlobalFlags{}, + wantUse: "optionals", + wantShortContain: "optional", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := NewOptionalsCommand(tt.globalFlags) + + if cmd.Use != tt.wantUse { + t.Errorf("cmd.Use = %q, want %q", cmd.Use, tt.wantUse) + } + + if !strings.Contains(cmd.Short, tt.wantShortContain) { + t.Errorf("cmd.Short = %q, want it to contain %q", cmd.Short, tt.wantShortContain) + } + + if cmd.RunE == nil { + t.Error("cmd.RunE is nil, want it to be set") + } + + if cmd.PreRun == nil { + t.Error("cmd.PreRun is nil, want it to be set") + } + }) + } +} + +func TestOptionalsRun_empty_plugin_dir(t *testing.T) { + // Create an empty temp directory for plugins + emptyDir := t.TempDir() + + o := &Options{ + globalFlags: &flags.GlobalFlags{}, + Flags: Flags{ + PluginDir: emptyDir, + SkipPlugins: nil, + }, + } + + err := o.run() + if err != nil { + t.Errorf("run() with empty plugin directory returned error: %v, want nil", err) + } +} + +func TestOptionalsRun_nonexistent_dir(t *testing.T) { + // Create a temp directory and then create a path that doesn't exist within it + tmpDir := t.TempDir() + nonexistentDir := filepath.Join(tmpDir, "nonexistent_plugin_dir") + + o := &Options{ + globalFlags: &flags.GlobalFlags{}, + Flags: Flags{ + PluginDir: nonexistentDir, + SkipPlugins: nil, + }, + } + + // According to the implementation, GetPlugins handles os.IsNotExist gracefully + // and returns the default pluginList (with kubernetes plugin) without error. + // So this test verifies that non-existent directories don't cause errors. + err := o.run() + if err != nil { + t.Errorf("run() with non-existent plugin directory returned error: %v, want nil", err) + } +} + +func TestOptionalsRun_unreadable_dir(t *testing.T) { + // Skip this test on systems where we can't change permissions (e.g., running as root) + if os.Getuid() == 0 { + t.Skip("Skipping test when running as root") + } + + // Create a directory that exists but cannot be read due to permissions + tmpDir := t.TempDir() + unreadableDir := filepath.Join(tmpDir, "unreadable") + if err := os.Mkdir(unreadableDir, 0000); err != nil { + t.Fatalf("Failed to create unreadable directory: %v", err) + } + // Ensure we restore permissions so cleanup works + t.Cleanup(func() { + os.Chmod(unreadableDir, 0755) + }) + + o := &Options{ + globalFlags: &flags.GlobalFlags{}, + Flags: Flags{ + PluginDir: unreadableDir, + SkipPlugins: nil, + }, + } + + err := o.run() + if err == nil { + t.Error("run() with unreadable plugin directory returned nil, want error") + } +} diff --git a/cmd/transform/transform_test.go b/cmd/transform/transform_test.go new file mode 100644 index 00000000..eda93f41 --- /dev/null +++ b/cmd/transform/transform_test.go @@ -0,0 +1,621 @@ +// Many tests in this file let stage execution fail intentionally — no real plugins +// or export data are provided. These tests verify the logic around execution (directory +// lifecycle, stage discovery, input validation, flag wiring) rather than the +// transformation itself. A successful end-to-end run requires real plugins and +// kubectl/kustomize, which belongs in integration tests. +package transform + +import ( + "io" + "os" + "strings" + "testing" + + "github.com/konveyor/crane/internal/flags" + internalTransform "github.com/konveyor/crane/internal/transform" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// testEnv provides a reusable test environment for transform tests +type testEnv struct { + t *testing.T + TempDir string + TransformDir string + ExportDir string + PluginDir string + Log *logrus.Logger + Opts *Options +} + +func newTestEnv(t *testing.T) *testEnv { + t.Helper() + tempDir := t.TempDir() + env := &testEnv{ + t: t, + TempDir: tempDir, + TransformDir: tempDir + "/transform", + ExportDir: tempDir + "/export", + PluginDir: tempDir + "/plugins", + Log: newQuietLogger(), + Opts: &Options{}, + } + env.mkdirAll(env.TransformDir, env.ExportDir, env.PluginDir) + return env +} + +func (e *testEnv) mkdirAll(dirs ...string) { + e.t.Helper() + for _, dir := range dirs { + if err := os.MkdirAll(dir, 0755); err != nil { + e.t.Fatalf("failed to create directory %s: %v", dir, err) + } + } +} + +func (e *testEnv) newOrchestrator() *internalTransform.Orchestrator { + return &internalTransform.Orchestrator{ + Log: e.Log, + ExportDir: e.ExportDir, + TransformDir: e.TransformDir, + PluginDir: e.PluginDir, + NewlyCreatedStages: make(map[string]bool), + } +} + +func (e *testEnv) newRunOptions(f Flags) *Options { + gf := &flags.GlobalFlags{} + return &Options{ + cobraGlobalFlags: gf, + globalFlags: gf, + Flags: f, + } +} + +func (e *testEnv) stageDir(stageName string) string { + return e.TransformDir + "/" + stageName +} + +func (e *testEnv) stageOutputDir(stageName string) string { + return e.TransformDir + "/.work/" + stageName + "/output" +} + +func newQuietLogger() *logrus.Logger { + log := logrus.New() + log.SetLevel(logrus.DebugLevel) + log.SetOutput(io.Discard) + return log +} + +func TestNewTransformCommand_subcommands_registered(t *testing.T) { + cmd := NewTransformCommand(&flags.GlobalFlags{}) + + subcommands := cmd.Commands() + + if len(subcommands) != 2 { + t.Errorf("len(cmd.Commands()) = %d, want 2", len(subcommands)) + } + + wantSubcommands := map[string]bool{ + "optionals": false, + "list-plugins": false, + } + + for _, subcmd := range subcommands { + if _, exists := wantSubcommands[subcmd.Use]; exists { + wantSubcommands[subcmd.Use] = true + } else { + t.Errorf("unexpected subcommand %q", subcmd.Use) + } + } + + for name, found := range wantSubcommands { + if !found { + t.Errorf("subcommand %q not found", name) + } + } +} + +func TestNewTransformCommand_flags_registered(t *testing.T) { + cmd := NewTransformCommand(&flags.GlobalFlags{}) + + // Test regular flags + regularFlags := []struct { + name string + defValue string + }{ + {"export-dir", "export"}, + {"transform-dir", "transform"}, + {"stage", ""}, + {"force", "false"}, + {"optional-flags", ""}, + {"kustomize-args", ""}, + {"ignored-patches-dir", ""}, + } + + for _, tt := range regularFlags { + flag := cmd.Flags().Lookup(tt.name) + if flag == nil { + t.Errorf("flag %q not registered on transform command", tt.name) + continue + } + if flag.DefValue != tt.defValue { + t.Errorf("flag %q default = %q, want %q", tt.name, flag.DefValue, tt.defValue) + } + } + + // Test persistent flags (passed down to subcommands) + persistentFlags := []string{ + "plugin-dir", + "skip-plugins", + } + + for _, name := range persistentFlags { + if cmd.PersistentFlags().Lookup(name) == nil { + t.Errorf("persistent flag %q not registered on transform command", name) + } + } +} + +func TestOptionalFlagsToLower(t *testing.T) { + tests := []struct { + name string + input map[string]string + expected map[string]string + }{ + { + name: "empty map returns empty map", + input: map[string]string{}, + expected: map[string]string{}, + }, + { + name: "already lowercase keys unchanged", + input: map[string]string{ + "foo-flag": "foo-value", + "bar-flag": "bar-value", + }, + expected: map[string]string{ + "foo-flag": "foo-value", + "bar-flag": "bar-value", + }, + }, + { + name: "mixed case keys converted to lowercase", + input: map[string]string{ + "Foo-Flag": "foo-value", + "BAR-FLAG": "bar-value", + }, + expected: map[string]string{ + "foo-flag": "foo-value", + "bar-flag": "bar-value", + }, + }, + { + name: "values are preserved unchanged", + input: map[string]string{ + "My-Key": "PreserveCase-Value", + }, + expected: map[string]string{ + "my-key": "PreserveCase-Value", + }, + }, + { + name: "JSON parsed result scenario", + input: map[string]string{ + "foo-flag": "foo-a=/data,foo-b=/data", + "bar-flag": "bar-value", + }, + expected: map[string]string{ + "foo-flag": "foo-a=/data,foo-b=/data", + "bar-flag": "bar-value", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := optionalFlagsToLower(tt.input) + + if len(result) != len(tt.expected) { + t.Errorf("len(result) = %d, want %d", len(result), len(tt.expected)) + } + + for key, expectedVal := range tt.expected { + actualVal, ok := result[key] + if !ok { + t.Errorf("expected key %q not found in result", key) + continue + } + if actualVal != expectedVal { + t.Errorf("result[%q] = %q, want %q", key, actualVal, expectedVal) + } + } + + // Verify all keys are lowercase + for key := range result { + if key != strings.ToLower(key) { + t.Errorf("key %q is not lowercase", key) + } + } + }) + } +} + +func TestEnsureStagesHaveOutput(t *testing.T) { + tests := []struct { + name string + stages []internalTransform.Stage + setupStageDirs []string + setupOutputDirs []string + wantErr bool + wantErrContains string + }{ + { + name: "empty stages", + stages: []internalTransform.Stage{}, + }, + { + name: "single stage with output", + stages: []internalTransform.Stage{ + {Priority: 10, PluginName: "KubernetesPlugin", DirName: "10_KubernetesPlugin"}, + }, + setupOutputDirs: []string{"10_KubernetesPlugin"}, + }, + { + // Intentional failure: no real plugins. Tests that missing output triggers RunMultiStage. + name: "single stage no output", + stages: []internalTransform.Stage{ + {Priority: 10, PluginName: "TestPlugin", DirName: "10_TestPlugin"}, + }, + setupStageDirs: []string{"10_TestPlugin"}, + wantErr: true, + wantErrContains: "failed to run stage 10_TestPlugin", + }, + { + // Intentional failure: no real plugins. Tests that only stages without output are run. + name: "multiple stages mixed", + stages: []internalTransform.Stage{ + {Priority: 10, PluginName: "Stage1", DirName: "10_Stage1"}, + {Priority: 20, PluginName: "Stage2", DirName: "20_Stage2"}, + }, + setupStageDirs: []string{"10_Stage1", "20_Stage2"}, + setupOutputDirs: []string{"10_Stage1"}, + wantErr: true, + wantErrContains: "failed to run stage 20_Stage2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := newTestEnv(t) + + for i := range tt.stages { + tt.stages[i].Path = env.stageDir(tt.stages[i].DirName) + } + for _, name := range tt.setupStageDirs { + env.mkdirAll(env.stageDir(name)) + } + for _, name := range tt.setupOutputDirs { + env.mkdirAll(env.stageOutputDir(name)) + } + + orchestrator := env.newOrchestrator() + err := env.Opts.ensureStagesHaveOutput(orchestrator, tt.stages, env.TransformDir, env.Log) + + if tt.wantErr { + if err == nil { + t.Fatal("ensureStagesHaveOutput() returned nil error, want error") + } + if tt.wantErrContains != "" && !strings.Contains(err.Error(), tt.wantErrContains) { + t.Errorf("error = %q, want it to contain %q", err.Error(), tt.wantErrContains) + } + } else if err != nil { + t.Errorf("ensureStagesHaveOutput() returned error = %v, want nil", err) + } + }) + } +} + +func TestEnsurePreviousStagesRun_no_stages(t *testing.T) { + env := newTestEnv(t) + orchestrator := env.newOrchestrator() + + // Transform directory is empty (no stage subdirectories) + err := env.Opts.ensurePreviousStagesRun(orchestrator, env.TransformDir, env.Log) + + if err != nil { + t.Errorf("ensurePreviousStagesRun() returned error = %v, want nil", err) + } +} + +func TestRunStageWithCleanup(t *testing.T) { + tests := []struct { + name string + stageName string + setupValidStage bool + cleanupOnError bool + wantError bool + wantDirPreserved bool + }{ + { + name: "success - no cleanup needed", + stageName: "10_PassThrough", + setupValidStage: true, + cleanupOnError: true, + wantError: false, + wantDirPreserved: true, + }, + { + // Intentional failure: no real plugins. + name: "error with cleanupOnError true - directory removed", + stageName: "10_MissingPlugin", + setupValidStage: false, + cleanupOnError: true, + wantError: true, + wantDirPreserved: false, + }, + { + // Intentional failure: no real plugins. + name: "error with cleanupOnError false - directory preserved", + stageName: "10_MissingPlugin", + setupValidStage: false, + cleanupOnError: false, + wantError: true, + wantDirPreserved: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := newTestEnv(t) + stageDir := env.stageDir(tt.stageName) + env.mkdirAll(stageDir) + + if tt.setupValidStage { + exportResourcesDir := env.ExportDir + "/resources/default" + env.mkdirAll(exportResourcesDir) + configMapYAML := `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config + namespace: default +data: + key: value +` + if err := os.WriteFile(exportResourcesDir+"/ConfigMap_default_test-config.yaml", []byte(configMapYAML), 0644); err != nil { + t.Fatalf("failed to write configmap: %v", err) + } + } + + orchestrator := env.newOrchestrator() + orchestrator.Force = true + + selector := internalTransform.StageSelector{Stage: tt.stageName} + err := env.Opts.runStageWithCleanup(orchestrator, selector, stageDir, tt.cleanupOnError, env.Log) + + if tt.wantError && err == nil { + t.Errorf("runStageWithCleanup() returned nil error, want error") + } + if !tt.wantError && err != nil { + t.Errorf("runStageWithCleanup() returned error = %v, want nil", err) + } + + _, statErr := os.Stat(stageDir) + dirExists := statErr == nil + + if tt.wantDirPreserved && !dirExists { + t.Errorf("stageDir should be preserved but was removed") + } + if !tt.wantDirPreserved && dirExists { + t.Errorf("stageDir should be removed but still exists") + } + }) + } +} + +func TestNewTransformCommand_prerun_binds_flags(t *testing.T) { + viper.Reset() + + cmd := NewTransformCommand(&flags.GlobalFlags{}) + + var capturedFlags Flags + cmd.RunE = func(c *cobra.Command, args []string) error { + viper.Unmarshal(&capturedFlags) + return nil + } + + cmd.SetArgs([]string{ + "--export-dir", "/tmp/test-export", + "--transform-dir", "/tmp/test-transform", + "--stage", "20_TestStage", + "--force", + "--optional-flags", `{"key":"val"}`, + "--kustomize-args", "--enable-helm", + "--skip-plugins", "pluginA,pluginB", + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("cmd.Execute() returned error: %v", err) + } + + if capturedFlags.ExportDir != "/tmp/test-export" { + t.Errorf("ExportDir = %q, want %q", capturedFlags.ExportDir, "/tmp/test-export") + } + if capturedFlags.TransformDir != "/tmp/test-transform" { + t.Errorf("TransformDir = %q, want %q", capturedFlags.TransformDir, "/tmp/test-transform") + } + if capturedFlags.Stage != "20_TestStage" { + t.Errorf("Stage = %q, want %q", capturedFlags.Stage, "20_TestStage") + } + if !capturedFlags.Force { + t.Error("Force = false, want true") + } + if capturedFlags.OptionalFlags != `{"key":"val"}` { + t.Errorf("OptionalFlags = %q, want %q", capturedFlags.OptionalFlags, `{"key":"val"}`) + } + if capturedFlags.KustomizeArgs != "--enable-helm" { + t.Errorf("KustomizeArgs = %q, want %q", capturedFlags.KustomizeArgs, "--enable-helm") + } +} + +func TestNewTransformCommand_rejects_invalid_flags(t *testing.T) { + tests := []struct { + name string + args []string + wantErrContains string + }{ + { + name: "unknown flag", + args: []string{"--does-not-exist", "value"}, + wantErrContains: "unknown flag", + }, + { + name: "missing flag value", + args: []string{"--export-dir"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + viper.Reset() + cmd := NewTransformCommand(&flags.GlobalFlags{}) + cmd.SetArgs(tt.args) + + err := cmd.Execute() + + if err == nil { + t.Fatal("expected error, got nil") + } + if tt.wantErrContains != "" && !strings.Contains(err.Error(), tt.wantErrContains) { + t.Errorf("error = %q, want it to contain %q", err.Error(), tt.wantErrContains) + } + }) + } +} + +func TestRun_invalid_input(t *testing.T) { + tests := []struct { + name string + flags Flags + wantErrContains string + }{ + { + name: "invalid optional flags JSON", + flags: Flags{OptionalFlags: "not-json"}, + wantErrContains: "invalid character", + }, + { + name: "kustomize args with shell injection", + flags: Flags{KustomizeArgs: "--enable-helm; rm -rf /"}, + wantErrContains: "invalid kustomize-args", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := newTestEnv(t) + tt.flags.ExportDir = env.ExportDir + tt.flags.PluginDir = env.PluginDir + tt.flags.TransformDir = env.TransformDir + opts := env.newRunOptions(tt.flags) + + err := opts.run() + + if err == nil { + t.Fatal("run() returned nil error, want error") + } + if !strings.Contains(err.Error(), tt.wantErrContains) { + t.Errorf("error = %q, want it to contain %q", err.Error(), tt.wantErrContains) + } + }) + } +} + +// Intentional failure: no real plugins. Tests that run() bootstraps the default stage directory. +func TestRun_no_stages_creates_default_stage_dir(t *testing.T) { + env := newTestEnv(t) + opts := env.newRunOptions(Flags{ + ExportDir: env.ExportDir, + PluginDir: env.PluginDir, + TransformDir: env.TransformDir, + }) + + _ = opts.run() + + defaultStageDir := env.stageDir("10_KubernetesPlugin") + if _, err := os.Stat(defaultStageDir); os.IsNotExist(err) { + t.Error("run() should create default stage directory 10_KubernetesPlugin") + } +} + +// Intentional failure: no real plugins. Tests directory cleanup behavior on error. +func TestRun_specific_stage_directory_lifecycle(t *testing.T) { + tests := []struct { + name string + stageName string + preCreateStage bool + wantDirPreserved bool + }{ + { + name: "existing stage preserved on error", + stageName: "10_ExistingPlugin", + preCreateStage: true, + wantDirPreserved: true, + }, + { + name: "new stage cleaned up on error", + stageName: "20_FailingPlugin", + preCreateStage: false, + wantDirPreserved: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := newTestEnv(t) + if tt.preCreateStage { + env.mkdirAll(env.stageDir(tt.stageName)) + } + + opts := env.newRunOptions(Flags{ + ExportDir: env.ExportDir, + PluginDir: env.PluginDir, + TransformDir: env.TransformDir, + Stage: tt.stageName, + }) + + _ = opts.run() + + _, statErr := os.Stat(env.stageDir(tt.stageName)) + dirExists := statErr == nil + + if tt.wantDirPreserved && !dirExists { + t.Error("stage directory should be preserved but was removed") + } + if !tt.wantDirPreserved && dirExists { + t.Error("stage directory should be cleaned up but still exists") + } + }) + } +} + +// Intentional failure: no real plugins. Tests that run() discovers stages from the directory. +func TestRun_discovers_existing_stages(t *testing.T) { + env := newTestEnv(t) + + // Create two stage directories matching the naming convention + env.mkdirAll(env.stageDir("10_FirstPlugin"), env.stageDir("20_SecondPlugin")) + + opts := env.newRunOptions(Flags{ + ExportDir: env.ExportDir, + PluginDir: env.PluginDir, + TransformDir: env.TransformDir, + }) + + err := opts.run() + + if err == nil { + t.Fatal("run() returned nil error, want error from stage execution") + } +} diff --git a/internal/transform/orchestrator_test.go b/internal/transform/orchestrator_test.go index d39f30a6..32cd032b 100644 --- a/internal/transform/orchestrator_test.go +++ b/internal/transform/orchestrator_test.go @@ -10,6 +10,7 @@ import ( cranelib "github.com/konveyor/crane-lib/transform" "github.com/konveyor/crane/internal/file" "github.com/sirupsen/logrus" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) func TestFilterPluginsByStage(t *testing.T) { @@ -416,6 +417,205 @@ type mockPlugin struct { name string } +// testPlugin implements cranelib.Plugin for unit testing getPluginForStage +type testPlugin struct { + pluginName string +} + +func (p testPlugin) Run(request cranelib.PluginRequest) (cranelib.PluginResponse, error) { + return cranelib.PluginResponse{}, nil +} + +func (p testPlugin) Metadata() cranelib.PluginMetadata { + return cranelib.PluginMetadata{Name: p.pluginName} +} + +// Tests plugin resolution logic that maps stage names to plugins — was only tested +// indirectly via mock filtering, never through the actual getPluginForStage function. +func TestGetPluginForStage(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + + o := &Orchestrator{Log: logger} + + kubePlugin := testPlugin{pluginName: "KubernetesPlugin"} + osPlugin := testPlugin{pluginName: "OpenshiftPlugin"} + twoPlugins := []cranelib.Plugin{kubePlugin, osPlugin} + + tests := []struct { + name string + stage Stage + allPlugins []cranelib.Plugin + expectNil bool + expectError bool + errorContains string + expectedName string + }{ + { + name: "plugin found", + stage: Stage{DirName: "10_KubernetesPlugin", PluginName: "KubernetesPlugin"}, + allPlugins: twoPlugins, + expectNil: false, + expectedName: "KubernetesPlugin", + }, + { + name: "required plugin missing", + stage: Stage{DirName: "10_MissingPlugin", PluginName: "MissingPlugin"}, + allPlugins: twoPlugins, + expectNil: true, + expectError: true, + errorContains: "not found", + }, + { + name: "pass-through stage", + stage: Stage{DirName: "90_ManualEdits", PluginName: "ManualEdits"}, + allPlugins: twoPlugins, + expectNil: true, + }, + { + name: "required plugin with empty list", + stage: Stage{DirName: "10_FooPlugin", PluginName: "FooPlugin"}, + allPlugins: []cranelib.Plugin{}, + expectNil: true, + expectError: true, + errorContains: "none", + }, + { + name: "pass-through with empty list", + stage: Stage{DirName: "90_CustomStage", PluginName: "CustomStage"}, + allPlugins: []cranelib.Plugin{}, + expectNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin, err := o.getPluginForStage(tt.stage, tt.allPlugins) + + if tt.expectError { + if err == nil { + t.Fatal("expected error but got nil") + } + if tt.errorContains != "" && !contains(err.Error(), tt.errorContains) { + t.Errorf("expected error containing %q, got %q", tt.errorContains, err.Error()) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tt.expectNil { + if plugin != nil { + t.Errorf("expected nil plugin, got %s", plugin.Metadata().Name) + } + } else { + if plugin == nil { + t.Fatal("expected non-nil plugin, got nil") + } + if plugin.Metadata().Name != tt.expectedName { + t.Errorf("expected plugin %q, got %q", tt.expectedName, plugin.Metadata().Name) + } + } + }) + } +} + +func TestWriteResourcesToDirectory(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + o := &Orchestrator{Log: logger} + + tests := []struct { + name string + resources []unstructured.Unstructured + expectSubdir string + expectFileCount int + }{ + { + name: "namespaced resource written to namespace subdir", + resources: []unstructured.Unstructured{{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{"name": "test-config", "namespace": "default"}, + }, + }}, + expectSubdir: "default", + expectFileCount: 1, + }, + { + name: "cluster-scoped resource written to _cluster subdir", + resources: []unstructured.Unstructured{{ + Object: map[string]interface{}{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "ClusterRole", + "metadata": map[string]interface{}{"name": "admin"}, + }, + }}, + expectSubdir: "_cluster", + expectFileCount: 1, + }, + { + name: "resource missing kind is skipped", + resources: []unstructured.Unstructured{{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "metadata": map[string]interface{}{"name": "orphan", "namespace": "default"}, + }, + }}, + expectFileCount: 0, + }, + { + name: "resource missing name is skipped", + resources: []unstructured.Unstructured{{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{"namespace": "default"}, + }, + }}, + expectFileCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + outputDir, err := os.MkdirTemp("", "write-resources-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(outputDir) + + targetDir := filepath.Join(outputDir, "output") + if err := o.writeResourcesToDirectory(tt.resources, targetDir); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var fileCount int + filepath.Walk(targetDir, func(path string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() && filepath.Ext(path) == ".yaml" { + fileCount++ + + if tt.expectSubdir != "" { + rel, _ := filepath.Rel(targetDir, path) + dir := filepath.Dir(rel) + if dir != tt.expectSubdir { + t.Errorf("expected file under %q, got %q", tt.expectSubdir, dir) + } + } + } + return nil + }) + + if fileCount != tt.expectFileCount { + t.Errorf("expected %d files, got %d", tt.expectFileCount, fileCount) + } + }) + } +} + func TestExecuteStage_PluginFiltering(t *testing.T) { // Test that stage configuration correctly specifies plugin filtering behavior diff --git a/internal/transform/writer_integration_test.go b/internal/transform/writer_integration_test.go index e9e370fc..e11f4eb8 100644 --- a/internal/transform/writer_integration_test.go +++ b/internal/transform/writer_integration_test.go @@ -666,3 +666,139 @@ func TestWriteStage_KustomizeBuildWithMixedResources(t *testing.T) { t.Error("ClusterRole not found in kustomize output") } } + +// Tests dedup logic for duplicate resourceIDs with different whiteout status — the dedup +// branches in WriteStage were only exercised with distinct resources, never same-ID duplicates. +func TestWriteStage_DuplicateResourceDedup(t *testing.T) { + makeConfigMap := func(data string, whiteout bool) cranelib.TransformArtifact { + resource := unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{"name": "test", "namespace": "default"}, + "data": map[string]interface{}{"key": data}, + }, + } + return cranelib.TransformArtifact{ + Resource: resource, + HaveWhiteOut: whiteout, + Target: cranelib.DeriveTargetFromResource(resource), + } + } + + t.Run("whiteout replaced by active", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dedup-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + opts := file.PathOpts{TransformDir: filepath.Join(tmpDir, "transform")} + writer := NewKustomizeWriter(opts, "10_test", logger) + + artifacts := []cranelib.TransformArtifact{ + makeConfigMap("first", true), + makeConfigMap("second", false), + } + + if err := writer.WriteStage(artifacts, true); err != nil { + t.Fatalf("WriteStage failed: %v", err) + } + + kData, err := os.ReadFile(opts.GetKustomizationPath("10_test")) + if err != nil { + t.Fatal(err) + } + + var kust kustomize.KustomizationFile + if err := yaml.Unmarshal(kData, &kust); err != nil { + t.Fatalf("Failed to parse kustomization.yaml: %v", err) + } + + if len(kust.Resources) != 1 { + t.Fatalf("expected 1 active resource, got %d", len(kust.Resources)) + } + if !strings.Contains(kust.Resources[0], "ConfigMap") { + t.Errorf("expected ConfigMap in resources list, got %q", kust.Resources[0]) + } + }) + + t.Run("active kept over whiteout", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dedup-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + opts := file.PathOpts{TransformDir: filepath.Join(tmpDir, "transform")} + writer := NewKustomizeWriter(opts, "10_test", logger) + + artifacts := []cranelib.TransformArtifact{ + makeConfigMap("first", false), + makeConfigMap("second", true), + } + + if err := writer.WriteStage(artifacts, true); err != nil { + t.Fatalf("WriteStage failed: %v", err) + } + + kData, err := os.ReadFile(opts.GetKustomizationPath("10_test")) + if err != nil { + t.Fatal(err) + } + + var kust kustomize.KustomizationFile + if err := yaml.Unmarshal(kData, &kust); err != nil { + t.Fatalf("Failed to parse kustomization.yaml: %v", err) + } + + if len(kust.Resources) != 1 { + t.Fatalf("expected 1 active resource, got %d", len(kust.Resources)) + } + }) + + t.Run("both active last wins", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dedup-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + opts := file.PathOpts{TransformDir: filepath.Join(tmpDir, "transform")} + writer := NewKustomizeWriter(opts, "10_test", logger) + + artifacts := []cranelib.TransformArtifact{ + makeConfigMap("first-data", false), + makeConfigMap("second-data", false), + } + + if err := writer.WriteStage(artifacts, true); err != nil { + t.Fatalf("WriteStage failed: %v", err) + } + + // Verify only one resource file + resourcesDir := opts.GetResourcesDir("10_test") + entries, err := os.ReadDir(resourcesDir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 resource file (deduped), got %d", len(entries)) + } + + // Verify content is from the last artifact + content, err := os.ReadFile(filepath.Join(resourcesDir, entries[0].Name())) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "second-data") { + t.Errorf("expected resource to contain 'second-data' (last wins), got:\n%s", string(content)) + } + }) +} diff --git a/internal/transform/writer_test.go b/internal/transform/writer_test.go index bb567765..4cc3dce7 100644 --- a/internal/transform/writer_test.go +++ b/internal/transform/writer_test.go @@ -1,9 +1,12 @@ package transform import ( + "os" + "path/filepath" "testing" jsonpatch "github.com/evanphx/json-patch" + "github.com/sirupsen/logrus" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) @@ -362,3 +365,84 @@ func TestPathExists(t *testing.T) { }) } } + +func TestCheckStageDirectory(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + w := &KustomizeWriter{log: logger} + + tests := []struct { + name string + setup func(t *testing.T, base string) string + expectError bool + errorContains string + }{ + { + name: "directory does not exist", + setup: func(t *testing.T, base string) string { + return filepath.Join(base, "nonexistent") + }, + }, + { + name: "directory exists and is empty", + setup: func(t *testing.T, base string) string { + dir := filepath.Join(base, "empty") + if err := os.Mkdir(dir, 0700); err != nil { + t.Fatal(err) + } + return dir + }, + }, + { + name: "directory exists with files", + setup: func(t *testing.T, base string) string { + dir := filepath.Join(base, "nonempty") + if err := os.Mkdir(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("data"), 0644); err != nil { + t.Fatal(err) + } + return dir + }, + expectError: true, + errorContains: "not empty", + }, + { + name: "path is a file not directory", + setup: func(t *testing.T, base string) string { + path := filepath.Join(base, "afile") + if err := os.WriteFile(path, []byte("data"), 0644); err != nil { + t.Fatal(err) + } + return path + }, + expectError: true, + errorContains: "not a directory", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + base, err := os.MkdirTemp("", "check-stage-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(base) + + stageDir := tt.setup(t, base) + err = w.checkStageDirectory(stageDir) + + if tt.expectError { + if err == nil { + t.Fatal("expected error but got nil") + } + if !contains(err.Error(), tt.errorContains) { + t.Errorf("expected error containing %q, got %q", tt.errorContains, err.Error()) + } + } else if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +}