From ca0c214ad53b77511f66e4d58f2381eff51569ec Mon Sep 17 00:00:00 2001 From: Alokzh Date: Mon, 13 Oct 2025 01:57:47 +0530 Subject: [PATCH 01/29] Add dependency injection infrastructure & improve coverage for helm-repo-add.go file Signed-off-by: Alokzh --- go.mod | 4 + go.sum | 14 ++ pkg/internal/helm-repo-add.go | 9 +- pkg/internal/helm-repo-add_test.go | 377 ++++++++++++++++++++++++++++- util/exec.go | 94 +++++++ util/executables.go | 30 +-- util/file-generation.go | 17 +- util/interfaces.go | 42 ++++ util/print-util.go | 18 +- util/testing.go | 183 ++++++++++++++ util/time.go | 13 + 11 files changed, 739 insertions(+), 62 deletions(-) create mode 100644 util/exec.go create mode 100644 util/interfaces.go create mode 100644 util/testing.go create mode 100644 util/time.go diff --git a/go.mod b/go.mod index 81667c2..e8a96d4 100644 --- a/go.mod +++ b/go.mod @@ -5,14 +5,18 @@ go 1.17 require ( github.com/go-yaml/yaml v2.1.0+incompatible github.com/spf13/cobra v1.6.1 + github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v2 v2.4.0 sigs.k8s.io/yaml v1.2.0 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/tidwall/gjson v1.14.2 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( diff --git a/go.sum b/go.sum index 07afaf7..990a50e 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,27 @@ github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-yaml/yaml v2.1.0+incompatible h1:RYi2hDdss1u4YE7GwixGzWwVo47T8UQwnTLB6vQiq+o= github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.14.2 h1:6BBkirS0rAHjumnjHF6qgy5d2YAJ1TLIaFE2lzfOLqo= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -23,6 +35,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= sigs.k8s.io/yaml v1.2.0 h1:kr/MCeFWJWTwyaHoR9c8EjH9OumOmoF9YGiZd7lFm/Q= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= diff --git a/pkg/internal/helm-repo-add.go b/pkg/internal/helm-repo-add.go index a57cd0f..d6b0c67 100644 --- a/pkg/internal/helm-repo-add.go +++ b/pkg/internal/helm-repo-add.go @@ -2,7 +2,6 @@ package internal import ( "fmt" - "log" "time" "github.com/kubeslice/kubeslice-cli/util" @@ -28,11 +27,11 @@ func AddHelmCharts(ApplicationConfiguration *ConfigurationSpecs) { addHelmChart(ApplicationConfiguration) util.Printf("%s Successfully added helm repo %s : %s", util.Tick, hc.RepoAlias, hc.RepoUrl) - time.Sleep(200 * time.Millisecond) + util.Sleep(200 * time.Millisecond) updateHelmChart() util.Printf("%s Successfully updated helm repo", util.Tick) - time.Sleep(200 * time.Millisecond) + util.Sleep(200 * time.Millisecond) util.Printf("%s Successfully added helm charts.\n", util.Tick) } @@ -47,14 +46,14 @@ func addHelmChart(ApplicationConfiguration *ConfigurationSpecs) { } err := util.RunCommand("helm", repoAddCommands...) if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } } func updateHelmChart() { err := util.RunCommand("helm", "repo", "update") if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } } diff --git a/pkg/internal/helm-repo-add_test.go b/pkg/internal/helm-repo-add_test.go index aa1c030..1c8111e 100644 --- a/pkg/internal/helm-repo-add_test.go +++ b/pkg/internal/helm-repo-add_test.go @@ -1,13 +1,19 @@ package internal import ( + "errors" "fmt" "strings" "testing" + "time" + + "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +// TestGenerateImagePullSecretsValue tests the pure function for generating image pull secrets. func TestGenerateImagePullSecretsValue(t *testing.T) { - t.Parallel() tests := []struct { name string @@ -64,19 +70,374 @@ func TestGenerateImagePullSecretsValue(t *testing.T) { } for _, tc := range tests { - tc := tc // Capture range variable for Go < 1.22 + tc := tc t.Run(tc.name, func(t *testing.T) { - t.Parallel() got := generateImagePullSecretsValue(tc.input) - // Trim whitespace to make the comparison robust - gotTrimmed := strings.TrimSpace(got) - expectedTrimmed := strings.TrimSpace(tc.expected) + assert.Equal(t, + strings.TrimSpace(tc.expected), + strings.TrimSpace(got), + "generateImagePullSecretsValue() output mismatch") + }) + } +} - if gotTrimmed != expectedTrimmed { - t.Errorf("generateImagePullSecretsValue() mismatch:\nwant: %q\ngot: %q", expectedTrimmed, gotTrimmed) +// TestAddHelmCharts tests the main helm chart addition workflow. +func TestAddHelmCharts(t *testing.T) { + + tests := []struct { + name string + config *ConfigurationSpecs + mockExecutor func(*util.FakeExecutor) + expectFatal bool + fatalContains string + expectedCalls int + }{ + { + name: "UseLocal skips helm commands", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + HelmChartConfiguration: HelmChartConfiguration{ + UseLocal: true, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + t.Error("Should not execute any commands when UseLocal is true") + return nil + } + }, + expectedCalls: 0, + }, + { + name: "Successful helm repo add and update", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice", + RepoUrl: "https://kubeslice.github.io/kubeslice/", + UseLocal: false, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + }, + expectedCalls: 2, + }, + { + name: "Helm repo add with credentials", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice-ent", + RepoUrl: "https://private.example.com/charts", + HelmUsername: "user", + HelmPassword: "pass", + UseLocal: false, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + assert.Equal(t, "helm", cli, "Expected helm command") + if len(args) > 1 && args[0] == "repo" && args[1] == "add" { + argsStr := strings.Join(args, " ") + assert.Contains(t, argsStr, "--username user", "Missing username") + assert.Contains(t, argsStr, "--password pass", "Missing password") + assert.Contains(t, argsStr, "--pass-credentials", "Missing pass-credentials flag") + } + return nil + } + }, + expectedCalls: 2, + }, + { + name: "Helm repo add fails", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice", + RepoUrl: "https://kubeslice.github.io/kubeslice/", + UseLocal: false, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + if len(args) > 1 && args[0] == "repo" && args[1] == "add" { + return errors.New("repository not found") + } + return nil + } + }, + expectFatal: true, + fatalContains: "Process failed", + expectedCalls: 1, + }, + { + name: "Helm repo update fails", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice", + RepoUrl: "https://kubeslice.github.io/kubeslice/", + UseLocal: false, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + if len(args) > 1 && args[0] == "repo" && args[1] == "update" { + return errors.New("update failed") + } + return nil + } + }, + expectFatal: true, + fatalContains: "Process failed", + expectedCalls: 2, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + fakeClock := util.SystemClock.(*util.FakeClock) + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + AddHelmCharts(tt.config) + + if tt.expectFatal { + assert.NotEmpty(t, fakeOutput.FatalCalls, "Expected fatal call but got none") + if tt.fatalContains != "" { + assert.Contains(t, fakeOutput.FatalCalls[0], tt.fatalContains, + "Fatal message should contain expected text") + } + } else { + assert.Empty(t, fakeOutput.FatalCalls, "Unexpected fatal calls") + } + + if !tt.expectFatal { + assert.Len(t, fakeExec.Calls, tt.expectedCalls, + "Command execution count mismatch") + } + + if !tt.expectFatal && !tt.config.Configuration.HelmChartConfiguration.UseLocal { + assert.Len(t, fakeClock.SleepCalls, 2, "Expected 2 sleep calls") + for i, d := range fakeClock.SleepCalls { + assert.Equal(t, 200*time.Millisecond, d, + "Sleep call %d should be 200ms", i) + } } }) } } + +// TestAddHelmChart tests the internal helm repo add function. +func TestAddHelmChart(t *testing.T) { + + tests := []struct { + name string + config *ConfigurationSpecs + mockExecutor func(*util.FakeExecutor) + expectFatal bool + validateCalls func(*testing.T, []util.ExecutorCall) + }{ + { + name: "Basic repo add without credentials", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice", + RepoUrl: "https://kubeslice.github.io/kubeslice/", + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + }, + validateCalls: func(t *testing.T, calls []util.ExecutorCall) { + require.Len(t, calls, 1, "Expected exactly 1 call") + call := calls[0] + assert.Equal(t, "helm", call.CLI, "Expected helm command") + expectedArgs := []string{ + "repo", "add", "kubeslice", + "https://kubeslice.github.io/kubeslice/", + "--force-update", + } + assert.Equal(t, expectedArgs, call.Args, "Args mismatch") + }, + }, + { + name: "Repo add with credentials", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice", + RepoUrl: "https://kubeslice.github.io/kubeslice/", + HelmUsername: "testuser", + HelmPassword: "testpass", + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + }, + validateCalls: func(t *testing.T, calls []util.ExecutorCall) { + require.Len(t, calls, 1, "Expected exactly 1 call") + args := calls[0].Args + argsStr := strings.Join(args, " ") + assert.Contains(t, argsStr, "--pass-credentials", + "Missing --pass-credentials flag") + assert.True(t, containsSequence(args, []string{"--username", "testuser"}), + "Missing --username testuser") + assert.True(t, containsSequence(args, []string{"--password", "testpass"}), + "Missing --password testpass") + }, + }, + { + name: "Command fails", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice", + RepoUrl: "https://invalid.url", + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return errors.New("connection refused") + } + }, + expectFatal: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + addHelmChart(tt.config) + + if tt.expectFatal { + assert.NotEmpty(t, fakeOutput.FatalCalls, "Expected fatal call") + } else { + assert.Empty(t, fakeOutput.FatalCalls, "Unexpected fatal calls") + if tt.validateCalls != nil { + tt.validateCalls(t, fakeExec.Calls) + } + } + }) + } +} + +// TestUpdateHelmChart tests the helm repo update function. +func TestUpdateHelmChart(t *testing.T) { + + tests := []struct { + name string + mockExecutor func(*util.FakeExecutor) + expectFatal bool + }{ + { + name: "Update succeeds", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + }, + expectFatal: false, + }, + { + name: "Update fails", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return errors.New("update failed") + } + }, + expectFatal: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + updateHelmChart() + + if tt.expectFatal { + assert.NotEmpty(t, fakeOutput.FatalCalls, "Expected fatal call") + } else { + assert.Empty(t, fakeOutput.FatalCalls, "Unexpected fatal calls") + if assert.NotEmpty(t, fakeExec.Calls, "Expected at least one command execution") { + call := fakeExec.Calls[0] + assert.Equal(t, "helm", call.CLI, "Expected helm command") + expectedArgs := []string{"repo", "update"} + assert.Equal(t, expectedArgs, call.Args, "Args mismatch") + } + } + }) + } +} + +// This helper checks if a slice contains a specific sequence of elements in order +func containsSequence(slice, sequence []string) bool { + if len(sequence) == 0 { + return true + } + if len(slice) < len(sequence) { + return false + } + for i := 0; i <= len(slice)-len(sequence); i++ { + match := true + for j := range sequence { + if slice[i+j] != sequence[j] { + match = false + break + } + } + if match { + return true + } + } + return false +} diff --git a/util/exec.go b/util/exec.go new file mode 100644 index 0000000..f534850 --- /dev/null +++ b/util/exec.go @@ -0,0 +1,94 @@ +package util + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "time" +) + +// defaultExecutor implements Executor using actual exec.Command. +type defaultExecutor struct{} + +func (e *defaultExecutor) Execute(cli string, args ...string) error { + var outB, errB bytes.Buffer + err := e.ExecuteWithOutput(cli, &outB, &errB, args...) + if err != nil { + Printf("%s Failed to run command\nOutput: %s\nError: %s %v", Cross, outB.String(), errB.String(), err) + } + return err +} + +func (e *defaultExecutor) ExecuteWithOutput(cli string, stdout, stderr io.Writer, args ...string) error { + path, exists := ExecutablePaths[cli] + if !exists { + return fmt.Errorf("executable %s not found in path", cli) + } + + cmd := exec.Command(path, args...) + cmd.Stdout = stdout + cmd.Stderr = stderr + + Printf("%s Running command: %s", Run, cmd.String()) + return cmd.Run() +} + +// defaultFileSystem implements FileOperations using actual os operations. +type defaultFileSystem struct{} + +func (fs *defaultFileSystem) Stat(name string) (os.FileInfo, error) { + return os.Stat(name) +} + +func (fs *defaultFileSystem) Create(name string) (*os.File, error) { + return os.Create(name) +} + +func (fs *defaultFileSystem) WriteFile(filename string, data []byte, perm os.FileMode) error { + return os.WriteFile(filename, data, perm) +} + +func (fs *defaultFileSystem) ReadFile(filename string) ([]byte, error) { + return os.ReadFile(filename) +} + +func (fs *defaultFileSystem) MkdirAll(path string, perm os.FileMode) error { + return os.MkdirAll(path, perm) +} + +func (fs *defaultFileSystem) RemoveAll(path string) error { + return os.RemoveAll(path) +} + +// defaultOutput implements OutputWriter using fmt and os operations. +type defaultOutput struct{} + +func (o *defaultOutput) Infof(format string, args ...interface{}) { + if len(args) > 0 { + fmt.Printf(format+"\n", args...) + } else { + fmt.Println(format) + } +} + +func (o *defaultOutput) Fatalf(format string, args ...interface{}) { + if len(args) > 0 { + fmt.Printf(format+"\n", args...) + } else { + fmt.Println(format + "\n") + } + os.Exit(1) +} + +// defaultClock implements Clock using actual time operations. +type defaultClock struct{} + +func (c *defaultClock) Sleep(d time.Duration) { + time.Sleep(d) +} + +func (c *defaultClock) Now() time.Time { + return time.Now() +} diff --git a/util/executables.go b/util/executables.go index 8c03c21..e8b3470 100644 --- a/util/executables.go +++ b/util/executables.go @@ -1,10 +1,8 @@ package util import ( - "bytes" "io" "os" - "os/exec" ) var ExecutablePaths map[string]string @@ -16,34 +14,22 @@ var ExecutableVerifyCommands = map[string][]string{ "helm": {"version"}, } +// RunCommand executes a command using the global CommandExecutor. func RunCommand(cli string, arg ...string) error { - var outB, errB bytes.Buffer - err := RunCommandCustomIO(cli, &outB, &errB, false, arg...) - if err != nil { - Printf("%s Failed to run command\nOutput: %s\nError: %s %v", Cross, outB.String(), errB.String(), err) - } - return err + return CommandExecutor.Execute(cli, arg...) } +// RunCommandWithoutPrint executes a command without printing output. func RunCommandWithoutPrint(cli string, arg ...string) error { - var outB, errB bytes.Buffer - err := RunCommandCustomIO(cli, &outB, &errB, true, arg...) - // if err != nil { - // Printf("%s Failed to run command\nOutput: %s\nError: %s %v", Cross, outB.String(), errB.String(), err) - // } - return err + return CommandExecutor.Execute(cli, arg...) } +// RunCommandOnStdIO executes a command with stdout/stderr. func RunCommandOnStdIO(cli string, arg ...string) error { - return RunCommandCustomIO(cli, os.Stdout, os.Stderr, false, arg...) + return CommandExecutor.ExecuteWithOutput(cli, os.Stdout, os.Stderr, arg...) } +// RunCommandCustomIO executes a command with custom IO writers. func RunCommandCustomIO(cli string, stdout, stderr io.Writer, suppressPrint bool, arg ...string) error { - cmd := exec.Command(ExecutablePaths[cli], arg...) - if !suppressPrint { - Printf("%s Running command: %s", Run, cmd.String()) - } - cmd.Stdout = stdout - cmd.Stderr = stderr - return cmd.Run() + return CommandExecutor.ExecuteWithOutput(cli, stdout, stderr, arg...) } diff --git a/util/file-generation.go b/util/file-generation.go index fa7270d..d077981 100644 --- a/util/file-generation.go +++ b/util/file-generation.go @@ -6,8 +6,8 @@ import ( ) func CreateDirectoryPath(path string) { - if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { - err := os.MkdirAll(path, os.ModePerm) + if _, err := FileSystem.Stat(path); errors.Is(err, os.ErrNotExist) { + err := FileSystem.MkdirAll(path, os.ModePerm) if err != nil { Fatalf("%s Failed to create kubeslice directory to generate configuration files.", Cross) } @@ -15,14 +15,9 @@ func CreateDirectoryPath(path string) { } func DumpFile(template, filename string) { - f, err := os.Create(filename) - if err != nil { - Fatalf("%s Failed to create %s", Cross, filename) - } - defer f.Close() data := []byte(template) - _, err2 := f.Write(data) - if err2 != nil { - Fatalf("%s Failed to write %s", Cross, filename) + err := FileSystem.WriteFile(filename, data, 0644) + if err != nil { + Fatalf("%s Failed to write %s: %v", Cross, filename, err) } -} \ No newline at end of file +} diff --git a/util/interfaces.go b/util/interfaces.go new file mode 100644 index 0000000..eb25964 --- /dev/null +++ b/util/interfaces.go @@ -0,0 +1,42 @@ +package util + +import ( + "io" + "os" + "time" +) + +var ( + CommandExecutor Executor = &defaultExecutor{} + FileSystem FileOperations = &defaultFileSystem{} + Output OutputWriter = &defaultOutput{} + SystemClock Clock = &defaultClock{} +) + +// Executor defines the interface for executing external commands. +type Executor interface { + Execute(cli string, args ...string) error + ExecuteWithOutput(cli string, stdout, stderr io.Writer, args ...string) error +} + +// FileOperations defines the interface for filesystem operations. +type FileOperations interface { + Stat(name string) (os.FileInfo, error) + Create(name string) (*os.File, error) + WriteFile(filename string, data []byte, perm os.FileMode) error + ReadFile(filename string) ([]byte, error) + MkdirAll(path string, perm os.FileMode) error + RemoveAll(path string) error +} + +// OutputWriter defines the interface for logging operations. +type OutputWriter interface { + Infof(format string, args ...interface{}) + Fatalf(format string, args ...interface{}) +} + +// Clock defines the interface for time operations. +type Clock interface { + Sleep(d time.Duration) + Now() time.Time +} diff --git a/util/print-util.go b/util/print-util.go index b16d610..5f510a7 100644 --- a/util/print-util.go +++ b/util/print-util.go @@ -1,10 +1,5 @@ package util -import ( - "fmt" - "os" -) - const ( Cross = string(rune(0x274c)) Tick = string(rune(0x2714)) @@ -16,18 +11,9 @@ const ( ) func Printf(format string, a ...interface{}) { - if len(a) > 0 { - fmt.Printf(format+"\n", a...) - } else { - fmt.Println(format) - } + Output.Infof(format, a...) } func Fatalf(format string, a ...interface{}) { - if len(a) > 0 { - fmt.Printf(format+"\n", a...) - } else { - fmt.Println(format + "\n") - } - os.Exit(1) + Output.Fatalf(format, a...) } diff --git a/util/testing.go b/util/testing.go new file mode 100644 index 0000000..3ec702f --- /dev/null +++ b/util/testing.go @@ -0,0 +1,183 @@ +package util + +import ( + "fmt" + "io" + "os" + "time" +) + +type FakeExecutor struct { + ExecuteFunc func(cli string, args ...string) error + ExecuteWithOutputFunc func(cli string, stdout, stderr io.Writer, args ...string) error + + Calls []ExecutorCall +} + +type ExecutorCall struct { + CLI string + Args []string +} + +func (f *FakeExecutor) Execute(cli string, args ...string) error { + f.Calls = append(f.Calls, ExecutorCall{CLI: cli, Args: args}) + if f.ExecuteFunc != nil { + return f.ExecuteFunc(cli, args...) + } + return nil +} + +func (f *FakeExecutor) ExecuteWithOutput(cli string, stdout, stderr io.Writer, args ...string) error { + f.Calls = append(f.Calls, ExecutorCall{CLI: cli, Args: args}) + if f.ExecuteWithOutputFunc != nil { + return f.ExecuteWithOutputFunc(cli, stdout, stderr, args...) + } + return nil +} + +type FakeFileSystem struct { + StatFunc func(name string) (os.FileInfo, error) + CreateFunc func(name string) (*os.File, error) + WriteFileFunc func(filename string, data []byte, perm os.FileMode) error + ReadFileFunc func(filename string) ([]byte, error) + MkdirAllFunc func(path string, perm os.FileMode) error + RemoveAllFunc func(path string) error + + WrittenFiles map[string][]byte +} + +func NewFakeFileSystem() *FakeFileSystem { + return &FakeFileSystem{ + WrittenFiles: make(map[string][]byte), + } +} + +func (f *FakeFileSystem) Stat(name string) (os.FileInfo, error) { + if f.StatFunc != nil { + return f.StatFunc(name) + } + return nil, os.ErrNotExist +} + +func (f *FakeFileSystem) Create(name string) (*os.File, error) { + if f.CreateFunc != nil { + return f.CreateFunc(name) + } + return nil, nil +} + +func (f *FakeFileSystem) WriteFile(filename string, data []byte, perm os.FileMode) error { + f.WrittenFiles[filename] = data + if f.WriteFileFunc != nil { + return f.WriteFileFunc(filename, data, perm) + } + return nil +} + +func (f *FakeFileSystem) ReadFile(filename string) ([]byte, error) { + if f.ReadFileFunc != nil { + return f.ReadFileFunc(filename) + } + if data, ok := f.WrittenFiles[filename]; ok { + return data, nil + } + return nil, os.ErrNotExist +} + +func (f *FakeFileSystem) MkdirAll(path string, perm os.FileMode) error { + if f.MkdirAllFunc != nil { + return f.MkdirAllFunc(path, perm) + } + return nil +} + +func (f *FakeFileSystem) RemoveAll(path string) error { + if f.RemoveAllFunc != nil { + return f.RemoveAllFunc(path) + } + return nil +} + +type FakeOutput struct { + InfofFunc func(format string, args ...interface{}) + FatalfFunc func(format string, args ...interface{}) + + FatalCalls []string + InfoCalls []string +} + +func (f *FakeOutput) Infof(format string, args ...interface{}) { + msg := format + if len(args) > 0 { + msg = fmt.Sprintf(format, args...) + } + f.InfoCalls = append(f.InfoCalls, msg) + + if f.InfofFunc != nil { + f.InfofFunc(format, args...) + } +} + +func (f *FakeOutput) Fatalf(format string, args ...interface{}) { + msg := format + if len(args) > 0 { + msg = fmt.Sprintf(format, args...) + } + f.FatalCalls = append(f.FatalCalls, msg) + + if f.FatalfFunc != nil { + f.FatalfFunc(format, args...) + } + // Don't call os.Exit in tests +} + +type FakeClock struct { + SleepFunc func(d time.Duration) + NowFunc func() time.Time + + SleepCalls []time.Duration + + // Current fake time + FakeTime time.Time +} + +func NewFakeClock(t time.Time) *FakeClock { + return &FakeClock{ + FakeTime: t, + } +} + +func (f *FakeClock) Sleep(d time.Duration) { + f.SleepCalls = append(f.SleepCalls, d) + f.FakeTime = f.FakeTime.Add(d) + if f.SleepFunc != nil { + f.SleepFunc(d) + } +} + +func (f *FakeClock) Now() time.Time { + if f.NowFunc != nil { + return f.NowFunc() + } + return f.FakeTime +} + +// NewTestEnvironment sets up fake implementations for testing and returns a cleanup function. +func NewTestEnvironment() (cleanup func()) { + origExecutor := CommandExecutor + origFS := FileSystem + origOutput := Output + origClock := SystemClock + + CommandExecutor = &FakeExecutor{} + FileSystem = NewFakeFileSystem() + Output = &FakeOutput{} + SystemClock = NewFakeClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + + return func() { + CommandExecutor = origExecutor + FileSystem = origFS + Output = origOutput + SystemClock = origClock + } +} diff --git a/util/time.go b/util/time.go new file mode 100644 index 0000000..7493240 --- /dev/null +++ b/util/time.go @@ -0,0 +1,13 @@ +package util + +import ( + "time" +) + +func Sleep(d time.Duration) { + SystemClock.Sleep(d) +} + +func Now() time.Time { + return SystemClock.Now() +} From 285eff533eea563c86c4fa6ced5d54407543a1b9 Mon Sep 17 00:00:00 2001 From: Alokzh Date: Mon, 13 Oct 2025 02:03:30 +0530 Subject: [PATCH 02/29] Move Sleep & Now functions to interfaces.go Signed-off-by: Alokzh --- util/interfaces.go | 10 ++++++++++ util/time.go | 13 ------------- 2 files changed, 10 insertions(+), 13 deletions(-) delete mode 100644 util/time.go diff --git a/util/interfaces.go b/util/interfaces.go index eb25964..359b4b7 100644 --- a/util/interfaces.go +++ b/util/interfaces.go @@ -40,3 +40,13 @@ type Clock interface { Sleep(d time.Duration) Now() time.Time } + +// Sleep pauses execution for the given duration using the global clock. +func Sleep(d time.Duration) { + SystemClock.Sleep(d) +} + +// Now returns the current time using the global clock. +func Now() time.Time { + return SystemClock.Now() +} diff --git a/util/time.go b/util/time.go deleted file mode 100644 index 7493240..0000000 --- a/util/time.go +++ /dev/null @@ -1,13 +0,0 @@ -package util - -import ( - "time" -) - -func Sleep(d time.Duration) { - SystemClock.Sleep(d) -} - -func Now() time.Time { - return SystemClock.Now() -} From 0b956c11d3dcba1da4dd2b20a95e013ed961754a Mon Sep 17 00:00:00 2001 From: Alokzh Date: Sun, 26 Oct 2025 18:50:02 +0530 Subject: [PATCH 03/29] Refactor cert-manager-setup.go file for testing Signed-off-by: Alokzh --- pkg/internal/cert-manager-setup.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/pkg/internal/cert-manager-setup.go b/pkg/internal/cert-manager-setup.go index 2e27002..0d80398 100644 --- a/pkg/internal/cert-manager-setup.go +++ b/pkg/internal/cert-manager-setup.go @@ -2,12 +2,14 @@ package internal import ( "fmt" - "log" "time" "github.com/kubeslice/kubeslice-cli/util" ) +// Function variable for testing - Allows mocking PodVerification in tests +var podVerificationFunc = PodVerification + func InstallCertManager(ApplicationConfiguration *ConfigurationSpecs) { cc := ApplicationConfiguration.Configuration.ClusterConfiguration @@ -16,10 +18,10 @@ func InstallCertManager(ApplicationConfiguration *ConfigurationSpecs) { installCertManager(cc.ControllerCluster, hc) util.Printf("%s Successfully installed helm chart %s/%s", util.Tick, hc.RepoAlias, hc.CertManagerChart.ChartName) - time.Sleep(200 * time.Millisecond) + util.Sleep(200 * time.Millisecond) util.Printf("%s Waiting for Cert Manager Pods to be Healthy...", util.Wait) - PodVerification("Waiting for Cert Manager Pods to be Healthy", cc.ControllerCluster, "cert-manager") + podVerificationFunc("Waiting for Cert Manager Pods to be Healthy", cc.ControllerCluster, "cert-manager") util.Printf("%s Successfully installed cert manager.\n", util.Tick) @@ -47,7 +49,7 @@ func installCertManager(cluster Cluster, hc HelmChartConfiguration) { } err := util.RunCommand("helm", args...) if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } } func uninstallCertManager(cluster Cluster, hc HelmChartConfiguration) error { From 27dbae361832b4ec8ea48fa04a794c147b26213d Mon Sep 17 00:00:00 2001 From: Alokzh Date: Sun, 26 Oct 2025 19:24:23 +0530 Subject: [PATCH 04/29] Add unit tests for cert-manager installation & uninstallation Signed-off-by: Alokzh --- pkg/internal/cert-manager-setup_test.go | 267 ++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 pkg/internal/cert-manager-setup_test.go diff --git a/pkg/internal/cert-manager-setup_test.go b/pkg/internal/cert-manager-setup_test.go new file mode 100644 index 0000000..5fac5cd --- /dev/null +++ b/pkg/internal/cert-manager-setup_test.go @@ -0,0 +1,267 @@ +package internal + +import ( + "errors" + "fmt" + "testing" + "time" + + "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestInstallCertManager tests the cert-manager installation workflow +func TestInstallCertManager(t *testing.T) { + tests := []struct { + name string + config *ConfigurationSpecs + mockExecutor func(*util.FakeExecutor) + expectFatal bool + fatalContains string + }{ + { + name: "successful installation without version", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "test-controller", + ContextName: "test-context", + KubeConfigPath: "/path/to/kubeconfig", + }, + }, + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice", + CertManagerChart: HelmChart{ + ChartName: "cert-manager", + }, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + }, + }, + { + name: "successful installation with version", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "test-controller", + ContextName: "test-context", + KubeConfigPath: "/path/to/kubeconfig", + }, + }, + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice", + CertManagerChart: HelmChart{ + ChartName: "cert-manager", + Version: "v1.13.0", + }, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + }, + }, + { + name: "helm command fails", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "test-controller", + ContextName: "test-context", + KubeConfigPath: "/path/to/kubeconfig", + }, + }, + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice", + CertManagerChart: HelmChart{ + ChartName: "cert-manager", + }, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + if cli == "helm" { + return errors.New("helm upgrade failed") + } + return nil + } + }, + expectFatal: true, + fatalContains: "Process failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + fakeClock := util.SystemClock.(*util.FakeClock) + + podVerifyCalled := false + var capturedNamespace string + originalPodVerify := podVerificationFunc + podVerificationFunc = func(msg string, cluster Cluster, namespace string) { + podVerifyCalled = true + capturedNamespace = namespace + } + defer func() { podVerificationFunc = originalPodVerify }() + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + InstallCertManager(tt.config) + + if tt.expectFatal { + assert.NotEmpty(t, fakeOutput.FatalCalls, "Expected fatal call") + if tt.fatalContains != "" { + assert.Contains(t, fakeOutput.FatalCalls[0], tt.fatalContains) + } + } else { + assert.Empty(t, fakeOutput.FatalCalls, "Unexpected fatal calls") + + require.Len(t, fakeExec.Calls, 1, "Expected exactly one helm command") + require.Equal(t, "helm", fakeExec.Calls[0].CLI, "Expected helm command") + + args := fakeExec.Calls[0].Args + assert.Contains(t, args, "--kube-context") + assert.Contains(t, args, "test-context") + assert.Contains(t, args, "--kubeconfig") + assert.Contains(t, args, "/path/to/kubeconfig") + assert.Contains(t, args, "upgrade") + assert.Contains(t, args, "-i") + assert.Contains(t, args, "cert-manager") + assert.Contains(t, args, "kubeslice/cert-manager") + assert.Contains(t, args, "--namespace") + assert.Contains(t, args, "cert-manager") + assert.Contains(t, args, "--create-namespace") + assert.Contains(t, args, "--set") + assert.Contains(t, args, "installCRDs=true") + + if tt.config.Configuration.HelmChartConfiguration.CertManagerChart.Version != "" { + expectedVersion := tt.config.Configuration.HelmChartConfiguration.CertManagerChart.Version + assert.True(t, containsSequence(args, []string{"--version", expectedVersion}), + "Expected --version %s in helm command", expectedVersion) + } + + assert.True(t, podVerifyCalled, "Expected pod verification to be called") + assert.Equal(t, "cert-manager", capturedNamespace, "Expected pod verification for cert-manager namespace") + assert.Contains(t, fakeClock.SleepCalls, 200*time.Millisecond, "Expected 200ms sleep call") + } + }) + } +} + +// TestUninstallCertManager tests cert-manager uninstallation +func TestUninstallCertManager(t *testing.T) { + tests := []struct { + name string + config *ConfigurationSpecs + mockExecutor func(*util.FakeExecutor) + expectSuccess bool + }{ + { + name: "successful uninstallation", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "test-controller", + ContextName: "test-context", + KubeConfigPath: "/path/to/kubeconfig", + }, + }, + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice", + CertManagerChart: HelmChart{ + ChartName: "cert-manager", + }, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + }, + expectSuccess: true, + }, + { + name: "uninstallation fails", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "test-controller", + ContextName: "test-context", + KubeConfigPath: "/path/to/kubeconfig", + }, + }, + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice", + CertManagerChart: HelmChart{ + ChartName: "cert-manager", + }, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return errors.New("uninstall failed") + } + }, + expectSuccess: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + UninstallCertManager(tt.config) + + require.Len(t, fakeExec.Calls, 1, "Expected exactly one helm command") + require.Equal(t, "helm", fakeExec.Calls[0].CLI, "Expected helm command") + + args := fakeExec.Calls[0].Args + assert.Contains(t, args, "--kube-context") + assert.Contains(t, args, "test-context") + assert.Contains(t, args, "--kubeconfig") + assert.Contains(t, args, "/path/to/kubeconfig") + assert.Contains(t, args, "uninstall") + assert.Contains(t, args, "cert-manager") + assert.Contains(t, args, "--namespace") + assert.Contains(t, args, "cert-manager") + + infoMessages := fmt.Sprint(fakeOutput.InfoCalls) + if tt.expectSuccess { + assert.Contains(t, infoMessages, "Successfully uninstalled cert manager") + } else { + assert.Contains(t, infoMessages, "Failed to uninstall cert manager") + } + }) + } +} From 3817b962ea68c6df7c2ddbc7d821b293a93f1fab Mon Sep 17 00:00:00 2001 From: Alokzh Date: Mon, 27 Oct 2025 17:46:44 +0530 Subject: [PATCH 05/29] Refactor cluster-manifest file & add unit tests for the operations Signed-off-by: Alokzh --- pkg/internal/cluster-manifests.go | 40 ++- pkg/internal/cluster-manifests_test.go | 395 +++++++++++++++++++++++++ 2 files changed, 421 insertions(+), 14 deletions(-) create mode 100644 pkg/internal/cluster-manifests_test.go diff --git a/pkg/internal/cluster-manifests.go b/pkg/internal/cluster-manifests.go index 5fa4dc9..c02da8a 100644 --- a/pkg/internal/cluster-manifests.go +++ b/pkg/internal/cluster-manifests.go @@ -42,6 +42,15 @@ var regionTemplates = map[string]string{ "ks-w-2": regionTemplate2, } +// Function variables for testing - Allows mocking kubectl operations +var ( + applyManifestFunc = ApplyKubectlManifest + getResourceFunc = GetKubectlResources + deleteResourceFunc = DeleteKubectlResources + editResourceFunc = EditKubectlResources + describeResourceFunc = DescribeKubectlResources +) + func RegisterWorkerClusters(ApplicationConfiguration *ConfigurationSpecs, cliOptions *CliOptionsStruct) { util.Printf("\nRegistering Worker Clusters with Project...") @@ -51,19 +60,19 @@ func RegisterWorkerClusters(ApplicationConfiguration *ConfigurationSpecs, cliOpt generateClusterRegistrationManifest(ApplicationConfiguration, cliOptions.FileName, cliOptions.Namespace) } util.Printf("%s Generated cluster registration manifest %s", util.Tick, cliOptions.FileName) - time.Sleep(200 * time.Millisecond) - ApplyKubectlManifest(cliOptions.FileName, cliOptions.Namespace, cliOptions.Cluster) + util.Sleep(200 * time.Millisecond) + applyManifestFunc(cliOptions.FileName, cliOptions.Namespace, cliOptions.Cluster) util.Printf("%s Applied %s", util.Tick, cliOptions.FileName) - time.Sleep(200 * time.Millisecond) + util.Sleep(200 * time.Millisecond) } else { ac := ApplicationConfiguration.Configuration generateClusterRegistrationManifest(ApplicationConfiguration, kubesliceDirectory+"/"+clusterRegistrationFileName, "kubeslice-"+ac.KubeSliceConfiguration.ProjectName) util.Printf("%s Generated cluster registration manifest %s", util.Tick, clusterRegistrationFileName) - time.Sleep(200 * time.Millisecond) + util.Sleep(200 * time.Millisecond) - ApplyKubectlManifest(kubesliceDirectory+"/"+clusterRegistrationFileName, "kubeslice-"+ac.KubeSliceConfiguration.ProjectName, &ac.ClusterConfiguration.ControllerCluster) + applyManifestFunc(kubesliceDirectory+"/"+clusterRegistrationFileName, "kubeslice-"+ac.KubeSliceConfiguration.ProjectName, &ac.ClusterConfiguration.ControllerCluster) util.Printf("%s Applied %s", util.Tick, clusterRegistrationFileName) - time.Sleep(200 * time.Millisecond) + util.Sleep(200 * time.Millisecond) } util.Printf("Registered Worker Clusters with Project.") } @@ -85,24 +94,27 @@ func generateClusterRegistrationManifest(ApplicationConfiguration *Configuration func GetKubeSliceCluster(clusterName string, namespace string, controllerCluster *Cluster, outputFormat string) { util.Printf("\nFetching KubeSlice Worker...") - GetKubectlResources(ClusterObject, clusterName, namespace, controllerCluster, outputFormat) - time.Sleep(200 * time.Millisecond) + getResourceFunc(ClusterObject, clusterName, namespace, controllerCluster, outputFormat) + util.Sleep(200 * time.Millisecond) } func DeleteKubeSliceCluster(clusterName string, namespace string, controllerCluster *Cluster) { util.Printf("\nDeleting KubeSlice Worker...") - DeleteKubectlResources(ClusterObject, clusterName, namespace, controllerCluster) - time.Sleep(200 * time.Millisecond) + deleteResourceFunc(ClusterObject, clusterName, namespace, controllerCluster) + + util.Sleep(200 * time.Millisecond) } func EditKubeSliceCluster(clusterName string, namespace string, controllerCluster *Cluster) { util.Printf("\nEditing KubeSlice Worker...") - EditKubectlResources(ClusterObject, clusterName, namespace, controllerCluster) - time.Sleep(200 * time.Millisecond) + editResourceFunc(ClusterObject, clusterName, namespace, controllerCluster) + + util.Sleep(200 * time.Millisecond) } func DescribeKubeSliceCluster(clusterName string, namespace string, controllerCluster *Cluster) { util.Printf("\nDescribe KubeSlice Worker...") - DescribeKubectlResources(ClusterObject, clusterName, namespace, controllerCluster) - time.Sleep(200 * time.Millisecond) + describeResourceFunc(ClusterObject, clusterName, namespace, controllerCluster) + + util.Sleep(200 * time.Millisecond) } diff --git a/pkg/internal/cluster-manifests_test.go b/pkg/internal/cluster-manifests_test.go new file mode 100644 index 0000000..aee1dab --- /dev/null +++ b/pkg/internal/cluster-manifests_test.go @@ -0,0 +1,395 @@ +package internal + +import ( + "strings" + "testing" + "time" + + "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestGenerateClusterRegistrationManifest tests YAML generation logic +func TestGenerateClusterRegistrationManifest(t *testing.T) { + tests := []struct { + name string + config *ConfigurationSpecs + filename string + namespace string + expectContent []string + expectRegionInfo bool + }{ + { + name: "single worker without enterprise profile", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + WorkerClusters: []Cluster{ + {Name: "worker-1"}, + }, + }, + KubeSliceConfiguration: KubeSliceConfiguration{ + ProjectName: "test-project", + }, + }, + }, + filename: "test-output.yaml", + namespace: "", + expectContent: []string{ + "apiVersion: controller.kubeslice.io/v1alpha1", + "kind: Cluster", + "name: worker-1", + "namespace: kubeslice-test-project", + "clusterProperty: {}", + }, + expectRegionInfo: false, + }, + { + name: "multiple workers with custom namespace", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + WorkerClusters: []Cluster{ + {Name: "worker-1"}, + {Name: "worker-2"}, + }, + }, + KubeSliceConfiguration: KubeSliceConfiguration{ + ProjectName: "test-project", + }, + }, + }, + filename: "test-output.yaml", + namespace: "custom-namespace", + expectContent: []string{ + "name: worker-1", + "name: worker-2", + "namespace: custom-namespace", + }, + expectRegionInfo: false, + }, + { + name: "enterprise profile with region templates", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + Profile: ProfileEntDemo, + WorkerClusters: []Cluster{ + {Name: "ks-w-1"}, + {Name: "ks-w-2"}, + }, + }, + KubeSliceConfiguration: KubeSliceConfiguration{ + ProjectName: "ent-project", + }, + }, + }, + filename: "test-output.yaml", + namespace: "", + expectContent: []string{ + "geoLocation:", + "cloudProvider: GCP", + "cloudProvider: DATACENTER", + }, + expectRegionInfo: true, + }, + { + name: "no worker clusters", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + WorkerClusters: []Cluster{}, + }, + KubeSliceConfiguration: KubeSliceConfiguration{ + ProjectName: "empty-project", + }, + }, + }, + filename: "test-output.yaml", + namespace: "", + expectContent: []string{}, + expectRegionInfo: false, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeFS := util.FileSystem.(*util.FakeFileSystem) + + generateClusterRegistrationManifest(tt.config, tt.filename, tt.namespace) + + content, exists := fakeFS.WrittenFiles[tt.filename] + require.True(t, exists, "Expected file %s to be written", tt.filename) + + contentStr := string(content) + + for _, expected := range tt.expectContent { + assert.Contains(t, contentStr, expected, + "Expected content not found in generated manifest") + } + + hasRegionInfo := strings.Contains(contentStr, "geoLocation") + assert.Equal(t, tt.expectRegionInfo, hasRegionInfo, + "Region info presence mismatch") + + // Validate YAML document count + if len(tt.config.Configuration.ClusterConfiguration.WorkerClusters) > 0 { + expectedDocs := len(tt.config.Configuration.ClusterConfiguration.WorkerClusters) + actualDocs := strings.Count(contentStr, "---") + assert.Equal(t, expectedDocs, actualDocs, + "Expected %d YAML documents", expectedDocs) + } + }) + } +} + +// TestRegisterWorkerClusters tests the full registration workflow +func TestRegisterWorkerClusters(t *testing.T) { + tests := []struct { + name string + config *ConfigurationSpecs + cliOptions *CliOptionsStruct + mockApply func(*testing.T, string, string, *Cluster) + applyCalled int + expectFileGeneration bool + }{ + { + name: "with CLI options - custom filename provided", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + WorkerClusters: []Cluster{{Name: "worker-1"}}, + }, + KubeSliceConfiguration: KubeSliceConfiguration{ + ProjectName: "test", + }, + }, + }, + cliOptions: &CliOptionsStruct{ + FileName: "custom-file.yaml", + Namespace: "custom-ns", + Cluster: &Cluster{Name: "controller"}, + }, + mockApply: func(t *testing.T, filename, namespace string, cluster *Cluster) { + assert.Equal(t, "custom-file.yaml", filename) + assert.Equal(t, "custom-ns", namespace) + assert.Equal(t, "controller", cluster.Name) + }, + applyCalled: 1, + expectFileGeneration: false, + }, + { + name: "with CLI options - filename auto-generated", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + WorkerClusters: []Cluster{{Name: "worker-1"}}, + }, + KubeSliceConfiguration: KubeSliceConfiguration{ + ProjectName: "test", + }, + }, + }, + cliOptions: &CliOptionsStruct{ + FileName: "", + Namespace: "custom-ns", + Cluster: &Cluster{Name: "controller"}, + }, + mockApply: func(t *testing.T, filename, namespace string, cluster *Cluster) { + assert.Contains(t, filename, "custom-cluster-registration.yaml") + assert.Equal(t, "custom-ns", namespace) + }, + applyCalled: 1, + expectFileGeneration: true, + }, + { + name: "without CLI options - default flow", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: "controller"}, + WorkerClusters: []Cluster{{Name: "worker-1"}}, + }, + KubeSliceConfiguration: KubeSliceConfiguration{ + ProjectName: "demo", + }, + }, + }, + cliOptions: nil, + mockApply: func(t *testing.T, filename, namespace string, cluster *Cluster) { + assert.Contains(t, filename, clusterRegistrationFileName) + assert.Equal(t, "kubeslice-demo", namespace) + assert.Equal(t, "controller", cluster.Name) + }, + applyCalled: 1, + expectFileGeneration: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeFS := util.FileSystem.(*util.FakeFileSystem) + fakeClock := util.SystemClock.(*util.FakeClock) + + applyCalls := 0 + originalApply := applyManifestFunc + applyManifestFunc = func(filename, namespace string, cluster *Cluster) { + applyCalls++ + if tt.mockApply != nil { + tt.mockApply(t, filename, namespace, cluster) + } + } + defer func() { applyManifestFunc = originalApply }() + + RegisterWorkerClusters(tt.config, tt.cliOptions) + + if tt.expectFileGeneration { + assert.NotEmpty(t, fakeFS.WrittenFiles, "Expected files to be generated") + } else { + assert.Empty(t, fakeFS.WrittenFiles, "Should not generate files when custom filename provided") + } + + assert.Equal(t, tt.applyCalled, applyCalls, + "ApplyKubectlManifest call count mismatch") + + assert.Len(t, fakeClock.SleepCalls, 2, "Expected 2 sleep calls") + for _, d := range fakeClock.SleepCalls { + assert.Equal(t, 200*time.Millisecond, d) + } + }) + } +} + +// TestGetKubeSliceCluster tests fetching cluster info +func TestGetKubeSliceCluster(t *testing.T) { + tests := []struct { + name string + clusterName string + namespace string + outputFormat string + }{ + { + name: "get cluster with YAML output", + clusterName: "worker-1", + namespace: "kubeslice-test", + outputFormat: "yaml", + }, + { + name: "get cluster with JSON output", + clusterName: "worker-2", + namespace: "default", + outputFormat: "json", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeClock := util.SystemClock.(*util.FakeClock) + + getCalled := false + original := getResourceFunc + getResourceFunc = func(resourceType, resourceName, namespace string, cluster *Cluster, outputFormat string) { + getCalled = true + assert.Equal(t, ClusterObject, resourceType) + assert.Equal(t, tt.clusterName, resourceName) + assert.Equal(t, tt.namespace, namespace) + assert.Equal(t, tt.outputFormat, outputFormat) + } + defer func() { getResourceFunc = original }() + + cluster := &Cluster{Name: "controller"} + GetKubeSliceCluster(tt.clusterName, tt.namespace, cluster, tt.outputFormat) + + assert.True(t, getCalled, "Expected GetKubectlResources to be called") + assert.Contains(t, fakeClock.SleepCalls, 200*time.Millisecond) + }) + } +} + +// TestDeleteKubeSliceCluster tests cluster deletion +func TestDeleteKubeSliceCluster(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeClock := util.SystemClock.(*util.FakeClock) + + deleteCalled := false + original := deleteResourceFunc + deleteResourceFunc = func(resourceType, resourceName, namespace string, cluster *Cluster) { + deleteCalled = true + assert.Equal(t, ClusterObject, resourceType) + assert.Equal(t, "worker-1", resourceName) + assert.Equal(t, "kubeslice-test", namespace) + assert.Equal(t, "controller", cluster.Name) + } + defer func() { deleteResourceFunc = original }() + + cluster := &Cluster{Name: "controller"} + DeleteKubeSliceCluster("worker-1", "kubeslice-test", cluster) + + assert.True(t, deleteCalled, "Expected DeleteKubectlResources to be called") + assert.Contains(t, fakeClock.SleepCalls, 200*time.Millisecond) +} + +// TestEditKubeSliceCluster tests cluster editing +func TestEditKubeSliceCluster(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeClock := util.SystemClock.(*util.FakeClock) + + editCalled := false + original := editResourceFunc + editResourceFunc = func(resourceType, resourceName, namespace string, cluster *Cluster) { + editCalled = true + assert.Equal(t, ClusterObject, resourceType) + assert.Equal(t, "worker-1", resourceName) + assert.Equal(t, "kubeslice-test", namespace) + assert.Equal(t, "controller", cluster.Name) + } + defer func() { editResourceFunc = original }() + + cluster := &Cluster{Name: "controller"} + EditKubeSliceCluster("worker-1", "kubeslice-test", cluster) + + assert.True(t, editCalled, "Expected EditKubectlResources to be called") + assert.Contains(t, fakeClock.SleepCalls, 200*time.Millisecond) +} + +// TestDescribeKubeSliceCluster tests cluster description +func TestDescribeKubeSliceCluster(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeClock := util.SystemClock.(*util.FakeClock) + + describeCalled := false + original := describeResourceFunc + describeResourceFunc = func(resourceType, resourceName, namespace string, cluster *Cluster) { + describeCalled = true + assert.Equal(t, ClusterObject, resourceType) + assert.Equal(t, "worker-1", resourceName) + assert.Equal(t, "kubeslice-test", namespace) + assert.Equal(t, "controller", cluster.Name) + } + defer func() { describeResourceFunc = original }() + + cluster := &Cluster{Name: "controller"} + DescribeKubeSliceCluster("worker-1", "kubeslice-test", cluster) + + assert.True(t, describeCalled, "Expected DescribeKubectlResources to be called") + assert.Contains(t, fakeClock.SleepCalls, 200*time.Millisecond) +} From 3b7f7e5274934c186321f4c11b6d327ceee358fc Mon Sep 17 00:00:00 2001 From: Alokzh Date: Tue, 28 Oct 2025 20:55:21 +0530 Subject: [PATCH 06/29] Refactored controller.go file & added tests for controller functions Signed-off-by: Alokzh --- pkg/internal/controller.go | 29 ++- pkg/internal/controller_test.go | 438 ++++++++++++++++++++++++++++++++ 2 files changed, 456 insertions(+), 11 deletions(-) create mode 100644 pkg/internal/controller_test.go diff --git a/pkg/internal/controller.go b/pkg/internal/controller.go index 349c916..12c5cb7 100644 --- a/pkg/internal/controller.go +++ b/pkg/internal/controller.go @@ -2,7 +2,6 @@ package internal import ( "fmt" - "log" "time" "github.com/kubeslice/kubeslice-cli/util" @@ -21,6 +20,12 @@ kubeslice: endpoint: %s ` +// Function variables for testing +var ( + licenseVerificationFunc = LicenseVerification + generateValuesFileFunc = generateValuesFile +) + func InstallKubeSliceController(ApplicationConfiguration *ConfigurationSpecs) { util.Printf("\nInstalling KubeSlice Controller...") @@ -28,18 +33,18 @@ func InstallKubeSliceController(ApplicationConfiguration *ConfigurationSpecs) { hc := ApplicationConfiguration.Configuration.HelmChartConfiguration generateControllerValuesFile(cc.ControllerCluster, ApplicationConfiguration.Configuration.HelmChartConfiguration) util.Printf("%s Generated Helm Values file for Controller Installation %s", util.Tick, controllerValuesFileName) - time.Sleep(200 * time.Millisecond) + util.Sleep(200 * time.Millisecond) installKubeSliceController(cc.ControllerCluster, hc) util.Printf("%s Successfully installed helm chart %s/%s", util.Tick, hc.RepoAlias, hc.ControllerChart.ChartName) - time.Sleep(2 * time.Second) + util.Sleep(2 * time.Second) util.Printf("%s Waiting for KubeSlice Controller Pods to be Healthy...", util.Wait) - PodVerification("Waiting for KubeSlice Controller Pods to be Healthy", cc.ControllerCluster, KUBESLICE_CONTROLLER_NAMESPACE) + podVerificationFunc("Waiting for KubeSlice Controller Pods to be Healthy", cc.ControllerCluster, KUBESLICE_CONTROLLER_NAMESPACE) if ApplicationConfiguration.Configuration.ClusterConfiguration.Profile != "" && ApplicationConfiguration.Configuration.ClusterConfiguration.Profile == ProfileEntDemo { util.Printf("%s Waiting for KubeSlice Trial License to be Ready...", util.Wait) - LicenseVerification("Waiting for KubeSlice Trial License to be Ready", cc.ControllerCluster, KUBESLICE_CONTROLLER_NAMESPACE) + licenseVerificationFunc("Waiting for KubeSlice Trial License to be Ready", cc.ControllerCluster, KUBESLICE_CONTROLLER_NAMESPACE) } util.Printf("%s Successfully installed KubeSlice Controller.\n", util.Tick) @@ -49,18 +54,20 @@ func InstallKubeSliceController(ApplicationConfiguration *ConfigurationSpecs) { func UninstallKubeSliceController(ApplicationConfiguration *ConfigurationSpecs) { util.Printf("\nUninstalling KubeSlice Controller...") cc := ApplicationConfiguration.Configuration.ClusterConfiguration - time.Sleep(200 * time.Millisecond) + + util.Sleep(200 * time.Millisecond) uninstallKubeSliceController(cc.ControllerCluster) - time.Sleep(200 * time.Millisecond) + util.Sleep(200 * time.Millisecond) + util.Printf("%s Successfully uninstalled KubeSlice Controller", util.Tick) // wait for pods to be cleaned up. // util.Printf("%s Waiting for KubeSlice Manager Pods to be removed...", util.Wait) } func generateControllerValuesFile(cluster Cluster, hcConfig HelmChartConfiguration) { - err := generateValuesFile(kubesliceDirectory+"/"+controllerValuesFileName, &hcConfig.ControllerChart, fmt.Sprintf(controllerValuesTemplate+generateImagePullSecretsValue(hcConfig.ImagePullSecret), cluster.ControlPlaneAddress)) + err := generateValuesFileFunc(kubesliceDirectory+"/"+controllerValuesFileName, &hcConfig.ControllerChart, fmt.Sprintf(controllerValuesTemplate+generateImagePullSecretsValue(hcConfig.ImagePullSecret), cluster.ControlPlaneAddress)) if err != nil { - log.Fatalf("%s %s", util.Cross, err) + util.Fatalf("%s %s", util.Cross, err) } } @@ -72,7 +79,7 @@ func installKubeSliceController(cluster Cluster, hc HelmChartConfiguration) { } err := util.RunCommand("helm", args...) if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } } @@ -81,6 +88,6 @@ func uninstallKubeSliceController(cluster Cluster) { args = append(args, "--kube-context", cluster.ContextName, "--kubeconfig", cluster.KubeConfigPath, "uninstall", KUBESLICE_CONTROLLER_NAMESPACE, "--namespace", KUBESLICE_CONTROLLER_NAMESPACE) err := util.RunCommand("helm", args...) if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } } diff --git a/pkg/internal/controller_test.go b/pkg/internal/controller_test.go new file mode 100644 index 0000000..e8f7c5e --- /dev/null +++ b/pkg/internal/controller_test.go @@ -0,0 +1,438 @@ +package internal + +import ( + "errors" + "testing" + "time" + + "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstallKubeSliceController(t *testing.T) { + tests := []struct { + name string + config *ConfigurationSpecs + mockExecutor func(*util.FakeExecutor) + mockGenerateValuesFile func(string, *HelmChart, string) error + expectFatal bool + fatalContains string + expectLicenseVerification bool + validateValuesContent bool + }{ + { + name: "successful installation without version", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "controller-context", + KubeConfigPath: "/path/to/config", + ControlPlaneAddress: "https://controller.example.com", + }, + }, + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice", + ControllerChart: HelmChart{ + ChartName: "kubeslice-controller", + }, + ImagePullSecret: ImagePullSecrets{}, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + }, + mockGenerateValuesFile: func(filename string, chart *HelmChart, content string) error { + return nil + }, + expectLicenseVerification: false, + validateValuesContent: true, + }, + { + name: "successful installation with version", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "controller-context", + KubeConfigPath: "/path/to/config", + ControlPlaneAddress: "https://controller.example.com", + }, + }, + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice", + ControllerChart: HelmChart{ + ChartName: "kubeslice-controller", + Version: "v1.0.0", + }, + ImagePullSecret: ImagePullSecrets{}, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + }, + mockGenerateValuesFile: func(filename string, chart *HelmChart, content string) error { + return nil + }, + expectLicenseVerification: false, + validateValuesContent: true, + }, + { + name: "successful installation with enterprise profile", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "controller-context", + KubeConfigPath: "/path/to/config", + ControlPlaneAddress: "https://controller.example.com", + }, + Profile: ProfileEntDemo, + }, + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice", + ControllerChart: HelmChart{ + ChartName: "kubeslice-controller", + }, + ImagePullSecret: ImagePullSecrets{}, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + }, + mockGenerateValuesFile: func(filename string, chart *HelmChart, content string) error { + return nil + }, + expectLicenseVerification: true, + validateValuesContent: true, + }, + { + name: "helm installation fails", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "controller-context", + KubeConfigPath: "/path/to/config", + ControlPlaneAddress: "https://controller.example.com", + }, + }, + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice", + ControllerChart: HelmChart{ + ChartName: "kubeslice-controller", + }, + ImagePullSecret: ImagePullSecrets{}, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return errors.New("helm upgrade failed") + } + }, + mockGenerateValuesFile: func(filename string, chart *HelmChart, content string) error { + return nil + }, + expectFatal: true, + fatalContains: "Process failed", + }, + { + name: "values file generation fails", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "controller-context", + KubeConfigPath: "/path/to/config", + ControlPlaneAddress: "https://controller.example.com", + }, + }, + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice", + ControllerChart: HelmChart{ + ChartName: "kubeslice-controller", + }, + ImagePullSecret: ImagePullSecrets{}, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + }, + mockGenerateValuesFile: func(filename string, chart *HelmChart, content string) error { + return errors.New("failed to write file") + }, + expectFatal: true, + fatalContains: "failed to write file", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + fakeClock := util.SystemClock.(*util.FakeClock) + + podVerifyCalled := false + originalPodVerify := podVerificationFunc + podVerificationFunc = func(msg string, cluster Cluster, namespace string) { + podVerifyCalled = true + assert.Equal(t, KUBESLICE_CONTROLLER_NAMESPACE, namespace) + } + defer func() { podVerificationFunc = originalPodVerify }() + + licenseVerifyCalled := false + originalLicenseVerify := licenseVerificationFunc + licenseVerificationFunc = func(msg string, cluster Cluster, namespace string) { + licenseVerifyCalled = true + assert.Equal(t, KUBESLICE_CONTROLLER_NAMESPACE, namespace) + } + defer func() { licenseVerificationFunc = originalLicenseVerify }() + + var capturedFilename string + var capturedContent string + originalGenerateValues := generateValuesFileFunc + generateValuesFileFunc = func(filename string, chart *HelmChart, content string) error { + capturedFilename = filename + capturedContent = content + return tt.mockGenerateValuesFile(filename, chart, content) + } + defer func() { generateValuesFileFunc = originalGenerateValues }() + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + InstallKubeSliceController(tt.config) + + if tt.expectFatal { + assert.NotEmpty(t, fakeOutput.FatalCalls, "Expected fatal call") + if tt.fatalContains != "" { + assert.Contains(t, fakeOutput.FatalCalls[0], tt.fatalContains) + } + } else { + assert.Empty(t, fakeOutput.FatalCalls, "Unexpected fatal calls") + + if tt.validateValuesContent { + assert.Contains(t, capturedFilename, controllerValuesFileName) + assert.Contains(t, capturedContent, "kubeslice:") + assert.Contains(t, capturedContent, "controller:") + assert.Contains(t, capturedContent, tt.config.Configuration.ClusterConfiguration.ControllerCluster.ControlPlaneAddress) + } + + require.Len(t, fakeExec.Calls, 1, "Expected exactly one helm command") + require.Equal(t, "helm", fakeExec.Calls[0].CLI) + + args := fakeExec.Calls[0].Args + assert.Contains(t, args, "--kube-context") + assert.Contains(t, args, "controller-context") + assert.Contains(t, args, "--kubeconfig") + assert.Contains(t, args, "/path/to/config") + assert.Contains(t, args, "upgrade") + assert.Contains(t, args, "-i") + assert.Contains(t, args, KUBESLICE_CONTROLLER_NAMESPACE) + assert.Contains(t, args, "kubeslice/kubeslice-controller") + assert.Contains(t, args, "--namespace") + assert.Contains(t, args, "--create-namespace") + assert.Contains(t, args, "-f") + + if tt.config.Configuration.HelmChartConfiguration.ControllerChart.Version != "" { + assert.True(t, containsSequence(args, []string{"--version", tt.config.Configuration.HelmChartConfiguration.ControllerChart.Version})) + } + + assert.True(t, podVerifyCalled, "Expected pod verification to be called") + assert.Equal(t, tt.expectLicenseVerification, licenseVerifyCalled, + "License verification call mismatch") + + assert.Contains(t, fakeClock.SleepCalls, 200*time.Millisecond) + assert.Contains(t, fakeClock.SleepCalls, 2*time.Second) + } + }) + } +} + +func TestUninstallKubeSliceController(t *testing.T) { + tests := []struct { + name string + config *ConfigurationSpecs + mockExecutor func(*util.FakeExecutor) + expectFatal bool + fatalContains string + }{ + { + name: "successful uninstallation", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "controller-context", + KubeConfigPath: "/path/to/config", + }, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + }, + }, + { + name: "uninstallation fails", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "controller-context", + KubeConfigPath: "/path/to/config", + }, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return errors.New("uninstall failed") + } + }, + expectFatal: true, + fatalContains: "Process failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + fakeClock := util.SystemClock.(*util.FakeClock) + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + UninstallKubeSliceController(tt.config) + + if tt.expectFatal { + assert.NotEmpty(t, fakeOutput.FatalCalls) + if tt.fatalContains != "" { + assert.Contains(t, fakeOutput.FatalCalls[0], tt.fatalContains) + } + } else { + assert.Empty(t, fakeOutput.FatalCalls) + + require.Len(t, fakeExec.Calls, 1) + require.Equal(t, "helm", fakeExec.Calls[0].CLI) + + args := fakeExec.Calls[0].Args + assert.Contains(t, args, "--kube-context") + assert.Contains(t, args, "controller-context") + assert.Contains(t, args, "--kubeconfig") + assert.Contains(t, args, "/path/to/config") + assert.Contains(t, args, "uninstall") + assert.Contains(t, args, KUBESLICE_CONTROLLER_NAMESPACE) + assert.Contains(t, args, "--namespace") + + assert.Len(t, fakeClock.SleepCalls, 2) + } + }) + } +} + +func TestControllerValuesContentGeneration(t *testing.T) { + tests := []struct { + name string + cluster Cluster + helmConfig HelmChartConfiguration + expectInContent []string + }{ + { + name: "basic values without image pull secrets", + cluster: Cluster{ + ControlPlaneAddress: "https://controller.example.com", + }, + helmConfig: HelmChartConfiguration{ + ControllerChart: HelmChart{ + ChartName: "kubeslice-controller", + }, + ImagePullSecret: ImagePullSecrets{}, + }, + expectInContent: []string{ + "kubeslice:", + "controller:", + "loglevel: info", + "endpoint: https://controller.example.com", + }, + }, + { + name: "values with image pull secrets", + cluster: Cluster{ + ControlPlaneAddress: "https://prod.controller.com", + }, + helmConfig: HelmChartConfiguration{ + ControllerChart: HelmChart{ + ChartName: "kubeslice-controller", + }, + ImagePullSecret: ImagePullSecrets{ + Registry: "docker.io", + Username: "testuser", + Password: "testpass", + Email: "test@example.com", + }, + }, + expectInContent: []string{ + "endpoint: https://prod.controller.com", + "imagePullSecrets:", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + var capturedContent string + originalGenerateValues := generateValuesFileFunc + generateValuesFileFunc = func(filename string, chart *HelmChart, content string) error { + capturedContent = content + assert.Contains(t, filename, controllerValuesFileName) + return nil + } + defer func() { generateValuesFileFunc = originalGenerateValues }() + + generateControllerValuesFile(tt.cluster, tt.helmConfig) + + require.NotEmpty(t, capturedContent, "Expected values content to be generated") + + for _, expected := range tt.expectInContent { + assert.Contains(t, capturedContent, expected, + "Expected '%s' in values content", expected) + } + }) + } +} From da0a2301f0d741cb9ab1461184d798203bc8e391 Mon Sep 17 00:00:00 2001 From: Alokzh Date: Wed, 29 Oct 2025 17:08:41 +0530 Subject: [PATCH 07/29] Refactored values.go & add tests for generateValueFile function Signed-off-by: Alokzh --- pkg/internal/values.go | 6 +- pkg/internal/values_test.go | 302 ++++++++++++++++++++++++++++++++++++ 2 files changed, 305 insertions(+), 3 deletions(-) create mode 100644 pkg/internal/values_test.go diff --git a/pkg/internal/values.go b/pkg/internal/values.go index e2db774..a55cc9b 100644 --- a/pkg/internal/values.go +++ b/pkg/internal/values.go @@ -2,9 +2,9 @@ package internal import ( "fmt" - "io/ioutil" "strings" + "github.com/kubeslice/kubeslice-cli/util" "gopkg.in/yaml.v2" ) @@ -46,14 +46,14 @@ func generateValuesFile(filePath string, hc *HelmChart, defaults string) error { return fmt.Errorf("error parsing defaults: %v", err) } - mergedMap := mergeMaps(valuesMap, defaultsMap) + mergedMap := mergeMaps(defaultsMap, valuesMap) finalData, err := yaml.Marshal(mergedMap) if err != nil { return fmt.Errorf("error encoding final data as YAML: %v", err) } - if err := ioutil.WriteFile(filePath, finalData, 0644); err != nil { + if err := util.FileSystem.WriteFile(filePath, finalData, 0644); err != nil { return fmt.Errorf("error writing values file: %v", err) } diff --git a/pkg/internal/values_test.go b/pkg/internal/values_test.go new file mode 100644 index 0000000..0163360 --- /dev/null +++ b/pkg/internal/values_test.go @@ -0,0 +1,302 @@ +package internal + +import ( + "testing" + + "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" +) + +func TestGenerateValuesFile(t *testing.T) { + tests := []struct { + name string + filePath string + helmChart *HelmChart + defaults string + expectError bool + errorContains string + validateYAML func(*testing.T, []byte) + }{ + { + name: "simple values generation", + filePath: "test-values.yaml", + helmChart: &HelmChart{ + Values: map[string]interface{}{ + "key1": "value1", + "key2": "value2", + }, + }, + defaults: ` +default1: defaultvalue1 +`, + validateYAML: func(t *testing.T, data []byte) { + var result map[string]interface{} + err := yaml.Unmarshal(data, &result) + require.NoError(t, err) + assert.Equal(t, "value1", result["key1"]) + assert.Equal(t, "value2", result["key2"]) + assert.Equal(t, "defaultvalue1", result["default1"]) + }, + }, + { + name: "nested values with dot notation", + filePath: "test-values.yaml", + helmChart: &HelmChart{ + Values: map[string]interface{}{ + "parent.child1": "value1", + "parent.child2": "value2", + }, + }, + defaults: ``, + validateYAML: func(t *testing.T, data []byte) { + var result map[string]interface{} + err := yaml.Unmarshal(data, &result) + require.NoError(t, err) + parent := result["parent"].(map[interface{}]interface{}) + assert.Equal(t, "value1", parent["child1"]) + assert.Equal(t, "value2", parent["child2"]) + }, + }, + { + name: "deep nested values", + filePath: "test-values.yaml", + helmChart: &HelmChart{ + Values: map[string]interface{}{ + "level1.level2.level3": "deepvalue", + }, + }, + defaults: ``, + validateYAML: func(t *testing.T, data []byte) { + var result map[string]interface{} + err := yaml.Unmarshal(data, &result) + require.NoError(t, err) + level1 := result["level1"].(map[interface{}]interface{}) + level2 := level1["level2"].(map[interface{}]interface{}) + assert.Equal(t, "deepvalue", level2["level3"]) + }, + }, + { + name: "values override defaults", + filePath: "test-values.yaml", + helmChart: &HelmChart{ + Values: map[string]interface{}{ + "key1": "customvalue", + }, + }, + defaults: ` +key1: defaultvalue +key2: defaultvalue2 +`, + validateYAML: func(t *testing.T, data []byte) { + var result map[string]interface{} + err := yaml.Unmarshal(data, &result) + require.NoError(t, err) + assert.Equal(t, "customvalue", result["key1"], "Custom value should override default") + assert.Equal(t, "defaultvalue2", result["key2"], "Non-overridden default should remain") + }, + }, + { + name: "empty values with defaults", + filePath: "test-values.yaml", + helmChart: &HelmChart{ + Values: map[string]interface{}{}, + }, + defaults: ` +key1: value1 +key2: value2 +`, + validateYAML: func(t *testing.T, data []byte) { + var result map[string]interface{} + err := yaml.Unmarshal(data, &result) + require.NoError(t, err) + assert.Equal(t, "value1", result["key1"]) + assert.Equal(t, "value2", result["key2"]) + }, + }, + { + name: "complex nested merge", + filePath: "test-values.yaml", + helmChart: &HelmChart{ + Values: map[string]interface{}{ + "app.database.host": "custom-host", + "app.cache.enabled": true, + }, + }, + defaults: ` +app: + database: + host: default-host + port: 5432 + cache: + enabled: false + ttl: 3600 +`, + validateYAML: func(t *testing.T, data []byte) { + var result map[string]interface{} + err := yaml.Unmarshal(data, &result) + require.NoError(t, err) + app := result["app"].(map[interface{}]interface{}) + database := app["database"].(map[interface{}]interface{}) + cache := app["cache"].(map[interface{}]interface{}) + assert.Equal(t, "custom-host", database["host"]) + assert.Equal(t, 5432, database["port"]) + assert.Equal(t, true, cache["enabled"]) + assert.Equal(t, 3600, cache["ttl"]) + }, + }, + { + name: "invalid YAML in defaults", + filePath: "test-values.yaml", + helmChart: &HelmChart{ + Values: map[string]interface{}{ + "key1": "value1", + }, + }, + defaults: ` +invalid yaml: + this is: [not: valid +`, + expectError: true, + errorContains: "error parsing defaults", + }, + { + name: "nil values map", + filePath: "test-values.yaml", + helmChart: &HelmChart{ + Values: nil, + }, + defaults: "key: value", + validateYAML: func(t *testing.T, data []byte) { + var result map[string]interface{} + err := yaml.Unmarshal(data, &result) + require.NoError(t, err) + assert.Equal(t, "value", result["key"]) + }, + }, + { + name: "empty defaults", + filePath: "test-values.yaml", + helmChart: &HelmChart{ + Values: map[string]interface{}{ + "key": "value", + }, + }, + defaults: "", + validateYAML: func(t *testing.T, data []byte) { + var result map[string]interface{} + err := yaml.Unmarshal(data, &result) + require.NoError(t, err) + assert.Equal(t, "value", result["key"]) + }, + }, + { + name: "special characters in keys", + filePath: "test-values.yaml", + helmChart: &HelmChart{ + Values: map[string]interface{}{ + "key-with-dash": "value1", + "key_with_underscore": "value2", + }, + }, + defaults: "", + validateYAML: func(t *testing.T, data []byte) { + var result map[string]interface{} + err := yaml.Unmarshal(data, &result) + require.NoError(t, err) + assert.Equal(t, "value1", result["key-with-dash"]) + assert.Equal(t, "value2", result["key_with_underscore"]) + }, + }, + { + name: "numeric and boolean values", + filePath: "test-values.yaml", + helmChart: &HelmChart{ + Values: map[string]interface{}{ + "port": 8080, + "timeout": 30.5, + "enabled": true, + }, + }, + defaults: "", + validateYAML: func(t *testing.T, data []byte) { + var result map[string]interface{} + err := yaml.Unmarshal(data, &result) + require.NoError(t, err) + assert.Equal(t, 8080, result["port"]) + assert.Equal(t, 30.5, result["timeout"]) + assert.Equal(t, true, result["enabled"]) + }, + }, + { + name: "array values", + filePath: "test-values.yaml", + helmChart: &HelmChart{ + Values: map[string]interface{}{ + "items": []string{"item1", "item2", "item3"}, + }, + }, + defaults: "", + validateYAML: func(t *testing.T, data []byte) { + var result map[string]interface{} + err := yaml.Unmarshal(data, &result) + require.NoError(t, err) + items := result["items"].([]interface{}) + assert.Len(t, items, 3) + assert.Equal(t, "item1", items[0]) + assert.Equal(t, "item2", items[1]) + assert.Equal(t, "item3", items[2]) + }, + }, + { + name: "nested map merging - custom and default values", + filePath: "test-values.yaml", + helmChart: &HelmChart{ + Values: map[string]interface{}{ + "parent.child1": "custom1", + }, + }, + defaults: ` +parent: + child1: default1 + child2: default2 +`, + validateYAML: func(t *testing.T, data []byte) { + var result map[string]interface{} + err := yaml.Unmarshal(data, &result) + require.NoError(t, err) + parent := result["parent"].(map[interface{}]interface{}) + assert.Equal(t, "custom1", parent["child1"], "Custom value should override default") + assert.Equal(t, "default2", parent["child2"], "Default should be preserved") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeFS := util.FileSystem.(*util.FakeFileSystem) + + err := generateValuesFile(tt.filePath, tt.helmChart, tt.defaults) + + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + + data, exists := fakeFS.WrittenFiles[tt.filePath] + require.True(t, exists, "Expected file to be written") + + if tt.validateYAML != nil { + tt.validateYAML(t, data) + } + } + }) + } +} From 1a6584e5e9c3ea7add9d2d45c47a954a3916365e Mon Sep 17 00:00:00 2001 From: Alokzh Date: Thu, 30 Oct 2025 13:22:21 +0530 Subject: [PATCH 08/29] Refactor enterprise.go & add tests for ui installation & uninstallation Signed-off-by: Alokzh --- pkg/internal/enterprise.go | 44 +- pkg/internal/enterprise_test.go | 757 ++++++++++++++++++++++++++++++++ 2 files changed, 783 insertions(+), 18 deletions(-) create mode 100644 pkg/internal/enterprise_test.go diff --git a/pkg/internal/enterprise.go b/pkg/internal/enterprise.go index ac7ec15..e73bf15 100644 --- a/pkg/internal/enterprise.go +++ b/pkg/internal/enterprise.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "log" "strings" "time" @@ -24,6 +23,11 @@ kubeslice: type: %s ` +// Function variables for testing +var ( + getNodeIPFunc = getNodeIP +) + func InstallKubeSliceUI(ApplicationConfiguration *ConfigurationSpecs) { util.Printf("\nInstalling KubeSlice Manager...") if ApplicationConfiguration.Configuration.HelmChartConfiguration.UIChart.ChartName == "" { @@ -32,33 +36,35 @@ func InstallKubeSliceUI(ApplicationConfiguration *ConfigurationSpecs) { } cc := ApplicationConfiguration.Configuration.ClusterConfiguration hc := ApplicationConfiguration.Configuration.HelmChartConfiguration - time.Sleep(200 * time.Millisecond) + + util.Sleep(200 * time.Millisecond) clusterType := ApplicationConfiguration.Configuration.ClusterConfiguration.ClusterType filename := "helm-values-ui.yaml" generateUIValuesFile(clusterType, cc.ControllerCluster, ApplicationConfiguration.Configuration.HelmChartConfiguration) util.Printf("%s Generated Helm Values file for Kubeslice Manager Installation %s", util.Tick, filename) - time.Sleep(200 * time.Millisecond) + util.Sleep(200 * time.Millisecond) installKubeSliceUI(cc.ControllerCluster, hc) util.Printf("%s Successfully installed helm chart %s/%s", util.Tick, hc.RepoAlias, hc.UIChart.ChartName) - time.Sleep(200 * time.Millisecond) + util.Sleep(200 * time.Millisecond) util.Printf("%s Waiting for KubeSlice Manager Pods to be Healthy...", util.Wait) - PodVerification("Waiting for KubeSlice Manager Pods to be Healthy", cc.ControllerCluster, "kubernetes-dashboard") + podVerificationFunc("Waiting for KubeSlice Manager Pods to be Healthy", cc.ControllerCluster, "kubernetes-dashboard") util.Printf("%s Successfully installed KubeSlice Manager.\n", util.Tick) } func UninstallKubeSliceUI(ApplicationConfiguration *ConfigurationSpecs) { util.Printf("\nUninstalling KubeSlice Manager...") cc := ApplicationConfiguration.Configuration.ClusterConfiguration - time.Sleep(200 * time.Millisecond) + + util.Sleep(200 * time.Millisecond) ok, err := uninstallKubeSliceUI(cc.ControllerCluster) if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } if ok { - time.Sleep(200 * time.Millisecond) + util.Sleep(200 * time.Millisecond) util.Printf("%s Successfully uninstalled KubeSlice Manager", util.Tick) } } @@ -70,9 +76,10 @@ func generateUIValuesFile(clusterType string, cluster Cluster, hcConfig HelmChar } else { serviceType = "LoadBalancer" } - err := generateValuesFile(kubesliceDirectory+"/"+uiValuesFileName, &hcConfig.UIChart, fmt.Sprintf(UIValuesTemplate+generateImagePullSecretsValue(hcConfig.ImagePullSecret), serviceType)) + + err := generateValuesFileFunc(kubesliceDirectory+"/"+uiValuesFileName, &hcConfig.UIChart, fmt.Sprintf(UIValuesTemplate+generateImagePullSecretsValue(hcConfig.ImagePullSecret), serviceType)) if err != nil { - log.Fatalf("%s %s", util.Cross, err) + util.Fatalf("%s %s", util.Cross, err) } } @@ -84,7 +91,7 @@ func installKubeSliceUI(cluster Cluster, hc HelmChartConfiguration) { } err := util.RunCommand("helm", args...) if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } } @@ -127,9 +134,9 @@ func GetUIEndpoint(cc *Cluster, profile string) string { ports := jsonMap["ports"].([]interface{}) for _, port := range ports { portMap := port.(map[string]interface{}) - if portMap["name"] == "http" { // Assuming that http is the name of the port that you want to use + if portMap["name"] == "http" { nodePort := int(portMap["nodePort"].(float64)) - nodeIP, err := getNodeIP(cc) + nodeIP, err := getNodeIPFunc(cc) if err == nil { ep = fmt.Sprintf("https://%s:%d", strings.Trim(nodeIP, "'"), nodePort) } else { @@ -145,7 +152,7 @@ func GetUIEndpoint(cc *Cluster, profile string) string { ports := jsonMap["ports"].([]interface{}) for _, port := range ports { portMap := port.(map[string]interface{}) - if portMap["name"] == "http" { // Assuming that http is the name of the port that you want to use + if portMap["name"] == "http" { nodePort := int(portMap["port"].(float64)) ep = fmt.Sprintf("https://%s:%d", lbIP, nodePort) break @@ -169,7 +176,7 @@ func findUserSecret(username string, projectName string, cc Cluster) string { var outB, errB bytes.Buffer err := util.RunCommandCustomIO("kubectl", &outB, &errB, true, "--context="+cc.ContextName, "--kubeconfig="+cc.KubeConfigPath, "get", "sa", "-n", "kubeslice-"+projectName, "-o", "name") if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } var secret string @@ -180,7 +187,7 @@ func findUserSecret(username string, projectName string, cc Cluster) string { } } if secret == "" { - log.Fatalf("failed to find secret for %s", username) + util.Fatalf("failed to find secret for %s", username) } return secret } @@ -192,14 +199,15 @@ func GetUIAdminToken(cc *Cluster, username, projectName string) string { var outB, errB bytes.Buffer err := util.RunCommandCustomIO("kubectl", &outB, &errB, false, "--context="+cc.ContextName, "--kubeconfig="+cc.KubeConfigPath, "get", secret, "-n", "kubeslice-"+projectName, "-o", "jsonpath={.data.token}") if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } x := outB.String() // base64 decode data, err := base64.StdEncoding.DecodeString(x) if err != nil { - log.Fatalf("Unable to decode token %v", err) + util.Fatalf("Unable to decode token %v", err) } + return string(data) } diff --git a/pkg/internal/enterprise_test.go b/pkg/internal/enterprise_test.go new file mode 100644 index 0000000..4bb5329 --- /dev/null +++ b/pkg/internal/enterprise_test.go @@ -0,0 +1,757 @@ +package internal + +import ( + "encoding/base64" + "errors" + "fmt" + "io" + "testing" + "time" + + "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstallKubeSliceUI(t *testing.T) { + tests := []struct { + name string + config *ConfigurationSpecs + mockExecutor func(*util.FakeExecutor) + mockGenerateValuesFile func(string, *HelmChart, string) error + expectFatal bool + fatalContains string + shouldSkip bool + validateValuesContent bool + expectedServiceType string + }{ + { + name: "successful installation with kind cluster", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "controller-context", + KubeConfigPath: "/path/to/config", + }, + ClusterType: "kind", + }, + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice", + UIChart: HelmChart{ + ChartName: "kubeslice-ui", + }, + ImagePullSecret: ImagePullSecrets{}, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + }, + mockGenerateValuesFile: func(filename string, chart *HelmChart, content string) error { + return nil + }, + validateValuesContent: true, + expectedServiceType: "NodePort", + }, + { + name: "successful installation with eks cluster", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "controller-context", + KubeConfigPath: "/path/to/config", + }, + ClusterType: "eks", + }, + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice", + UIChart: HelmChart{ + ChartName: "kubeslice-ui", + }, + ImagePullSecret: ImagePullSecrets{}, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + }, + mockGenerateValuesFile: func(filename string, chart *HelmChart, content string) error { + return nil + }, + validateValuesContent: true, + expectedServiceType: "LoadBalancer", + }, + { + name: "successful installation with version", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "controller-context", + KubeConfigPath: "/path/to/config", + }, + ClusterType: "kind", + }, + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice", + UIChart: HelmChart{ + ChartName: "kubeslice-ui", + Version: "v1.0.0", + }, + ImagePullSecret: ImagePullSecrets{}, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + }, + mockGenerateValuesFile: func(filename string, chart *HelmChart, content string) error { + return nil + }, + validateValuesContent: true, + expectedServiceType: "NodePort", + }, + { + name: "skip installation when UI chart not configured", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "controller-context", + KubeConfigPath: "/path/to/config", + }, + }, + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice", + UIChart: HelmChart{ + ChartName: "", + }, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + t.Error("Should not execute helm when UI chart not configured") + return nil + } + }, + mockGenerateValuesFile: func(filename string, chart *HelmChart, content string) error { + return nil + }, + shouldSkip: true, + }, + { + name: "helm installation fails", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "controller-context", + KubeConfigPath: "/path/to/config", + }, + ClusterType: "kind", + }, + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice", + UIChart: HelmChart{ + ChartName: "kubeslice-ui", + }, + ImagePullSecret: ImagePullSecrets{}, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return errors.New("helm upgrade failed") + } + }, + mockGenerateValuesFile: func(filename string, chart *HelmChart, content string) error { + return nil + }, + expectFatal: true, + fatalContains: "Process failed", + }, + { + name: "values file generation fails", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "controller-context", + KubeConfigPath: "/path/to/config", + }, + ClusterType: "kind", + }, + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice", + UIChart: HelmChart{ + ChartName: "kubeslice-ui", + }, + ImagePullSecret: ImagePullSecrets{}, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + }, + mockGenerateValuesFile: func(filename string, chart *HelmChart, content string) error { + return errors.New("failed to write file") + }, + expectFatal: true, + fatalContains: "failed to write file", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + fakeClock := util.SystemClock.(*util.FakeClock) + + podVerifyCalled := false + originalPodVerify := podVerificationFunc + podVerificationFunc = func(msg string, cluster Cluster, namespace string) { + podVerifyCalled = true + assert.Equal(t, "kubernetes-dashboard", namespace) + } + defer func() { podVerificationFunc = originalPodVerify }() + + var capturedFilename string + var capturedContent string + originalGenerateValues := generateValuesFileFunc + generateValuesFileFunc = func(filename string, chart *HelmChart, content string) error { + capturedFilename = filename + capturedContent = content + return tt.mockGenerateValuesFile(filename, chart, content) + } + defer func() { generateValuesFileFunc = originalGenerateValues }() + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + InstallKubeSliceUI(tt.config) + + if tt.expectFatal { + assert.NotEmpty(t, fakeOutput.FatalCalls, "Expected fatal call") + if tt.fatalContains != "" { + assert.Contains(t, fakeOutput.FatalCalls[0], tt.fatalContains) + } + } else if tt.shouldSkip { + assert.Empty(t, fakeExec.Calls, "Should not execute helm when UI chart not configured") + assert.False(t, podVerifyCalled, "Should not verify pods when skipping") + } else { + assert.Empty(t, fakeOutput.FatalCalls, "Unexpected fatal calls") + + if tt.validateValuesContent { + assert.Contains(t, capturedFilename, uiValuesFileName) + assert.Contains(t, capturedContent, "kubeslice:") + assert.Contains(t, capturedContent, "uiproxy:") + assert.Contains(t, capturedContent, fmt.Sprintf("type: %s", tt.expectedServiceType)) + } + + require.Len(t, fakeExec.Calls, 1, "Expected exactly one helm command") + require.Equal(t, "helm", fakeExec.Calls[0].CLI) + + args := fakeExec.Calls[0].Args + assert.Contains(t, args, "--kube-context") + assert.Contains(t, args, "controller-context") + assert.Contains(t, args, "--kubeconfig") + assert.Contains(t, args, "/path/to/config") + assert.Contains(t, args, "upgrade") + assert.Contains(t, args, "-i") + assert.Contains(t, args, "kubeslice-ui") + assert.Contains(t, args, "kubeslice/kubeslice-ui") + assert.Contains(t, args, "--namespace") + assert.Contains(t, args, KUBESLICE_CONTROLLER_NAMESPACE) + assert.Contains(t, args, "-f") + + if tt.config.Configuration.HelmChartConfiguration.UIChart.Version != "" { + assert.True(t, containsSequence(args, []string{"--version", tt.config.Configuration.HelmChartConfiguration.UIChart.Version})) + } + + assert.True(t, podVerifyCalled, "Expected pod verification to be called") + assert.Contains(t, fakeClock.SleepCalls, 200*time.Millisecond) + } + }) + } +} + +func TestUninstallKubeSliceUI(t *testing.T) { + tests := []struct { + name string + config *ConfigurationSpecs + mockExecutor func(*util.FakeExecutor) + expectFatal bool + fatalContains string + expectSuccess bool + }{ + { + name: "successful uninstallation", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "controller-context", + KubeConfigPath: "/path/to/config", + }, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + callCount := 0 + fe.ExecuteFunc = func(cli string, args ...string) error { + callCount++ + return nil + } + }, + expectSuccess: true, + }, + { + name: "UI not installed - skip uninstallation", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "controller-context", + KubeConfigPath: "/path/to/config", + }, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return errors.New("release not found") + } + }, + expectSuccess: false, + }, + { + name: "uninstallation fails", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "controller-context", + KubeConfigPath: "/path/to/config", + }, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + callCount := 0 + fe.ExecuteFunc = func(cli string, args ...string) error { + callCount++ + if callCount == 1 { + return nil + } + return errors.New("uninstall failed") + } + }, + expectFatal: true, + fatalContains: "Process failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + fakeClock := util.SystemClock.(*util.FakeClock) + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + UninstallKubeSliceUI(tt.config) + + if tt.expectFatal { + assert.NotEmpty(t, fakeOutput.FatalCalls) + if tt.fatalContains != "" { + assert.Contains(t, fakeOutput.FatalCalls[0], tt.fatalContains) + } + } else { + assert.Empty(t, fakeOutput.FatalCalls) + + infoMessages := fmt.Sprint(fakeOutput.InfoCalls) + if tt.expectSuccess { + assert.Contains(t, infoMessages, "Successfully uninstalled KubeSlice Manager") + assert.Len(t, fakeExec.Calls, 2) + } else { + assert.Contains(t, infoMessages, "not installed, skipping uninstall") + assert.Len(t, fakeExec.Calls, 1) + } + + assert.Contains(t, fakeClock.SleepCalls, 200*time.Millisecond) + } + }) + } +} + +func TestGetUIEndpoint(t *testing.T) { + tests := []struct { + name string + profile string + mockExecutor func(*util.FakeExecutor) + mockGetNodeIP func(*Cluster) (string, error) + expectedEndpoint string + expectError bool + }{ + { + name: "NodePort service with EntDemo profile", + profile: ProfileEntDemo, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + jsonOutput := `'{"type":"NodePort","ports":[{"name":"http","nodePort":30443,"port":443}]}'` + stdout.Write([]byte(jsonOutput)) + return nil + } + }, + expectedEndpoint: "https://localhost:8443", + }, + { + name: "NodePort service without EntDemo profile", + profile: "", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + jsonOutput := `'{"type":"NodePort","ports":[{"name":"http","nodePort":30443,"port":443}]}'` + stdout.Write([]byte(jsonOutput)) + return nil + } + }, + mockGetNodeIP: func(cc *Cluster) (string, error) { + return "'192.168.1.100'", nil + }, + expectedEndpoint: "https://192.168.1.100:30443", + }, + { + name: "LoadBalancer service with externalIPs", + profile: "", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + jsonOutput := `'{"type":"LoadBalancer","externalIPs":["10.0.0.1"],"ports":[{"name":"http","port":443}]}'` + stdout.Write([]byte(jsonOutput)) + return nil + } + }, + expectedEndpoint: "https://10.0.0.1:443", + }, + { + name: "kubectl command fails", + profile: "", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + return errors.New("service not found") + } + }, + expectedEndpoint: "", + expectError: true, + }, + { + name: "unsupported service type", + profile: "", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + jsonOutput := `'{"type":"ClusterIP"}'` + stdout.Write([]byte(jsonOutput)) + return nil + } + }, + expectedEndpoint: "", + expectError: true, + }, + { + name: "node IP retrieval fails", + profile: "", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + jsonOutput := `'{"type":"NodePort","ports":[{"name":"http","nodePort":30443}]}'` + stdout.Write([]byte(jsonOutput)) + return nil + } + }, + mockGetNodeIP: func(cc *Cluster) (string, error) { + return "", errors.New("no nodes found") + }, + expectedEndpoint: "", + expectError: true, + }, + { + name: "invalid JSON response from kubectl", + profile: "", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + invalidJSON := `'{"type":"NodePort","ports":[invalid json here]}'` + stdout.Write([]byte(invalidJSON)) + return nil + } + }, + expectedEndpoint: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + if tt.mockGetNodeIP != nil { + originalGetNodeIP := getNodeIPFunc + getNodeIPFunc = tt.mockGetNodeIP + defer func() { getNodeIPFunc = originalGetNodeIP }() + } + + cluster := &Cluster{ + ContextName: "test-context", + KubeConfigPath: "/path/to/config", + } + + endpoint := GetUIEndpoint(cluster, tt.profile) + + if tt.expectError { + assert.Empty(t, endpoint, "Expected empty endpoint on error") + } else { + assert.Equal(t, tt.expectedEndpoint, endpoint) + } + }) + } +} + +func TestGetUIAdminToken(t *testing.T) { + tests := []struct { + name string + username string + projectName string + mockExecutor func(*util.FakeExecutor) + expectedToken string + expectFatal bool + fatalContains string + }{ + { + name: "successful token retrieval", + username: "admin", + projectName: "test-project", + mockExecutor: func(fe *util.FakeExecutor) { + callCount := 0 + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + callCount++ + if callCount == 1 { + stdout.Write([]byte("serviceaccount/rbac-rw-admin\nserviceaccount/other-sa")) + return nil + } + token := base64.StdEncoding.EncodeToString([]byte("test-token-12345")) + stdout.Write([]byte(token)) + return nil + } + }, + expectedToken: "test-token-12345", + }, + { + name: "service account not found", + username: "admin", + projectName: "test-project", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stdout.Write([]byte("serviceaccount/other-sa")) + return nil + } + }, + expectFatal: true, + fatalContains: "failed to find secret", + }, + { + name: "kubectl get sa fails", + username: "admin", + projectName: "test-project", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + return errors.New("namespace not found") + } + }, + expectFatal: true, + fatalContains: "Process failed", + }, + { + name: "kubectl get secret fails", + username: "admin", + projectName: "test-project", + mockExecutor: func(fe *util.FakeExecutor) { + callCount := 0 + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + callCount++ + if callCount == 1 { + stdout.Write([]byte("serviceaccount/rbac-rw-admin")) + return nil + } + return errors.New("secret not found") + } + }, + expectFatal: true, + fatalContains: "Process failed", + }, + { + name: "invalid base64 token", + username: "admin", + projectName: "test-project", + mockExecutor: func(fe *util.FakeExecutor) { + callCount := 0 + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + callCount++ + if callCount == 1 { + stdout.Write([]byte("serviceaccount/rbac-rw-admin")) + return nil + } + stdout.Write([]byte("not-valid-base64!!!")) + return nil + } + }, + expectFatal: true, + fatalContains: "Unable to decode token", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + cluster := &Cluster{ + ContextName: "test-context", + KubeConfigPath: "/path/to/config", + } + + token := GetUIAdminToken(cluster, tt.username, tt.projectName) + + if tt.expectFatal { + assert.NotEmpty(t, fakeOutput.FatalCalls) + if tt.fatalContains != "" { + assert.Contains(t, fakeOutput.FatalCalls[0], tt.fatalContains) + } + } else { + assert.Empty(t, fakeOutput.FatalCalls) + assert.Equal(t, tt.expectedToken, token) + } + }) + } +} + +func TestGetNodeIP(t *testing.T) { + tests := []struct { + name string + mockExecutor func(*util.FakeExecutor) + expectedNodeIP string + expectError bool + errorContains string + }{ + { + name: "successful node IP retrieval", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stdout.Write([]byte("'192.168.1.100' '192.168.1.101'")) + return nil + } + }, + expectedNodeIP: "'192.168.1.100'", + }, + { + name: "single node IP", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stdout.Write([]byte("'10.0.0.1'")) + return nil + } + }, + expectedNodeIP: "'10.0.0.1'", + }, + { + name: "no nodes found", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stdout.Write([]byte("")) + return nil + } + }, + expectError: true, + errorContains: "No nodes found", + }, + { + name: "kubectl command fails", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + return errors.New("connection refused") + } + }, + expectError: true, + errorContains: "connection refused", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + cluster := &Cluster{ + ContextName: "test-context", + KubeConfigPath: "/path/to/config", + } + + nodeIP, err := getNodeIP(cluster) + + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.expectedNodeIP, nodeIP) + } + }) + } +} From be7a053811ec366c4afb22d679d6fe452c1451ff Mon Sep 17 00:00:00 2001 From: Alokzh Date: Fri, 31 Oct 2025 11:45:09 +0530 Subject: [PATCH 09/29] Refactor verify-executables.go & add tests for executable verification Signed-off-by: Alokzh --- pkg/internal/verify-executables.go | 33 ++- pkg/internal/verify-executables_test.go | 378 ++++++++++++++++++++++++ 2 files changed, 399 insertions(+), 12 deletions(-) create mode 100644 pkg/internal/verify-executables_test.go diff --git a/pkg/internal/verify-executables.go b/pkg/internal/verify-executables.go index 8545150..d8ab6c0 100644 --- a/pkg/internal/verify-executables.go +++ b/pkg/internal/verify-executables.go @@ -1,7 +1,6 @@ package internal import ( - "fmt" "os" "os/exec" "runtime" @@ -11,9 +10,16 @@ import ( "github.com/kubeslice/kubeslice-cli/util" ) +var ( + lookPathFunc = exec.LookPath + getEnvFunc = os.Getenv + runtimeGOOSFunc = func() string { return runtime.GOOS } +) + func VerifyExecutables(ApplicationConfiguration *ConfigurationSpecs) { util.Printf("Verifying Executables...") - time.Sleep(200 * time.Millisecond) + util.Sleep(200 * time.Millisecond) + if ApplicationConfiguration.Configuration.ClusterConfiguration.Profile != "" || ApplicationConfiguration.Configuration.ClusterConfiguration.ClusterType == "kind" { util.ExecutablePaths = map[string]string{ "kind": "kind", @@ -28,11 +34,11 @@ func VerifyExecutables(ApplicationConfiguration *ConfigurationSpecs) { } } for key := range util.ExecutablePaths { - time.Sleep(200 * time.Millisecond) + util.Sleep(200 * time.Millisecond) verificationResult(verifyBinary(key), key) } - time.Sleep(200 * time.Millisecond) + util.Sleep(200 * time.Millisecond) util.Printf("All required executables were found\n") } @@ -42,14 +48,16 @@ func verifyBinary(name string) int { func _verifyBinary(name, environmentVariable string, executable []string) int { cli := name - if os.Getenv(environmentVariable) != "" { - cli = strings.Trim(os.Getenv(environmentVariable), "\"") + if envPath := getEnvFunc(environmentVariable); envPath != "" { + cli = strings.Trim(envPath, "\"") } - path, err := exec.LookPath(cli) + + path, err := lookPathFunc(cli) if err != nil || path == "" { return 1 } - if err = exec.Command(path, executable...).Run(); err != nil { + args := append([]string{}, executable...) + if err = util.CommandExecutor.Execute(name, args...); err != nil { return 2 } util.ExecutablePaths[name] = path @@ -57,15 +65,16 @@ func _verifyBinary(name, environmentVariable string, executable []string) int { } func executableDownloadMessage(executable string) string { + goos := runtimeGOOSFunc() switch executable { case "kind": - return kindExecutableMessage[fmt.Sprintf("%s", runtime.GOOS)] + return kindExecutableMessage[goos] case "kubectl": - return kubectlExecutableMessage[fmt.Sprintf("%s", runtime.GOOS)] + return kubectlExecutableMessage[goos] case "helm": - return helmExecutableMessage[fmt.Sprintf("%s", runtime.GOOS)] + return helmExecutableMessage[goos] case "docker": - return dockerExecutableMessage[fmt.Sprintf("%s", runtime.GOOS)] + return dockerExecutableMessage[goos] } return "" } diff --git a/pkg/internal/verify-executables_test.go b/pkg/internal/verify-executables_test.go new file mode 100644 index 0000000..e6e0ef6 --- /dev/null +++ b/pkg/internal/verify-executables_test.go @@ -0,0 +1,378 @@ +package internal + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVerifyExecutables(t *testing.T) { + tests := []struct { + name string + config *ConfigurationSpecs + mockLookPath func(string) (string, error) + mockExecutor func(*util.FakeExecutor) + mockGetEnv func(string) string + expectedExecutables map[string]string + expectFatal bool + fatalContains string + }{ + { + name: "all executables found for kind cluster", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ClusterType: "kind", + }, + }, + }, + mockLookPath: func(file string) (string, error) { + return "/usr/local/bin/" + file, nil + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + }, + mockGetEnv: func(key string) string { + return "" + }, + expectedExecutables: map[string]string{ + "kind": "/usr/local/bin/kind", + "kubectl": "/usr/local/bin/kubectl", + "docker": "/usr/local/bin/docker", + "helm": "/usr/local/bin/helm", + }, + }, + { + name: "all executables found for non-kind cluster", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ClusterType: "eks", + }, + }, + }, + mockLookPath: func(file string) (string, error) { + return "/usr/local/bin/" + file, nil + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + }, + mockGetEnv: func(key string) string { + return "" + }, + expectedExecutables: map[string]string{ + "kubectl": "/usr/local/bin/kubectl", + "helm": "/usr/local/bin/helm", + }, + }, + { + name: "enterprise profile requires all executables", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + Profile: ProfileEntDemo, + }, + }, + }, + mockLookPath: func(file string) (string, error) { + return "/usr/local/bin/" + file, nil + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + }, + mockGetEnv: func(key string) string { + return "" + }, + expectedExecutables: map[string]string{ + "kind": "/usr/local/bin/kind", + "kubectl": "/usr/local/bin/kubectl", + "docker": "/usr/local/bin/docker", + "helm": "/usr/local/bin/helm", + }, + }, + { + name: "kubectl not found", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ClusterType: "eks", + }, + }, + }, + mockLookPath: func(file string) (string, error) { + if file == "kubectl" { + return "", errors.New("not found") + } + return "/usr/local/bin/" + file, nil + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + }, + mockGetEnv: func(key string) string { + return "" + }, + expectFatal: true, + fatalContains: "not found on path", + }, + { + name: "helm not executable", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ClusterType: "eks", + }, + }, + }, + mockLookPath: func(file string) (string, error) { + return "/usr/local/bin/" + file, nil + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + if cli == "helm" { + return errors.New("permission denied") + } + return nil + } + }, + mockGetEnv: func(key string) string { + return "" + }, + expectFatal: true, + fatalContains: "not executable", + }, + { + name: "docker not found for kind cluster", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ClusterType: "kind", + }, + }, + }, + mockLookPath: func(file string) (string, error) { + if file == "docker" { + return "", errors.New("not found") + } + return "/usr/local/bin/" + file, nil + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + }, + mockGetEnv: func(key string) string { + return "" + }, + expectFatal: true, + fatalContains: "not found on path", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + + originalLookPath := lookPathFunc + lookPathFunc = tt.mockLookPath + defer func() { lookPathFunc = originalLookPath }() + + originalGetEnv := getEnvFunc + getEnvFunc = tt.mockGetEnv + defer func() { getEnvFunc = originalGetEnv }() + + originalRuntimeGOOS := runtimeGOOSFunc + runtimeGOOSFunc = func() string { return "linux" } + defer func() { runtimeGOOSFunc = originalRuntimeGOOS }() + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + if util.ExecutableVerifyCommands == nil { + util.ExecutableVerifyCommands = map[string][]string{ + "kubectl": {"version", "--client=true"}, + "helm": {"version"}, + "kind": {"version"}, + "docker": {"version"}, + } + } + + VerifyExecutables(tt.config) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls, "Expected fatal call") + if tt.fatalContains != "" { + found := false + for _, call := range fakeOutput.FatalCalls { + callStr := fmt.Sprint(call) + if strings.Contains(callStr, tt.fatalContains) { + found = true + break + } + } + if !found { + for _, call := range fakeOutput.InfoCalls { + callStr := fmt.Sprint(call) + if strings.Contains(callStr, tt.fatalContains) { + found = true + break + } + } + } + assert.True(t, found, "Expected message containing: %s", tt.fatalContains) + } + } else { + assert.Empty(t, fakeOutput.FatalCalls, "Unexpected fatal calls") + for name, expectedPath := range tt.expectedExecutables { + path, exists := util.ExecutablePaths[name] + assert.True(t, exists, "Expected %s to be in ExecutablePaths", name) + assert.Equal(t, expectedPath, path, "Path mismatch for %s", name) + } + } + }) + } +} + +func TestVerifyBinaryWithEnvironmentVariable(t *testing.T) { + tests := []struct { + name string + binaryName string + envValue string + mockLookPath func(string) (string, error) + mockExecutor func(*util.FakeExecutor) + expectedCode int + }{ + { + name: "use custom path from environment", + binaryName: "kubectl", + envValue: "/custom/path/kubectl", + mockLookPath: func(file string) (string, error) { + if file == "/custom/path/kubectl" { + return file, nil + } + return "", errors.New("not found") + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + }, + expectedCode: 0, + }, + { + name: "environment variable with quotes", + binaryName: "helm", + envValue: "\"/custom/path/helm\"", + mockLookPath: func(file string) (string, error) { + if file == "/custom/path/helm" { + return file, nil + } + return "", errors.New("not found") + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + }, + expectedCode: 0, + }, + { + name: "fallback to default when env not set", + binaryName: "kubectl", + envValue: "", + mockLookPath: func(file string) (string, error) { + if file == "kubectl" { + return "/usr/bin/kubectl", nil + } + return "", errors.New("not found") + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + }, + expectedCode: 0, + }, + { + name: "lookPath returns empty string", + binaryName: "kubectl", + envValue: "", + mockLookPath: func(file string) (string, error) { + return "", nil + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + }, + expectedCode: 1, + }, + { + name: "binary not executable returns code 2", + binaryName: "helm", + envValue: "", + mockLookPath: func(file string) (string, error) { + return "/usr/bin/helm", nil + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return errors.New("permission denied") + } + }, + expectedCode: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + + originalLookPath := lookPathFunc + lookPathFunc = tt.mockLookPath + defer func() { lookPathFunc = originalLookPath }() + + originalGetEnv := getEnvFunc + getEnvFunc = func(key string) string { + if key == "KUBECTL_PATH" || key == "HELM_PATH" { + return tt.envValue + } + return "" + } + defer func() { getEnvFunc = originalGetEnv }() + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + if util.ExecutableVerifyCommands == nil { + util.ExecutableVerifyCommands = map[string][]string{ + "kubectl": {"version", "--client=true"}, + "helm": {"version"}, + } + } + + code := verifyBinary(tt.binaryName) + assert.Equal(t, tt.expectedCode, code) + }) + } +} From 60daef78ba623cd5c69f6c1008f99dd28a4ad7f0 Mon Sep 17 00:00:00 2001 From: Alokzh Date: Fri, 31 Oct 2025 16:47:49 +0530 Subject: [PATCH 10/29] Refactored generate-kind-templates.go & added tests for directory deletion & configuration generation Signed-off-by: Alokzh --- pkg/internal/generate-kind-templates.go | 13 +- pkg/internal/generate-kind-templates_test.go | 413 +++++++++++++++++++ 2 files changed, 420 insertions(+), 6 deletions(-) create mode 100644 pkg/internal/generate-kind-templates_test.go diff --git a/pkg/internal/generate-kind-templates.go b/pkg/internal/generate-kind-templates.go index fde2541..718a463 100644 --- a/pkg/internal/generate-kind-templates.go +++ b/pkg/internal/generate-kind-templates.go @@ -2,8 +2,6 @@ package internal import ( "fmt" - "log" - "os" "time" "github.com/kubeslice/kubeslice-cli/util" @@ -60,10 +58,13 @@ nodes: node-labels: "kubeslice.io/node-type=gateway" ` +// Function variable for testing +var removeAllFunc = util.FileSystem.RemoveAll + func DeleteKubeSliceDirectory() { - err := os.RemoveAll(kubesliceDirectory) + err := removeAllFunc(kubesliceDirectory) if err != nil { - log.Fatalf("\nFailed to delete directory %s\n", kubesliceDirectory) + util.Fatalf("\nFailed to delete directory %s\n", kubesliceDirectory) } } @@ -85,11 +86,11 @@ func GenerateKindConfiguration(ApplicationConfiguration *ConfigurationSpecs) { util.DumpFile(fmt.Sprintf(controllerTemplate, cc.ControllerCluster.Name), directory+"/"+cc.ControllerCluster.Name+".yaml") util.Printf("%s Generated %s", util.Tick, directory+"/"+cc.ControllerCluster.Name+".yaml") - time.Sleep(200 * time.Millisecond) + util.Sleep(200 * time.Millisecond) for _, cluster := range cc.WorkerClusters { util.DumpFile(fmt.Sprintf(kubesliceWorkerTemplate, cluster.Name), directory+"/"+cluster.Name+".yaml") util.Printf("%s Generated %s", util.Tick, directory+"/"+cluster.Name+".yaml") - time.Sleep(200 * time.Millisecond) + util.Sleep(200 * time.Millisecond) } } diff --git a/pkg/internal/generate-kind-templates_test.go b/pkg/internal/generate-kind-templates_test.go new file mode 100644 index 0000000..ec6bc3b --- /dev/null +++ b/pkg/internal/generate-kind-templates_test.go @@ -0,0 +1,413 @@ +package internal + +import ( + "fmt" + "os" + "testing" + "time" + + "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeleteKubeSliceDirectory(t *testing.T) { + tests := []struct { + name string + mockRemoveAll func(string) error + expectFatal bool + fatalContains string + }{ + { + name: "successful deletion", + mockRemoveAll: func(path string) error { + assert.Equal(t, kubesliceDirectory, path) + return nil + }, + }, + { + name: "deletion fails", + mockRemoveAll: func(path string) error { + return fmt.Errorf("permission denied") + }, + expectFatal: true, + fatalContains: "Failed to delete directory", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeFS := util.FileSystem.(*util.FakeFileSystem) + fakeOutput := util.Output.(*util.FakeOutput) + + if tt.mockRemoveAll != nil { + fakeFS.RemoveAllFunc = tt.mockRemoveAll + } + + originalRemoveAll := removeAllFunc + removeAllFunc = fakeFS.RemoveAll + defer func() { removeAllFunc = originalRemoveAll }() + + DeleteKubeSliceDirectory() + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls, "Expected fatal call but got none") + if tt.fatalContains != "" { + callStr := fmt.Sprint(fakeOutput.FatalCalls[0]) + assert.Contains(t, callStr, tt.fatalContains) + } + } else { + assert.Empty(t, fakeOutput.FatalCalls) + } + }) + } +} + +func TestGenerateKubeSliceDirectory(t *testing.T) { + tests := []struct { + name string + mockMkdirAll func(string, os.FileMode) error + mockStat func(string) (os.FileInfo, error) + expectFatal bool + fatalContains string + }{ + { + name: "successful directory creation", + mockStat: func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + }, + mockMkdirAll: func(path string, perm os.FileMode) error { + assert.Equal(t, kubesliceDirectory, path) + return nil + }, + }, + { + name: "directory already exists", + mockStat: func(name string) (os.FileInfo, error) { + return nil, nil + }, + mockMkdirAll: func(path string, perm os.FileMode) error { + return nil + }, + }, + { + name: "directory creation fails", + mockStat: func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + }, + mockMkdirAll: func(path string, perm os.FileMode) error { + return fmt.Errorf("permission denied") + }, + expectFatal: true, + fatalContains: "Failed to create kubeslice directory", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeFS := util.FileSystem.(*util.FakeFileSystem) + fakeOutput := util.Output.(*util.FakeOutput) + + if tt.mockStat != nil { + fakeFS.StatFunc = tt.mockStat + } + if tt.mockMkdirAll != nil { + fakeFS.MkdirAllFunc = tt.mockMkdirAll + } + + GenerateKubeSliceDirectory() + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls, "Expected fatal call but got none") + if tt.fatalContains != "" { + callStr := fmt.Sprint(fakeOutput.FatalCalls[0]) + assert.Contains(t, callStr, tt.fatalContains) + } + } else { + assert.Empty(t, fakeOutput.FatalCalls) + } + }) + } +} + +func TestGenerateKindConfiguration(t *testing.T) { + tests := []struct { + name string + config *ConfigurationSpecs + expectedFiles []string + validateContent func(*testing.T, map[string][]byte) + mockFileWrite func(string, []byte, os.FileMode) error + expectFatal bool + fatalContains string + }{ + { + name: "standard profile with single worker", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + Profile: "", + ControllerCluster: Cluster{ + Name: "controller", + }, + WorkerClusters: []Cluster{ + {Name: "worker-1"}, + }, + }, + }, + }, + expectedFiles: []string{ + "kubeslice/kind/controller.yaml", + "kubeslice/kind/worker-1.yaml", + }, + validateContent: func(t *testing.T, files map[string][]byte) { + controllerContent := string(files["kubeslice/kind/controller.yaml"]) + assert.Contains(t, controllerContent, "name: controller") + assert.Contains(t, controllerContent, "kind: Cluster") + assert.Contains(t, controllerContent, "disableDefaultCNI: true") + assert.NotContains(t, controllerContent, "extraPortMappings") + + workerContent := string(files["kubeslice/kind/worker-1.yaml"]) + assert.Contains(t, workerContent, "name: worker-1") + assert.Contains(t, workerContent, "kubeslice.io/node-type=gateway") + }, + }, + { + name: "enterprise profile with port mappings", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + Profile: ProfileEntDemo, + ControllerCluster: Cluster{ + Name: "ent-controller", + }, + WorkerClusters: []Cluster{ + {Name: "ent-worker-1"}, + }, + }, + }, + }, + expectedFiles: []string{ + "kubeslice/kind/ent-controller.yaml", + "kubeslice/kind/ent-worker-1.yaml", + }, + validateContent: func(t *testing.T, files map[string][]byte) { + controllerContent := string(files["kubeslice/kind/ent-controller.yaml"]) + assert.Contains(t, controllerContent, "name: ent-controller") + assert.Contains(t, controllerContent, "extraPortMappings") + assert.Contains(t, controllerContent, "containerPort: 31000") + assert.Contains(t, controllerContent, "hostPort: 8443") + }, + }, + { + name: "multiple workers", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + }, + WorkerClusters: []Cluster{ + {Name: "worker-1"}, + {Name: "worker-2"}, + {Name: "worker-3"}, + }, + }, + }, + }, + expectedFiles: []string{ + "kubeslice/kind/controller.yaml", + "kubeslice/kind/worker-1.yaml", + "kubeslice/kind/worker-2.yaml", + "kubeslice/kind/worker-3.yaml", + }, + validateContent: func(t *testing.T, files map[string][]byte) { + assert.Len(t, files, 4) + for i := 1; i <= 3; i++ { + workerFile := fmt.Sprintf("kubeslice/kind/worker-%d.yaml", i) + content := string(files[workerFile]) + assert.Contains(t, content, fmt.Sprintf("name: worker-%d", i)) + assert.Contains(t, content, "kubeslice.io/node-type=gateway") + } + }, + }, + { + name: "no workers", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller-only", + }, + WorkerClusters: []Cluster{}, + }, + }, + }, + expectedFiles: []string{ + "kubeslice/kind/controller-only.yaml", + }, + validateContent: func(t *testing.T, files map[string][]byte) { + assert.Len(t, files, 1) + }, + }, + { + name: "special characters in cluster names", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "my-controller-01", + }, + WorkerClusters: []Cluster{ + {Name: "my-worker-01"}, + }, + }, + }, + }, + expectedFiles: []string{ + "kubeslice/kind/my-controller-01.yaml", + "kubeslice/kind/my-worker-01.yaml", + }, + validateContent: func(t *testing.T, files map[string][]byte) { + controllerContent := string(files["kubeslice/kind/my-controller-01.yaml"]) + assert.Contains(t, controllerContent, "name: my-controller-01") + }, + }, + { + name: "file write fails", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: "controller"}, + WorkerClusters: []Cluster{{Name: "worker"}}, + }, + }, + }, + mockFileWrite: func(filename string, data []byte, perm os.FileMode) error { + return fmt.Errorf("permission denied") + }, + expectFatal: true, + fatalContains: "Failed to write", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeFS := util.FileSystem.(*util.FakeFileSystem) + fakeOutput := util.Output.(*util.FakeOutput) + fakeClock := util.SystemClock.(*util.FakeClock) + + if tt.mockFileWrite != nil { + fakeFS.WriteFileFunc = tt.mockFileWrite + } + + GenerateKindConfiguration(tt.config) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + if tt.fatalContains != "" { + callStr := fmt.Sprint(fakeOutput.FatalCalls[0]) + assert.Contains(t, callStr, tt.fatalContains) + } + } else { + assert.Empty(t, fakeOutput.FatalCalls) + + for _, expectedFile := range tt.expectedFiles { + _, exists := fakeFS.WrittenFiles[expectedFile] + assert.True(t, exists, "Expected file %s to be created", expectedFile) + } + + if tt.validateContent != nil { + tt.validateContent(t, fakeFS.WrittenFiles) + } + + expectedSleeps := len(tt.config.Configuration.ClusterConfiguration.WorkerClusters) + 1 + assert.Len(t, fakeClock.SleepCalls, expectedSleeps) + for _, d := range fakeClock.SleepCalls { + assert.Equal(t, 200*time.Millisecond, d) + } + } + }) + } +} + +func TestTemplateFormats(t *testing.T) { + tests := []struct { + name string + template string + clusterName string + expectedContents []string + notExpectedContents []string + }{ + { + name: "standard controller template", + template: kubesliceControllerTemplate, + clusterName: "test-controller", + expectedContents: []string{ + "kind: Cluster", + "apiVersion: kind.x-k8s.io/v1alpha4", + "name: test-controller", + "disableDefaultCNI: true", + "podSubnet: 192.168.0.0/16", + "role: control-plane", + "image: kindest/node:v1.25.11", + }, + notExpectedContents: []string{ + "extraPortMappings", + "containerPort", + }, + }, + { + name: "enterprise controller template", + template: kubesliceEntControllerTemplate, + clusterName: "ent-controller", + expectedContents: []string{ + "kind: Cluster", + "name: ent-controller", + "extraPortMappings", + "containerPort: 31000", + "hostPort: 8443", + "protocol: TCP", + }, + }, + { + name: "worker template", + template: kubesliceWorkerTemplate, + clusterName: "test-worker", + expectedContents: []string{ + "kind: Cluster", + "name: test-worker", + "kubeadmConfigPatches", + "kubeletExtraArgs", + "kubeslice.io/node-type=gateway", + }, + notExpectedContents: []string{ + "extraPortMappings", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + content := fmt.Sprintf(tt.template, tt.clusterName) + + for _, expected := range tt.expectedContents { + assert.Contains(t, content, expected, + "Template should contain: %s", expected) + } + + for _, notExpected := range tt.notExpectedContents { + assert.NotContains(t, content, notExpected, + "Template should NOT contain: %s", notExpected) + } + }) + } +} From d8e8bd414b9d3acd4c79a6fbe73c3df6f0fad757 Mon Sep 17 00:00:00 2001 From: Alokzh Date: Sat, 1 Nov 2025 15:52:16 +0530 Subject: [PATCH 11/29] Refactored get-network-info.go & added tests for network information gathering Signed-off-by: Alokzh --- pkg/internal/get-network-info.go | 33 +- pkg/internal/get-network-info_test.go | 556 ++++++++++++++++++++++++++ 2 files changed, 573 insertions(+), 16 deletions(-) create mode 100644 pkg/internal/get-network-info_test.go diff --git a/pkg/internal/get-network-info.go b/pkg/internal/get-network-info.go index de8f623..3b6e8e1 100644 --- a/pkg/internal/get-network-info.go +++ b/pkg/internal/get-network-info.go @@ -3,13 +3,16 @@ package internal import ( "bytes" "fmt" - "os" "strings" "time" "github.com/kubeslice/kubeslice-cli/util" ) +var ( + getAllClustersFunc = getAllClusters +) + func GatherNetworkInformation(ApplicationConfiguration *ConfigurationSpecs) { util.Printf("\nFetching Network Address for Clusters...") @@ -24,29 +27,28 @@ func GatherNetworkInformation(ApplicationConfiguration *ConfigurationSpecs) { } func setNodeIPForKindClusters(clusterConfig *ClusterConfiguration) { - clusters := getAllClusters(clusterConfig) + clusters := getAllClustersFunc(clusterConfig) + for _, cluster := range clusters { ip := runDockerInspectForNodeIP(cluster.Name) cluster.NodeIP = ip cluster.ControlPlaneAddress = "https://" + ip + ":6443" util.Printf("%s Fetched Network Address for %s : %s", util.Tick, cluster.Name, ip) - time.Sleep(200 * time.Millisecond) - + util.Sleep(200 * time.Millisecond) } } func runDockerInspectForNodeIP(clusterName string) string { var outB, errB bytes.Buffer - err := util.RunCommandCustomIO("docker", &outB, &errB, true, "inspect", "--format={{.NetworkSettings.Networks.kind.IPAddress}}", fmt.Sprintf("%s-control-plane", clusterName)) + err := util.CommandExecutor.ExecuteWithOutput("docker", &outB, &errB, "inspect", "--format={{.NetworkSettings.Networks.kind.IPAddress}}", fmt.Sprintf("%s-control-plane", clusterName)) if err != nil { - util.Printf("%s Failed to run command\nOutput: %s\nError: %s %v", util.Cross, outB.String(), errB.String(), err) - os.Exit(1) + util.Fatalf("%s Failed to run command\nOutput: %s\nError: %s %v", util.Cross, outB.String(), errB.String(), err) } return strings.TrimSpace(outB.String()) } func setControlPlaneAddress(clusterConfig *ClusterConfiguration) { - for _, cluster := range getAllClusters(clusterConfig) { + for _, cluster := range getAllClustersFunc(clusterConfig) { if cluster.ControlPlaneAddress == "" { ip := _getControlPlaneAddress(cluster) cluster.ControlPlaneAddress = ip @@ -57,16 +59,15 @@ func setControlPlaneAddress(clusterConfig *ClusterConfiguration) { func _getControlPlaneAddress(cluster *Cluster) string { var outB, errB bytes.Buffer - err := util.RunCommandCustomIO("kubectl", &outB, &errB, true, "--context="+cluster.ContextName, "--kubeconfig="+cluster.KubeConfigPath, "config", "view", "--minify=true", "-o", "jsonpath={.clusters[0].cluster.server}") + err := util.CommandExecutor.ExecuteWithOutput("kubectl", &outB, &errB, "--context="+cluster.ContextName, "--kubeconfig="+cluster.KubeConfigPath, "config", "view", "--minify=true", "-o", "jsonpath={.clusters[0].cluster.server}") if err != nil { - util.Printf("%s Failed to run command\nOutput: %s\nError: %s %v", util.Cross, outB.String(), errB.String(), err) - os.Exit(1) + util.Fatalf("%s Failed to run command\nOutput: %s\nError: %s %v", util.Cross, outB.String(), errB.String(), err) } return outB.String() } func setNodeIP(clusterConfig *ClusterConfiguration) { - for _, cluster := range getAllClusters(clusterConfig) { + for _, cluster := range getAllClustersFunc(clusterConfig) { if cluster.NodeIP == "" { ip := _getNodeIP(cluster) cluster.NodeIP = ip @@ -77,14 +78,14 @@ func setNodeIP(clusterConfig *ClusterConfiguration) { func _getNodeIP(cluster *Cluster) string { var outB, errB bytes.Buffer - err := util.RunCommandCustomIO("kubectl", &outB, &errB, true, "--context="+cluster.ContextName, "--kubeconfig="+cluster.KubeConfigPath, "get", "nodes", "-o", "jsonpath={\"ExternalIP=\"}{.items[0].status.addresses[?(@.type==\"ExternalIP\")].address}{\"\\n\"}{\"InternalIP=\"}{.items[0].status.addresses[?(@.type==\"InternalIP\")].address}") + err := util.CommandExecutor.ExecuteWithOutput("kubectl", &outB, &errB, "--context="+cluster.ContextName, "--kubeconfig="+cluster.KubeConfigPath, "get", "nodes", "-o", "jsonpath={\"ExternalIP=\"}{.items[0].status.addresses[?(@.type==\"ExternalIP\")].address}{\"\\n\"}{\"InternalIP=\"}{.items[0].status.addresses[?(@.type==\"InternalIP\")].address}") if err != nil { - util.Printf("%s Failed to run command\nOutput: %s\nError: %s %v", util.Cross, outB.String(), errB.String(), err) - os.Exit(1) + util.Fatalf("%s Failed to run command\nOutput: %s\nError: %s %v", util.Cross, outB.String(), errB.String(), err) } + for _, s := range strings.Split(outB.String(), "\n") { splits := strings.Split(s, "=") - if strings.TrimSpace(splits[1]) != "" { + if len(splits) > 1 && strings.TrimSpace(splits[1]) != "" { return strings.TrimSpace(splits[1]) } } diff --git a/pkg/internal/get-network-info_test.go b/pkg/internal/get-network-info_test.go new file mode 100644 index 0000000..61aa58d --- /dev/null +++ b/pkg/internal/get-network-info_test.go @@ -0,0 +1,556 @@ +package internal + +import ( + "errors" + "fmt" + "io" + "strings" + "testing" + "time" + + "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGatherNetworkInformation(t *testing.T) { + tests := []struct { + name string + config *ConfigurationSpecs + mockExecutor func(*util.FakeExecutor) + mockGetAllClusters func(*ClusterConfiguration) []*Cluster + expectFatal bool + validateClusters func(*testing.T, *ConfigurationSpecs) + }{ + { + name: "kind cluster network setup", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ClusterType: "kind", + ControllerCluster: Cluster{ + Name: "controller", + }, + WorkerClusters: []Cluster{ + {Name: "worker-1"}, + }, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + if cli == "docker" { + stdout.Write([]byte("172.18.0.2")) + } + return nil + } + }, + mockGetAllClusters: func(cc *ClusterConfiguration) []*Cluster { + return []*Cluster{&cc.ControllerCluster, &cc.WorkerClusters[0]} + }, + validateClusters: func(t *testing.T, config *ConfigurationSpecs) { + cc := &config.Configuration.ClusterConfiguration + assert.Equal(t, "172.18.0.2", cc.ControllerCluster.NodeIP) + assert.Equal(t, "https://172.18.0.2:6443", cc.ControllerCluster.ControlPlaneAddress) + assert.Equal(t, "172.18.0.2", cc.WorkerClusters[0].NodeIP) + }, + }, + { + name: "profile set triggers kind-like behavior", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ClusterType: "eks", + Profile: ProfileEntDemo, + ControllerCluster: Cluster{ + Name: "controller", + }, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + if cli == "docker" { + stdout.Write([]byte("172.18.0.3")) + } + return nil + } + }, + mockGetAllClusters: func(cc *ClusterConfiguration) []*Cluster { + return []*Cluster{&cc.ControllerCluster} + }, + validateClusters: func(t *testing.T, config *ConfigurationSpecs) { + cc := &config.Configuration.ClusterConfiguration + assert.Equal(t, "172.18.0.3", cc.ControllerCluster.NodeIP) + assert.Equal(t, "https://172.18.0.3:6443", cc.ControllerCluster.ControlPlaneAddress) + }, + }, + { + name: "non-kind cluster network setup", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ClusterType: "eks", + Profile: "", + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "controller-ctx", + KubeConfigPath: "/path/config", + }, + WorkerClusters: []Cluster{ + { + Name: "worker-1", + ContextName: "worker-ctx", + KubeConfigPath: "/path/config", + }, + }, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + if cli == "kubectl" { + argsStr := strings.Join(args, " ") + if strings.Contains(argsStr, "config view") { + stdout.Write([]byte("https://api.cluster.example.com:6443")) + } else if strings.Contains(argsStr, "get nodes") { + stdout.Write([]byte("ExternalIP=1.2.3.4\nInternalIP=10.0.0.1")) + } + } + return nil + } + }, + mockGetAllClusters: func(cc *ClusterConfiguration) []*Cluster { + return []*Cluster{&cc.ControllerCluster, &cc.WorkerClusters[0]} + }, + validateClusters: func(t *testing.T, config *ConfigurationSpecs) { + cc := &config.Configuration.ClusterConfiguration + assert.Equal(t, "https://api.cluster.example.com:6443", cc.ControllerCluster.ControlPlaneAddress) + assert.Equal(t, "1.2.3.4", cc.ControllerCluster.NodeIP) + }, + }, + { + name: "docker command fails for kind cluster", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ClusterType: "kind", + ControllerCluster: Cluster{ + Name: "controller", + }, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + return errors.New("docker not running") + } + }, + mockGetAllClusters: func(cc *ClusterConfiguration) []*Cluster { + return []*Cluster{&cc.ControllerCluster} + }, + expectFatal: true, + }, + { + name: "kubectl command fails for non-kind cluster", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ClusterType: "eks", + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "ctx", + KubeConfigPath: "/path", + }, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + return errors.New("kubectl error") + } + }, + mockGetAllClusters: func(cc *ClusterConfiguration) []*Cluster { + return []*Cluster{&cc.ControllerCluster} + }, + expectFatal: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + fakeClock := util.SystemClock.(*util.FakeClock) + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + if tt.mockGetAllClusters != nil { + originalGetAllClusters := getAllClustersFunc + getAllClustersFunc = tt.mockGetAllClusters + defer func() { getAllClustersFunc = originalGetAllClusters }() + } + + GatherNetworkInformation(tt.config) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + } else { + assert.Empty(t, fakeOutput.FatalCalls) + if tt.validateClusters != nil { + tt.validateClusters(t, tt.config) + } + + if tt.config.Configuration.ClusterConfiguration.ClusterType == "kind" || + tt.config.Configuration.ClusterConfiguration.Profile != "" { + assert.NotEmpty(t, fakeClock.SleepCalls) + for _, d := range fakeClock.SleepCalls { + assert.Equal(t, 200*time.Millisecond, d) + } + } + } + }) + } +} + +func TestRunDockerInspectForNodeIP(t *testing.T) { + tests := []struct { + name string + clusterName string + mockExecutor func(*util.FakeExecutor) + expectedIP string + expectFatal bool + fatalContains string + }{ + { + name: "successful IP retrieval", + clusterName: "test-cluster", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + assert.Equal(t, "docker", cli) + assert.Contains(t, args, "inspect") + assert.Contains(t, args, "test-cluster-control-plane") + stdout.Write([]byte("172.18.0.5\n")) + return nil + } + }, + expectedIP: "172.18.0.5", + }, + { + name: "IP with whitespace trimmed", + clusterName: "cluster", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stdout.Write([]byte(" 192.168.1.100 \n")) + return nil + } + }, + expectedIP: "192.168.1.100", + }, + { + name: "empty output", + clusterName: "cluster", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stdout.Write([]byte("")) + return nil + } + }, + expectedIP: "", + }, + { + name: "docker command fails", + clusterName: "cluster", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stderr.Write([]byte("container not found")) + return errors.New("exit code 1") + } + }, + expectFatal: true, + fatalContains: "Failed to run command", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + ip := runDockerInspectForNodeIP(tt.clusterName) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + if tt.fatalContains != "" { + callStr := fmt.Sprint(fakeOutput.FatalCalls[0]) + assert.Contains(t, callStr, tt.fatalContains) + } + } else { + assert.Empty(t, fakeOutput.FatalCalls) + assert.Equal(t, tt.expectedIP, ip) + } + }) + } +} + +func TestGetControlPlaneAddress(t *testing.T) { + tests := []struct { + name string + cluster *Cluster + mockExecutor func(*util.FakeExecutor) + expectedAddress string + expectFatal bool + fatalContains string + }{ + { + name: "successful address retrieval", + cluster: &Cluster{ + Name: "test", + ContextName: "test-ctx", + KubeConfigPath: "/path/config", + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + assert.Equal(t, "kubectl", cli) + assert.Contains(t, args, "--context=test-ctx") + assert.Contains(t, args, "--kubeconfig=/path/config") + stdout.Write([]byte("https://api.example.com:6443")) + return nil + } + }, + expectedAddress: "https://api.example.com:6443", + }, + { + name: "empty address returned", + cluster: &Cluster{ + Name: "test", + ContextName: "test-ctx", + KubeConfigPath: "/path/config", + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stdout.Write([]byte("")) + return nil + } + }, + expectedAddress: "", + }, + { + name: "kubectl command fails", + cluster: &Cluster{ + Name: "test", + ContextName: "test-ctx", + KubeConfigPath: "/path/config", + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stderr.Write([]byte("context not found")) + return errors.New("context not found") + } + }, + expectFatal: true, + fatalContains: "Failed to run command", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + address := _getControlPlaneAddress(tt.cluster) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + if tt.fatalContains != "" { + callStr := fmt.Sprint(fakeOutput.FatalCalls[0]) + assert.Contains(t, callStr, tt.fatalContains) + } + } else { + assert.Empty(t, fakeOutput.FatalCalls) + assert.Equal(t, tt.expectedAddress, address) + } + }) + } +} + +func TestSetControlPlaneAddress(t *testing.T) { + tests := []struct { + name string + clusterConfig *ClusterConfiguration + mockExecutor func(*util.FakeExecutor) + mockGetAllClusters func(*ClusterConfiguration) []*Cluster + validateClusters func(*testing.T, *ClusterConfiguration) + }{ + { + name: "skips cluster with existing control plane address", + clusterConfig: &ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "ctx", + KubeConfigPath: "/path", + ControlPlaneAddress: "https://existing.com:6443", + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + t.Fatal("Should not call kubectl when address already exists") + return nil + } + }, + mockGetAllClusters: func(cc *ClusterConfiguration) []*Cluster { + return []*Cluster{&cc.ControllerCluster} + }, + validateClusters: func(t *testing.T, cc *ClusterConfiguration) { + assert.Equal(t, "https://existing.com:6443", cc.ControllerCluster.ControlPlaneAddress) + }, + }, + { + name: "fetches address for cluster without one", + clusterConfig: &ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "ctx", + KubeConfigPath: "/path", + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stdout.Write([]byte("https://new.com:6443")) + return nil + } + }, + mockGetAllClusters: func(cc *ClusterConfiguration) []*Cluster { + return []*Cluster{&cc.ControllerCluster} + }, + validateClusters: func(t *testing.T, cc *ClusterConfiguration) { + assert.Equal(t, "https://new.com:6443", cc.ControllerCluster.ControlPlaneAddress) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + if tt.mockGetAllClusters != nil { + originalGetAllClusters := getAllClustersFunc + getAllClustersFunc = tt.mockGetAllClusters + defer func() { getAllClustersFunc = originalGetAllClusters }() + } + + setControlPlaneAddress(tt.clusterConfig) + + if tt.validateClusters != nil { + tt.validateClusters(t, tt.clusterConfig) + } + }) + } +} + +func TestSetNodeIP(t *testing.T) { + tests := []struct { + name string + clusterConfig *ClusterConfiguration + mockExecutor func(*util.FakeExecutor) + mockGetAllClusters func(*ClusterConfiguration) []*Cluster + validateClusters func(*testing.T, *ClusterConfiguration) + }{ + { + name: "skips cluster with existing node IP", + clusterConfig: &ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "ctx", + KubeConfigPath: "/path", + NodeIP: "1.2.3.4", + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + t.Fatal("Should not call kubectl when IP already exists") + return nil + } + }, + mockGetAllClusters: func(cc *ClusterConfiguration) []*Cluster { + return []*Cluster{&cc.ControllerCluster} + }, + validateClusters: func(t *testing.T, cc *ClusterConfiguration) { + assert.Equal(t, "1.2.3.4", cc.ControllerCluster.NodeIP) + }, + }, + { + name: "fetches IP for cluster without one", + clusterConfig: &ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "ctx", + KubeConfigPath: "/path", + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stdout.Write([]byte("ExternalIP=5.6.7.8\nInternalIP=10.0.0.1")) + return nil + } + }, + mockGetAllClusters: func(cc *ClusterConfiguration) []*Cluster { + return []*Cluster{&cc.ControllerCluster} + }, + validateClusters: func(t *testing.T, cc *ClusterConfiguration) { + assert.Equal(t, "5.6.7.8", cc.ControllerCluster.NodeIP) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + if tt.mockGetAllClusters != nil { + originalGetAllClusters := getAllClustersFunc + getAllClustersFunc = tt.mockGetAllClusters + defer func() { getAllClustersFunc = originalGetAllClusters }() + } + + setNodeIP(tt.clusterConfig) + + if tt.validateClusters != nil { + tt.validateClusters(t, tt.clusterConfig) + } + }) + } +} From 8a99c9f141f20d14335d1c2974fa88895117054e Mon Sep 17 00:00:00 2001 From: Alokzh Date: Tue, 4 Nov 2025 11:29:26 +0530 Subject: [PATCH 12/29] Refactored install-calico.go & added tests for Calico installation & verification Signed-off-by: Alokzh --- pkg/internal/install-calico.go | 32 +- pkg/internal/install-calico_test.go | 476 ++++++++++++++++++++++++++++ 2 files changed, 498 insertions(+), 10 deletions(-) create mode 100644 pkg/internal/install-calico_test.go diff --git a/pkg/internal/install-calico.go b/pkg/internal/install-calico.go index db01cd5..f369dee 100644 --- a/pkg/internal/install-calico.go +++ b/pkg/internal/install-calico.go @@ -2,13 +2,23 @@ package internal import ( "bytes" - "log" "strings" "time" "github.com/kubeslice/kubeslice-cli/util" ) +const ( + calicoOperatorURL = "https://raw.githubusercontent.com/projectcalico/calico/v3.24.0/manifests/tigera-operator.yaml" + calicoCustomResourceURL = "https://raw.githubusercontent.com/projectcalico/calico/v3.24.0/manifests/custom-resources.yaml" + calicoNamespace = "calico-system" +) + +// Function variables for testing +var ( + podVerificationFuncCalico = PodVerification +) + func InstallCalico(clusterConfig *ClusterConfiguration) { util.Printf("\nInstalling Calico Networking...") @@ -18,14 +28,14 @@ func InstallCalico(clusterConfig *ClusterConfiguration) { util.Printf("Installing on Cluster %s", cluster.Name) installCalicoOperatorPrerequisites(cluster) util.Printf("%s Successfully applied Calico Operator Prerequisites on Cluster %s", util.Tick, cluster.Name) - time.Sleep(200 * time.Millisecond) + util.Sleep(200 * time.Millisecond) createCalicoOperator(cluster) util.Printf("%s Successfully installed Calico Operator on Cluster %s", util.Tick, cluster.Name) - time.Sleep(200 * time.Millisecond) + util.Sleep(200 * time.Millisecond) util.Printf("%s Waiting for Calico Pods to be Healthy on Cluster %s...", util.Wait, cluster.Name) - PodVerification("Waiting for Calico Pods to be Healthy", *cluster, "calico-system") + podVerificationFuncCalico("Waiting for Calico Pods to be Healthy", *cluster, calicoNamespace) } } @@ -34,27 +44,29 @@ func InstallCalico(clusterConfig *ClusterConfiguration) { func calicoAlreadyInstalled(cluster *Cluster) bool { var outB, errB bytes.Buffer - err := util.RunCommandCustomIO("kubectl", &outB, &errB, true, "--context="+cluster.ContextName, "--kubeconfig="+cluster.KubeConfigPath, "get", "namespace", "calico-system") + err := util.CommandExecutor.ExecuteWithOutput("kubectl", &outB, &errB, "--context="+cluster.ContextName, "--kubeconfig="+cluster.KubeConfigPath, "get", "namespace", calicoNamespace) + if err != nil { if strings.Contains(errB.String(), "NotFound") { return false } } - PodVerification("Waiting for Calico Pods to be Healthy", *cluster, "calico-system") + + podVerificationFuncCalico("Waiting for Calico Pods to be Healthy", *cluster, calicoNamespace) util.Printf("%s Calico Networking already present on cluster %s", util.Tick, cluster.Name) return true } func installCalicoOperatorPrerequisites(cluster *Cluster) { - err := util.RunCommand("kubectl", "--context="+cluster.ContextName, "--kubeconfig="+cluster.KubeConfigPath, "create", "-f", "https://raw.githubusercontent.com/projectcalico/calico/v3.24.0/manifests/tigera-operator.yaml") + err := util.CommandExecutor.Execute("kubectl", "--context="+cluster.ContextName, "--kubeconfig="+cluster.KubeConfigPath, "create", "-f", calicoOperatorURL) if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } } func createCalicoOperator(cluster *Cluster) { - err := util.RunCommand("kubectl", "--context="+cluster.ContextName, "--kubeconfig="+cluster.KubeConfigPath, "create", "-f", "https://raw.githubusercontent.com/projectcalico/calico/v3.24.0/manifests/custom-resources.yaml") + err := util.CommandExecutor.Execute("kubectl", "--context="+cluster.ContextName, "--kubeconfig="+cluster.KubeConfigPath, "create", "-f", calicoCustomResourceURL) if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } } diff --git a/pkg/internal/install-calico_test.go b/pkg/internal/install-calico_test.go new file mode 100644 index 0000000..cf68f22 --- /dev/null +++ b/pkg/internal/install-calico_test.go @@ -0,0 +1,476 @@ +package internal + +import ( + "errors" + "fmt" + "io" + "testing" + + "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstallCalico(t *testing.T) { + tests := []struct { + name string + clusterConfig *ClusterConfiguration + mockExecutor func(*util.FakeExecutor) + mockPodVerification func(string, Cluster, string) + expectFatal bool + expectedPodVerifications int + }{ + { + name: "install on fresh clusters", + clusterConfig: &ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "controller-ctx", + KubeConfigPath: "/path/config", + }, + WorkerClusters: []Cluster{ + { + Name: "worker-1", + ContextName: "worker-1-ctx", + KubeConfigPath: "/path/config", + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stderr.Write([]byte("Error from server (NotFound): namespaces \"calico-system\" not found")) + return errors.New("not found") + } + }, + mockPodVerification: func(msg string, cluster Cluster, namespace string) { + assert.Equal(t, calicoNamespace, namespace) + }, + expectedPodVerifications: 2, + }, + { + name: "calico already installed on all clusters", + clusterConfig: &ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "controller-ctx", + KubeConfigPath: "/path/config", + }, + WorkerClusters: []Cluster{ + { + Name: "worker-1", + ContextName: "worker-1-ctx", + KubeConfigPath: "/path/config", + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stdout.Write([]byte("NAME STATUS AGE\ncalico-system Active 5d")) + return nil + } + }, + mockPodVerification: func(msg string, cluster Cluster, namespace string) { + // Verification called for existing installation + }, + expectedPodVerifications: 2, + }, + { + name: "mixed installation states", + clusterConfig: &ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "controller-ctx", + KubeConfigPath: "/path/config", + }, + WorkerClusters: []Cluster{ + { + Name: "worker-1", + ContextName: "worker-1-ctx", + KubeConfigPath: "/path/config", + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + checkCount := 0 + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + checkCount++ + if checkCount == 1 { + stdout.Write([]byte("calico-system Active 5d")) + return nil + } + stderr.Write([]byte("NotFound")) + return errors.New("not found") + } + }, + expectedPodVerifications: 2, + }, + { + name: "operator prerequisites installation fails", + clusterConfig: &ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "controller-ctx", + KubeConfigPath: "/path/config", + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return errors.New("failed to apply operator") + } + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stderr.Write([]byte("NotFound")) + return errors.New("not found") + } + }, + expectFatal: true, + }, + { + name: "custom resources creation fails", + clusterConfig: &ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "controller-ctx", + KubeConfigPath: "/path/config", + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + callCount := 0 + fe.ExecuteFunc = func(cli string, args ...string) error { + callCount++ + if callCount == 1 { + return nil + } + return errors.New("failed to create custom resources") + } + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stderr.Write([]byte("NotFound")) + return errors.New("not found") + } + }, + expectFatal: true, + }, + { + name: "multiple worker clusters", + clusterConfig: &ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "controller-ctx", + KubeConfigPath: "/path/config", + }, + WorkerClusters: []Cluster{ + { + Name: "worker-1", + ContextName: "worker-1-ctx", + KubeConfigPath: "/path/config", + }, + { + Name: "worker-2", + ContextName: "worker-2-ctx", + KubeConfigPath: "/path/config", + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return nil + } + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stderr.Write([]byte("NotFound")) + return errors.New("not found") + } + }, + expectedPodVerifications: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + podVerificationCallCount := 0 + originalPodVerify := podVerificationFuncCalico + podVerificationFuncCalico = func(msg string, cluster Cluster, namespace string) { + podVerificationCallCount++ + if tt.mockPodVerification != nil { + tt.mockPodVerification(msg, cluster, namespace) + } + } + defer func() { podVerificationFuncCalico = originalPodVerify }() + + InstallCalico(tt.clusterConfig) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + } else { + assert.Empty(t, fakeOutput.FatalCalls) + assert.Equal(t, tt.expectedPodVerifications, podVerificationCallCount) + } + }) + } +} + +func TestCalicoAlreadyInstalled(t *testing.T) { + tests := []struct { + name string + cluster *Cluster + mockExecutor func(*util.FakeExecutor) + mockPodVerification func(string, Cluster, string) + expectedInstalled bool + }{ + { + name: "calico namespace exists", + cluster: &Cluster{ + Name: "test-cluster", + ContextName: "test-ctx", + KubeConfigPath: "/path/config", + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stdout.Write([]byte("NAME STATUS AGE\ncalico-system Active 5d")) + return nil + } + }, + mockPodVerification: func(msg string, cluster Cluster, namespace string) { + assert.Equal(t, calicoNamespace, namespace) + assert.Equal(t, "test-cluster", cluster.Name) + }, + expectedInstalled: true, + }, + { + name: "calico namespace not found", + cluster: &Cluster{ + Name: "test-cluster", + ContextName: "test-ctx", + KubeConfigPath: "/path/config", + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stderr.Write([]byte("Error from server (NotFound): namespaces \"calico-system\" not found")) + return errors.New("not found") + } + }, + expectedInstalled: false, + }, + { + name: "empty output treated as installed", + cluster: &Cluster{ + Name: "test-cluster", + ContextName: "test-ctx", + KubeConfigPath: "/path/config", + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stdout.Write([]byte("")) + return nil + } + }, + mockPodVerification: func(msg string, cluster Cluster, namespace string) { + }, + expectedInstalled: true, + }, + { + name: "non-NotFound error treated as installed", + cluster: &Cluster{ + Name: "test-cluster", + ContextName: "test-ctx", + KubeConfigPath: "/path/config", + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stderr.Write([]byte("connection refused")) + return errors.New("connection error") + } + }, + mockPodVerification: func(msg string, cluster Cluster, namespace string) { + }, + expectedInstalled: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + podVerifyCalled := false + originalPodVerify := podVerificationFuncCalico + podVerificationFuncCalico = func(msg string, cluster Cluster, namespace string) { + podVerifyCalled = true + if tt.mockPodVerification != nil { + tt.mockPodVerification(msg, cluster, namespace) + } + } + defer func() { podVerificationFuncCalico = originalPodVerify }() + + result := calicoAlreadyInstalled(tt.cluster) + + assert.Equal(t, tt.expectedInstalled, result) + + if tt.expectedInstalled { + assert.True(t, podVerifyCalled, "Expected pod verification to be called") + } + }) + } +} + +func TestInstallCalicoOperatorPrerequisites(t *testing.T) { + tests := []struct { + name string + cluster *Cluster + mockExecutor func(*util.FakeExecutor) + expectFatal bool + fatalContains string + }{ + { + name: "successful prerequisites installation", + cluster: &Cluster{ + Name: "test-cluster", + ContextName: "test-ctx", + KubeConfigPath: "/path/config", + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + assert.Equal(t, "kubectl", cli) + assert.Contains(t, args, "--context=test-ctx") + assert.Contains(t, args, "--kubeconfig=/path/config") + assert.Contains(t, args, "create") + assert.Contains(t, args, "-f") + assert.Contains(t, args, calicoOperatorURL) + return nil + } + }, + }, + { + name: "kubectl create fails", + cluster: &Cluster{ + Name: "test-cluster", + ContextName: "test-ctx", + KubeConfigPath: "/path/config", + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return errors.New("unable to apply manifest") + } + }, + expectFatal: true, + fatalContains: "Process failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + installCalicoOperatorPrerequisites(tt.cluster) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + if tt.fatalContains != "" { + callStr := fmt.Sprint(fakeOutput.FatalCalls[0]) + assert.Contains(t, callStr, tt.fatalContains) + } + } else { + assert.Empty(t, fakeOutput.FatalCalls) + } + }) + } +} + +func TestCreateCalicoOperator(t *testing.T) { + tests := []struct { + name string + cluster *Cluster + mockExecutor func(*util.FakeExecutor) + expectFatal bool + fatalContains string + }{ + { + name: "successful operator creation", + cluster: &Cluster{ + Name: "test-cluster", + ContextName: "test-ctx", + KubeConfigPath: "/path/config", + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + assert.Equal(t, "kubectl", cli) + assert.Contains(t, args, "--context=test-ctx") + assert.Contains(t, args, "--kubeconfig=/path/config") + assert.Contains(t, args, "create") + assert.Contains(t, args, "-f") + assert.Contains(t, args, calicoCustomResourceURL) + return nil + } + }, + }, + { + name: "kubectl create fails", + cluster: &Cluster{ + Name: "test-cluster", + ContextName: "test-ctx", + KubeConfigPath: "/path/config", + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return errors.New("failed to create custom resources") + } + }, + expectFatal: true, + fatalContains: "Process failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + createCalicoOperator(tt.cluster) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + if tt.fatalContains != "" { + callStr := fmt.Sprint(fakeOutput.FatalCalls[0]) + assert.Contains(t, callStr, tt.fatalContains) + } + } else { + assert.Empty(t, fakeOutput.FatalCalls) + } + }) + } +} From 08ea708eb3ac8582f8b204a6b949af0576a05e92 Mon Sep 17 00:00:00 2001 From: Alokzh Date: Tue, 4 Nov 2025 23:47:05 +0530 Subject: [PATCH 13/29] Refactored install-iperf.go & added tests for iPerf installation & verification Signed-off-by: Alokzh --- pkg/internal/install-iperf.go | 38 ++- pkg/internal/install-iperf_test.go | 434 +++++++++++++++++++++++++++++ 2 files changed, 457 insertions(+), 15 deletions(-) create mode 100644 pkg/internal/install-iperf_test.go diff --git a/pkg/internal/install-iperf.go b/pkg/internal/install-iperf.go index 06c5ad2..6028604 100644 --- a/pkg/internal/install-iperf.go +++ b/pkg/internal/install-iperf.go @@ -1,7 +1,6 @@ package internal import ( - "log" "time" "github.com/kubeslice/kubeslice-cli/util" @@ -11,6 +10,7 @@ const ( iPerfClientFileName = "iperf-client.yaml" iPerfServerFileName = "iperf-server.yaml" iPerfServerServiceExportFileName = "iperf-server-service-export.yaml" + iPerfNamespace = "iperf" ) const iPerfServiceExportTemplate = ` @@ -118,6 +118,12 @@ spec: privileged: true ` +// Function variables for testing +var ( + applyKubectlManifestFunc = ApplyKubectlManifest + podVerificationFuncIPerf = PodVerification +) + func InstallIPerf(ApplicationConfiguration *ConfigurationSpecs) { util.Printf("\nInstalling iPerf Application...") @@ -126,21 +132,21 @@ func InstallIPerf(ApplicationConfiguration *ConfigurationSpecs) { cc := ApplicationConfiguration.Configuration.ClusterConfiguration wc := cc.WorkerClusters - ApplyKubectlManifest(kubesliceDirectory+"/"+serverFileName, "iperf", &wc[0]) + applyKubectlManifestFunc(kubesliceDirectory+"/"+serverFileName, iPerfNamespace, &wc[0]) util.Printf("%s Applied %s to %s", util.Tick, serverFileName, wc[0].Name) - time.Sleep(200 * time.Millisecond) + util.Sleep(200 * time.Millisecond) util.Printf("%s Waiting for iPerf Server pod to be running...", util.Wait) - PodVerification("Waiting for iPerf Server pod to be running", wc[0], "iperf") + podVerificationFuncIPerf("Waiting for iPerf Server pod to be running", wc[0], iPerfNamespace) util.Printf("%s Successfully installed iPerf Server on %s...", util.Tick, wc[0].Name) for i := 1; i < len(wc); i++ { - ApplyKubectlManifest(kubesliceDirectory+"/"+clientFileName, "iperf", &wc[i]) + applyKubectlManifestFunc(kubesliceDirectory+"/"+clientFileName, iPerfNamespace, &wc[i]) util.Printf("%s Applied %s to %s", util.Tick, clientFileName, wc[i].Name) - time.Sleep(200 * time.Millisecond) + util.Sleep(200 * time.Millisecond) util.Printf("%s Waiting for iPerf Client pod to be running...", util.Wait) - PodVerification("Waiting for iPerf Client pod to be running", wc[i], "iperf") + podVerificationFuncIPerf("Waiting for iPerf Client pod to be running", wc[i], iPerfNamespace) util.Printf("%s Successfully installed iPerf Client on %s...", util.Tick, wc[i].Name) } @@ -151,34 +157,36 @@ func GenerateIPerfManifests() { // --- Client Manifests util.DumpFile(iPerfClientTemplate, kubesliceDirectory+"/"+iPerfClientFileName) util.Printf("%s Generated iPerf Client manifest %s", util.Tick, iPerfClientFileName) - time.Sleep(200 * time.Millisecond) + util.Sleep(200 * time.Millisecond) // --- Server Manifests util.DumpFile(iPerfServerTemplate, kubesliceDirectory+"/"+iPerfServerFileName) util.Printf("%s Generated iPerf Server manifest %s", util.Tick, iPerfServerFileName) - time.Sleep(200 * time.Millisecond) + util.Sleep(200 * time.Millisecond) } func GenerateIPerfServiceExportManifest(ApplicationConfiguration *ConfigurationSpecs) { util.DumpFile(iPerfServiceExportTemplate, kubesliceDirectory+"/"+iPerfServerServiceExportFileName) util.Printf("%s Generated iPerf Server Service Export manifest %s for cluster %s", util.Tick, iPerfServerServiceExportFileName, ApplicationConfiguration.Configuration.ClusterConfiguration.WorkerClusters[0].Name) - time.Sleep(200 * time.Millisecond) + util.Sleep(200 * time.Millisecond) } func ApplyIPerfServiceExportManifest(ApplicationConfiguration *ConfigurationSpecs) { - ApplyKubectlManifest(kubesliceDirectory+"/"+iPerfServerServiceExportFileName, "iperf", &ApplicationConfiguration.Configuration.ClusterConfiguration.WorkerClusters[0]) + applyKubectlManifestFunc(kubesliceDirectory+"/"+iPerfServerServiceExportFileName, iPerfNamespace, &ApplicationConfiguration.Configuration.ClusterConfiguration.WorkerClusters[0]) } func RolloutRestartIPerf(ApplicationConfiguration *ConfigurationSpecs) { clusters := getAllClusters(&ApplicationConfiguration.Configuration.ClusterConfiguration)[1:] - err := util.RunCommand("kubectl", "rollout", "restart", "deployment/iperf-server", "-n", "iperf", "--context="+clusters[0].ContextName, "--kubeconfig="+clusters[0].KubeConfigPath) + + err := util.CommandExecutor.Execute("kubectl", "rollout", "restart", "deployment/iperf-server", "-n", iPerfNamespace, "--context="+clusters[0].ContextName, "--kubeconfig="+clusters[0].KubeConfigPath) if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } + for i := 1; i < len(clusters); i++ { - err = util.RunCommand("kubectl", "rollout", "restart", "deployment/iperf-sleep", "-n", "iperf", "--context="+clusters[i].ContextName, "--kubeconfig="+clusters[i].KubeConfigPath) + err = util.CommandExecutor.Execute("kubectl", "rollout", "restart", "deployment/iperf-sleep", "-n", iPerfNamespace, "--context="+clusters[i].ContextName, "--kubeconfig="+clusters[i].KubeConfigPath) if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } } diff --git a/pkg/internal/install-iperf_test.go b/pkg/internal/install-iperf_test.go new file mode 100644 index 0000000..5d64ffb --- /dev/null +++ b/pkg/internal/install-iperf_test.go @@ -0,0 +1,434 @@ +package internal + +import ( + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstallIPerf(t *testing.T) { + tests := []struct { + name string + config *ConfigurationSpecs + mockApplyManifest func(string, string, *Cluster) + mockPodVerification func(string, Cluster, string) + expectFatal bool + fatalContains string + expectedManifestCalls int + expectedPodVerifyCalls int + }{ + { + name: "install on two workers (server + client)", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + WorkerClusters: []Cluster{ + {Name: "worker-1", ContextName: "w1-ctx"}, + {Name: "worker-2", ContextName: "w2-ctx"}, + }, + }, + }, + }, + mockApplyManifest: func(manifestPath string, namespace string, cluster *Cluster) { + assert.Equal(t, iPerfNamespace, namespace) + if strings.Contains(cluster.Name, "worker-1") { + assert.Contains(t, manifestPath, iPerfServerFileName, "Should apply server to worker-1") + } + if strings.Contains(cluster.Name, "worker-2") { + assert.Contains(t, manifestPath, iPerfClientFileName, "Should apply client to worker-2") + } + }, + mockPodVerification: func(msg string, cluster Cluster, namespace string) { + assert.Equal(t, iPerfNamespace, namespace) + if strings.Contains(cluster.Name, "worker-1") { + assert.Contains(t, msg, "iPerf Server") + } + if strings.Contains(cluster.Name, "worker-2") { + assert.Contains(t, msg, "iPerf Client") + } + }, + expectedManifestCalls: 2, + expectedPodVerifyCalls: 2, + }, + { + name: "install on multiple workers (1 server + 2 clients)", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + WorkerClusters: []Cluster{ + {Name: "worker-1"}, + {Name: "worker-2"}, + {Name: "worker-3"}, + }, + }, + }, + }, + mockApplyManifest: func(manifestPath string, namespace string, cluster *Cluster) { + assert.Equal(t, iPerfNamespace, namespace) + if cluster.Name == "worker-1" { + assert.Contains(t, manifestPath, iPerfServerFileName) + } else { + assert.Contains(t, manifestPath, iPerfClientFileName) + } + }, + mockPodVerification: func(msg string, cluster Cluster, namespace string) { + assert.Equal(t, iPerfNamespace, namespace) + }, + expectedManifestCalls: 3, + expectedPodVerifyCalls: 3, + }, + { + name: "single worker - server only", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + WorkerClusters: []Cluster{ + {Name: "worker-1"}, + }, + }, + }, + }, + mockApplyManifest: func(manifestPath string, namespace string, cluster *Cluster) { + assert.Equal(t, iPerfNamespace, namespace) + assert.Equal(t, "worker-1", cluster.Name) + assert.Contains(t, manifestPath, iPerfServerFileName) + }, + mockPodVerification: func(msg string, cluster Cluster, namespace string) { + assert.Equal(t, "worker-1", cluster.Name) + assert.Contains(t, msg, "iPerf Server") + }, + expectedManifestCalls: 1, + expectedPodVerifyCalls: 1, + }, + { + name: "server manifest apply fails", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + WorkerClusters: []Cluster{{Name: "worker-1"}}, + }, + }, + }, + mockApplyManifest: func(manifestPath string, namespace string, cluster *Cluster) { + util.Fatalf("Process failed %v", errors.New("apply failed")) + }, + expectFatal: true, + fatalContains: "apply failed", + expectedManifestCalls: 1, + expectedPodVerifyCalls: 0, + }, + { + name: "server pod verification fails", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + WorkerClusters: []Cluster{{Name: "worker-1"}}, + }, + }, + }, + mockApplyManifest: func(manifestPath string, namespace string, cluster *Cluster) { + }, + mockPodVerification: func(msg string, cluster Cluster, namespace string) { + util.Fatalf("Process failed %v", errors.New("pod timeout")) + }, + expectFatal: true, + fatalContains: "pod timeout", + expectedManifestCalls: 1, + expectedPodVerifyCalls: 1, + }, + { + name: "client manifest apply fails", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + WorkerClusters: []Cluster{{Name: "worker-1"}, {Name: "worker-2"}}, + }, + }, + }, + mockApplyManifest: func(manifestPath string, namespace string, cluster *Cluster) { + if cluster.Name == "worker-2" { + util.Fatalf("Process failed %v", errors.New("client apply failed")) + } + }, + mockPodVerification: func(msg string, cluster Cluster, namespace string) { + // Server pod verification succeeds + }, + expectFatal: true, + fatalContains: "client apply failed", + expectedManifestCalls: 2, + expectedPodVerifyCalls: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeOutput := util.Output.(*util.FakeOutput) + fakeClock := util.SystemClock.(*util.FakeClock) + + manifestCallCount := 0 + originalApplyManifest := applyKubectlManifestFunc + applyKubectlManifestFunc = func(manifestPath string, namespace string, cluster *Cluster) { + manifestCallCount++ + if tt.mockApplyManifest != nil { + tt.mockApplyManifest(manifestPath, namespace, cluster) + } + } + defer func() { applyKubectlManifestFunc = originalApplyManifest }() + + podVerifyCallCount := 0 + originalPodVerify := podVerificationFuncIPerf + podVerificationFuncIPerf = func(msg string, cluster Cluster, namespace string) { + podVerifyCallCount++ + if tt.mockPodVerification != nil { + tt.mockPodVerification(msg, cluster, namespace) + } + } + defer func() { podVerificationFuncIPerf = originalPodVerify }() + + InstallIPerf(tt.config) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls, "Expected Fatalf to be called") + if tt.fatalContains != "" { + callStr := fmt.Sprint(fakeOutput.FatalCalls[0]) + assert.Contains(t, callStr, tt.fatalContains) + } + } else { + assert.Empty(t, fakeOutput.FatalCalls, "Expected no Fatalf calls") + assert.Equal(t, tt.expectedManifestCalls, manifestCallCount, "Manifest apply call count mismatch") + assert.Equal(t, tt.expectedPodVerifyCalls, podVerifyCallCount, "Pod verification call count mismatch") + expectedSleepCalls := tt.expectedManifestCalls + assert.Len(t, fakeClock.SleepCalls, expectedSleepCalls) + for _, d := range fakeClock.SleepCalls { + assert.Equal(t, 200*time.Millisecond, d) + } + } + }) + } +} + +func TestGenerateIPerfManifests(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeFS := util.FileSystem.(*util.FakeFileSystem) + fakeClock := util.SystemClock.(*util.FakeClock) + + GenerateIPerfManifests() + + // Verify both files were created + clientFile := kubesliceDirectory + "/" + iPerfClientFileName + serverFile := kubesliceDirectory + "/" + iPerfServerFileName + + clientContent, clientExists := fakeFS.WrittenFiles[clientFile] + serverContent, serverExists := fakeFS.WrittenFiles[serverFile] + + require.True(t, clientExists, "Client manifest should be created") + require.True(t, serverExists, "Server manifest should be created") + + assert.Contains(t, string(clientContent), "name: iperf-sleep") + assert.Contains(t, string(clientContent), "kind: Namespace") + assert.Contains(t, string(serverContent), "name: iperf-server") + assert.Contains(t, string(serverContent), "containerPort: 5201") + assert.Len(t, fakeClock.SleepCalls, 2) + assert.Equal(t, 200*time.Millisecond, fakeClock.SleepCalls[0]) + assert.Equal(t, 200*time.Millisecond, fakeClock.SleepCalls[1]) +} + +func TestGenerateIPerfServiceExportManifest(t *testing.T) { + config := &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + WorkerClusters: []Cluster{ + {Name: "worker-1"}, + }, + }, + }, + } + + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeFS := util.FileSystem.(*util.FakeFileSystem) + fakeClock := util.SystemClock.(*util.FakeClock) + + GenerateIPerfServiceExportManifest(config) + + exportFile := kubesliceDirectory + "/" + iPerfServerServiceExportFileName + content, exists := fakeFS.WrittenFiles[exportFile] + + require.True(t, exists, "Service export manifest should be created") + + contentStr := string(content) + assert.Contains(t, contentStr, "kind: ServiceExport") + assert.Contains(t, contentStr, "name: iperf-server") + assert.Contains(t, contentStr, "networking.kubeslice.io/v1beta1") + assert.Contains(t, contentStr, "slice: demo") + assert.Len(t, fakeClock.SleepCalls, 1) + assert.Equal(t, 200*time.Millisecond, fakeClock.SleepCalls[0]) +} + +func TestApplyIPerfServiceExportManifest(t *testing.T) { + config := &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + WorkerClusters: []Cluster{ + {Name: "worker-1", ContextName: "w1-ctx"}, + {Name: "worker-2", ContextName: "w2-ctx"}, + }, + }, + }, + } + + cleanup := util.NewTestEnvironment() + defer cleanup() + + applyCalled := false + originalApplyManifest := applyKubectlManifestFunc + applyKubectlManifestFunc = func(manifestPath string, namespace string, cluster *Cluster) { + applyCalled = true + assert.Contains(t, manifestPath, iPerfServerServiceExportFileName) + assert.Equal(t, iPerfNamespace, namespace) + assert.Equal(t, "worker-1", cluster.Name, "ServiceExport should only be applied to worker-1") + } + defer func() { applyKubectlManifestFunc = originalApplyManifest }() + + ApplyIPerfServiceExportManifest(config) + + assert.True(t, applyCalled, "ApplyKubectlManifest should be called") +} + +func TestRolloutRestartIPerf(t *testing.T) { + tests := []struct { + name string + config *ConfigurationSpecs + mockExecutor func(*util.FakeExecutor) + expectFatal bool + fatalContains string + }{ + { + name: "successful rollout restart (1 server, 2 clients)", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: "controller"}, + WorkerClusters: []Cluster{ + {Name: "worker-1", ContextName: "w1-ctx", KubeConfigPath: "/path"}, + {Name: "worker-2", ContextName: "w2-ctx", KubeConfigPath: "/path"}, + {Name: "worker-3", ContextName: "w3-ctx", KubeConfigPath: "/path"}, + }, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + callCount := 0 + fe.ExecuteFunc = func(cli string, args ...string) error { + callCount++ + assert.Equal(t, "kubectl", cli) + assert.Contains(t, args, "rollout") + assert.Contains(t, args, "restart") + assert.Contains(t, args, "-n") + assert.Contains(t, args, iPerfNamespace) + assert.Contains(t, args, "--kubeconfig=/path") + + argsStr := strings.Join(args, " ") + if callCount == 1 { + assert.Contains(t, argsStr, "deployment/iperf-server") + assert.Contains(t, argsStr, "--context=w1-ctx") + } else { + assert.Contains(t, argsStr, "deployment/iperf-sleep") + if callCount == 2 { + assert.Contains(t, argsStr, "--context=w2-ctx") + } else if callCount == 3 { + assert.Contains(t, argsStr, "--context=w3-ctx") + } + } + return nil + } + }, + }, + { + name: "server restart fails", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: "controller"}, + WorkerClusters: []Cluster{ + {Name: "worker-1", ContextName: "w1-ctx", KubeConfigPath: "/path"}, + }, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return errors.New("deployment not found") + } + }, + expectFatal: true, + fatalContains: "deployment not found", + }, + { + name: "client restart fails", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: "controller"}, + WorkerClusters: []Cluster{ + {Name: "worker-1", ContextName: "w1-ctx", KubeConfigPath: "/path"}, + {Name: "worker-2", ContextName: "w2-ctx", KubeConfigPath: "/path"}, + }, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor) { + callCount := 0 + fe.ExecuteFunc = func(cli string, args ...string) error { + callCount++ + if callCount == 1 { + return nil + } + return errors.New("client deployment not found") + } + }, + expectFatal: true, + fatalContains: "client deployment not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + RolloutRestartIPerf(tt.config) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + if tt.fatalContains != "" { + callStr := fmt.Sprint(fakeOutput.FatalCalls[0]) + assert.Contains(t, callStr, tt.fatalContains) + } + } else { + assert.Empty(t, fakeOutput.FatalCalls) + expectedCalls := len(tt.config.Configuration.ClusterConfiguration.WorkerClusters) + require.Len(t, fakeExec.Calls, expectedCalls) + } + }) + } +} From ebaa312fb7221d1d74faf51cfae0217be8538a52 Mon Sep 17 00:00:00 2001 From: Alokzh Date: Wed, 5 Nov 2025 19:51:52 +0530 Subject: [PATCH 14/29] Refactored kind-clusters.go & added tests for cluster creation, deletion & kubeconfig management Signed-off-by: Alokzh --- pkg/internal/kind-clusters.go | 36 +- pkg/internal/kind-clusters_test.go | 550 +++++++++++++++++++++++++++++ 2 files changed, 573 insertions(+), 13 deletions(-) create mode 100644 pkg/internal/kind-clusters_test.go diff --git a/pkg/internal/kind-clusters.go b/pkg/internal/kind-clusters.go index 078dd3f..5d501a1 100644 --- a/pkg/internal/kind-clusters.go +++ b/pkg/internal/kind-clusters.go @@ -4,7 +4,6 @@ import ( "bytes" "errors" "fmt" - "log" "os" "strings" "time" @@ -14,6 +13,11 @@ import ( const KubeconfigPath = kubesliceDirectory + "/kubeconfig.yaml" +// Function variables for testing +var ( + setEnvFunc = os.Setenv +) + func CreateKindClusters(ApplicationConfiguration *ConfigurationSpecs) { clusters := getAllClusters(&ApplicationConfiguration.Configuration.ClusterConfiguration) @@ -25,7 +29,7 @@ func CreateKindClusters(ApplicationConfiguration *ConfigurationSpecs) { created = true createKindCluster(cluster.Name + ".yaml") util.Printf("%s Created Kind Cluster : %s", util.Tick, cluster.Name) - time.Sleep(200 * time.Millisecond) + util.Sleep(200 * time.Millisecond) } } if !created { @@ -36,23 +40,28 @@ func CreateKindClusters(ApplicationConfiguration *ConfigurationSpecs) { } func SetKubeConfigPath() { - os.Setenv("KUBECONFIG", KubeconfigPath) + err := setEnvFunc("KUBECONFIG", KubeconfigPath) + if err != nil { + util.Printf("%s Warning: Failed to set KUBECONFIG environment variable: %v", util.Warn, err) + } } func CreateKubeConfig() { - if _, err := os.Stat(KubeconfigPath); errors.Is(err, os.ErrNotExist) { + _, err := util.FileSystem.Stat(KubeconfigPath) + if errors.Is(err, os.ErrNotExist) { util.DumpFile("", KubeconfigPath) util.Printf("%s Created Empty KubeConfig file : %s", util.Tick, KubeconfigPath) - time.Sleep(200 * time.Millisecond) + util.Sleep(200 * time.Millisecond) } } func getExistingClusters(clusters []*Cluster) []bool { - result := make([]bool, len(clusters), len(clusters)) + result := make([]bool, len(clusters)) + var outB, errB bytes.Buffer - err := util.RunCommandCustomIO("kind", &outB, &errB, true, "get", "clusters") + err := util.CommandExecutor.ExecuteWithOutput("kind", &outB, &errB, "get", "clusters") if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } for i, cluster := range clusters { for _, line := range strings.Split(outB.String(), "\n") { @@ -66,16 +75,17 @@ func getExistingClusters(clusters []*Cluster) []bool { } func createKindCluster(configFile string) { - err := util.RunCommandOnStdIO("kind", "create", "cluster", fmt.Sprintf("--config=%s/%s/%s", kubesliceDirectory, kindSubDirectory, configFile)) + err := util.CommandExecutor.Execute("kind", "create", "cluster", fmt.Sprintf("--config=%s/%s/%s", kubesliceDirectory, kindSubDirectory, configFile)) if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } } func DeleteKindClusters(ApplicationConfiguration *ConfigurationSpecs) { clusters := getAllClusters(&ApplicationConfiguration.Configuration.ClusterConfiguration) existingClusters := getExistingClusters(clusters) - args := make([]string, 0, 0) + + args := make([]string, 0) args = append(args, "delete", "clusters") cNames := make([]string, 0) for i, cluster := range clusters { @@ -88,9 +98,9 @@ func DeleteKindClusters(ApplicationConfiguration *ConfigurationSpecs) { return } args = append(args, cNames...) - err := util.RunCommand("kind", args...) + err := util.CommandExecutor.Execute("kind", args...) if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } } diff --git a/pkg/internal/kind-clusters_test.go b/pkg/internal/kind-clusters_test.go new file mode 100644 index 0000000..6c6460d --- /dev/null +++ b/pkg/internal/kind-clusters_test.go @@ -0,0 +1,550 @@ +package internal + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateKindClusters(t *testing.T) { + tests := []struct { + name string + config *ConfigurationSpecs + mockExecutor func(*util.FakeExecutor, *[]string) + expectCreatedClusters []string + expectedSleeps int + expectFatal bool + fatalContains string + clustersToGetFromKind string + }{ + { + name: "create new clusters (controller + 2 workers)", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: "controller"}, + WorkerClusters: []Cluster{ + {Name: "worker-1"}, + {Name: "worker-2"}, + }, + }, + }, + }, + clustersToGetFromKind: "", + mockExecutor: func(fe *util.FakeExecutor, created *[]string) { + fe.ExecuteFunc = func(cli string, args ...string) error { + assert.Equal(t, "kind", cli) + require.Len(t, args, 3, "Expected 3 arguments for 'kind create cluster'") + assert.Equal(t, "create", args[0]) + assert.Equal(t, "cluster", args[1]) + configArg := args[2] + assert.True(t, strings.HasPrefix(configArg, "--config="), "Expected --config= flag") + + parts := strings.Split(configArg, "/") + fileNameWithExt := parts[len(parts)-1] + fileName := strings.TrimSuffix(fileNameWithExt, ".yaml") + *created = append(*created, fileName) + return nil + } + }, + expectCreatedClusters: []string{"controller", "worker-1", "worker-2"}, + expectedSleeps: 3, + }, + { + name: "clusters already exist", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: "controller"}, + WorkerClusters: []Cluster{ + {Name: "worker-1"}, + }, + }, + }, + }, + clustersToGetFromKind: "controller\nworker-1\n", + mockExecutor: func(fe *util.FakeExecutor, created *[]string) {}, + expectCreatedClusters: []string{}, + expectedSleeps: 0, + }, + { + name: "partial cluster exists (creates 2 workers)", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: "controller"}, + WorkerClusters: []Cluster{ + {Name: "worker-1"}, + {Name: "worker-2"}, + }, + }, + }, + }, + clustersToGetFromKind: "controller\n", + mockExecutor: func(fe *util.FakeExecutor, created *[]string) { + fe.ExecuteFunc = func(cli string, args ...string) error { + assert.Equal(t, "kind", cli) + require.Len(t, args, 3, "Expected 3 arguments for 'kind create cluster'") + configArg := args[2] + parts := strings.Split(configArg, "/") + fileNameWithExt := parts[len(parts)-1] + fileName := strings.TrimSuffix(fileNameWithExt, ".yaml") + *created = append(*created, fileName) + return nil + } + }, + expectCreatedClusters: []string{"worker-1", "worker-2"}, + expectedSleeps: 2, + }, + { + name: "kind get clusters fails", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: "controller"}, + }, + }, + }, + clustersToGetFromKind: "", + mockExecutor: func(fe *util.FakeExecutor, created *[]string) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + return errors.New("kind not found") + } + }, + expectFatal: true, + fatalContains: "kind not found", + }, + { + name: "kind create cluster fails", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: "controller"}, + }, + }, + }, + clustersToGetFromKind: "", + mockExecutor: func(fe *util.FakeExecutor, created *[]string) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return errors.New("failed to create cluster") + } + }, + expectFatal: true, + fatalContains: "failed to create cluster", + expectedSleeps: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + fakeClock := util.SystemClock.(*util.FakeClock) + + var createdClusters []string + + fakeExec.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + assert.Equal(t, "kind", cli) + assert.Equal(t, "get", args[0]) + assert.Equal(t, "clusters", args[1]) + stdout.Write([]byte(tt.clustersToGetFromKind)) + return nil + } + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec, &createdClusters) + } + + CreateKindClusters(tt.config) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls, "Expected a fatal error") + if tt.fatalContains != "" { + callStr := fmt.Sprint(fakeOutput.FatalCalls[0]) + assert.Contains(t, callStr, tt.fatalContains) + } + } else { + assert.Empty(t, fakeOutput.FatalCalls, "Expected no fatal errors") + + assert.ElementsMatch(t, tt.expectCreatedClusters, createdClusters, "Mismatch in clusters created") + + assert.Len(t, fakeClock.SleepCalls, tt.expectedSleeps) + for _, d := range fakeClock.SleepCalls { + assert.Equal(t, 200*time.Millisecond, d) + } + } + }) + } +} + +func TestSetKubeConfigPath(t *testing.T) { + tests := []struct { + name string + mockSetEnv func(string, string) error + expectWarn bool + }{ + { + name: "successful env set", + mockSetEnv: func(key, value string) error { + assert.Equal(t, "KUBECONFIG", key) + assert.Equal(t, KubeconfigPath, value) + return nil + }, + expectWarn: false, + }, + { + name: "env set fails", + mockSetEnv: func(key, value string) error { + return errors.New("permission denied") + }, + expectWarn: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeOutput := util.Output.(*util.FakeOutput) + + originalSetEnv := setEnvFunc + setEnvFunc = tt.mockSetEnv + defer func() { setEnvFunc = originalSetEnv }() + + SetKubeConfigPath() + + if tt.expectWarn { + require.NotEmpty(t, fakeOutput.InfoCalls) + allOutput := strings.Join(fakeOutput.InfoCalls, " ") + assert.Contains(t, allOutput, "Warning: Failed to set KUBECONFIG") + } else { + assert.Empty(t, fakeOutput.InfoCalls) + } + }) + } +} + +func TestCreateKubeConfig(t *testing.T) { + tests := []struct { + name string + fileExists bool + expectCreation bool + }{ + { + name: "file does not exist - create it", + fileExists: false, + expectCreation: true, + }, + { + name: "file already exists - skip creation", + fileExists: true, + expectCreation: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeFS := util.FileSystem.(*util.FakeFileSystem) + fakeClock := util.SystemClock.(*util.FakeClock) + + fakeFS.StatFunc = func(name string) (os.FileInfo, error) { + assert.Equal(t, KubeconfigPath, name) + if tt.fileExists { + return nil, nil + } + return nil, os.ErrNotExist + } + + CreateKubeConfig() + + if tt.expectCreation { + _, exists := fakeFS.WrittenFiles[KubeconfigPath] + assert.True(t, exists, "Expected kubeconfig file to be created") + assert.Len(t, fakeClock.SleepCalls, 1) + assert.Equal(t, 200*time.Millisecond, fakeClock.SleepCalls[0]) + } else { + _, exists := fakeFS.WrittenFiles[KubeconfigPath] + assert.False(t, exists, "Should not create file if it already exists") + assert.Empty(t, fakeClock.SleepCalls, "Should not sleep if file exists") + } + }) + } +} + +func TestGetExistingClusters(t *testing.T) { + tests := []struct { + name string + clusters []*Cluster + mockExecutor func(*util.FakeExecutor) + expectedExisting []bool + expectFatal bool + fatalContains string + }{ + { + name: "all clusters exist", + clusters: []*Cluster{ + {Name: "controller"}, + {Name: "worker-1"}, + {Name: "worker-2"}, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stdout.Write([]byte("controller\nworker-1\nworker-2\n")) + return nil + } + }, + expectedExisting: []bool{true, true, true}, + }, + { + name: "no clusters exist", + clusters: []*Cluster{ + {Name: "controller"}, + {Name: "worker-1"}, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stdout.Write([]byte("")) + return nil + } + }, + expectedExisting: []bool{false, false}, + }, + { + name: "some clusters exist", + clusters: []*Cluster{ + {Name: "controller"}, + {Name: "worker-1"}, + {Name: "worker-2"}, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stdout.Write([]byte("controller\nworker-2\n")) + return nil + } + }, + expectedExisting: []bool{true, false, true}, + }, + { + name: "kind command fails", + clusters: []*Cluster{ + {Name: "controller"}, + }, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + return errors.New("kind not installed") + } + }, + expectFatal: true, + fatalContains: "kind not installed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + result := getExistingClusters(tt.clusters) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + if tt.fatalContains != "" { + callStr := fmt.Sprint(fakeOutput.FatalCalls[0]) + assert.Contains(t, callStr, tt.fatalContains) + } + } else { + assert.Empty(t, fakeOutput.FatalCalls) + assert.Equal(t, tt.expectedExisting, result) + } + }) + } +} + +func TestDeleteKindClusters(t *testing.T) { + tests := []struct { + name string + config *ConfigurationSpecs + mockExecutor func(*util.FakeExecutor, *[]string) + expectDeleted []string + expectFatal bool + fatalContains string + clustersToGetFromKind string + }{ + { + name: "delete existing clusters", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: "controller"}, + WorkerClusters: []Cluster{ + {Name: "worker-1"}, + }, + }, + }, + }, + clustersToGetFromKind: "controller\nworker-1\nworker-2\n", + mockExecutor: func(fe *util.FakeExecutor, deleted *[]string) { + fe.ExecuteFunc = func(cli string, args ...string) error { + assert.Equal(t, "kind", cli) + assert.Equal(t, "delete", args[0]) + assert.Equal(t, "clusters", args[1]) + *deleted = append(*deleted, args[2:]...) + return nil + } + }, + expectDeleted: []string{"controller", "worker-1"}, + }, + { + name: "no clusters to delete", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: "controller"}, + }, + }, + }, + clustersToGetFromKind: "", + mockExecutor: func(fe *util.FakeExecutor, deleted *[]string) {}, + expectDeleted: nil, + }, + { + name: "deletion fails", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: "controller"}, + }, + }, + }, + clustersToGetFromKind: "controller\n", + mockExecutor: func(fe *util.FakeExecutor, deleted *[]string) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return errors.New("failed to delete") + } + }, + expectFatal: true, + fatalContains: "failed to delete", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + + var deletedClusters []string + + fakeExec.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stdout.Write([]byte(tt.clustersToGetFromKind)) + return nil + } + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec, &deletedClusters) + } + + DeleteKindClusters(tt.config) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + callStr := fmt.Sprint(fakeOutput.FatalCalls[0]) + assert.Contains(t, callStr, tt.fatalContains) + } else { + assert.Empty(t, fakeOutput.FatalCalls) + assert.Equal(t, tt.expectDeleted, deletedClusters) + if tt.expectDeleted == nil { + allOutput := strings.Join(fakeOutput.InfoCalls, " ") + assert.Contains(t, allOutput, "No Kind Clusters found for deletion") + } + } + }) + } +} + +func TestGetAllClusters(t *testing.T) { + tests := []struct { + name string + config *ClusterConfiguration + expectedCount int + expectedNames []string + }{ + { + name: "controller and workers", + config: &ClusterConfiguration{ + ControllerCluster: Cluster{Name: "controller"}, + WorkerClusters: []Cluster{ + {Name: "worker-1"}, + {Name: "worker-2"}, + }, + }, + expectedCount: 3, + expectedNames: []string{"controller", "worker-1", "worker-2"}, + }, + { + name: "controller only", + config: &ClusterConfiguration{ + ControllerCluster: Cluster{Name: "controller"}, + WorkerClusters: []Cluster{}, + }, + expectedCount: 1, + expectedNames: []string{"controller"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clusters := getAllClusters(tt.config) + + assert.Len(t, clusters, tt.expectedCount) + + names := make([]string, len(clusters)) + for i, cluster := range clusters { + names[i] = cluster.Name + } + assert.Equal(t, tt.expectedNames, names) + }) + } +} + +func TestGetControllerCluster(t *testing.T) { + config := ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "my-controller", + ContextName: "my-ctx", + }, + WorkerClusters: []Cluster{ + {Name: "worker"}, + }, + } + + controller := getControllerCluster(config) + + require.NotNil(t, controller) + assert.Equal(t, "my-controller", controller.Name) + assert.Equal(t, "my-ctx", controller.ContextName) +} From 544c7f7b1f76f52e00d39349c99ffa8a5bef34ab Mon Sep 17 00:00:00 2001 From: Alokzh Date: Thu, 6 Nov 2025 13:28:56 +0530 Subject: [PATCH 15/29] Refactored project-manifest.go & added tests for KubeSlice project management functions Signed-off-by: Alokzh --- pkg/internal/project-manifest.go | 37 ++- pkg/internal/project-manifest_test.go | 454 ++++++++++++++++++++++++++ 2 files changed, 478 insertions(+), 13 deletions(-) create mode 100644 pkg/internal/project-manifest_test.go diff --git a/pkg/internal/project-manifest.go b/pkg/internal/project-manifest.go index a924e8c..2ee3e50 100644 --- a/pkg/internal/project-manifest.go +++ b/pkg/internal/project-manifest.go @@ -22,55 +22,66 @@ spec: readWrite: %s ` +// Function variables for testing +var ( + applyKubectlManifestFuncProject = ApplyKubectlManifest + getKubectlResourcesFuncProject = GetKubectlResources + deleteKubectlResourcesFuncProject = DeleteKubectlResources + editKubectlResourcesFuncProject = EditKubectlResources + describeKubectlResourcesFuncProject = DescribeKubectlResources +) + func CreateKubeSliceProject(ApplicationConfiguration *ConfigurationSpecs, cliOptions *CliOptionsStruct) { util.Printf("\nCreating KubeSlice Project...") generateKubeSliceProjectManifest(ApplicationConfiguration.Configuration.KubeSliceConfiguration.ProjectName, ApplicationConfiguration.Configuration.KubeSliceConfiguration.ProjectUsers) util.Printf("%s Generated project manifest %s", util.Tick, projectFileName) - time.Sleep(200 * time.Millisecond) + util.SystemClock.Sleep(200 * time.Millisecond) + if cliOptions != nil { if cliOptions.FileName == "" { cliOptions.FileName = kubesliceDirectory + "/" + projectFileName } - ApplyKubectlManifest(cliOptions.FileName, cliOptions.Namespace, cliOptions.Cluster) + applyKubectlManifestFuncProject(cliOptions.FileName, cliOptions.Namespace, cliOptions.Cluster) } else { - ApplyKubectlManifest(kubesliceDirectory+"/"+projectFileName, KUBESLICE_CONTROLLER_NAMESPACE, &ApplicationConfiguration.Configuration.ClusterConfiguration.ControllerCluster) + applyKubectlManifestFuncProject(kubesliceDirectory+"/"+projectFileName, KUBESLICE_CONTROLLER_NAMESPACE, &ApplicationConfiguration.Configuration.ClusterConfiguration.ControllerCluster) } util.Printf("%s Applied %s", util.Tick, projectFileName) - time.Sleep(3 * time.Second) + util.SystemClock.Sleep(3 * time.Second) util.Printf("Created KubeSlice Project.") } func GetKubeSliceProject(projectName string, namespace string, controllerCluster *Cluster) { util.Printf("\nFetching KubeSlice Project...") - GetKubectlResources(ProjectObject, projectName, namespace, controllerCluster, "") - time.Sleep(200 * time.Millisecond) + getKubectlResourcesFuncProject(ProjectObject, projectName, namespace, controllerCluster, "") + util.SystemClock.Sleep(200 * time.Millisecond) } + func generateKubeSliceProjectManifest(projectName string, users []string) { if len(users) == 0 { users = []string{"admin"} } userString := "\n" for _, user := range users { - userString = fmt.Sprintf(`%s - %s%s`, userString, user, "\n") + userString = fmt.Sprintf("%s - %s%s", userString, user, "\n") } util.DumpFile(fmt.Sprintf(kubesliceProjectTemplate, projectName, userString), kubesliceDirectory+"/"+projectFileName) } func DeleteKubeSliceProject(projectName string, namespace string, controllerCluster *Cluster) { util.Printf("\nDeleting KubeSlice Project...") - DeleteKubectlResources(ProjectObject, projectName, namespace, controllerCluster) - time.Sleep(200 * time.Millisecond) + deleteKubectlResourcesFuncProject(ProjectObject, projectName, namespace, controllerCluster) + util.SystemClock.Sleep(200 * time.Millisecond) } func EditKubeSliceProject(projectName string, namespace string, controllerCluster *Cluster) { util.Printf("\nEditing KubeSlice Project...") - EditKubectlResources(ProjectObject, projectName, namespace, controllerCluster) - time.Sleep(200 * time.Millisecond) + editKubectlResourcesFuncProject(ProjectObject, projectName, namespace, controllerCluster) + util.SystemClock.Sleep(200 * time.Millisecond) } func DescribeKubeSliceProject(projectName string, namespace string, controllerCluster *Cluster) { util.Printf("\nDescribe KubeSlice Project...") - DescribeKubectlResources(ProjectObject, projectName, namespace, controllerCluster) - time.Sleep(200 * time.Millisecond) + describeKubectlResourcesFuncProject(ProjectObject, projectName, namespace, controllerCluster) + util.SystemClock.Sleep(200 * time.Millisecond) } diff --git a/pkg/internal/project-manifest_test.go b/pkg/internal/project-manifest_test.go new file mode 100644 index 0000000..1e301d8 --- /dev/null +++ b/pkg/internal/project-manifest_test.go @@ -0,0 +1,454 @@ +package internal + +import ( + "errors" + "fmt" + "testing" + "time" + + "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateKubeSliceProject(t *testing.T) { + tests := []struct { + name string + config *ConfigurationSpecs + cliOptions *CliOptionsStruct + mockApplyManifest func(string, string, *Cluster) + applyShouldFail bool + expectedFileName string + expectedNamespace string + expectedUsers []string + }{ + { + name: "create project with default options", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + KubeSliceConfiguration: KubeSliceConfiguration{ + ProjectName: "test-project", + ProjectUsers: []string{"user1", "user2"}, + }, + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "controller-ctx", + }, + }, + }, + }, + cliOptions: nil, + mockApplyManifest: func(fileName, namespace string, cluster *Cluster) { + assert.Contains(t, fileName, projectFileName) + assert.Equal(t, KUBESLICE_CONTROLLER_NAMESPACE, namespace) + assert.Equal(t, "controller", cluster.Name) + }, + expectedFileName: kubesliceDirectory + "/" + projectFileName, + expectedNamespace: KUBESLICE_CONTROLLER_NAMESPACE, + expectedUsers: []string{"user1", "user2"}, + }, + { + name: "create project with custom CLI options", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + KubeSliceConfiguration: KubeSliceConfiguration{ + ProjectName: "custom-project", + ProjectUsers: []string{"admin"}, + }, + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: "controller"}, + }, + }, + }, + cliOptions: &CliOptionsStruct{ + FileName: "custom-project.yaml", + Namespace: "custom-namespace", + Cluster: &Cluster{ + Name: "custom-cluster", + ContextName: "custom-ctx", + }, + }, + mockApplyManifest: func(fileName, namespace string, cluster *Cluster) { + assert.Equal(t, "custom-project.yaml", fileName) + assert.Equal(t, "custom-namespace", namespace) + assert.Equal(t, "custom-cluster", cluster.Name) + }, + expectedFileName: "custom-project.yaml", + expectedNamespace: "custom-namespace", + expectedUsers: []string{"admin"}, + }, + { + name: "create project with empty filename in CLI options", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + KubeSliceConfiguration: KubeSliceConfiguration{ + ProjectName: "test-project", + ProjectUsers: []string{}, + }, + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: "controller"}, + }, + }, + }, + cliOptions: &CliOptionsStruct{ + FileName: "", + Namespace: "test-namespace", + Cluster: &Cluster{ + Name: "test-cluster", + ContextName: "test-ctx", + }, + }, + mockApplyManifest: func(fileName, namespace string, cluster *Cluster) { + assert.Contains(t, fileName, projectFileName) + assert.Equal(t, "test-namespace", namespace) + }, + expectedFileName: kubesliceDirectory + "/" + projectFileName, + expectedNamespace: "test-namespace", + expectedUsers: []string{"admin"}, + }, + { + name: "apply manifest fails", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + KubeSliceConfiguration: KubeSliceConfiguration{ + ProjectName: "fail-project", + }, + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: "controller"}, + }, + }, + }, + cliOptions: nil, + applyShouldFail: true, + mockApplyManifest: func(fileName, namespace string, cluster *Cluster) { + util.Fatalf("Process failed %v", errors.New("apply failed")) + }, + expectedUsers: []string{"admin"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeFS := util.FileSystem.(*util.FakeFileSystem) + fakeOutput := util.Output.(*util.FakeOutput) + fakeClock := util.SystemClock.(*util.FakeClock) + + applyCalled := false + originalApply := applyKubectlManifestFuncProject + applyKubectlManifestFuncProject = func(fileName, namespace string, cluster *Cluster) { + applyCalled = true + if tt.mockApplyManifest != nil { + tt.mockApplyManifest(fileName, namespace, cluster) + } + } + defer func() { applyKubectlManifestFuncProject = originalApply }() + + CreateKubeSliceProject(tt.config, tt.cliOptions) + + // Verify manifest was generated + manifestPath := kubesliceDirectory + "/" + projectFileName + content, exists := fakeFS.WrittenFiles[manifestPath] + require.True(t, exists, "Project manifest should be generated") + + contentStr := string(content) + assert.Contains(t, contentStr, "kind: Project") + assert.Contains(t, contentStr, "apiVersion: controller.kubeslice.io/v1alpha1") + assert.Contains(t, contentStr, "name: "+tt.config.Configuration.KubeSliceConfiguration.ProjectName) + for _, user := range tt.expectedUsers { + assert.Contains(t, contentStr, user) + } + + assert.True(t, applyCalled, "Apply manifest should be called") + + if tt.applyShouldFail { + require.NotEmpty(t, fakeOutput.FatalCalls) + assert.Contains(t, fmt.Sprint(fakeOutput.FatalCalls[0]), "apply failed") + } else { + assert.Empty(t, fakeOutput.FatalCalls) + require.Len(t, fakeClock.SleepCalls, 2) + assert.Equal(t, 200*time.Millisecond, fakeClock.SleepCalls[0]) + assert.Equal(t, 3*time.Second, fakeClock.SleepCalls[1]) + } + }) + } +} + +func TestGenerateKubeSliceProjectManifest(t *testing.T) { + tests := []struct { + name string + projectName string + users []string + expectedUsers []string + }{ + { + name: "generate with multiple users", + projectName: "test-project", + users: []string{"user1", "user2", "user3"}, + expectedUsers: []string{"user1", "user2", "user3"}, + }, + { + name: "generate with single user", + projectName: "single-user-project", + users: []string{"admin"}, + expectedUsers: []string{"admin"}, + }, + { + name: "generate with no users (default to admin)", + projectName: "default-project", + users: []string{}, + expectedUsers: []string{"admin"}, + }, + { + name: "generate with nil users (default to admin)", + projectName: "nil-users-project", + users: nil, + expectedUsers: []string{"admin"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeFS := util.FileSystem.(*util.FakeFileSystem) + + generateKubeSliceProjectManifest(tt.projectName, tt.users) + + manifestPath := kubesliceDirectory + "/" + projectFileName + content, exists := fakeFS.WrittenFiles[manifestPath] + require.True(t, exists, "Manifest file should be created") + + contentStr := string(content) + assert.Contains(t, contentStr, "kind: Project") + assert.Contains(t, contentStr, "apiVersion: controller.kubeslice.io/v1alpha1") + assert.Contains(t, contentStr, "name: "+tt.projectName) + + for _, user := range tt.expectedUsers { + assert.Contains(t, contentStr, user, "Content should contain user: %s", user) + } + }) + } +} + +func TestGetKubeSliceProject(t *testing.T) { + cluster := &Cluster{Name: "controller", ContextName: "controller-ctx"} + + tests := []struct { + name string + projectName string + namespace string + mockGet func(string, string, string, *Cluster, string) + expectFatal bool + fatalContains string + }{ + { + name: "successful get", + projectName: "my-project", + namespace: "test-namespace", + mockGet: func(resourceType, resourceName, namespace string, c *Cluster, outputFormat string) { + assert.Equal(t, ProjectObject, resourceType) + assert.Equal(t, "my-project", resourceName) + assert.Equal(t, "test-namespace", namespace) + assert.Equal(t, cluster, c) + assert.Equal(t, "", outputFormat) + }, + }, + { + name: "get fails", + projectName: "my-project", + namespace: "test-namespace", + mockGet: func(resourceType, resourceName, namespace string, c *Cluster, outputFormat string) { + util.Fatalf("Process failed %v", errors.New("get failed")) + }, + expectFatal: true, + fatalContains: "get failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeClock := util.SystemClock.(*util.FakeClock) + fakeOutput := util.Output.(*util.FakeOutput) + + originalGet := getKubectlResourcesFuncProject + getKubectlResourcesFuncProject = tt.mockGet + defer func() { getKubectlResourcesFuncProject = originalGet }() + + GetKubeSliceProject(tt.projectName, tt.namespace, cluster) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + assert.Contains(t, fmt.Sprint(fakeOutput.FatalCalls[0]), tt.fatalContains) + } else { + assert.Empty(t, fakeOutput.FatalCalls) + assert.Len(t, fakeClock.SleepCalls, 1) + assert.Equal(t, 200*time.Millisecond, fakeClock.SleepCalls[0]) + } + }) + } +} + +func TestDeleteKubeSliceProject(t *testing.T) { + cluster := &Cluster{Name: "controller"} + + tests := []struct { + name string + mockDelete func(string, string, string, *Cluster) + expectFatal bool + fatalContains string + }{ + { + name: "successful delete", + mockDelete: func(resourceType, resourceName, namespace string, c *Cluster) { + assert.Equal(t, ProjectObject, resourceType) + assert.Equal(t, "my-project", resourceName) + assert.Equal(t, "test-namespace", namespace) + }, + }, + { + name: "delete fails", + mockDelete: func(resourceType, resourceName, namespace string, c *Cluster) { + util.Fatalf("Process failed %v", errors.New("delete failed")) + }, + expectFatal: true, + fatalContains: "delete failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeClock := util.SystemClock.(*util.FakeClock) + fakeOutput := util.Output.(*util.FakeOutput) + + originalDelete := deleteKubectlResourcesFuncProject + deleteKubectlResourcesFuncProject = tt.mockDelete + defer func() { deleteKubectlResourcesFuncProject = originalDelete }() + + DeleteKubeSliceProject("my-project", "test-namespace", cluster) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + assert.Contains(t, fmt.Sprint(fakeOutput.FatalCalls[0]), tt.fatalContains) + } else { + assert.Empty(t, fakeOutput.FatalCalls) + assert.Len(t, fakeClock.SleepCalls, 1) + assert.Equal(t, 200*time.Millisecond, fakeClock.SleepCalls[0]) + } + }) + } +} + +func TestEditKubeSliceProject(t *testing.T) { + cluster := &Cluster{Name: "controller"} + + tests := []struct { + name string + mockEdit func(string, string, string, *Cluster) + expectFatal bool + fatalContains string + }{ + { + name: "successful edit", + mockEdit: func(resourceType, resourceName, namespace string, c *Cluster) { + assert.Equal(t, ProjectObject, resourceType) + assert.Equal(t, "my-project", resourceName) + }, + }, + { + name: "edit fails", + mockEdit: func(resourceType, resourceName, namespace string, c *Cluster) { + util.Fatalf("Process failed %v", errors.New("edit failed")) + }, + expectFatal: true, + fatalContains: "edit failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeClock := util.SystemClock.(*util.FakeClock) + fakeOutput := util.Output.(*util.FakeOutput) + + originalEdit := editKubectlResourcesFuncProject + editKubectlResourcesFuncProject = tt.mockEdit + defer func() { editKubectlResourcesFuncProject = originalEdit }() + + EditKubeSliceProject("my-project", "test-namespace", cluster) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + assert.Contains(t, fmt.Sprint(fakeOutput.FatalCalls[0]), tt.fatalContains) + } else { + assert.Empty(t, fakeOutput.FatalCalls) + assert.Len(t, fakeClock.SleepCalls, 1) + assert.Equal(t, 200*time.Millisecond, fakeClock.SleepCalls[0]) + } + }) + } +} + +func TestDescribeKubeSliceProject(t *testing.T) { + cluster := &Cluster{Name: "controller"} + + tests := []struct { + name string + mockDescribe func(string, string, string, *Cluster) + expectFatal bool + fatalContains string + }{ + { + name: "successful describe", + mockDescribe: func(resourceType, resourceName, namespace string, c *Cluster) { + assert.Equal(t, ProjectObject, resourceType) + assert.Equal(t, "my-project", resourceName) + }, + }, + { + name: "describe fails", + mockDescribe: func(resourceType, resourceName, namespace string, c *Cluster) { + util.Fatalf("Process failed %v", errors.New("describe failed")) + }, + expectFatal: true, + fatalContains: "describe failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeClock := util.SystemClock.(*util.FakeClock) + fakeOutput := util.Output.(*util.FakeOutput) + + originalDescribe := describeKubectlResourcesFuncProject + describeKubectlResourcesFuncProject = tt.mockDescribe + defer func() { describeKubectlResourcesFuncProject = originalDescribe }() + + DescribeKubeSliceProject("my-project", "test-namespace", cluster) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + assert.Contains(t, fmt.Sprint(fakeOutput.FatalCalls[0]), tt.fatalContains) + } else { + assert.Empty(t, fakeOutput.FatalCalls) + assert.Len(t, fakeClock.SleepCalls, 1) + assert.Equal(t, 200*time.Millisecond, fakeClock.SleepCalls[0]) + } + }) + } +} From ea8ef2f0aafd5fbc13a2da50acb5c05d28923e60 Mon Sep 17 00:00:00 2001 From: Alokzh Date: Fri, 7 Nov 2025 11:29:33 +0530 Subject: [PATCH 16/29] Refactored print-next-steps.go to use buildKubectlCommand for command construction & added tests Signed-off-by: Alokzh --- pkg/internal/print-next-steps.go | 128 +++++++-- pkg/internal/print-next-steps_test.go | 398 ++++++++++++++++++++++++++ 2 files changed, 504 insertions(+), 22 deletions(-) create mode 100644 pkg/internal/print-next-steps_test.go diff --git a/pkg/internal/print-next-steps.go b/pkg/internal/print-next-steps.go index 251915b..580ca04 100644 --- a/pkg/internal/print-next-steps.go +++ b/pkg/internal/print-next-steps.go @@ -2,7 +2,7 @@ package internal import ( "fmt" - "os/exec" + "strings" "github.com/kubeslice/kubeslice-cli/util" ) @@ -102,6 +102,29 @@ Note: The DNS propagation may take a minute or two. %s %s ` +// Function variables for testing +var ( + getUIAdminTokenFunc = GetUIAdminToken + getUIEndpointFunc = GetUIEndpoint +) + +// buildKubectlCommand constructs a kubectl command string for display +func buildKubectlCommand(cluster *Cluster, args ...string) string { + kubectlPath := util.ExecutablePaths["kubectl"] + if kubectlPath == "" { + kubectlPath = "kubectl" + } + parts := []string{kubectlPath} + + if cluster != nil { + parts = append(parts, "--context="+cluster.ContextName) + parts = append(parts, "--kubeconfig="+cluster.KubeConfigPath) + } + + parts = append(parts, args...) + return strings.Join(parts, " ") +} + func PrintNextSteps(verificationOnly bool, ApplicationConfiguration *ConfigurationSpecs) { if verificationOnly { printVerificationSteps(ApplicationConfiguration) @@ -114,23 +137,40 @@ func printVerificationSteps(ApplicationConfiguration *ConfigurationSpecs) { var template string username := "admin" clusters := ApplicationConfiguration.Configuration.ClusterConfiguration.WorkerClusters - iperfCommand := exec.Command(util.ExecutablePaths["kubectl"], "--context="+clusters[1].ContextName, "--kubeconfig="+clusters[1].KubeConfigPath, "exec", "-it", "deploy/iperf-sleep", "-c", "iperf", "-n", "iperf", "--", "iperf", "-c", "iperf-server.iperf.svc.slice.local", "-p", "5201", "-i", "1", "-b", "10Mb;") + if len(clusters) < 2 { + util.Printf("Error: At least 2 worker clusters required\n") + return + } + + iperfCommand := buildKubectlCommand( + &clusters[1], + "exec", "-it", "deploy/iperf-sleep", + "-c", "iperf", + "-n", "iperf", + "--", + "iperf", "-c", "iperf-server.iperf.svc.slice.local", + "-p", "5201", + "-i", "1", + "-b", "10Mb;", + ) if ApplicationConfiguration.Configuration.ClusterConfiguration.Profile == ProfileEntDemo { - token := GetUIAdminToken( + token := getUIAdminTokenFunc( &ApplicationConfiguration.Configuration.ClusterConfiguration.ControllerCluster, username, ApplicationConfiguration.Configuration.KubeSliceConfiguration.ProjectName) - endpoint := GetUIEndpoint(&ApplicationConfiguration.Configuration.ClusterConfiguration.ControllerCluster, ProfileEntDemo) + endpoint := getUIEndpointFunc( + &ApplicationConfiguration.Configuration.ClusterConfiguration.ControllerCluster, + ProfileEntDemo) + template = fmt.Sprintf(printEntVerificationStepsTemplate, util.Globe, endpoint, util.Lock, token, - util.Run, iperfCommand.String(), + util.Run, iperfCommand, ) - } else { template = fmt.Sprintf(printVerificationStepsTemplate, - util.Run, iperfCommand.String(), + util.Run, iperfCommand, ) } util.Printf(template) @@ -139,22 +179,66 @@ func printVerificationSteps(ApplicationConfiguration *ConfigurationSpecs) { func printNamespaceIsolationSteps(ApplicationConfiguration *ConfigurationSpecs) { cc := ApplicationConfiguration.Configuration.ClusterConfiguration.ControllerCluster wc := ApplicationConfiguration.Configuration.ClusterConfiguration.WorkerClusters - iperfCommand := exec.Command(util.ExecutablePaths["kubectl"], "--context="+wc[1].ContextName, "--kubeconfig="+wc[1].KubeConfigPath, "exec", "-it", "deploy/iperf-sleep", "-c", "iperf", "-n", "iperf", "--", "iperf", "-c", "iperf-server.iperf.svc.slice.local", "-p", "5201", "-i", "1", "-b", "10Mb;") - sliceApplyCommand := exec.Command(util.ExecutablePaths["kubectl"], "--context="+cc.ContextName, "--kubeconfig="+cc.KubeConfigPath, "apply", "-f", kubesliceDirectory+"/"+sliceTemplateFileName) - sliceVerifyCommandWorker1 := exec.Command(util.ExecutablePaths["kubectl"], "--context="+wc[0].ContextName, "--kubeconfig="+wc[0].KubeConfigPath, "get", "slice", "-n", "kubeslice-system") - sliceVerifyCommandWorker2 := exec.Command(util.ExecutablePaths["kubectl"], "--context="+wc[1].ContextName, "--kubeconfig="+wc[1].KubeConfigPath, "get", "slice", "-n", "kubeslice-system") - applyIPerfWorker1 := exec.Command(util.ExecutablePaths["kubectl"], "rollout ", "restart", "deployment/iperf-server", "-n", "iperf", "--context="+wc[0].ContextName, "--kubeconfig="+wc[0].KubeConfigPath) - applyIPerfWorker2 := exec.Command(util.ExecutablePaths["kubectl"], "rollout ", "restart", "deployment/iperf-sleep", "-n", "iperf", "--context="+wc[1].ContextName, "--kubeconfig="+wc[1].KubeConfigPath) - applyIPerfServiceExportWorker2 := exec.Command(util.ExecutablePaths["kubectl"], "--context="+wc[0].ContextName, "--kubeconfig="+wc[0].KubeConfigPath, "apply ", "-f", kubesliceDirectory+"/"+iPerfServerServiceExportFileName, "-n", "iperf") + if len(wc) < 2 { + util.Printf("Error: At least 2 worker clusters required\n") + return + } + + iperfCommand := buildKubectlCommand( + &wc[1], + "exec", "-it", "deploy/iperf-sleep", + "-c", "iperf", + "-n", "iperf", + "--", + "iperf", "-c", "iperf-server.iperf.svc.slice.local", + "-p", "5201", + "-i", "1", + "-b", "10Mb;", + ) + + sliceApplyCommand := buildKubectlCommand( + &cc, + "apply", "-f", kubesliceDirectory+"/"+sliceTemplateFileName, + ) + + sliceVerifyCommandWorker1 := buildKubectlCommand( + &wc[0], + "get", "slice", "-n", "kubeslice-system", + ) + + sliceVerifyCommandWorker2 := buildKubectlCommand( + &wc[1], + "get", "slice", "-n", "kubeslice-system", + ) + + applyIPerfWorker1 := buildKubectlCommand( + &wc[0], + "rollout", "restart", "deployment/iperf-server", + "-n", "iperf", + ) + + applyIPerfWorker2 := buildKubectlCommand( + &wc[1], + "rollout", "restart", "deployment/iperf-sleep", + "-n", "iperf", + ) + + applyIPerfServiceExportWorker2 := buildKubectlCommand( + &wc[0], + "apply", "-f", kubesliceDirectory+"/"+iPerfServerServiceExportFileName, + "-n", "iperf", + ) + template := fmt.Sprintf(printNextStepsTemplateForSliceInstallation, - util.Run, iperfCommand.String(), - util.Run, sliceApplyCommand.String(), - util.Run, sliceVerifyCommandWorker1.String(), - util.Run, sliceVerifyCommandWorker2.String(), - util.Run, applyIPerfWorker1.String(), - util.Run, applyIPerfWorker2.String(), - util.Run, applyIPerfServiceExportWorker2.String(), - util.Run, iperfCommand.String(), + util.Run, iperfCommand, + util.Run, sliceApplyCommand, + util.Run, sliceVerifyCommandWorker1, + util.Run, sliceVerifyCommandWorker2, + util.Run, applyIPerfWorker1, + util.Run, applyIPerfWorker2, + util.Run, applyIPerfServiceExportWorker2, + util.Run, iperfCommand, ) + util.Printf(template) } diff --git a/pkg/internal/print-next-steps_test.go b/pkg/internal/print-next-steps_test.go new file mode 100644 index 0000000..8ab5adb --- /dev/null +++ b/pkg/internal/print-next-steps_test.go @@ -0,0 +1,398 @@ +package internal + +import ( + "testing" + + "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPrintNextSteps(t *testing.T) { + tests := []struct { + name string + verificationOnly bool + config *ConfigurationSpecs + expectedContains []string + notExpected []string + }{ + { + name: "verification only - standard profile", + verificationOnly: true, + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + Profile: "", + WorkerClusters: []Cluster{ + {Name: "worker-1", ContextName: "w1-ctx", KubeConfigPath: "/path1"}, + {Name: "worker-2", ContextName: "w2-ctx", KubeConfigPath: "/path2"}, + }, + }, + }, + }, + expectedContains: []string{ + "KubeSlice Cluster Setup", + "iPerf Connectivity", + "--context=w2-ctx", + "--kubeconfig=/path2", + "iperf-server.iperf.svc.slice.local", + }, + notExpected: []string{ + "create a Slice", + "Kubeslice Manager UI", + }, + }, + { + name: "verification only - enterprise profile", + verificationOnly: true, + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + Profile: ProfileEntDemo, + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "ctrl-ctx", + KubeConfigPath: "/path/ctrl", + }, + WorkerClusters: []Cluster{ + {Name: "worker-1", ContextName: "w1-ctx", KubeConfigPath: "/path1"}, + {Name: "worker-2", ContextName: "w2-ctx", KubeConfigPath: "/path2"}, + }, + }, + KubeSliceConfiguration: KubeSliceConfiguration{ + ProjectName: "test-project", + }, + }, + }, + expectedContains: []string{ + "KubeSlice Enterprise Setup", + "Kubeslice Manager UI", + "following token", + }, + }, + { + name: "namespace isolation steps", + verificationOnly: false, + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "ctrl-ctx", + KubeConfigPath: "/path/ctrl", + }, + WorkerClusters: []Cluster{ + {Name: "worker-1", ContextName: "w1-ctx", KubeConfigPath: "/path1"}, + {Name: "worker-2", ContextName: "w2-ctx", KubeConfigPath: "/path2"}, + }, + }, + }, + }, + expectedContains: []string{ + "create a Slice", + "slice propagation", + "restart the iPerf deployment", + "export the iPerf server", + "--context=ctrl-ctx", + "--context=w1-ctx", + "--context=w2-ctx", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeOutput := util.Output.(*util.FakeOutput) + if util.ExecutablePaths == nil { + util.ExecutablePaths = make(map[string]string) + } + util.ExecutablePaths["kubectl"] = "kubectl" + + // Mock GetUIAdminToken and GetUIEndpoint for enterprise tests + originalTokenFunc := getUIAdminTokenFunc + getUIAdminTokenFunc = func(cluster *Cluster, user, project string) string { + return "mock-token-12345" + } + defer func() { getUIAdminTokenFunc = originalTokenFunc }() + + originalEndpointFunc := getUIEndpointFunc + getUIEndpointFunc = func(cluster *Cluster, profile string) string { + return "http://mock-endpoint:8080" + } + defer func() { getUIEndpointFunc = originalEndpointFunc }() + + PrintNextSteps(tt.verificationOnly, tt.config) + + require.NotEmpty(t, fakeOutput.InfoCalls) + + allOutput := "" + for _, call := range fakeOutput.InfoCalls { + allOutput += call + } + + for _, expected := range tt.expectedContains { + assert.Contains(t, allOutput, expected, "Output should contain: %s", expected) + } + + for _, notExpected := range tt.notExpected { + assert.NotContains(t, allOutput, notExpected, "Output should NOT contain: %s", notExpected) + } + }) + } +} + +func TestPrintVerificationSteps(t *testing.T) { + tests := []struct { + name string + config *ConfigurationSpecs + mockToken string + mockEndpoint string + expectedContains []string + notExpected []string + }{ + { + name: "standard profile", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + Profile: "", + WorkerClusters: []Cluster{ + {Name: "worker-1", ContextName: "w1-ctx", KubeConfigPath: "/path1"}, + {Name: "worker-2", ContextName: "w2-ctx", KubeConfigPath: "/path2"}, + }, + }, + }, + }, + expectedContains: []string{ + "KubeSlice Cluster Setup", + "kubectl --context=w2-ctx --kubeconfig=/path2", + "exec -it deploy/iperf-sleep", + "iperf-server.iperf.svc.slice.local", + }, + notExpected: []string{ + "Enterprise", + "Manager UI", + "token", + }, + }, + { + name: "enterprise profile", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + Profile: ProfileEntDemo, + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "ctrl-ctx", + KubeConfigPath: "/path/ctrl", + }, + WorkerClusters: []Cluster{ + {Name: "worker-1", ContextName: "w1-ctx", KubeConfigPath: "/path1"}, + {Name: "worker-2", ContextName: "w2-ctx", KubeConfigPath: "/path2"}, + }, + }, + KubeSliceConfiguration: KubeSliceConfiguration{ + ProjectName: "test-project", + }, + }, + }, + mockToken: "enterprise-token-xyz", + mockEndpoint: "https://enterprise.example.com", + expectedContains: []string{ + "KubeSlice Enterprise Setup", + "Kubeslice Manager UI", + "https://enterprise.example.com", + "enterprise-token-xyz", + "kubectl --context=w2-ctx", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeOutput := util.Output.(*util.FakeOutput) + if util.ExecutablePaths == nil { + util.ExecutablePaths = make(map[string]string) + } + util.ExecutablePaths["kubectl"] = "kubectl" + + originalTokenFunc := getUIAdminTokenFunc + getUIAdminTokenFunc = func(cluster *Cluster, user, project string) string { + if tt.mockToken != "" { + assert.Equal(t, "ctrl-ctx", cluster.ContextName) + assert.Equal(t, "admin", user) + assert.Equal(t, "test-project", project) + return tt.mockToken + } + return "" + } + defer func() { getUIAdminTokenFunc = originalTokenFunc }() + + originalEndpointFunc := getUIEndpointFunc + getUIEndpointFunc = func(cluster *Cluster, profile string) string { + if tt.mockEndpoint != "" { + assert.Equal(t, "ctrl-ctx", cluster.ContextName) + assert.Equal(t, ProfileEntDemo, profile) + return tt.mockEndpoint + } + return "" + } + defer func() { getUIEndpointFunc = originalEndpointFunc }() + + printVerificationSteps(tt.config) + + require.Len(t, fakeOutput.InfoCalls, 1) + output := fakeOutput.InfoCalls[0] + + for _, expected := range tt.expectedContains { + assert.Contains(t, output, expected) + } + + for _, notExpected := range tt.notExpected { + assert.NotContains(t, output, notExpected) + } + }) + } +} + +func TestPrintVerificationSteps_InsufficientClusters(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + fakeOutput := util.Output.(*util.FakeOutput) + + config := &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + WorkerClusters: []Cluster{ + {Name: "worker-1", ContextName: "w1-ctx", KubeConfigPath: "/path1"}, + // Only 1 cluster - should not crash + }, + }, + }, + } + + printVerificationSteps(config) + require.NotEmpty(t, fakeOutput.InfoCalls) + assert.Contains(t, fakeOutput.InfoCalls[0], "Error: At least 2 worker clusters required") +} + +func TestPrintNamespaceIsolationSteps(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeOutput := util.Output.(*util.FakeOutput) + if util.ExecutablePaths == nil { + util.ExecutablePaths = make(map[string]string) + } + util.ExecutablePaths["kubectl"] = "kubectl" + + config := &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "ctrl-ctx", + KubeConfigPath: "/path/ctrl", + }, + WorkerClusters: []Cluster{ + {Name: "worker-1", ContextName: "w1-ctx", KubeConfigPath: "/path1"}, + {Name: "worker-2", ContextName: "w2-ctx", KubeConfigPath: "/path2"}, + }, + }, + }, + } + + printNamespaceIsolationSteps(config) + + require.Len(t, fakeOutput.InfoCalls, 1) + output := fakeOutput.InfoCalls[0] + + expectedCommands := []string{ + "kubectl --context=w2-ctx --kubeconfig=/path2 exec -it deploy/iperf-sleep", + "kubectl --context=ctrl-ctx --kubeconfig=/path/ctrl apply -f", + "kubectl --context=w1-ctx --kubeconfig=/path1 get slice -n kubeslice-system", + "kubectl --context=w2-ctx --kubeconfig=/path2 get slice -n kubeslice-system", + "kubectl --context=w1-ctx --kubeconfig=/path1 rollout restart deployment/iperf-server -n iperf", + "kubectl --context=w2-ctx --kubeconfig=/path2 rollout restart deployment/iperf-sleep -n iperf", + "kubectl --context=w1-ctx --kubeconfig=/path1 apply -f", + } + + for _, cmd := range expectedCommands { + assert.Contains(t, output, cmd, "Output should contain command: %s", cmd) + } + + expectedSections := []string{ + "create a Slice", + "slice propagation", + "restart the iPerf deployment", + "export the iPerf server", + "Verify the iPerf Connectivity Again", + } + + for _, section := range expectedSections { + assert.Contains(t, output, section) + } +} + +func TestPrintNamespaceIsolationSteps_InsufficientClusters(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + fakeOutput := util.Output.(*util.FakeOutput) + + config := &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + WorkerClusters: []Cluster{ + {Name: "worker-1", ContextName: "w1-ctx", KubeConfigPath: "/path1"}, + }, + }, + }, + } + + printNamespaceIsolationSteps(config) + + require.NotEmpty(t, fakeOutput.InfoCalls) + assert.Contains(t, fakeOutput.InfoCalls[0], "Error: At least 2 worker clusters required") +} + +func TestBuildKubectlCommand(t *testing.T) { + tests := []struct { + name string + cluster *Cluster + args []string + expected string + }{ + { + name: "with cluster context", + cluster: &Cluster{ + ContextName: "test-ctx", + KubeConfigPath: "/path/config", + }, + args: []string{"get", "pods", "-n", "default"}, + expected: "kubectl --context=test-ctx --kubeconfig=/path/config get pods -n default", + }, + { + name: "without cluster context", + cluster: nil, + args: []string{"version"}, + expected: "kubectl version", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if util.ExecutablePaths == nil { + util.ExecutablePaths = make(map[string]string) + } + util.ExecutablePaths["kubectl"] = "kubectl" + result := buildKubectlCommand(tt.cluster, tt.args...) + assert.Equal(t, tt.expected, result) + }) + } +} From 80325cc45d77f96ee400f2393da5610209997290 Mon Sep 17 00:00:00 2001 From: Alokzh Date: Fri, 7 Nov 2025 13:51:29 +0530 Subject: [PATCH 17/29] Refactored prometheus.go & added tests for installation & patching of Prometheus Signed-off-by: Alokzh --- pkg/internal/prometheus.go | 35 +- pkg/internal/prometheus_test.go | 566 ++++++++++++++++++++++++++++++++ 2 files changed, 586 insertions(+), 15 deletions(-) create mode 100644 pkg/internal/prometheus_test.go diff --git a/pkg/internal/prometheus.go b/pkg/internal/prometheus.go index be7ffac..172dc9f 100644 --- a/pkg/internal/prometheus.go +++ b/pkg/internal/prometheus.go @@ -2,7 +2,6 @@ package internal import ( "fmt" - "log" "time" "github.com/kubeslice/kubeslice-cli/util" @@ -13,6 +12,11 @@ const ( PrometheusNamespace = "monitoring" ) +// Function variables for testing +var ( + podVerificationFuncPrometheus = PodVerification +) + func InstallPrometheus(ApplicationConfiguration *ConfigurationSpecs) { util.Printf("\nInstalling Prometheus...") @@ -21,30 +25,31 @@ func InstallPrometheus(ApplicationConfiguration *ConfigurationSpecs) { hc := ApplicationConfiguration.Configuration.HelmChartConfiguration generatePrometheusValuesFile(hc) util.Printf("%s Generated Helm Values file for Prometheus Installation %s", util.Tick, PrometheusValuesFileName) - time.Sleep(200 * time.Millisecond) + util.SystemClock.Sleep(200 * time.Millisecond) + installPrometheus(wc, &cc, hc, PrometheusValuesFileName) util.Printf("%s Successfully installed Prometheus on Worker clusters.", util.Tick) - time.Sleep(200 * time.Millisecond) + util.SystemClock.Sleep(200 * time.Millisecond) + util.Printf("%s Setting Prometheus endpoint in cluster objects...", util.Wait) - projectNamespce := fmt.Sprintf("kubeslice-%s", ApplicationConfiguration.Configuration.KubeSliceConfiguration.ProjectName) - patchClusterObjectInControllerCluster(wc, &cc, projectNamespce) + projectNamespace := fmt.Sprintf("kubeslice-%s", ApplicationConfiguration.Configuration.KubeSliceConfiguration.ProjectName) + patchClusterObjectInControllerCluster(wc, &cc, projectNamespace) } func patchClusterObjectInControllerCluster(wc []Cluster, cc *Cluster, projectNS string) { for _, cluster := range wc { - // Patch cluster object in controller cluster - err := util.RunCommand("kubectl", "--context", cc.ContextName, "--kubeconfig", cc.KubeConfigPath, "patch", ClusterObject, cluster.Name, "-n", projectNS, "--type", "merge", "-p", fmt.Sprintf("{\"spec\":{\"clusterProperty\":{\"telemetry\":{\"enabled\":true,\"endpoint\":\"http://%s:32700\",\"telemetryProvider\":\"prometheus\"}}}}", cluster.NodeIP)) + err := util.CommandExecutor.Execute("kubectl", "--context", cc.ContextName, "--kubeconfig", cc.KubeConfigPath, "patch", ClusterObject, cluster.Name, "-n", projectNS, "--type", "merge", "-p", fmt.Sprintf("{\"spec\":{\"clusterProperty\":{\"telemetry\":{\"enabled\":true,\"endpoint\":\"http://%s:32700\",\"telemetryProvider\":\"prometheus\"}}}}", cluster.NodeIP)) if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } util.Printf("%s Successfully set prometheus endpoint in %s", util.Tick, cluster.Name) } } func generatePrometheusValuesFile(hcConfig HelmChartConfiguration) { - err := generateValuesFile(kubesliceDirectory+"/"+PrometheusValuesFileName, &hcConfig.PrometheusChart, "") + err := generateValuesFileFunc(kubesliceDirectory+"/"+PrometheusValuesFileName, &hcConfig.PrometheusChart, "") if err != nil { - log.Fatalf("%s %s", util.Cross, err) + util.Fatalf("%s %s", util.Cross, err) } } @@ -52,17 +57,17 @@ func installPrometheus(clusters []Cluster, cc *Cluster, hc HelmChartConfiguratio for _, cluster := range clusters { args := make([]string, 0) args = append(args, "--kube-context", cluster.ContextName, "--kubeconfig", cluster.KubeConfigPath, "upgrade", "-i", hc.PrometheusChart.ChartName, fmt.Sprintf("%s/%s", hc.RepoAlias, hc.PrometheusChart.ChartName), "--namespace", PrometheusNamespace, "--create-namespace", "-f", kubesliceDirectory+"/"+filename) - if hc.ControllerChart.Version != "" { + if hc.PrometheusChart.Version != "" { args = append(args, "--version", hc.PrometheusChart.Version) } - err := util.RunCommand("helm", args...) + err := util.CommandExecutor.Execute("helm", args...) if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } util.Printf("%s Successfully installed helm chart %s/%s on cluster %s", util.Tick, hc.RepoAlias, hc.PrometheusChart.ChartName, cluster.Name) - time.Sleep(200 * time.Millisecond) + util.SystemClock.Sleep(200 * time.Millisecond) util.Printf("%s Waiting for Prometheus Pods to be Healthy...", util.Wait) - PodVerification("Waiting for Prometheus Pods to be Healthy", cluster, PrometheusNamespace) + podVerificationFuncPrometheus("Waiting for Prometheus Pods to be Healthy", cluster, PrometheusNamespace) // Patch cluster object in controller cluster } diff --git a/pkg/internal/prometheus_test.go b/pkg/internal/prometheus_test.go new file mode 100644 index 0000000..c8722a9 --- /dev/null +++ b/pkg/internal/prometheus_test.go @@ -0,0 +1,566 @@ +package internal + +import ( + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstallPrometheus(t *testing.T) { + tests := []struct { + name string + config *ConfigurationSpecs + mockExecutor func(*util.FakeExecutor, *int, *int) + mockPodVerification func(string, Cluster, string) + mockGenerateValues func(string, *HelmChart, string) error + expectFatal bool + fatalContains string + expectedHelmCalls int + expectedPatchCalls int + }{ + { + name: "successful installation on multiple workers", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{ + Name: "controller", + ContextName: "controller-ctx", + KubeConfigPath: "/path/config", + }, + WorkerClusters: []Cluster{ + {Name: "worker-1", ContextName: "w1-ctx", KubeConfigPath: "/path", NodeIP: "10.0.0.1"}, + {Name: "worker-2", ContextName: "w2-ctx", KubeConfigPath: "/path", NodeIP: "10.0.0.2"}, + }, + }, + KubeSliceConfiguration: KubeSliceConfiguration{ + ProjectName: "test-project", + }, + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "prometheus-community", + PrometheusChart: HelmChart{ + ChartName: "prometheus", + Version: "v1.0.0", + }, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor, helmCalls, patchCalls *int) { + fe.ExecuteFunc = func(cli string, args ...string) error { + if cli == "helm" { + *helmCalls++ + assert.Contains(t, args, "upgrade") + assert.Contains(t, args, "-i") + assert.Contains(t, args, "prometheus") + assert.Contains(t, args, "--version") + assert.Contains(t, args, "v1.0.0") + } else if cli == "kubectl" { + *patchCalls++ + assert.Contains(t, args, "patch") + assert.Contains(t, args, ClusterObject) + argsStr := strings.Join(args, " ") + assert.Contains(t, argsStr, "prometheus") + assert.Contains(t, argsStr, "32700") + } + return nil + } + }, + mockPodVerification: func(msg string, cluster Cluster, namespace string) { + assert.Equal(t, PrometheusNamespace, namespace) + }, + mockGenerateValues: func(fileName string, chart *HelmChart, values string) error { + assert.Contains(t, fileName, PrometheusValuesFileName) + return nil + }, + expectedHelmCalls: 2, + expectedPatchCalls: 2, + }, + { + name: "installation with single worker", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: "controller", ContextName: "ctrl-ctx", KubeConfigPath: "/path"}, + WorkerClusters: []Cluster{ + {Name: "worker-1", ContextName: "w1-ctx", KubeConfigPath: "/path", NodeIP: "10.0.0.1"}, + }, + }, + KubeSliceConfiguration: KubeSliceConfiguration{ + ProjectName: "single-project", + }, + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "prometheus-community", + PrometheusChart: HelmChart{ChartName: "prometheus"}, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor, helmCalls, patchCalls *int) { + fe.ExecuteFunc = func(cli string, args ...string) error { + if cli == "helm" { + *helmCalls++ + } else if cli == "kubectl" { + *patchCalls++ + } + return nil + } + }, + mockPodVerification: func(msg string, cluster Cluster, namespace string) {}, + mockGenerateValues: func(fileName string, chart *HelmChart, values string) error { + return nil + }, + expectedHelmCalls: 1, + expectedPatchCalls: 1, + }, + { + name: "values file generation fails", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: "controller"}, + WorkerClusters: []Cluster{{Name: "worker-1", NodeIP: "10.0.0.1"}}, + }, + KubeSliceConfiguration: KubeSliceConfiguration{ProjectName: "test"}, + HelmChartConfiguration: HelmChartConfiguration{ + PrometheusChart: HelmChart{ChartName: "prometheus"}, + }, + }, + }, + mockGenerateValues: func(fileName string, chart *HelmChart, values string) error { + return errors.New("file write failed") + }, + expectFatal: true, + fatalContains: "file write failed", + }, + { + name: "helm installation fails", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: "controller", ContextName: "ctrl-ctx", KubeConfigPath: "/path"}, + WorkerClusters: []Cluster{{Name: "worker-1", ContextName: "w1-ctx", KubeConfigPath: "/path", NodeIP: "10.0.0.1"}}, + }, + KubeSliceConfiguration: KubeSliceConfiguration{ProjectName: "test"}, + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "prometheus-community", + PrometheusChart: HelmChart{ChartName: "prometheus"}, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor, helmCalls, patchCalls *int) { + fe.ExecuteFunc = func(cli string, args ...string) error { + if cli == "helm" { + return errors.New("helm installation failed") + } + return nil + } + }, + mockGenerateValues: func(fileName string, chart *HelmChart, values string) error { + return nil + }, + expectFatal: true, + fatalContains: "helm installation failed", + }, + { + name: "cluster patch fails", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: "controller", ContextName: "ctrl-ctx", KubeConfigPath: "/path"}, + WorkerClusters: []Cluster{{Name: "worker-1", ContextName: "w1-ctx", KubeConfigPath: "/path", NodeIP: "10.0.0.1"}}, + }, + KubeSliceConfiguration: KubeSliceConfiguration{ProjectName: "test"}, + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "prometheus-community", + PrometheusChart: HelmChart{ChartName: "prometheus"}, + }, + }, + }, + mockExecutor: func(fe *util.FakeExecutor, helmCalls, patchCalls *int) { + fe.ExecuteFunc = func(cli string, args ...string) error { + if cli == "kubectl" && strings.Contains(strings.Join(args, " "), "patch") { + return errors.New("patch failed") + } + return nil + } + }, + mockPodVerification: func(msg string, cluster Cluster, namespace string) {}, + mockGenerateValues: func(fileName string, chart *HelmChart, values string) error { + return nil + }, + expectFatal: true, + fatalContains: "patch failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeFS := util.FileSystem.(*util.FakeFileSystem) + fakeOutput := util.Output.(*util.FakeOutput) + fakeClock := util.SystemClock.(*util.FakeClock) + + helmCalls := 0 + patchCalls := 0 + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec, &helmCalls, &patchCalls) + } + + podVerifyCallCount := 0 + originalPodVerify := podVerificationFuncPrometheus + podVerificationFuncPrometheus = func(msg string, cluster Cluster, namespace string) { + podVerifyCallCount++ + if tt.mockPodVerification != nil { + tt.mockPodVerification(msg, cluster, namespace) + } + } + defer func() { podVerificationFuncPrometheus = originalPodVerify }() + + originalGenerateValues := generateValuesFileFunc + generateValuesFileFunc = func(fileName string, chart *HelmChart, values string) error { + if tt.mockGenerateValues != nil { + err := tt.mockGenerateValues(fileName, chart, values) + if err == nil { + fakeFS.WrittenFiles[fileName] = []byte("mock values") + } + return err + } + fakeFS.WrittenFiles[fileName] = []byte("mock values") + return nil + } + defer func() { generateValuesFileFunc = originalGenerateValues }() + + InstallPrometheus(tt.config) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + if tt.fatalContains != "" { + assert.Contains(t, fmt.Sprint(fakeOutput.FatalCalls[0]), tt.fatalContains) + } + } else { + assert.Empty(t, fakeOutput.FatalCalls) + assert.Equal(t, tt.expectedHelmCalls, helmCalls) + assert.Equal(t, tt.expectedPatchCalls, patchCalls) + assert.Equal(t, tt.expectedHelmCalls, podVerifyCallCount) + + valuesPath := kubesliceDirectory + "/" + PrometheusValuesFileName + _, exists := fakeFS.WrittenFiles[valuesPath] + assert.True(t, exists, "Prometheus values file should be created") + + // Verify exact sleeps: 1 (after generate) + N (in install loop) + 1 (after install) + expectedSleeps := 2 + tt.expectedHelmCalls + require.Len(t, fakeClock.SleepCalls, expectedSleeps) + for _, d := range fakeClock.SleepCalls { + assert.Equal(t, 200*time.Millisecond, d) + } + } + }) + } +} + +func TestPatchClusterObjectInControllerCluster(t *testing.T) { + controller := &Cluster{ + Name: "controller", + ContextName: "controller-ctx", + KubeConfigPath: "/path/config", + } + + tests := []struct { + name string + workers []Cluster + projectNS string + mockExecutor func(*util.FakeExecutor) + expectFatal bool + fatalContains string + }{ + { + name: "successful patch for multiple workers", + workers: []Cluster{ + {Name: "worker-1", NodeIP: "10.0.0.1"}, + {Name: "worker-2", NodeIP: "10.0.0.2"}, + }, + projectNS: "kubeslice-test-project", + mockExecutor: func(fe *util.FakeExecutor) { + callCount := 0 + fe.ExecuteFunc = func(cli string, args ...string) error { + callCount++ + assert.Equal(t, "kubectl", cli) + assert.Contains(t, args, "patch") + assert.Contains(t, args, ClusterObject) + + argsStr := strings.Join(args, " ") + assert.Contains(t, argsStr, "kubeslice-test-project") + assert.Contains(t, argsStr, "prometheus") + assert.Contains(t, argsStr, "32700") + + if callCount == 1 { + assert.Contains(t, argsStr, "worker-1") + assert.Contains(t, argsStr, "10.0.0.1") + } else if callCount == 2 { + assert.Contains(t, argsStr, "worker-2") + assert.Contains(t, argsStr, "10.0.0.2") + } + return nil + } + }, + }, + { + name: "patch fails for worker", + workers: []Cluster{ + {Name: "worker-1", NodeIP: "10.0.0.1"}, + }, + projectNS: "kubeslice-test-project", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return errors.New("cluster not found") + } + }, + expectFatal: true, + fatalContains: "cluster not found", + }, + { + name: "verify endpoint format", + workers: []Cluster{ + {Name: "worker-1", NodeIP: "192.168.1.10"}, + }, + projectNS: "kubeslice-test", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + argsStr := strings.Join(args, " ") + assert.Contains(t, argsStr, "http://192.168.1.10:32700") + assert.Contains(t, argsStr, "telemetryProvider\":\"prometheus\"") + assert.Contains(t, argsStr, "enabled\":true") + return nil + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + patchClusterObjectInControllerCluster(tt.workers, controller, tt.projectNS) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + if tt.fatalContains != "" { + assert.Contains(t, fmt.Sprint(fakeOutput.FatalCalls[0]), tt.fatalContains) + } + } else { + assert.Empty(t, fakeOutput.FatalCalls) + assert.Len(t, fakeExec.Calls, len(tt.workers)) + } + }) + } +} + +func TestGeneratePrometheusValuesFile(t *testing.T) { + tests := []struct { + name string + helmConfig HelmChartConfiguration + mockGenerateValues func(string, *HelmChart, string) error + expectFatal bool + fatalContains string + }{ + { + name: "successful values file generation", + helmConfig: HelmChartConfiguration{ + PrometheusChart: HelmChart{ + ChartName: "prometheus", + Version: "v1.0.0", + }, + }, + mockGenerateValues: func(fileName string, chart *HelmChart, values string) error { + assert.Contains(t, fileName, PrometheusValuesFileName) + assert.Equal(t, "prometheus", chart.ChartName) + return nil + }, + }, + { + name: "values file generation fails", + helmConfig: HelmChartConfiguration{ + PrometheusChart: HelmChart{ChartName: "prometheus"}, + }, + mockGenerateValues: func(fileName string, chart *HelmChart, values string) error { + return errors.New("file write failed") + }, + expectFatal: true, + fatalContains: "file write failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeFS := util.FileSystem.(*util.FakeFileSystem) + fakeOutput := util.Output.(*util.FakeOutput) + + originalGenerateValues := generateValuesFileFunc + generateValuesFileFunc = func(fileName string, chart *HelmChart, values string) error { + err := tt.mockGenerateValues(fileName, chart, values) + if err == nil { + fakeFS.WrittenFiles[fileName] = []byte("mock values") + } + return err + } + defer func() { generateValuesFileFunc = originalGenerateValues }() + + generatePrometheusValuesFile(tt.helmConfig) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + if tt.fatalContains != "" { + assert.Contains(t, fmt.Sprint(fakeOutput.FatalCalls[0]), tt.fatalContains) + } + } else { + assert.Empty(t, fakeOutput.FatalCalls) + valuesPath := kubesliceDirectory + "/" + PrometheusValuesFileName + _, exists := fakeFS.WrittenFiles[valuesPath] + assert.True(t, exists) + } + }) + } +} + +func TestInstallPrometheusFunction(t *testing.T) { + controller := &Cluster{Name: "controller"} + + tests := []struct { + name string + clusters []Cluster + helmConfig HelmChartConfiguration + mockExecutor func(*util.FakeExecutor, *int) + mockPodVerification func(string, Cluster, string) + expectFatal bool + fatalContains string + expectedCalls int + }{ + { + name: "install on multiple clusters with version", + clusters: []Cluster{ + {Name: "worker-1", ContextName: "w1-ctx", KubeConfigPath: "/path"}, + {Name: "worker-2", ContextName: "w2-ctx", KubeConfigPath: "/path"}, + }, + helmConfig: HelmChartConfiguration{ + RepoAlias: "prometheus-community", + PrometheusChart: HelmChart{ + ChartName: "prometheus", + Version: "v1.0.0", + }, + }, + mockExecutor: func(fe *util.FakeExecutor, callCount *int) { + fe.ExecuteFunc = func(cli string, args ...string) error { + *callCount++ + assert.Equal(t, "helm", cli) + assert.Contains(t, args, "upgrade") + assert.Contains(t, args, "--version") + assert.Contains(t, args, "v1.0.0") + return nil + } + }, + mockPodVerification: func(msg string, cluster Cluster, namespace string) { + assert.Equal(t, PrometheusNamespace, namespace) + }, + expectedCalls: 2, + }, + { + name: "install without version", + clusters: []Cluster{ + {Name: "worker-1", ContextName: "w1-ctx", KubeConfigPath: "/path"}, + }, + helmConfig: HelmChartConfiguration{ + RepoAlias: "prometheus-community", + PrometheusChart: HelmChart{ChartName: "prometheus", Version: ""}, + }, + mockExecutor: func(fe *util.FakeExecutor, callCount *int) { + fe.ExecuteFunc = func(cli string, args ...string) error { + *callCount++ + assert.NotContains(t, args, "--version") + return nil + } + }, + mockPodVerification: func(msg string, cluster Cluster, namespace string) {}, + expectedCalls: 1, + }, + { + name: "helm install fails", + clusters: []Cluster{ + {Name: "worker-1", ContextName: "w1-ctx", KubeConfigPath: "/path"}, + }, + helmConfig: HelmChartConfiguration{ + RepoAlias: "prometheus-community", + PrometheusChart: HelmChart{ChartName: "prometheus"}, + }, + mockExecutor: func(fe *util.FakeExecutor, callCount *int) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return errors.New("helm repo not found") + } + }, + expectFatal: true, + fatalContains: "helm repo not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + fakeClock := util.SystemClock.(*util.FakeClock) + + callCount := 0 + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec, &callCount) + } + + podVerifyCount := 0 + originalPodVerify := podVerificationFuncPrometheus + podVerificationFuncPrometheus = func(msg string, cluster Cluster, namespace string) { + podVerifyCount++ + if tt.mockPodVerification != nil { + tt.mockPodVerification(msg, cluster, namespace) + } + } + defer func() { podVerificationFuncPrometheus = originalPodVerify }() + + installPrometheus(tt.clusters, controller, tt.helmConfig, PrometheusValuesFileName) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + if tt.fatalContains != "" { + assert.Contains(t, fmt.Sprint(fakeOutput.FatalCalls[0]), tt.fatalContains) + } + } else { + assert.Empty(t, fakeOutput.FatalCalls) + assert.Equal(t, tt.expectedCalls, callCount) + assert.Equal(t, tt.expectedCalls, podVerifyCount) + require.Len(t, fakeClock.SleepCalls, tt.expectedCalls) + for _, d := range fakeClock.SleepCalls { + assert.Equal(t, 200*time.Millisecond, d) + } + } + }) + } +} From ce1440f65e01584478b3471a5a11e94cc6b8a3f4 Mon Sep 17 00:00:00 2001 From: Alokzh Date: Sat, 8 Nov 2025 09:45:08 +0530 Subject: [PATCH 18/29] Refactored secrets.go & added tests for GetSecrets & GetSecretName functions Signed-off-by: Alokzh --- pkg/internal/secrets.go | 66 +++++++--- pkg/internal/secrets_test.go | 232 +++++++++++++++++++++++++++++++++++ 2 files changed, 278 insertions(+), 20 deletions(-) create mode 100644 pkg/internal/secrets_test.go diff --git a/pkg/internal/secrets.go b/pkg/internal/secrets.go index ed339d3..0227f7d 100644 --- a/pkg/internal/secrets.go +++ b/pkg/internal/secrets.go @@ -2,35 +2,61 @@ package internal import ( "bytes" - "os/exec" + "fmt" "strings" "time" "github.com/kubeslice/kubeslice-cli/util" ) +var ( + getSecretNameFunc = GetSecretName + getKubectlResourcesFuncSecrets = GetKubectlResources +) + func GetSecrets(workerName string, namespace string, controllerCluster *Cluster, outputFormat string) { + if controllerCluster == nil { + util.Printf("%s Controller cluster cannot be nil", util.Cross) + return + } + util.Printf("\nFetching KubeSlice secret...") - SecretName := GetSecretName(workerName, namespace, controllerCluster) - GetKubectlResources(SecretObject, SecretName, namespace, controllerCluster, outputFormat) - time.Sleep(200 * time.Millisecond) + SecretName := getSecretNameFunc(workerName, namespace, controllerCluster) + if SecretName == "" { + util.Printf("%s No secret found for worker %s", util.Cross, workerName) + return + } + getKubectlResourcesFuncSecrets(SecretObject, SecretName, namespace, controllerCluster, outputFormat) + util.SystemClock.Sleep(200 * time.Millisecond) } func GetSecretName(workerName string, namespace string, controllerCluster *Cluster) string { - cmdArgs := []string{} - cmdArgs = append(cmdArgs, "get", SecretObject, "-n", namespace) - var outB bytes.Buffer - c1 := exec.Command("/home/excellarate/.local/bin/kubectl", cmdArgs...) - c2 := exec.Command("grep", "worker-"+workerName) - c3 := exec.Command("awk", "{print $1}") - c2.Stdin, _ = c1.StdoutPipe() - c3.Stdin, _ = c2.StdoutPipe() - c3.Stdout = &outB - _ = c2.Start() - _ = c3.Start() - _ = c1.Run() - _ = c2.Wait() - _ = c3.Wait() - s := outB.String() - return strings.TrimSuffix(s, "\n") + if controllerCluster == nil { + util.Printf("%s Controller cluster cannot be nil", util.Cross) + return "" + } + + jsonpath := fmt.Sprintf(`{.items[?(@.metadata.name contains "worker-%s")].metadata.name}`, workerName) + var outB, errB bytes.Buffer + err := util.CommandExecutor.ExecuteWithOutput( + "kubectl", + &outB, + &errB, + "--context="+controllerCluster.ContextName, + "--kubeconfig="+controllerCluster.KubeConfigPath, + "get", SecretObject, + "-n", namespace, + "-o", "jsonpath="+jsonpath, + ) + + if err != nil { + util.Printf("%s Failed to get secret: %v", util.Cross, err) + return "" + } + + secretName := strings.TrimSpace(outB.String()) + if secretName == "" { + util.Printf("%s No matching secret found for worker-%s", util.Cross, workerName) + } + return secretName } diff --git a/pkg/internal/secrets_test.go b/pkg/internal/secrets_test.go new file mode 100644 index 0000000..81a4a90 --- /dev/null +++ b/pkg/internal/secrets_test.go @@ -0,0 +1,232 @@ +package internal + +import ( + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetSecrets(t *testing.T) { + cluster := &Cluster{Name: "controller"} + + tests := []struct { + name string + workerName string + namespace string + outputFormat string + mockGetSecretName string + mockGetKubectlResources func(string, string, string, *Cluster, string) + expectGetResourcesCall bool + expectFatal bool + }{ + { + name: "successful secret retrieval", + workerName: "worker-1", + namespace: "kubeslice-test", + outputFormat: "yaml", + mockGetSecretName: "kubeslice-rbac-worker-worker-1-token-abc123", + mockGetKubectlResources: func(resourceType, resourceName, namespace string, cluster *Cluster, outputFormat string) { + assert.Equal(t, SecretObject, resourceType) + assert.Equal(t, "kubeslice-rbac-worker-worker-1-token-abc123", resourceName) + assert.Equal(t, "yaml", outputFormat) + }, + expectGetResourcesCall: true, + }, + { + name: "no secret found for worker", + workerName: "worker-2", + namespace: "kubeslice-test", + outputFormat: "", + mockGetSecretName: "", + expectGetResourcesCall: false, + }, + { + name: "get kubectl resources fails", + workerName: "worker-1", + namespace: "kubeslice-test", + outputFormat: "yaml", + mockGetSecretName: "kubeslice-rbac-worker-worker-1-token-abc123", + mockGetKubectlResources: func(resourceType, resourceName, namespace string, cluster *Cluster, outputFormat string) { + util.Fatalf("Process failed %v", errors.New("get failed")) + }, + expectGetResourcesCall: true, + expectFatal: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeClock := util.SystemClock.(*util.FakeClock) + fakeOutput := util.Output.(*util.FakeOutput) + + originalGetSecretName := getSecretNameFunc + getSecretNameFunc = func(workerName, namespace string, c *Cluster) string { + return tt.mockGetSecretName + } + defer func() { getSecretNameFunc = originalGetSecretName }() + + getCalled := false + originalGetKubectlResources := getKubectlResourcesFuncSecrets + getKubectlResourcesFuncSecrets = func(resourceType, resourceName, namespace string, cluster *Cluster, outputFormat string) { + getCalled = true + if tt.mockGetKubectlResources != nil { + tt.mockGetKubectlResources(resourceType, resourceName, namespace, cluster, outputFormat) + } + } + defer func() { getKubectlResourcesFuncSecrets = originalGetKubectlResources }() + + GetSecrets(tt.workerName, tt.namespace, cluster, tt.outputFormat) + + assert.Equal(t, tt.expectGetResourcesCall, getCalled) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + } else { + assert.Empty(t, fakeOutput.FatalCalls) + } + + if !tt.expectGetResourcesCall { + allOutput := strings.Join(fakeOutput.InfoCalls, " ") + assert.Contains(t, allOutput, "No secret found") + } else { + require.Len(t, fakeClock.SleepCalls, 1) + assert.Equal(t, 200*time.Millisecond, fakeClock.SleepCalls[0]) + } + }) + } +} + +func TestGetSecrets_NilCluster(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeOutput := util.Output.(*util.FakeOutput) + + originalGetSecretName := getSecretNameFunc + defer func() { getSecretNameFunc = originalGetSecretName }() + + getSecretNameFunc = func(workerName, namespace string, c *Cluster) string { + t.Fatal("Should not call GetSecretName with nil cluster") + return "" + } + + GetSecrets("worker-1", "kubeslice-test", nil, "yaml") + + allOutput := strings.Join(fakeOutput.InfoCalls, " ") + assert.Contains(t, allOutput, "Controller cluster cannot be nil") +} + +func TestGetSecretName(t *testing.T) { + cluster := &Cluster{ContextName: "controller-ctx", KubeConfigPath: "/path/config"} + + tests := []struct { + name string + workerName string + namespace string + mockExecutor func(*util.FakeExecutor) + expectedSecret string + expectMessage string + }{ + { + name: "secret found for worker", + workerName: "worker-1", + namespace: "kubeslice-test", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stdout.Write([]byte("kubeslice-rbac-worker-worker-1-token-abc123")) + return nil + } + }, + expectedSecret: "kubeslice-rbac-worker-worker-1-token-abc123", + }, + { + name: "no secret found for worker", + workerName: "worker-2", + namespace: "kubeslice-test", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stdout.Write([]byte("")) + return nil + } + }, + expectedSecret: "", + expectMessage: "No matching secret found", + }, + { + name: "kubectl command fails", + workerName: "worker-3", + namespace: "kubeslice-test", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + return errors.New("namespace not found") + } + }, + expectedSecret: "", + expectMessage: "Failed to get secret", + }, + { + name: "secret with whitespace trimming", + workerName: "worker-4", + namespace: "kubeslice-test", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stdout.Write([]byte(" kubeslice-rbac-worker-worker-4-token-xyz \n")) + return nil + } + }, + expectedSecret: "kubeslice-rbac-worker-worker-4-token-xyz", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + secretName := GetSecretName(tt.workerName, tt.namespace, cluster) + + assert.Equal(t, tt.expectedSecret, secretName) + assert.Empty(t, fakeOutput.FatalCalls) + + if tt.expectMessage != "" { + allOutput := strings.Join(fakeOutput.InfoCalls, " ") + assert.Contains(t, allOutput, tt.expectMessage) + } + }) + } +} + +func TestGetSecretName_NilCluster(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeOutput := util.Output.(*util.FakeOutput) + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + + fakeExec.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + t.Fatal("Should not execute kubectl with nil cluster") + return nil + } + + secretName := GetSecretName("worker-1", "kubeslice-test", nil) + + assert.Equal(t, "", secretName) + allOutput := strings.Join(fakeOutput.InfoCalls, " ") + assert.Contains(t, allOutput, "Controller cluster cannot be nil") +} From 4becd8b38b8d4bc22dd112a00bd6086a5ef52c96 Mon Sep 17 00:00:00 2001 From: Alokzh Date: Sat, 8 Nov 2025 11:41:15 +0530 Subject: [PATCH 19/29] Refactored file-generation_test.go to improve & add tests for directory creation & file writing scenarios Signed-off-by: Alokzh --- util/file-generation_test.go | 265 +++++++++++++++++------------------ 1 file changed, 130 insertions(+), 135 deletions(-) diff --git a/util/file-generation_test.go b/util/file-generation_test.go index f4f9ae1..2e23e06 100644 --- a/util/file-generation_test.go +++ b/util/file-generation_test.go @@ -1,201 +1,196 @@ package util import ( + "errors" "os" - "path/filepath" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestCreateDirectoryPath(t *testing.T) { - t.Parallel() - - testDir := filepath.Join(os.TempDir(), "kubeslice-test-"+t.Name()) - t.Cleanup(func() { - os.RemoveAll(testDir) - }) - tests := []struct { - name string - path string + name string + path string + statErr error + mkdirErr error + expectFatal bool + fatalContains string }{ { - name: "Create simple directory", - path: "simple-dir", + name: "directory already exists", + path: "/existing/path", + statErr: nil, + }, + { + name: "directory does not exist - creates successfully", + path: "/new/path", + statErr: os.ErrNotExist, }, { - name: "Create nested directories", - path: filepath.Join("nested", "deep", "deeper", "deepest"), + name: "mkdir fails - permission denied", + path: "/restricted/path", + statErr: os.ErrNotExist, + mkdirErr: os.ErrPermission, + expectFatal: true, + fatalContains: "Failed to create kubeslice directory", }, { - name: "Create directory with special characters", - path: "special-chars-dir_123", + name: "mkdir fails - disk full", + path: "/new/path", + statErr: os.ErrNotExist, + mkdirErr: errors.New("no space left on device"), + expectFatal: true, + fatalContains: "Failed to create kubeslice directory", }, { - name: "Handle directory that already exists", - path: "existing-dir", + name: "stat returns non-ErrNotExist error - no mkdir attempted", + path: "/some/path", + statErr: errors.New("disk error"), }, } - for _, tc := range tests { - tc := tc // Capture range variable for parallel execution - t.Run(tc.name, func(t *testing.T) { - t.Parallel() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := NewTestEnvironment() - targetPath := filepath.Join(testDir, tc.path) + fakeFS := FileSystem.(*FakeFileSystem) + fakeOutput := Output.(*FakeOutput) - if tc.name == "Handle directory that already exists" { - if err := os.MkdirAll(targetPath, os.ModePerm); err != nil { - t.Fatalf("Failed to setup test: %v", err) - } + mkdirCalled := false + fakeFS.StatFunc = func(name string) (os.FileInfo, error) { + assert.Equal(t, tt.path, name) + return nil, tt.statErr + } + + fakeFS.MkdirAllFunc = func(path string, perm os.FileMode) error { + mkdirCalled = true + assert.Equal(t, tt.path, path) + assert.Equal(t, os.ModePerm, perm) + return tt.mkdirErr } - CreateDirectoryPath(targetPath) + CreateDirectoryPath(tt.path) - info, err := os.Stat(targetPath) - if err != nil { - t.Errorf("CreateDirectoryPath() failed to create directory: %v", err) - return + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls, "Expected Fatalf to be called") + assert.Contains(t, fakeOutput.FatalCalls[0], tt.fatalContains) + } else { + assert.Empty(t, fakeOutput.FatalCalls, "Did not expect Fatalf to be called") } - if !info.IsDir() { - t.Errorf("CreateDirectoryPath() created a file instead of directory") + + if tt.statErr == os.ErrNotExist { + assert.True(t, mkdirCalled, "Expected MkdirAll to be called") + } else if tt.statErr != nil { + assert.False(t, mkdirCalled, "Did not expect MkdirAll to be called when Stat returns non-ErrNotExist error") } + + cleanup() }) } } func TestDumpFile(t *testing.T) { - t.Parallel() - - testDir := filepath.Join(os.TempDir(), "kubeslice-test-"+t.Name()) - if err := os.MkdirAll(testDir, os.ModePerm); err != nil { - t.Fatalf("Failed to create test directory: %v", err) - } - t.Cleanup(func() { - os.RemoveAll(testDir) - }) - tests := []struct { - name string - content string - filename string + name string + template string + filename string + writeErr error + expectFatal bool + fatalContains string }{ { - name: "Write simple text file", - content: "Hello, World!", - filename: "test-simple.txt", + name: "writes simple text successfully", + template: "Hello, World!", + filename: "/test/file.txt", }, { - name: "Write multi-line content", - content: `Line 1 -Line 2 -Line 3`, - filename: "test-multiline.txt", - }, - { - name: "Write YAML content", - content: `apiVersion: v1 + name: "writes multi-line YAML successfully", + template: `apiVersion: v1 kind: ConfigMap metadata: - name: test-config -data: - key1: value1 - key2: value2`, - filename: "test-config.yaml", + name: test-config`, + filename: "/test/config.yaml", }, { - name: "Write empty content", - content: "", - filename: "test-empty.txt", + name: "writes empty content successfully", + template: "", + filename: "/test/empty.txt", }, { - name: "Write JSON content", - content: `{ + name: "writes JSON content successfully", + template: `{ "name": "test", - "version": "1.0.0", - "description": "test file" + "version": "1.0.0" }`, - filename: "test-config.json", + filename: "/test/config.json", + }, + { + name: "writes content with special characters", + template: "Special: @#$%^&*()_+-=[]{}|;':\",./<>?", + filename: "/test/special.txt", }, { - name: "Overwrite existing file", - content: "New content", - filename: "test-overwrite.txt", + name: "write fails - permission denied", + template: "test content", + filename: "/restricted/file.txt", + writeErr: os.ErrPermission, + expectFatal: true, + fatalContains: "Failed to write /restricted/file.txt", }, { - name: "Write to nested directory", - content: "Nested content", - filename: filepath.Join("nested", "dir", "test-nested.txt"), + name: "write fails - disk full", + template: "test content", + filename: "/test/file.txt", + writeErr: errors.New("no space left on device"), + expectFatal: true, + fatalContains: "Failed to write", }, { - name: "Write file with special characters in content", - content: "Special chars: @#$%^&*()_+-=[]{}|;':\",./<>?", - filename: "test-special-chars.txt", + name: "write fails - invalid path", + template: "test content", + filename: "/invalid\x00path/file.txt", + writeErr: errors.New("invalid argument"), + expectFatal: true, + fatalContains: "Failed to write", }, } - for _, tc := range tests { - tc := tc // Capture range variable for parallel execution - t.Run(tc.name, func(t *testing.T) { - t.Parallel() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := NewTestEnvironment() - targetFile := filepath.Join(testDir, tc.filename) + fakeFS := FileSystem.(*FakeFileSystem) + fakeOutput := Output.(*FakeOutput) - if tc.name == "Overwrite existing file" { - if err := os.MkdirAll(filepath.Dir(targetFile), os.ModePerm); err != nil { - t.Fatalf("Failed to create directory: %v", err) - } - if err := os.WriteFile(targetFile, []byte("Old content"), 0644); err != nil { - t.Fatalf("Failed to setup test: %v", err) - } - } + fakeFS.WriteFileFunc = func(filename string, data []byte, perm os.FileMode) error { + assert.Equal(t, tt.filename, filename) + assert.Equal(t, []byte(tt.template), data) + assert.Equal(t, os.FileMode(0644), perm) - if tc.name == "Write to nested directory" { - if err := os.MkdirAll(filepath.Dir(targetFile), os.ModePerm); err != nil { - t.Fatalf("Failed to create nested directory: %v", err) + if tt.writeErr != nil { + return tt.writeErr } - } - - DumpFile(tc.content, targetFile) - actualContent, err := os.ReadFile(targetFile) - if err != nil { - t.Errorf("DumpFile() failed to create file: %v", err) - return - } - if string(actualContent) != tc.content { - t.Errorf("DumpFile() content mismatch\nwant: %q\ngot: %q", tc.content, string(actualContent)) + fakeFS.WrittenFiles[filename] = data + return nil } - info, err := os.Stat(targetFile) - if err == nil && info.IsDir() { - t.Errorf("DumpFile() created a directory instead of a file") - } - }) - } -} - -func TestDumpFile_CreatesFileWithCorrectPermissions(t *testing.T) { - t.Parallel() - - testDir := filepath.Join(os.TempDir(), "kubeslice-test-"+t.Name()) - if err := os.MkdirAll(testDir, os.ModePerm); err != nil { - t.Fatalf("Failed to create test directory: %v", err) - } - t.Cleanup(func() { - os.RemoveAll(testDir) - }) + DumpFile(tt.template, tt.filename) - targetFile := filepath.Join(testDir, "test-permissions.txt") + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls, "Expected Fatalf to be called") + assert.Contains(t, fakeOutput.FatalCalls[0], tt.fatalContains) + } else { + assert.Empty(t, fakeOutput.FatalCalls, "Did not expect Fatalf to be called") - DumpFile("test content", targetFile) - - info, err := os.Stat(targetFile) - if err != nil { - t.Fatalf("Failed to stat file: %v", err) - } + content, exists := fakeFS.WrittenFiles[tt.filename] + require.True(t, exists, "File should have been written") + assert.Equal(t, tt.template, string(content)) + } - mode := info.Mode() - if mode&0600 == 0 { - t.Errorf("File should be readable and writable by owner, got mode: %v", mode) + cleanup() + }) } } From 04ae3db6d790385f9b92fd9bc531279580d675ba Mon Sep 17 00:00:00 2001 From: Alokzh Date: Sat, 8 Nov 2025 23:59:28 +0530 Subject: [PATCH 20/29] Refactored serviceExport.go & added tests for service export configuration functions Signed-off-by: Alokzh --- pkg/internal/serviceExport.go | 30 ++- pkg/internal/servieExport_test.go | 303 ++++++++++++++++++++++++++++++ 2 files changed, 323 insertions(+), 10 deletions(-) create mode 100644 pkg/internal/servieExport_test.go diff --git a/pkg/internal/serviceExport.go b/pkg/internal/serviceExport.go index 6cd956e..f254c46 100644 --- a/pkg/internal/serviceExport.go +++ b/pkg/internal/serviceExport.go @@ -10,34 +10,44 @@ const ( serviceExportConfigFileName = "serviceExportConfig.yaml" ) +// Function variables for testing +var ( + applyFileFuncServiceExport = ApplyFile + getKubectlResourcesFuncServiceExport = GetKubectlResources + deleteKubectlResourcesFuncServiceExport = DeleteKubectlResources + editKubectlResourcesFuncServiceExport = EditKubectlResources + describeKubectlResourcesFuncServiceExport = DescribeKubectlResources +) + func CreateServiceExportConfig(namespace string, controllerCluster *Cluster, filename string) { - ApplyFile(filename, namespace, controllerCluster) - util.Printf("\nSuccessfully Applied Slice Configuration.") + applyFileFuncServiceExport(filename, namespace, controllerCluster) + util.Printf("\nSuccessfully Applied Service Export Configuration.") } func GetServiceExportConfig(serviceExportConfigName string, namespace string, controllerCluster *Cluster) { util.Printf("\nFetching KubeSlice serviceExportConfig...") - GetKubectlResources(ServiceExportConfigObject, serviceExportConfigName, namespace, controllerCluster, "") - time.Sleep(200 * time.Millisecond) + getKubectlResourcesFuncServiceExport(ServiceExportConfigObject, serviceExportConfigName, namespace, controllerCluster, "") + util.SystemClock.Sleep(200 * time.Millisecond) } + func generateServiceExportConfigManifest(serviceExportConfigName string) { //util.DumpFile(fmt.Sprintf(ServiceExportConfigTemplate, serviceExportConfigName), kubesliceDirectory+"/"+serviceExportConfigFileName) } func DeleteServiceExportConfig(serviceExportConfigName string, namespace string, controllerCluster *Cluster) { util.Printf("\nDeleting KubeSlice serviceExportConfig...") - DeleteKubectlResources(ServiceExportConfigObject, serviceExportConfigName, namespace, controllerCluster) - time.Sleep(200 * time.Millisecond) + deleteKubectlResourcesFuncServiceExport(ServiceExportConfigObject, serviceExportConfigName, namespace, controllerCluster) + util.SystemClock.Sleep(200 * time.Millisecond) } func EditServiceExportConfig(serviceExportConfigName string, namespace string, controllerCluster *Cluster) { util.Printf("\nEditing KubeSlice serviceExportConfig...") - EditKubectlResources(ServiceExportConfigObject, serviceExportConfigName, namespace, controllerCluster) - time.Sleep(200 * time.Millisecond) + editKubectlResourcesFuncServiceExport(ServiceExportConfigObject, serviceExportConfigName, namespace, controllerCluster) + util.SystemClock.Sleep(200 * time.Millisecond) } func DescribeServiceExportConfig(serviceExportConfigName string, namespace string, controllerCluster *Cluster) { util.Printf("\nDescribe KubeSlice serviceExportConfig...") - DescribeKubectlResources(ServiceExportConfigObject, serviceExportConfigName, namespace, controllerCluster) - time.Sleep(200 * time.Millisecond) + describeKubectlResourcesFuncServiceExport(ServiceExportConfigObject, serviceExportConfigName, namespace, controllerCluster) + util.SystemClock.Sleep(200 * time.Millisecond) } diff --git a/pkg/internal/servieExport_test.go b/pkg/internal/servieExport_test.go new file mode 100644 index 0000000..546b700 --- /dev/null +++ b/pkg/internal/servieExport_test.go @@ -0,0 +1,303 @@ +package internal + +import ( + "errors" + "fmt" + "testing" + "time" + + "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateServiceExportConfig(t *testing.T) { + cluster := &Cluster{ + Name: "controller", + ContextName: "controller-ctx", + KubeConfigPath: "/path/config", + } + + tests := []struct { + name string + namespace string + filename string + mockApplyFile func(string, string, *Cluster) + expectFatal bool + fatalContains string + }{ + { + name: "create service export config", + namespace: "test-namespace", + filename: "service-export.yaml", + mockApplyFile: func(fileName, namespace string, c *Cluster) { + assert.Equal(t, "service-export.yaml", fileName) + assert.Equal(t, "test-namespace", namespace) + assert.Equal(t, cluster, c) + }, + }, + { + name: "create with different namespace", + namespace: "custom-namespace", + filename: "custom-export.yaml", + mockApplyFile: func(fileName, namespace string, c *Cluster) { + assert.Equal(t, "custom-export.yaml", fileName) + assert.Equal(t, "custom-namespace", namespace) + }, + }, + { + name: "apply file fails", + namespace: "test-namespace", + filename: "service-export.yaml", + mockApplyFile: func(fileName, namespace string, c *Cluster) { + util.Fatalf("Process failed %v", errors.New("apply failed")) + }, + expectFatal: true, + fatalContains: "apply failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeOutput := util.Output.(*util.FakeOutput) + + applyCalled := false + originalApply := applyFileFuncServiceExport + applyFileFuncServiceExport = func(fileName, namespace string, cluster *Cluster) { + applyCalled = true + if tt.mockApplyFile != nil { + tt.mockApplyFile(fileName, namespace, cluster) + } + } + defer func() { applyFileFuncServiceExport = originalApply }() + + CreateServiceExportConfig(tt.namespace, cluster, tt.filename) + + assert.True(t, applyCalled, "ApplyFile should be called") + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + assert.Contains(t, fmt.Sprint(fakeOutput.FatalCalls[0]), tt.fatalContains) + } else { + assert.Empty(t, fakeOutput.FatalCalls) + } + }) + } +} + +func TestGetServiceExportConfig(t *testing.T) { + cluster := &Cluster{Name: "controller", ContextName: "ctrl-ctx"} + + tests := []struct { + name string + mockGet func(string, string, string, *Cluster, string) + expectFatal bool + fatalContains string + }{ + { + name: "successful get", + mockGet: func(resourceType, resourceName, namespace string, c *Cluster, outputFormat string) { + assert.Equal(t, ServiceExportConfigObject, resourceType) + assert.Equal(t, "my-export", resourceName) + assert.Equal(t, "test-namespace", namespace) + assert.Equal(t, cluster, c) + assert.Equal(t, "", outputFormat) + }, + }, + { + name: "get fails", + mockGet: func(resourceType, resourceName, namespace string, c *Cluster, outputFormat string) { + util.Fatalf("Process failed %v", errors.New("get failed")) + }, + expectFatal: true, + fatalContains: "get failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeClock := util.SystemClock.(*util.FakeClock) + fakeOutput := util.Output.(*util.FakeOutput) + + originalGet := getKubectlResourcesFuncServiceExport + getKubectlResourcesFuncServiceExport = tt.mockGet + defer func() { getKubectlResourcesFuncServiceExport = originalGet }() + + GetServiceExportConfig("my-export", "test-namespace", cluster) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + assert.Contains(t, fmt.Sprint(fakeOutput.FatalCalls[0]), tt.fatalContains) + } else { + assert.Empty(t, fakeOutput.FatalCalls) + require.Len(t, fakeClock.SleepCalls, 1) + assert.Equal(t, 200*time.Millisecond, fakeClock.SleepCalls[0]) + } + }) + } +} + +func TestDeleteServiceExportConfig(t *testing.T) { + cluster := &Cluster{Name: "controller"} + + tests := []struct { + name string + mockDelete func(string, string, string, *Cluster) + expectFatal bool + fatalContains string + }{ + { + name: "successful delete", + mockDelete: func(resourceType, resourceName, namespace string, c *Cluster) { + assert.Equal(t, ServiceExportConfigObject, resourceType) + assert.Equal(t, "my-export", resourceName) + assert.Equal(t, "test-namespace", namespace) + }, + }, + { + name: "delete fails", + mockDelete: func(resourceType, resourceName, namespace string, c *Cluster) { + util.Fatalf("Process failed %v", errors.New("delete failed")) + }, + expectFatal: true, + fatalContains: "delete failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeClock := util.SystemClock.(*util.FakeClock) + fakeOutput := util.Output.(*util.FakeOutput) + + originalDelete := deleteKubectlResourcesFuncServiceExport + deleteKubectlResourcesFuncServiceExport = tt.mockDelete + defer func() { deleteKubectlResourcesFuncServiceExport = originalDelete }() + + DeleteServiceExportConfig("my-export", "test-namespace", cluster) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + assert.Contains(t, fmt.Sprint(fakeOutput.FatalCalls[0]), tt.fatalContains) + } else { + assert.Empty(t, fakeOutput.FatalCalls) + require.Len(t, fakeClock.SleepCalls, 1) + assert.Equal(t, 200*time.Millisecond, fakeClock.SleepCalls[0]) + } + }) + } +} + +func TestEditServiceExportConfig(t *testing.T) { + cluster := &Cluster{Name: "controller"} + + tests := []struct { + name string + mockEdit func(string, string, string, *Cluster) + expectFatal bool + fatalContains string + }{ + { + name: "successful edit", + mockEdit: func(resourceType, resourceName, namespace string, c *Cluster) { + assert.Equal(t, ServiceExportConfigObject, resourceType) + assert.Equal(t, "my-export", resourceName) + assert.Equal(t, "test-namespace", namespace) + }, + }, + { + name: "edit fails", + mockEdit: func(resourceType, resourceName, namespace string, c *Cluster) { + util.Fatalf("Process failed %v", errors.New("edit failed")) + }, + expectFatal: true, + fatalContains: "edit failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeClock := util.SystemClock.(*util.FakeClock) + fakeOutput := util.Output.(*util.FakeOutput) + + originalEdit := editKubectlResourcesFuncServiceExport + editKubectlResourcesFuncServiceExport = tt.mockEdit + defer func() { editKubectlResourcesFuncServiceExport = originalEdit }() + + EditServiceExportConfig("my-export", "test-namespace", cluster) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + assert.Contains(t, fmt.Sprint(fakeOutput.FatalCalls[0]), tt.fatalContains) + } else { + assert.Empty(t, fakeOutput.FatalCalls) + require.Len(t, fakeClock.SleepCalls, 1) + assert.Equal(t, 200*time.Millisecond, fakeClock.SleepCalls[0]) + } + }) + } +} + +func TestDescribeServiceExportConfig(t *testing.T) { + cluster := &Cluster{Name: "controller"} + + tests := []struct { + name string + mockDescribe func(string, string, string, *Cluster) + expectFatal bool + fatalContains string + }{ + { + name: "successful describe", + mockDescribe: func(resourceType, resourceName, namespace string, c *Cluster) { + assert.Equal(t, ServiceExportConfigObject, resourceType) + assert.Equal(t, "my-export", resourceName) + assert.Equal(t, "test-namespace", namespace) + }, + }, + { + name: "describe fails", + mockDescribe: func(resourceType, resourceName, namespace string, c *Cluster) { + util.Fatalf("Process failed %v", errors.New("describe failed")) + }, + expectFatal: true, + fatalContains: "describe failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeClock := util.SystemClock.(*util.FakeClock) + fakeOutput := util.Output.(*util.FakeOutput) + + originalDescribe := describeKubectlResourcesFuncServiceExport + describeKubectlResourcesFuncServiceExport = tt.mockDescribe + defer func() { describeKubectlResourcesFuncServiceExport = originalDescribe }() + + DescribeServiceExportConfig("my-export", "test-namespace", cluster) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + assert.Contains(t, fmt.Sprint(fakeOutput.FatalCalls[0]), tt.fatalContains) + } else { + assert.Empty(t, fakeOutput.FatalCalls) + require.Len(t, fakeClock.SleepCalls, 1) + assert.Equal(t, 200*time.Millisecond, fakeClock.SleepCalls[0]) + } + }) + } +} From 889511f588e050fbc754b077ab2ad761f204ec86 Mon Sep 17 00:00:00 2001 From: Alokzh Date: Sun, 9 Nov 2025 11:34:07 +0530 Subject: [PATCH 21/29] Refactored slice-config.go & added tests for slice configuration functions Signed-off-by: Alokzh --- pkg/internal/slice-config.go | 55 ++-- pkg/internal/slice-config_test.go | 453 ++++++++++++++++++++++++++++++ 2 files changed, 488 insertions(+), 20 deletions(-) create mode 100644 pkg/internal/slice-config_test.go diff --git a/pkg/internal/slice-config.go b/pkg/internal/slice-config.go index 2636356..37f8d64 100644 --- a/pkg/internal/slice-config.go +++ b/pkg/internal/slice-config.go @@ -41,6 +41,21 @@ spec: - '*' ` +const ( + defaultNodeIPWaitTime = 5 * time.Second + defaultNodeIPRetryLimit = 10 +) + +var ( + applyKubectlManifestFuncSlice = ApplyKubectlManifest + getKubectlResourcesFuncSlice = GetKubectlResources + deleteKubectlResourcesFuncSlice = DeleteKubectlResources + editKubectlResourcesFuncSlice = EditKubectlResources + describeKubectlResourcesFuncSlice = DescribeKubectlResources + verifyNodeIPsInClustersFunc = verifyNodeIPsInClusters + applyFileFuncSlice = ApplyFile +) + func GenerateSliceConfiguration(ApplicationConfiguration *ConfigurationSpecs, worker []string, sliceConfigName string, namespace string) { util.Printf("\nGenerating Slice Configuration to %s directory", kubesliceDirectory) clusters := make([]string, 0) @@ -62,69 +77,69 @@ func GenerateSliceConfiguration(ApplicationConfiguration *ConfigurationSpecs, wo } util.DumpFile(fmt.Sprintf(sliceTemplate, sliceConfigName, projectNamespace, clusterString), kubesliceDirectory+"/"+"slice-"+sliceConfigName+".yaml") util.Printf("%s Generated %s", util.Tick, "slice-"+sliceConfigName+".yaml") - time.Sleep(200 * time.Millisecond) - + util.SystemClock.Sleep(200 * time.Millisecond) util.Printf("Generated Slice Configuration") } func ApplySliceConfiguration(ApplicationConfiguration *ConfigurationSpecs) { - verifyNodeIPsInClusters(ApplicationConfiguration) + verifyNodeIPsInClustersFunc(ApplicationConfiguration) util.Printf("\nApplying Slice Manifest %s to %s cluster", sliceTemplateFileName, ApplicationConfiguration.Configuration.ClusterConfiguration.ControllerCluster.Name) - - ApplyKubectlManifest(kubesliceDirectory+"/"+sliceTemplateFileName, "kubeslice-demo", &ApplicationConfiguration.Configuration.ClusterConfiguration.ControllerCluster) - + applyKubectlManifestFuncSlice(kubesliceDirectory+"/"+sliceTemplateFileName, "kubeslice-demo", &ApplicationConfiguration.Configuration.ClusterConfiguration.ControllerCluster) util.Printf("\nSuccessfully Applied Slice Configuration.") } func verifyNodeIPsInClusters(ApplicationConfiguration *ConfigurationSpecs) { - var outB, errB bytes.Buffer cc := ApplicationConfiguration.Configuration.ClusterConfiguration.ControllerCluster wc := ApplicationConfiguration.Configuration.ClusterConfiguration.WorkerClusters projectNamespace := "kubeslice-" + ApplicationConfiguration.Configuration.KubeSliceConfiguration.ProjectName for _, cluster := range wc { util.Printf("%s Waiting for NodeIPs to be populated in %s...", util.Wait, cluster.Name) var nodeIPs string - i := 1 // retry for 50 seconds - for nodeIPs == "" && i < 11 { - util.RunCommandCustomIO("kubectl", &outB, &errB, true, "--context="+cc.ContextName, "--kubeconfig="+cc.KubeConfigPath, "get", ClusterObject, cluster.Name, "-n", projectNamespace, "-o", "jsonpath='{.status.nodeIPs}'") + i := 1 + for nodeIPs == "" && i < defaultNodeIPRetryLimit+1 { + var outB, errB bytes.Buffer + util.CommandExecutor.ExecuteWithOutput("kubectl", &outB, &errB, "--context="+cc.ContextName, "--kubeconfig="+cc.KubeConfigPath, "get", ClusterObject, cluster.Name, "-n", projectNamespace, "-o", "jsonpath='{.status.nodeIPs}'") nodeIPs = outB.String() if nodeIPs == "" { - time.Sleep(5 * time.Second) + util.SystemClock.Sleep(defaultNodeIPWaitTime) util.Printf("%s Waiting for NodeIPs to be populated in %s... %d seconds elapsed", util.Wait, cluster.Name, i*5) i++ } else { util.Printf("%s NodeIPs populated in %s", util.Tick, cluster.Name) } } + if nodeIPs == "" { + util.Printf("%s Warning: NodeIPs not populated in %s after %d seconds", util.Warn, cluster.Name, defaultNodeIPRetryLimit*5) + } } } func GetSliceConfig(sliceConfigName string, namespace string, controllerCluster *Cluster) { util.Printf("\nFetching KubeSlice sliceConfig...") - GetKubectlResources(SliceConfigObject, sliceConfigName, namespace, controllerCluster, "") - time.Sleep(200 * time.Millisecond) + getKubectlResourcesFuncSlice(SliceConfigObject, sliceConfigName, namespace, controllerCluster, "") + util.SystemClock.Sleep(200 * time.Millisecond) } func DeleteSliceConfig(sliceConfigName string, namespace string, controllerCluster *Cluster) { util.Printf("\nDeleting KubeSlice SliceConfig...") - DeleteKubectlResources(SliceConfigObject, sliceConfigName, namespace, controllerCluster) - time.Sleep(200 * time.Millisecond) + deleteKubectlResourcesFuncSlice(SliceConfigObject, sliceConfigName, namespace, controllerCluster) + util.SystemClock.Sleep(200 * time.Millisecond) } func EditSliceConfig(sliceConfigName string, namespace string, controllerCluster *Cluster) { util.Printf("\nEditing KubeSlice SliceConfig...") - EditKubectlResources(SliceConfigObject, sliceConfigName, namespace, controllerCluster) - time.Sleep(200 * time.Millisecond) + editKubectlResourcesFuncSlice(SliceConfigObject, sliceConfigName, namespace, controllerCluster) + util.SystemClock.Sleep(200 * time.Millisecond) } func DescribeSliceConfig(sliceConfigName string, namespace string, controllerCluster *Cluster) { util.Printf("\nDescribing KubeSlice SliceConfig...") - DescribeKubectlResources(SliceConfigObject, sliceConfigName, namespace, controllerCluster) - time.Sleep(200 * time.Millisecond) + describeKubectlResourcesFuncSlice(SliceConfigObject, sliceConfigName, namespace, controllerCluster) + util.SystemClock.Sleep(200 * time.Millisecond) } func CreateSliceConfig(namespace string, controllerCluster *Cluster, filename string) { - ApplyFile(filename, namespace, controllerCluster) + applyFileFuncSlice(filename, namespace, controllerCluster) util.Printf("\nSuccessfully Applied Slice Configuration.") } diff --git a/pkg/internal/slice-config_test.go b/pkg/internal/slice-config_test.go new file mode 100644 index 0000000..d8b3008 --- /dev/null +++ b/pkg/internal/slice-config_test.go @@ -0,0 +1,453 @@ +package internal + +import ( + "errors" + "fmt" + "io" + "testing" + "time" + + "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateSliceConfiguration(t *testing.T) { + tests := []struct { + name string + config *ConfigurationSpecs + workers []string + sliceConfigName string + namespace string + expectedFileName string + expectedClusters string + expectedNamespace string + }{ + { + name: "generate with default workers and name", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + WorkerClusters: []Cluster{{Name: "worker-1"}, {Name: "worker-2"}}, + }, + KubeSliceConfiguration: KubeSliceConfiguration{ProjectName: "test-project"}, + }, + }, + workers: []string{}, + sliceConfigName: "", + namespace: "", + expectedFileName: "slice-demo.yaml", + expectedClusters: "clusters: [worker-1,worker-2]", + expectedNamespace: "namespace: kubeslice-test-project", + }, + { + name: "generate with custom workers", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + WorkerClusters: []Cluster{{Name: "worker-1"}, {Name: "worker-2"}}, + }, + KubeSliceConfiguration: KubeSliceConfiguration{ProjectName: "test-project"}, + }, + }, + workers: []string{"worker-1", "worker-3"}, + sliceConfigName: "custom-slice", + namespace: "custom-namespace", + expectedFileName: "slice-custom-slice.yaml", + expectedClusters: "clusters: [worker-1,worker-3]", + expectedNamespace: "namespace: custom-namespace", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeFS := util.FileSystem.(*util.FakeFileSystem) + fakeClock := util.SystemClock.(*util.FakeClock) + + GenerateSliceConfiguration(tt.config, tt.workers, tt.sliceConfigName, tt.namespace) + + filePath := kubesliceDirectory + "/" + tt.expectedFileName + content, exists := fakeFS.WrittenFiles[filePath] + require.True(t, exists) + contentStr := string(content) + assert.Contains(t, contentStr, "kind: SliceConfig") + assert.Contains(t, contentStr, tt.expectedClusters) + assert.Contains(t, contentStr, tt.expectedNamespace) + + require.Len(t, fakeClock.SleepCalls, 1) + assert.Equal(t, 200*time.Millisecond, fakeClock.SleepCalls[0]) + }) + } +} + +func TestApplySliceConfiguration(t *testing.T) { + config := &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: "controller"}, + }, + }, + } + + tests := []struct { + name string + mockVerify func(*ConfigurationSpecs) + mockApply func(string, string, *Cluster) + expectFatal bool + fatalContains string + }{ + { + name: "successful application", + mockVerify: func(cfg *ConfigurationSpecs) {}, + mockApply: func(fileName, namespace string, cluster *Cluster) { + assert.Contains(t, fileName, sliceTemplateFileName) + assert.Equal(t, "kubeslice-demo", namespace) + }, + }, + { + name: "apply fails", + mockVerify: func(cfg *ConfigurationSpecs) {}, + mockApply: func(fileName, namespace string, cluster *Cluster) { + util.Fatalf("Process failed %v", errors.New("apply failed")) + }, + expectFatal: true, + fatalContains: "apply failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeOutput := util.Output.(*util.FakeOutput) + + originalVerify := verifyNodeIPsInClustersFunc + verifyNodeIPsInClustersFunc = tt.mockVerify + defer func() { verifyNodeIPsInClustersFunc = originalVerify }() + + originalApply := applyKubectlManifestFuncSlice + applyKubectlManifestFuncSlice = tt.mockApply + defer func() { applyKubectlManifestFuncSlice = originalApply }() + + ApplySliceConfiguration(config) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + assert.Contains(t, fmt.Sprint(fakeOutput.FatalCalls[0]), tt.fatalContains) + } else { + assert.Empty(t, fakeOutput.FatalCalls) + } + }) + } +} + +func TestVerifyNodeIPsInClusters(t *testing.T) { + config := &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: "controller", ContextName: "ctrl-ctx"}, + WorkerClusters: []Cluster{{Name: "worker-1"}}, + }, + KubeSliceConfiguration: KubeSliceConfiguration{ProjectName: "test-project"}, + }, + } + + tests := []struct { + name string + mockExecutor func(*util.FakeExecutor) + expectedSleeps int + }{ + { + name: "IPs populated immediately", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stdout.Write([]byte("'10.0.0.1'")) + return nil + } + }, + expectedSleeps: 0, + }, + { + name: "IPs populated after 3 retries", + mockExecutor: func(fe *util.FakeExecutor) { + callCount := 0 + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + callCount++ + if callCount < 3 { + stdout.Write([]byte("")) + } else { + stdout.Write([]byte("'10.0.0.1'")) + } + return nil + } + }, + expectedSleeps: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeClock := util.SystemClock.(*util.FakeClock) + fakeOutput := util.Output.(*util.FakeOutput) + + tt.mockExecutor(fakeExec) + + verifyNodeIPsInClusters(config) + + assert.Empty(t, fakeOutput.FatalCalls) + assert.Len(t, fakeClock.SleepCalls, tt.expectedSleeps) + }) + } +} + +func TestGetSliceConfig(t *testing.T) { + cluster := &Cluster{Name: "controller"} + + tests := []struct { + name string + mockGet func(string, string, string, *Cluster, string) + expectFatal bool + fatalContains string + }{ + { + name: "successful get", + mockGet: func(resourceType, resourceName, namespace string, c *Cluster, outputFormat string) { + assert.Equal(t, SliceConfigObject, resourceType) + assert.Equal(t, "my-slice", resourceName) + }, + }, + { + name: "get fails", + mockGet: func(resourceType, resourceName, namespace string, c *Cluster, outputFormat string) { + util.Fatalf("Process failed %v", errors.New("get failed")) + }, + expectFatal: true, + fatalContains: "get failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeClock := util.SystemClock.(*util.FakeClock) + fakeOutput := util.Output.(*util.FakeOutput) + + originalGet := getKubectlResourcesFuncSlice + getKubectlResourcesFuncSlice = tt.mockGet + defer func() { getKubectlResourcesFuncSlice = originalGet }() + + GetSliceConfig("my-slice", "test-namespace", cluster) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + assert.Contains(t, fmt.Sprint(fakeOutput.FatalCalls[0]), tt.fatalContains) + } else { + assert.Empty(t, fakeOutput.FatalCalls) + require.Len(t, fakeClock.SleepCalls, 1) + assert.Equal(t, 200*time.Millisecond, fakeClock.SleepCalls[0]) + } + }) + } +} + +func TestDeleteSliceConfig(t *testing.T) { + cluster := &Cluster{Name: "controller"} + + tests := []struct { + name string + mockDelete func(string, string, string, *Cluster) + expectFatal bool + fatalContains string + }{ + { + name: "successful delete", + mockDelete: func(resourceType, resourceName, namespace string, c *Cluster) { + assert.Equal(t, SliceConfigObject, resourceType) + }, + }, + { + name: "delete fails", + mockDelete: func(resourceType, resourceName, namespace string, c *Cluster) { + util.Fatalf("Process failed %v", errors.New("delete failed")) + }, + expectFatal: true, + fatalContains: "delete failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeClock := util.SystemClock.(*util.FakeClock) + fakeOutput := util.Output.(*util.FakeOutput) + + originalDelete := deleteKubectlResourcesFuncSlice + deleteKubectlResourcesFuncSlice = tt.mockDelete + defer func() { deleteKubectlResourcesFuncSlice = originalDelete }() + + DeleteSliceConfig("my-slice", "test-namespace", cluster) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + assert.Contains(t, fmt.Sprint(fakeOutput.FatalCalls[0]), tt.fatalContains) + } else { + assert.Empty(t, fakeOutput.FatalCalls) + require.Len(t, fakeClock.SleepCalls, 1) + assert.Equal(t, 200*time.Millisecond, fakeClock.SleepCalls[0]) + } + }) + } +} + +func TestEditSliceConfig(t *testing.T) { + cluster := &Cluster{Name: "controller"} + + tests := []struct { + name string + mockEdit func(string, string, string, *Cluster) + expectFatal bool + fatalContains string + }{ + { + name: "successful edit", + mockEdit: func(resourceType, resourceName, namespace string, c *Cluster) { + assert.Equal(t, SliceConfigObject, resourceType) + }, + }, + { + name: "edit fails", + mockEdit: func(resourceType, resourceName, namespace string, c *Cluster) { + util.Fatalf("Process failed %v", errors.New("edit failed")) + }, + expectFatal: true, + fatalContains: "edit failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeClock := util.SystemClock.(*util.FakeClock) + fakeOutput := util.Output.(*util.FakeOutput) + + originalEdit := editKubectlResourcesFuncSlice + editKubectlResourcesFuncSlice = tt.mockEdit + defer func() { editKubectlResourcesFuncSlice = originalEdit }() + + EditSliceConfig("my-slice", "test-namespace", cluster) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + assert.Contains(t, fmt.Sprint(fakeOutput.FatalCalls[0]), tt.fatalContains) + } else { + assert.Empty(t, fakeOutput.FatalCalls) + require.Len(t, fakeClock.SleepCalls, 1) + assert.Equal(t, 200*time.Millisecond, fakeClock.SleepCalls[0]) + } + }) + } +} + +func TestDescribeSliceConfig(t *testing.T) { + cluster := &Cluster{Name: "controller"} + + tests := []struct { + name string + mockDescribe func(string, string, string, *Cluster) + expectFatal bool + fatalContains string + }{ + { + name: "successful describe", + mockDescribe: func(resourceType, resourceName, namespace string, c *Cluster) { + assert.Equal(t, SliceConfigObject, resourceType) + }, + }, + { + name: "describe fails", + mockDescribe: func(resourceType, resourceName, namespace string, c *Cluster) { + util.Fatalf("Process failed %v", errors.New("describe failed")) + }, + expectFatal: true, + fatalContains: "describe failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeClock := util.SystemClock.(*util.FakeClock) + fakeOutput := util.Output.(*util.FakeOutput) + + originalDescribe := describeKubectlResourcesFuncSlice + describeKubectlResourcesFuncSlice = tt.mockDescribe + defer func() { describeKubectlResourcesFuncSlice = originalDescribe }() + + DescribeSliceConfig("my-slice", "test-namespace", cluster) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + assert.Contains(t, fmt.Sprint(fakeOutput.FatalCalls[0]), tt.fatalContains) + } else { + assert.Empty(t, fakeOutput.FatalCalls) + require.Len(t, fakeClock.SleepCalls, 1) + assert.Equal(t, 200*time.Millisecond, fakeClock.SleepCalls[0]) + } + }) + } +} + +func TestCreateSliceConfig(t *testing.T) { + cluster := &Cluster{Name: "controller"} + + tests := []struct { + name string + expectFatal bool + }{ + {name: "create slice config from file"}, + {name: "create fails", expectFatal: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeOutput := util.Output.(*util.FakeOutput) + + originalApply := applyFileFuncSlice + defer func() { applyFileFuncSlice = originalApply }() + + applyFileFuncSlice = func(fileName, namespace string, cluster *Cluster) { + if tt.expectFatal { + util.Fatalf("Process failed %v", errors.New("apply failed")) + } + } + + CreateSliceConfig("test-namespace", cluster, "slice-config.yaml") + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + assert.Contains(t, fmt.Sprint(fakeOutput.FatalCalls[0]), "apply failed") + } else { + assert.Empty(t, fakeOutput.FatalCalls) + } + }) + } +} From 2724007608c707592e563ddeccbd4fcd54b0c2ea Mon Sep 17 00:00:00 2001 From: Alokzh Date: Sun, 9 Nov 2025 14:31:41 +0530 Subject: [PATCH 22/29] Refactored worker.go & added worker_test.go with tests for installation, uninstallation & secret fetching Signed-off-by: Alokzh --- pkg/internal/worker.go | 65 +++---- pkg/internal/worker_test.go | 363 ++++++++++++++++++++++++++++++++++++ 2 files changed, 396 insertions(+), 32 deletions(-) create mode 100644 pkg/internal/worker_test.go diff --git a/pkg/internal/worker.go b/pkg/internal/worker.go index ba0fe45..2c2f907 100644 --- a/pkg/internal/worker.go +++ b/pkg/internal/worker.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/json" "fmt" - "log" "strings" "time" @@ -23,9 +22,18 @@ metrics: cluster: name: %s endpoint: %s - ` +const ( + workerNamespace = "kubeslice-system" +) + +var ( + podVerificationFuncWorker = PodVerification + retryFunc = Retry + fetchSecretFunc = fetchSecret +) + func InstallKubeSliceWorker(ApplicationConfiguration *ConfigurationSpecs) { util.Printf("\nInstalling KubeSlice Worker...") @@ -40,13 +48,12 @@ func InstallKubeSliceWorker(ApplicationConfiguration *ConfigurationSpecs) { ) util.Printf("%s Generated Helm Values file for Worker Installation %s", util.Tick, filename) - time.Sleep(200 * time.Millisecond) - + util.SystemClock.Sleep(200 * time.Millisecond) installWorker(cluster, filename, ApplicationConfiguration.Configuration.HelmChartConfiguration) } util.Printf("%s Successfully Installed Kubeslice Worker", util.Tick) - time.Sleep(200 * time.Millisecond) + util.SystemClock.Sleep(200 * time.Millisecond) } func UninstallKubeSliceWorker(ApplicationConfiguration *ConfigurationSpecs, workersToUninstall map[string]string) { @@ -59,20 +66,17 @@ func UninstallKubeSliceWorker(ApplicationConfiguration *ConfigurationSpecs, work _, found := workersToUninstall[cluster.Name] if found || uninstallAllWorker { uninstallKubeSliceWorkerHelm(cluster) - time.Sleep(200 * time.Millisecond) + util.SystemClock.Sleep(200 * time.Millisecond) } } - - // util.Printf("%s Successfully Installed Kubeslice Worker", util.Tick) - time.Sleep(200 * time.Millisecond) + util.SystemClock.Sleep(200 * time.Millisecond) } -// Retry tries to execute the funtion, If failed reattempts till backoffLimit func Retry(backoffLimit int, sleep time.Duration, f func() error) (err error) { start := time.Now() for i := 0; i < backoffLimit; i++ { if i > 0 { - time.Sleep(sleep) + util.SystemClock.Sleep(sleep) sleep *= 2 } err = f() @@ -86,19 +90,19 @@ func Retry(backoffLimit int, sleep time.Duration, f func() error) (err error) { func generateWorkerValuesFile(cluster Cluster, valuesFile string, config Configuration, insecureMetrics bool) { var secrets map[string]string - err := Retry(3, 1*time.Second, func() (err error) { - secrets = fetchSecret(cluster.Name, config.ClusterConfiguration.ControllerCluster, config.KubeSliceConfiguration.ProjectName) + err := retryFunc(3, 1*time.Second, func() (err error) { + secrets = fetchSecretFunc(cluster.Name, config.ClusterConfiguration.ControllerCluster, config.KubeSliceConfiguration.ProjectName) if secrets["namespace"] == "" || secrets["controllerEndpoint"] == "" || secrets["ca.crt"] == "" || secrets["token"] == "" { return fmt.Errorf("secret is empty") } return nil }) if err != nil { - log.Fatalf("Unable to fetch secrets\n%s", err) + util.Fatalf("Unable to fetch secrets\n%s", err) } - err = generateValuesFile(kubesliceDirectory+"/"+valuesFile, &config.HelmChartConfiguration.WorkerChart, fmt.Sprintf(workerValuesTemplate+generateImagePullSecretsValue(config.HelmChartConfiguration.ImagePullSecret), secrets["namespace"], secrets["controllerEndpoint"], secrets["ca.crt"], secrets["token"], insecureMetrics, cluster.Name, cluster.ControlPlaneAddress)) + err = generateValuesFileFunc(kubesliceDirectory+"/"+valuesFile, &config.HelmChartConfiguration.WorkerChart, fmt.Sprintf(workerValuesTemplate+generateImagePullSecretsValue(config.HelmChartConfiguration.ImagePullSecret), secrets["namespace"], secrets["controllerEndpoint"], secrets["ca.crt"], secrets["token"], insecureMetrics, cluster.Name, cluster.ControlPlaneAddress)) if err != nil { - log.Fatalf("%s %s", util.Cross, err) + util.Fatalf("%s %s", util.Cross, err) } } @@ -106,23 +110,21 @@ func installWorker(cluster Cluster, valuesName string, helmChartConfig HelmChart hc := helmChartConfig installKubeSliceWorkerHelm(cluster, valuesName, hc) util.Printf("%s Successfully installed helm chart %s/%s on %s", util.Tick, hc.RepoAlias, hc.WorkerChart.ChartName, cluster.Name) - time.Sleep(200 * time.Millisecond) - + util.SystemClock.Sleep(200 * time.Millisecond) util.Printf("%s Waiting for KubeSlice Worker Pods to be Healthy...", util.Wait) - PodVerification("Waiting for KubeSlice Worker Pods to be Healthy", cluster, "kubeslice-system") - + podVerificationFuncWorker("Waiting for KubeSlice Worker Pods to be Healthy", cluster, workerNamespace) util.Printf("%s Successfully installed KubeSlice Worker %s.", util.Tick, cluster.Name) } func installKubeSliceWorkerHelm(cluster Cluster, valuesFile string, hc HelmChartConfiguration) { args := make([]string, 0) - args = append(args, "--kube-context", cluster.ContextName, "--kubeconfig", cluster.KubeConfigPath, "upgrade", "-i", "kubeslice-worker", fmt.Sprintf("%s/%s", hc.RepoAlias, hc.WorkerChart.ChartName), "--namespace", "kubeslice-system", "--create-namespace", "-f", kubesliceDirectory+"/"+valuesFile) + args = append(args, "--kube-context", cluster.ContextName, "--kubeconfig", cluster.KubeConfigPath, "upgrade", "-i", "kubeslice-worker", fmt.Sprintf("%s/%s", hc.RepoAlias, hc.WorkerChart.ChartName), "--namespace", workerNamespace, "--create-namespace", "-f", kubesliceDirectory+"/"+valuesFile) if hc.WorkerChart.Version != "" { args = append(args, "--version", hc.WorkerChart.Version) } - err := util.RunCommand("helm", args...) + err := util.CommandExecutor.Execute("helm", args...) if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } } @@ -131,23 +133,23 @@ func fetchSecret(clusterName string, cc Cluster, projectName string) map[string] secret := findSecret(clusterName, projectName, cc) //kubectl get secret/kubeslice-rbac-worker-kubeslice-worker-1-token-h99pc -n kubeslice-demo -o jsonpath={.data} var outB, errB bytes.Buffer - err := util.RunCommandCustomIO("kubectl", &outB, &errB, true, "--context="+cc.ContextName, "--kubeconfig="+cc.KubeConfigPath, "get", secret, "-n", "kubeslice-"+projectName, "-o", "jsonpath={.data}") + err := util.CommandExecutor.ExecuteWithOutput("kubectl", &outB, &errB, "--context="+cc.ContextName, "--kubeconfig="+cc.KubeConfigPath, "get", secret, "-n", "kubeslice-"+projectName, "-o", "jsonpath={.data}") if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } x := map[string]string{} err = json.Unmarshal(outB.Bytes(), &x) if err != nil { - log.Fatalf("failed to read secret %s", secret) + util.Fatalf("failed to read secret %s", secret) } return x } func findSecret(workerName string, projectName string, cc Cluster) string { var outB, errB bytes.Buffer - err := util.RunCommandCustomIO("kubectl", &outB, &errB, true, "--context="+cc.ContextName, "--kubeconfig="+cc.KubeConfigPath, "get", "sa", "-n", "kubeslice-"+projectName, "-o", "name") + err := util.CommandExecutor.ExecuteWithOutput("kubectl", &outB, &errB, "--context="+cc.ContextName, "--kubeconfig="+cc.KubeConfigPath, "get", "sa", "-n", "kubeslice-"+projectName, "-o", "name") if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } var secret string @@ -158,16 +160,15 @@ func findSecret(workerName string, projectName string, cc Cluster) string { } } if secret == "" { - log.Fatalf("failed to find secret for %s", workerName) + util.Fatalf("failed to find secret for %s", workerName) } return secret } func uninstallKubeSliceWorkerHelm(cluster Cluster) { args := make([]string, 0) - args = append(args, "--kube-context", cluster.ContextName, "--kubeconfig", cluster.KubeConfigPath, "uninstall", "kubeslice-worker", "--namespace", "kubeslice-system") - - err := util.RunCommand("helm", args...) + args = append(args, "--kube-context", cluster.ContextName, "--kubeconfig", cluster.KubeConfigPath, "uninstall", "kubeslice-worker", "--namespace", workerNamespace) + err := util.CommandExecutor.Execute("helm", args...) if err != nil { util.Printf("%s Uninstall failed. %v", util.Cross, err) } diff --git a/pkg/internal/worker_test.go b/pkg/internal/worker_test.go new file mode 100644 index 0000000..791ecc7 --- /dev/null +++ b/pkg/internal/worker_test.go @@ -0,0 +1,363 @@ +package internal + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "testing" + "time" + + "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstallKubeSliceWorker(t *testing.T) { + baseConfig := &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ClusterType: "eks", + ControllerCluster: Cluster{Name: "controller", ContextName: "controller-ctx"}, + WorkerClusters: []Cluster{ + {Name: "worker-1", ContextName: "worker-1-ctx"}, + {Name: "worker-2", ContextName: "worker-2-ctx"}, + }, + }, + KubeSliceConfiguration: KubeSliceConfiguration{ProjectName: "test-project"}, + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice", + WorkerChart: HelmChart{ChartName: "kubeslice-worker"}, + }, + }, + } + + mockSecretData := map[string]string{ + "namespace": "ns", + "controllerEndpoint": "https://controller", + "ca.crt": "cert", + "token": "token", + } + + tests := []struct { + name string + config *ConfigurationSpecs + mockExecutor func(*util.FakeExecutor) + mockPodVerification func(string, Cluster, string) + mockRetry func(int, time.Duration, func() error) error + mockGenerateValues func(string, *HelmChart, string) error + mockFetchSecret func(string, Cluster, string) map[string]string + expectFatal bool + fatalContains string + expectedWorkerCount int + }{ + { + name: "successful installation", + config: baseConfig, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { return nil } + }, + mockPodVerification: func(msg string, cluster Cluster, namespace string) {}, + mockRetry: func(backoff int, sleep time.Duration, f func() error) error { return f() }, + mockFetchSecret: func(clusterName string, cc Cluster, projectName string) map[string]string { return mockSecretData }, + mockGenerateValues: func(fileName string, chart *HelmChart, values string) error { return nil }, + expectedWorkerCount: 2, + }, + { + name: "secret fetch fails", + config: baseConfig, + mockRetry: func(backoff int, sleep time.Duration, f func() error) error { return errors.New("retry failed") }, + expectFatal: true, + fatalContains: "Unable to fetch secrets", + }, + { + name: "helm install fails", + config: baseConfig, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { return errors.New("helm failed") } + }, + mockRetry: func(backoff int, sleep time.Duration, f func() error) error { return f() }, + mockFetchSecret: func(clusterName string, cc Cluster, projectName string) map[string]string { return mockSecretData }, + mockGenerateValues: func(fileName string, chart *HelmChart, values string) error { return nil }, + expectFatal: true, + fatalContains: "helm failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + fakeClock := util.SystemClock.(*util.FakeClock) + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + podVerifyCallCount := 0 + originalPodVerify := podVerificationFuncWorker + podVerificationFuncWorker = func(msg string, cluster Cluster, namespace string) { + podVerifyCallCount++ + if tt.mockPodVerification != nil { + tt.mockPodVerification(msg, cluster, namespace) + } + } + defer func() { podVerificationFuncWorker = originalPodVerify }() + + originalRetry := retryFunc + if tt.mockRetry != nil { + retryFunc = tt.mockRetry + } + defer func() { retryFunc = originalRetry }() + + originalGenerateValues := generateValuesFileFunc + if tt.mockGenerateValues != nil { + generateValuesFileFunc = tt.mockGenerateValues + } + defer func() { generateValuesFileFunc = originalGenerateValues }() + + originalFetchSecret := fetchSecretFunc + if tt.mockFetchSecret != nil { + fetchSecretFunc = tt.mockFetchSecret + } + defer func() { fetchSecretFunc = originalFetchSecret }() + + InstallKubeSliceWorker(tt.config) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + if tt.fatalContains != "" { + assert.Contains(t, fmt.Sprint(fakeOutput.FatalCalls[0]), tt.fatalContains) + } + } else { + assert.Empty(t, fakeOutput.FatalCalls) + assert.Equal(t, tt.expectedWorkerCount, podVerifyCallCount) + expectedSleeps := tt.expectedWorkerCount*2 + 1 + require.Len(t, fakeClock.SleepCalls, expectedSleeps) + } + + cleanup() + }) + } +} + +func TestUninstallKubeSliceWorker(t *testing.T) { + config := &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + WorkerClusters: []Cluster{ + {Name: "worker-1", ContextName: "w1-ctx"}, + {Name: "worker-2", ContextName: "w2-ctx"}, + }, + }, + }, + } + + tests := []struct { + name string + workersToUninstall map[string]string + expectedUninstalls int + }{ + { + name: "uninstall all with wildcard", + workersToUninstall: map[string]string{"*": ""}, + expectedUninstalls: 2, + }, + { + name: "uninstall specific worker", + workersToUninstall: map[string]string{"worker-1": ""}, + expectedUninstalls: 1, + }, + { + name: "no workers to uninstall", + workersToUninstall: map[string]string{}, + expectedUninstalls: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeClock := util.SystemClock.(*util.FakeClock) + + fakeExec.ExecuteFunc = func(cli string, args ...string) error { + assert.Equal(t, "helm", cli) + return nil + } + + UninstallKubeSliceWorker(config, tt.workersToUninstall) + + assert.Len(t, fakeExec.Calls, tt.expectedUninstalls) + expectedSleeps := tt.expectedUninstalls + 1 + require.Len(t, fakeClock.SleepCalls, expectedSleeps) + + cleanup() + }) + } +} + +func TestRetry(t *testing.T) { + tests := []struct { + name string + backoffLimit int + sleep time.Duration + function func() error + expectError bool + errorContains string + expectedCalls int + expectedSleeps int + }{ + { + name: "succeeds on first attempt", + backoffLimit: 3, + sleep: 1 * time.Second, + function: func() error { return nil }, + expectedCalls: 1, + expectedSleeps: 0, + }, + { + name: "succeeds on third attempt", + backoffLimit: 5, + sleep: 1 * time.Second, + function: func() func() error { + callCount := 0 + return func() error { + callCount++ + if callCount < 3 { + return errors.New("temp error") + } + return nil + } + }(), + expectedCalls: 3, + expectedSleeps: 2, + }, + { + name: "fails after all retries", + backoffLimit: 3, + sleep: 1 * time.Second, + function: func() error { return errors.New("persistent") }, + expectError: true, + errorContains: "retry failed after 3 attempts", + expectedCalls: 3, + expectedSleeps: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + fakeClock := util.SystemClock.(*util.FakeClock) + + callCount := 0 + testFunc := func() error { + callCount++ + return tt.function() + } + + err := Retry(tt.backoffLimit, tt.sleep, testFunc) + + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + + assert.Equal(t, tt.expectedCalls, callCount) + assert.Len(t, fakeClock.SleepCalls, tt.expectedSleeps) + + cleanup() + }) + } +} + +func TestFetchSecret(t *testing.T) { + cluster := Cluster{ContextName: "test-ctx"} + + tests := []struct { + name string + clusterName string + projectName string + mockExecutor func(*util.FakeExecutor) + expectedData map[string]string + expectFatal bool + fatalContains string + }{ + { + name: "successful secret fetch", + clusterName: "worker-1", + projectName: "test-project", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + cmdStr := strings.Join(args, " ") + if strings.Contains(cmdStr, "get sa") { + stdout.Write([]byte("serviceaccount/rbac-worker-worker-1")) + } else if strings.Contains(cmdStr, "get secrets") { + secretData := map[string]string{"token": "token-data"} + data, _ := json.Marshal(secretData) + stdout.Write(data) + } + return nil + } + }, + expectedData: map[string]string{"token": "token-data"}, + }, + { + name: "kubectl get sa fails", + clusterName: "worker-1", + projectName: "test-project", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + return errors.New("sa not found") + } + }, + expectFatal: true, + fatalContains: "sa not found", + }, + { + name: "secret not found for worker", + clusterName: "worker-1", + projectName: "test-project", + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stdout.Write([]byte("serviceaccount/default")) + return nil + } + }, + expectFatal: true, + fatalContains: "failed to find secret", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + secrets := fetchSecret(tt.clusterName, cluster, tt.projectName) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + if tt.fatalContains != "" { + assert.Contains(t, fmt.Sprint(fakeOutput.FatalCalls[0]), tt.fatalContains) + } + } else { + assert.Empty(t, fakeOutput.FatalCalls) + assert.Equal(t, tt.expectedData, secrets) + } + + cleanup() + }) + } +} From 81e6cca74802376f1c763e65f7f19e25fc18c8ae Mon Sep 17 00:00:00 2001 From: Alokzh Date: Fri, 14 Nov 2025 15:40:02 +0530 Subject: [PATCH 23/29] Added integration tests for AddHelmCharts functionality in helm-repo-add_integration_test.go Signed-off-by: Alokzh --- .../helm-repo-add_integration_test.go | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 pkg/internal/helm-repo-add_integration_test.go diff --git a/pkg/internal/helm-repo-add_integration_test.go b/pkg/internal/helm-repo-add_integration_test.go new file mode 100644 index 0000000..f2ce1ae --- /dev/null +++ b/pkg/internal/helm-repo-add_integration_test.go @@ -0,0 +1,145 @@ +//go:build integration +// +build integration + +package internal + +import ( + "bytes" + "os/exec" + "strings" + "testing" + + "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/assert" +) + +// TestHelmRepoAddIntegration verifies that AddHelmCharts can successfully communicate with the real helm binary while safely handling any util.Fatalf calls. +func TestHelmRepoAddIntegration(t *testing.T) { + helmPath, available := isHelmAvailable(t) + if !available { + t.Skip("Skipping: helm binary not found in PATH") + } + + if util.ExecutablePaths == nil { + util.ExecutablePaths = make(map[string]string) + } + util.ExecutablePaths["helm"] = helmPath + + tests := []struct { + name string + config *ConfigurationSpecs + skipCleanup bool + validateResult func(*testing.T, string) + }{ + { + name: "Successfully add public helm repository", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice-itest-1", + RepoUrl: "https://kubeslice.github.io/kubeslice/", + UseLocal: false, + }, + }, + }, + validateResult: func(t *testing.T, repoAlias string) { + output := getHelmRepoList(t) + assert.Contains(t, output, repoAlias, + "helm repo list should contain the added repository") + assert.Contains(t, output, "https://kubeslice.github.io/kubeslice/", + "helm repo list should contain the correct URL") + }, + }, + { + name: "UseLocal flag skips helm operations", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "should-not-be-added", + RepoUrl: "https://example.com/charts", + UseLocal: true, + }, + }, + }, + skipCleanup: true, + validateResult: func(t *testing.T, repoAlias string) { + output := getHelmRepoList(t) + assert.NotContains(t, output, repoAlias, + "Repository should not be added when UseLocal is true") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repoAlias := tt.config.Configuration.HelmChartConfiguration.RepoAlias + + originalExecutor := util.CommandExecutor + + cleanupEnv := util.NewTestEnvironment() + util.CommandExecutor = originalExecutor + + fakeOutput := util.Output.(*util.FakeOutput) + defer cleanupEnv() + + if !tt.skipCleanup { + t.Cleanup(func() { + cleanupHelmRepo(t, repoAlias) + }) + } + + AddHelmCharts(tt.config) + + assert.Empty(t, fakeOutput.FatalCalls, + "AddHelmCharts should not have called Fatalf. Errors: %v", fakeOutput.FatalCalls) + + if tt.validateResult != nil { + tt.validateResult(t, repoAlias) + } + }) + } +} + +func isHelmAvailable(t *testing.T) (string, bool) { + t.Helper() + path, err := exec.LookPath("helm") + return path, err == nil +} + +func getHelmRepoList(t *testing.T) string { + t.Helper() + cmd := exec.Command(util.ExecutablePaths["helm"], "repo", "list") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + if strings.Contains(stderr.String(), "no repositories") { + return "" + } + t.Logf("helm repo list returned error: %v, stderr: %s", err, stderr.String()) + return "" + } + return stdout.String() +} + +func cleanupHelmRepo(t *testing.T, repoAlias string) { + t.Helper() + output := getHelmRepoList(t) + if !strings.Contains(output, repoAlias) { + t.Logf("Repository %s not found, skipping cleanup", repoAlias) + return + } + + cmd := exec.Command(util.ExecutablePaths["helm"], "repo", "remove", repoAlias) + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + t.Logf("Warning: Failed to cleanup helm repo %s: %v, stderr: %s", + repoAlias, err, stderr.String()) + } else { + t.Logf("Successfully cleaned up helm repo: %s", repoAlias) + } +} From df032a952e0db2e257b14dfce7574fc071d69330 Mon Sep 17 00:00:00 2001 From: Alokzh Date: Mon, 17 Nov 2025 11:55:44 +0530 Subject: [PATCH 24/29] Improved helm-repo-add integration tests & added new Integration tests for verify-executables.go Signed-off-by: Alokzh --- .../helm-repo-add_integration_test.go | 58 ++++-- .../verify-executables_integration_test.go | 168 ++++++++++++++++++ 2 files changed, 207 insertions(+), 19 deletions(-) create mode 100644 pkg/internal/verify-executables_integration_test.go diff --git a/pkg/internal/helm-repo-add_integration_test.go b/pkg/internal/helm-repo-add_integration_test.go index f2ce1ae..6e9b4ad 100644 --- a/pkg/internal/helm-repo-add_integration_test.go +++ b/pkg/internal/helm-repo-add_integration_test.go @@ -11,14 +11,12 @@ import ( "github.com/kubeslice/kubeslice-cli/util" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -// TestHelmRepoAddIntegration verifies that AddHelmCharts can successfully communicate with the real helm binary while safely handling any util.Fatalf calls. func TestHelmRepoAddIntegration(t *testing.T) { helmPath, available := isHelmAvailable(t) - if !available { - t.Skip("Skipping: helm binary not found in PATH") - } + require.True(t, available, "helm binary not found in PATH") if util.ExecutablePaths == nil { util.ExecutablePaths = make(map[string]string) @@ -29,6 +27,7 @@ func TestHelmRepoAddIntegration(t *testing.T) { name string config *ConfigurationSpecs skipCleanup bool + expectFatal bool validateResult func(*testing.T, string) }{ { @@ -36,17 +35,18 @@ func TestHelmRepoAddIntegration(t *testing.T) { config: &ConfigurationSpecs{ Configuration: Configuration{ HelmChartConfiguration: HelmChartConfiguration{ - RepoAlias: "kubeslice-itest-1", + RepoAlias: "kubeslice-itest-success", RepoUrl: "https://kubeslice.github.io/kubeslice/", UseLocal: false, }, }, }, + expectFatal: false, validateResult: func(t *testing.T, repoAlias string) { output := getHelmRepoList(t) - assert.Contains(t, output, repoAlias, + require.Contains(t, output, repoAlias, "helm repo list should contain the added repository") - assert.Contains(t, output, "https://kubeslice.github.io/kubeslice/", + require.Contains(t, output, "https://kubeslice.github.io/kubeslice/", "helm repo list should contain the correct URL") }, }, @@ -55,19 +55,39 @@ func TestHelmRepoAddIntegration(t *testing.T) { config: &ConfigurationSpecs{ Configuration: Configuration{ HelmChartConfiguration: HelmChartConfiguration{ - RepoAlias: "should-not-be-added", + RepoAlias: "kubeslice-itest-skip", RepoUrl: "https://example.com/charts", UseLocal: true, }, }, }, skipCleanup: true, + expectFatal: false, validateResult: func(t *testing.T, repoAlias string) { output := getHelmRepoList(t) - assert.NotContains(t, output, repoAlias, + require.NotContains(t, output, repoAlias, "Repository should not be added when UseLocal is true") }, }, + { + name: "Fails to add non-existent repository", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + HelmChartConfiguration: HelmChartConfiguration{ + RepoAlias: "kubeslice-itest-fail", + RepoUrl: "https://non-existent-repo-12345.example.com/charts", + UseLocal: false, + }, + }, + }, + skipCleanup: true, + expectFatal: true, + validateResult: func(t *testing.T, repoAlias string) { + output := getHelmRepoList(t) + require.NotContains(t, output, repoAlias, + "Failed test should not add the repository") + }, + }, } for _, tt := range tests { @@ -75,10 +95,8 @@ func TestHelmRepoAddIntegration(t *testing.T) { repoAlias := tt.config.Configuration.HelmChartConfiguration.RepoAlias originalExecutor := util.CommandExecutor - cleanupEnv := util.NewTestEnvironment() util.CommandExecutor = originalExecutor - fakeOutput := util.Output.(*util.FakeOutput) defer cleanupEnv() @@ -90,8 +108,14 @@ func TestHelmRepoAddIntegration(t *testing.T) { AddHelmCharts(tt.config) - assert.Empty(t, fakeOutput.FatalCalls, - "AddHelmCharts should not have called Fatalf. Errors: %v", fakeOutput.FatalCalls) + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls, + "Expected AddHelmCharts to call Fatalf for failed repo add") + assert.Contains(t, fakeOutput.FatalCalls[0], "Process failed") + } else { + require.Empty(t, fakeOutput.FatalCalls, + "AddHelmCharts should not have called Fatalf. Errors: %v", fakeOutput.FatalCalls) + } if tt.validateResult != nil { tt.validateResult(t, repoAlias) @@ -118,7 +142,7 @@ func getHelmRepoList(t *testing.T) string { if strings.Contains(stderr.String(), "no repositories") { return "" } - t.Logf("helm repo list returned error: %v, stderr: %s", err, stderr.String()) + t.Logf("helm repo list error: %v, stderr: %s", err, stderr.String()) return "" } return stdout.String() @@ -128,7 +152,6 @@ func cleanupHelmRepo(t *testing.T, repoAlias string) { t.Helper() output := getHelmRepoList(t) if !strings.Contains(output, repoAlias) { - t.Logf("Repository %s not found, skipping cleanup", repoAlias) return } @@ -137,9 +160,6 @@ func cleanupHelmRepo(t *testing.T, repoAlias string) { cmd.Stderr = &stderr if err := cmd.Run(); err != nil { - t.Logf("Warning: Failed to cleanup helm repo %s: %v, stderr: %s", - repoAlias, err, stderr.String()) - } else { - t.Logf("Successfully cleaned up helm repo: %s", repoAlias) + t.Logf("Failed to cleanup helm repo %s: %v", repoAlias, err) } } diff --git a/pkg/internal/verify-executables_integration_test.go b/pkg/internal/verify-executables_integration_test.go new file mode 100644 index 0000000..ec4c1ea --- /dev/null +++ b/pkg/internal/verify-executables_integration_test.go @@ -0,0 +1,168 @@ +//go:build integration +// +build integration + +package internal + +import ( + "errors" + "os/exec" + "testing" + + "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// isBinaryAvailable is a helper to check if a binary exists for skipping tests. +func isBinaryAvailable(t *testing.T, name string) bool { + t.Helper() + _, err := exec.LookPath(name) + return err == nil +} + +func TestVerifyExecutablesIntegration(t *testing.T) { + // Setup: Check for binaries required by the test suite itself + requiredBinaries := []string{"kubectl", "helm"} + for _, bin := range requiredBinaries { + if !isBinaryAvailable(t, bin) { + t.Skipf("Required binary '%s' not found in PATH, skipping suite", bin) + } + } + + tests := []struct { + name string + config *ConfigurationSpecs + requiredForTest []string + mockLookPath func(string) (string, error) + expectFatal bool + fatalContains string + validatePaths func(*testing.T) + }{ + { + name: "Kind cluster requires all binaries", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ClusterType: "kind", + }, + }, + }, + requiredForTest: []string{"kind", "docker"}, + expectFatal: false, + validatePaths: func(t *testing.T) { + assert.Contains(t, util.ExecutablePaths, "kind") + assert.Contains(t, util.ExecutablePaths, "kubectl") + assert.Contains(t, util.ExecutablePaths, "docker") + assert.Contains(t, util.ExecutablePaths, "helm") + assert.NotEmpty(t, util.ExecutablePaths["kind"]) + assert.NotEmpty(t, util.ExecutablePaths["kubectl"]) + assert.NotEmpty(t, util.ExecutablePaths["docker"]) + assert.NotEmpty(t, util.ExecutablePaths["helm"]) + }, + }, + { + name: "Non-kind cluster requires only kubectl and helm", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ClusterType: "eks", + }, + }, + }, + requiredForTest: []string{}, + expectFatal: false, + validatePaths: func(t *testing.T) { + assert.NotContains(t, util.ExecutablePaths, "kind") + assert.NotContains(t, util.ExecutablePaths, "docker") + assert.Contains(t, util.ExecutablePaths, "kubectl") + assert.Contains(t, util.ExecutablePaths, "helm") + assert.NotEmpty(t, util.ExecutablePaths["kubectl"]) + assert.NotEmpty(t, util.ExecutablePaths["helm"]) + }, + }, + { + name: "Enterprise profile requires all binaries", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + Profile: ProfileEntDemo, + }, + }, + }, + requiredForTest: []string{"kind", "docker"}, + expectFatal: false, + validatePaths: func(t *testing.T) { + assert.Contains(t, util.ExecutablePaths, "kind") + assert.Contains(t, util.ExecutablePaths, "kubectl") + assert.Contains(t, util.ExecutablePaths, "docker") + assert.Contains(t, util.ExecutablePaths, "helm") + }, + }, + { + name: "Missing kind binary triggers fatal error", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ClusterType: "kind", + }, + }, + }, + requiredForTest: []string{"docker"}, + mockLookPath: func(file string) (string, error) { + if file == "kind" { + return "", errors.New("not found") + } + return exec.LookPath(file) + }, + expectFatal: true, + fatalContains: "To Install Kind CLI", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, bin := range tt.requiredForTest { + if !isBinaryAvailable(t, bin) { + t.Skipf("Skipping test case: Required binary '%s' not found", bin) + } + } + + originalExecutor := util.CommandExecutor + originalLookPath := lookPathFunc + + cleanupEnv := util.NewTestEnvironment() + + util.CommandExecutor = originalExecutor + + if tt.mockLookPath != nil { + lookPathFunc = tt.mockLookPath + } else { + lookPathFunc = exec.LookPath + } + + defer func() { + cleanupEnv() + lookPathFunc = originalLookPath + }() + + fakeOutput := util.Output.(*util.FakeOutput) + util.ExecutablePaths = make(map[string]string) + + VerifyExecutables(tt.config) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls, + "Expected VerifyExecutables to call Fatalf") + assert.Contains(t, fakeOutput.FatalCalls[0], tt.fatalContains) + } else { + require.Empty(t, fakeOutput.FatalCalls, + "VerifyExecutables should not have called Fatalf. Errors: %v", + fakeOutput.FatalCalls) + + if tt.validatePaths != nil { + tt.validatePaths(t) + } + } + }) + } +} From c563db07e6da607328a883c332be2a82bc483deb Mon Sep 17 00:00:00 2001 From: Alokzh Date: Mon, 17 Nov 2025 13:30:30 +0530 Subject: [PATCH 25/29] Added integration tests for Kind cluster creation & deletion Signed-off-by: Alokzh --- .../kind-clusters_integration_test.go | 308 ++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 pkg/internal/kind-clusters_integration_test.go diff --git a/pkg/internal/kind-clusters_integration_test.go b/pkg/internal/kind-clusters_integration_test.go new file mode 100644 index 0000000..649675f --- /dev/null +++ b/pkg/internal/kind-clusters_integration_test.go @@ -0,0 +1,308 @@ +//go:build integration +// +build integration + +package internal + +import ( + "bytes" + "os" + "os/exec" + "strings" + "testing" + + "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateKindClustersIntegration(t *testing.T) { + if !isKindAvailable(t) { + t.SkipNow() + } + + tests := []struct { + name string + config *ConfigurationSpecs + clusterNames []string + }{ + { + name: "Create controller cluster only", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: "kubeslice-itest-ctrl"}, + WorkerClusters: []Cluster{}, + }, + }, + }, + clusterNames: []string{"kubeslice-itest-ctrl"}, + }, + { + name: "Create controller and worker clusters", + config: &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: "kubeslice-itest-ctrl-2"}, + WorkerClusters: []Cluster{ + {Name: "kubeslice-itest-w1"}, + {Name: "kubeslice-itest-w2"}, + }, + }, + }, + }, + clusterNames: []string{"kubeslice-itest-ctrl-2", "kubeslice-itest-w1", "kubeslice-itest-w2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + originalExecutor := util.CommandExecutor + originalFileSystem := util.FileSystem + cleanupEnv := util.NewTestEnvironment() + util.CommandExecutor = originalExecutor + util.FileSystem = originalFileSystem + fakeOutput := util.Output.(*util.FakeOutput) + defer cleanupEnv() + + t.Cleanup(func() { + cleanupKindClusters(t, tt.clusterNames) + os.RemoveAll(kubesliceDirectory) + }) + + util.CreateDirectoryPath(kubesliceDirectory + "/" + kindSubDirectory) + for _, name := range tt.clusterNames { + kindConfig := `kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +name: ` + name + util.DumpFile(kindConfig, kubesliceDirectory+"/"+kindSubDirectory+"/"+name+".yaml") + } + + CreateKindClusters(tt.config) + + require.Empty(t, fakeOutput.FatalCalls, + "CreateKindClusters should not have called Fatalf. Errors: %v", + fakeOutput.FatalCalls) + + existingClusters := getKindClusterList(t) + for _, name := range tt.clusterNames { + assert.Contains(t, existingClusters, name, + "Cluster %s should exist after creation", name) + } + }) + } +} + +func TestCreateKindClustersIdempotencyIntegration(t *testing.T) { + if !isKindAvailable(t) { + t.SkipNow() + } + + clusterName := "kubeslice-itest-idempotent" + config := &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: clusterName}, + }, + }, + } + + originalExecutor := util.CommandExecutor + originalFileSystem := util.FileSystem + cleanupEnv := util.NewTestEnvironment() + util.CommandExecutor = originalExecutor + util.FileSystem = originalFileSystem + fakeOutput := util.Output.(*util.FakeOutput) + defer cleanupEnv() + + t.Cleanup(func() { + cleanupKindClusters(t, []string{clusterName}) + os.RemoveAll(kubesliceDirectory) + }) + + util.CreateDirectoryPath(kubesliceDirectory + "/" + kindSubDirectory) + kindConfig := `kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +name: ` + clusterName + util.DumpFile(kindConfig, kubesliceDirectory+"/"+kindSubDirectory+"/"+clusterName+".yaml") + + CreateKindClusters(config) + require.Empty(t, fakeOutput.FatalCalls) + require.Contains(t, getKindClusterList(t), clusterName) + + fakeOutput.InfoCalls = []string{} + + CreateKindClusters(config) + + require.Empty(t, fakeOutput.FatalCalls) + infoOutput := strings.Join(fakeOutput.InfoCalls, "\n") + assert.Contains(t, infoOutput, "Kind clusters already exist... Skipping") +} + +func TestDeleteKindClustersIntegration(t *testing.T) { + if !isKindAvailable(t) { + t.SkipNow() + } + + t.Run("Delete existing clusters", func(t *testing.T) { + clusterNames := []string{"kubeslice-itest-del-1", "kubeslice-itest-del-2"} + + originalExecutor := util.CommandExecutor + cleanupEnv := util.NewTestEnvironment() + util.CommandExecutor = originalExecutor + fakeOutput := util.Output.(*util.FakeOutput) + defer cleanupEnv() + + config := &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: clusterNames[0]}, + WorkerClusters: []Cluster{ + {Name: clusterNames[1]}, + }, + }, + }, + } + + createTestKindClusters(t, clusterNames) + t.Cleanup(func() { + cleanupKindClusters(t, clusterNames) + }) + + DeleteKindClusters(config) + + require.Empty(t, fakeOutput.FatalCalls, + "DeleteKindClusters should not have called Fatalf. Errors: %v", + fakeOutput.FatalCalls) + + existingAfter := getKindClusterList(t) + for _, name := range clusterNames { + assert.NotContains(t, existingAfter, name) + } + }) + + t.Run("Delete non-existent clusters", func(t *testing.T) { + originalExecutor := util.CommandExecutor + cleanupEnv := util.NewTestEnvironment() + util.CommandExecutor = originalExecutor + fakeOutput := util.Output.(*util.FakeOutput) + defer cleanupEnv() + + config := &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ControllerCluster: Cluster{Name: "non-existent-cluster"}, + }, + }, + } + + DeleteKindClusters(config) + + require.Empty(t, fakeOutput.FatalCalls) + allOutput := strings.Join(fakeOutput.InfoCalls, " ") + assert.Contains(t, allOutput, "No Kind Clusters found for deletion") + }) +} + +func TestCreateKubeConfigIntegration(t *testing.T) { + originalFileSystem := util.FileSystem + cleanupEnv := util.NewTestEnvironment() + util.FileSystem = originalFileSystem + defer cleanupEnv() + + t.Cleanup(func() { + os.RemoveAll(kubesliceDirectory) + }) + + util.CreateDirectoryPath(kubesliceDirectory) + + CreateKubeConfig() + + _, err := os.Stat(KubeconfigPath) + assert.NoError(t, err) +} + +func TestSetKubeConfigPathIntegration(t *testing.T) { + originalSetEnv := setEnvFunc + cleanupEnv := util.NewTestEnvironment() + setEnvFunc = os.Setenv + defer func() { + cleanupEnv() + setEnvFunc = originalSetEnv + os.Unsetenv("KUBECONFIG") + }() + + SetKubeConfigPath() + + assert.Equal(t, KubeconfigPath, os.Getenv("KUBECONFIG")) +} + +func isKindAvailable(t *testing.T) bool { + t.Helper() + path, err := exec.LookPath("kind") + if err != nil { + t.Logf("Skipping: kind binary not found in PATH") + return false + } + if util.ExecutablePaths == nil { + util.ExecutablePaths = make(map[string]string) + } + util.ExecutablePaths["kind"] = path + return true +} + +func getKindClusterList(t *testing.T) []string { + t.Helper() + cmd := exec.Command(util.ExecutablePaths["kind"], "get", "clusters") + var outB bytes.Buffer + cmd.Stdout = &outB + cmd.Stderr = &outB + err := cmd.Run() + if err != nil { + t.Logf("Warning: kind get clusters returned error: %v", err) + } + + clusters := []string{} + for _, line := range strings.Split(outB.String(), "\n") { + line = strings.TrimSpace(line) + if line != "" { + clusters = append(clusters, line) + } + } + return clusters +} + +func createTestKindClusters(t *testing.T, clusterNames []string) { + t.Helper() + for _, name := range clusterNames { + cmd := exec.Command(util.ExecutablePaths["kind"], "create", "cluster", "--name", name) + if err := cmd.Run(); err != nil { + cleanupKindClusters(t, clusterNames) + t.Fatalf("Failed to create test cluster %s: %v", name, err) + } + } +} + +func cleanupKindClusters(t *testing.T, clusterNames []string) { + t.Helper() + existingClusters := getKindClusterList(t) + clustersToDelete := []string{} + + for _, name := range clusterNames { + for _, existing := range existingClusters { + if existing == name { + clustersToDelete = append(clustersToDelete, name) + break + } + } + } + + if len(clustersToDelete) == 0 { + return + } + + args := append([]string{"delete", "clusters"}, clustersToDelete...) + cmd := exec.Command(util.ExecutablePaths["kind"], args...) + if err := cmd.Run(); err != nil { + t.Logf("Warning: Failed to cleanup: %v", err) + } +} From 6250e1da8bef8aa3b86e2f5e9bde3e43b8f2e267 Mon Sep 17 00:00:00 2001 From: Alokzh Date: Mon, 17 Nov 2025 18:34:22 +0530 Subject: [PATCH 26/29] Refactored integration tests to improve checks & created a helper files for common functions Signed-off-by: Alokzh --- .../helm-repo-add_integration_test.go | 75 +-------- pkg/internal/integration_helpers_test.go | 142 ++++++++++++++++++ .../kind-clusters_integration_test.go | 95 +----------- .../verify-executables_integration_test.go | 22 +-- 4 files changed, 158 insertions(+), 176 deletions(-) create mode 100644 pkg/internal/integration_helpers_test.go diff --git a/pkg/internal/helm-repo-add_integration_test.go b/pkg/internal/helm-repo-add_integration_test.go index 6e9b4ad..4aebab1 100644 --- a/pkg/internal/helm-repo-add_integration_test.go +++ b/pkg/internal/helm-repo-add_integration_test.go @@ -4,9 +4,6 @@ package internal import ( - "bytes" - "os/exec" - "strings" "testing" "github.com/kubeslice/kubeslice-cli/util" @@ -15,13 +12,9 @@ import ( ) func TestHelmRepoAddIntegration(t *testing.T) { - helmPath, available := isHelmAvailable(t) - require.True(t, available, "helm binary not found in PATH") - - if util.ExecutablePaths == nil { - util.ExecutablePaths = make(map[string]string) + if !isBinaryAvailable(t, "helm") { + t.Skip("helm binary not found in PATH") } - util.ExecutablePaths["helm"] = helmPath tests := []struct { name string @@ -44,10 +37,8 @@ func TestHelmRepoAddIntegration(t *testing.T) { expectFatal: false, validateResult: func(t *testing.T, repoAlias string) { output := getHelmRepoList(t) - require.Contains(t, output, repoAlias, - "helm repo list should contain the added repository") - require.Contains(t, output, "https://kubeslice.github.io/kubeslice/", - "helm repo list should contain the correct URL") + require.Contains(t, output, repoAlias) + require.Contains(t, output, "https://kubeslice.github.io/kubeslice/") }, }, { @@ -65,8 +56,7 @@ func TestHelmRepoAddIntegration(t *testing.T) { expectFatal: false, validateResult: func(t *testing.T, repoAlias string) { output := getHelmRepoList(t) - require.NotContains(t, output, repoAlias, - "Repository should not be added when UseLocal is true") + require.NotContains(t, output, repoAlias) }, }, { @@ -84,82 +74,33 @@ func TestHelmRepoAddIntegration(t *testing.T) { expectFatal: true, validateResult: func(t *testing.T, repoAlias string) { output := getHelmRepoList(t) - require.NotContains(t, output, repoAlias, - "Failed test should not add the repository") + require.NotContains(t, output, repoAlias) }, }, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { repoAlias := tt.config.Configuration.HelmChartConfiguration.RepoAlias - originalExecutor := util.CommandExecutor cleanupEnv := util.NewTestEnvironment() util.CommandExecutor = originalExecutor fakeOutput := util.Output.(*util.FakeOutput) defer cleanupEnv() - if !tt.skipCleanup { t.Cleanup(func() { cleanupHelmRepo(t, repoAlias) }) } - AddHelmCharts(tt.config) - if tt.expectFatal { - require.NotEmpty(t, fakeOutput.FatalCalls, - "Expected AddHelmCharts to call Fatalf for failed repo add") + require.NotEmpty(t, fakeOutput.FatalCalls) assert.Contains(t, fakeOutput.FatalCalls[0], "Process failed") } else { - require.Empty(t, fakeOutput.FatalCalls, - "AddHelmCharts should not have called Fatalf. Errors: %v", fakeOutput.FatalCalls) + require.Empty(t, fakeOutput.FatalCalls) } - if tt.validateResult != nil { tt.validateResult(t, repoAlias) } }) } } - -func isHelmAvailable(t *testing.T) (string, bool) { - t.Helper() - path, err := exec.LookPath("helm") - return path, err == nil -} - -func getHelmRepoList(t *testing.T) string { - t.Helper() - cmd := exec.Command(util.ExecutablePaths["helm"], "repo", "list") - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() - if err != nil { - if strings.Contains(stderr.String(), "no repositories") { - return "" - } - t.Logf("helm repo list error: %v, stderr: %s", err, stderr.String()) - return "" - } - return stdout.String() -} - -func cleanupHelmRepo(t *testing.T, repoAlias string) { - t.Helper() - output := getHelmRepoList(t) - if !strings.Contains(output, repoAlias) { - return - } - - cmd := exec.Command(util.ExecutablePaths["helm"], "repo", "remove", repoAlias) - var stderr bytes.Buffer - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - t.Logf("Failed to cleanup helm repo %s: %v", repoAlias, err) - } -} diff --git a/pkg/internal/integration_helpers_test.go b/pkg/internal/integration_helpers_test.go new file mode 100644 index 0000000..60ef789 --- /dev/null +++ b/pkg/internal/integration_helpers_test.go @@ -0,0 +1,142 @@ +//go:build integration +// +build integration + +package internal + +import ( + "bytes" + "os/exec" + "strings" + "testing" + + "github.com/kubeslice/kubeslice-cli/util" +) + +// isBinaryAvailable is the helper to check if a binary exists for skipping tests. +// It also populates the util.ExecutablePaths map, which is critical. +func isBinaryAvailable(t *testing.T, name string) bool { + t.Helper() + path, err := exec.LookPath(name) + if err != nil { + t.Logf("Skipping: %s binary not found in PATH", name) + return false + } + if util.ExecutablePaths == nil { + util.ExecutablePaths = make(map[string]string) + } + util.ExecutablePaths[name] = path + return true +} + +// getKindClusterList shells out to the 'kind' binary to get the current list of clusters. +func getKindClusterList(t *testing.T) []string { + t.Helper() + if !isBinaryAvailable(t, "kind") { + return []string{} + } + cmd := exec.Command(util.ExecutablePaths["kind"], "get", "clusters") + var outB bytes.Buffer + cmd.Stdout = &outB + cmd.Stderr = &outB + err := cmd.Run() + if err != nil { + t.Logf("Warning: kind get clusters returned error: %v", err) + } + + clusters := []string{} + for _, line := range strings.Split(outB.String(), "\n") { + line = strings.TrimSpace(line) + if line != "" { + clusters = append(clusters, line) + } + } + return clusters +} + +// createTestKindClusters is a setup helper to create clusters for deletion tests. +func createTestKindClusters(t *testing.T, clusterNames []string) { + t.Helper() + if !isBinaryAvailable(t, "kind") { + t.Skip("kind binary not found, skipping cluster creation") + return + } + for _, name := range clusterNames { + cmd := exec.Command(util.ExecutablePaths["kind"], "create", "cluster", "--name", name) + if err := cmd.Run(); err != nil { + cleanupKindClusters(t, clusterNames) // Attempt cleanup + t.Fatalf("Failed to create test cluster %s: %v", name, err) + } + t.Logf("Created test cluster: %s", name) + } +} + +// cleanupKindClusters is a teardown helper to delete clusters. +func cleanupKindClusters(t *testing.T, clusterNames []string) { + t.Helper() + if !isBinaryAvailable(t, "kind") { + return + } + existingClusters := getKindClusterList(t) + clustersToDelete := []string{} + + for _, name := range clusterNames { + for _, existing := range existingClusters { + if existing == name { + clustersToDelete = append(clustersToDelete, name) + break + } + } + } + + if len(clustersToDelete) == 0 { + return + } + + args := append([]string{"delete", "clusters"}, clustersToDelete...) + cmd := exec.Command(util.ExecutablePaths["kind"], args...) + if err := cmd.Run(); err != nil { + t.Logf("Warning: Failed to cleanup: %v", err) + } else { + t.Logf("Successfully cleaned up: %v", clustersToDelete) + } +} + +// getHelmRepoList retrieves the current list of helm repositories. +func getHelmRepoList(t *testing.T) string { + t.Helper() + if !isBinaryAvailable(t, "helm") { + return "" + } + cmd := exec.Command(util.ExecutablePaths["helm"], "repo", "list") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if err != nil { + if strings.Contains(stderr.String(), "no repositories") { + return "" + } + t.Logf("helm repo list error: %v, stderr: %s", err, stderr.String()) + return "" + } + return stdout.String() +} + +// cleanupHelmRepo removes a helm repository if it exists. +func cleanupHelmRepo(t *testing.T, repoAlias string) { + t.Helper() + if !isBinaryAvailable(t, "helm") { + return + } + output := getHelmRepoList(t) + if !strings.Contains(output, repoAlias) { + return + } + + cmd := exec.Command(util.ExecutablePaths["helm"], "repo", "remove", repoAlias) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + t.Logf("Failed to cleanup helm repo %s: %v", repoAlias, err) + } +} diff --git a/pkg/internal/kind-clusters_integration_test.go b/pkg/internal/kind-clusters_integration_test.go index 649675f..790e34b 100644 --- a/pkg/internal/kind-clusters_integration_test.go +++ b/pkg/internal/kind-clusters_integration_test.go @@ -4,9 +4,7 @@ package internal import ( - "bytes" "os" - "os/exec" "strings" "testing" @@ -16,10 +14,9 @@ import ( ) func TestCreateKindClustersIntegration(t *testing.T) { - if !isKindAvailable(t) { + if !isBinaryAvailable(t, "kind") { t.SkipNow() } - tests := []struct { name string config *ConfigurationSpecs @@ -78,22 +75,17 @@ name: ` + name } CreateKindClusters(tt.config) - - require.Empty(t, fakeOutput.FatalCalls, - "CreateKindClusters should not have called Fatalf. Errors: %v", - fakeOutput.FatalCalls) - + require.Empty(t, fakeOutput.FatalCalls) existingClusters := getKindClusterList(t) for _, name := range tt.clusterNames { - assert.Contains(t, existingClusters, name, - "Cluster %s should exist after creation", name) + assert.Contains(t, existingClusters, name) } }) } } func TestCreateKindClustersIdempotencyIntegration(t *testing.T) { - if !isKindAvailable(t) { + if !isBinaryAvailable(t, "kind") { t.SkipNow() } @@ -139,7 +131,7 @@ name: ` + clusterName } func TestDeleteKindClustersIntegration(t *testing.T) { - if !isKindAvailable(t) { + if !isBinaryAvailable(t, "kind") { t.SkipNow() } @@ -169,11 +161,7 @@ func TestDeleteKindClustersIntegration(t *testing.T) { }) DeleteKindClusters(config) - - require.Empty(t, fakeOutput.FatalCalls, - "DeleteKindClusters should not have called Fatalf. Errors: %v", - fakeOutput.FatalCalls) - + require.Empty(t, fakeOutput.FatalCalls) existingAfter := getKindClusterList(t) for _, name := range clusterNames { assert.NotContains(t, existingAfter, name) @@ -235,74 +223,3 @@ func TestSetKubeConfigPathIntegration(t *testing.T) { assert.Equal(t, KubeconfigPath, os.Getenv("KUBECONFIG")) } - -func isKindAvailable(t *testing.T) bool { - t.Helper() - path, err := exec.LookPath("kind") - if err != nil { - t.Logf("Skipping: kind binary not found in PATH") - return false - } - if util.ExecutablePaths == nil { - util.ExecutablePaths = make(map[string]string) - } - util.ExecutablePaths["kind"] = path - return true -} - -func getKindClusterList(t *testing.T) []string { - t.Helper() - cmd := exec.Command(util.ExecutablePaths["kind"], "get", "clusters") - var outB bytes.Buffer - cmd.Stdout = &outB - cmd.Stderr = &outB - err := cmd.Run() - if err != nil { - t.Logf("Warning: kind get clusters returned error: %v", err) - } - - clusters := []string{} - for _, line := range strings.Split(outB.String(), "\n") { - line = strings.TrimSpace(line) - if line != "" { - clusters = append(clusters, line) - } - } - return clusters -} - -func createTestKindClusters(t *testing.T, clusterNames []string) { - t.Helper() - for _, name := range clusterNames { - cmd := exec.Command(util.ExecutablePaths["kind"], "create", "cluster", "--name", name) - if err := cmd.Run(); err != nil { - cleanupKindClusters(t, clusterNames) - t.Fatalf("Failed to create test cluster %s: %v", name, err) - } - } -} - -func cleanupKindClusters(t *testing.T, clusterNames []string) { - t.Helper() - existingClusters := getKindClusterList(t) - clustersToDelete := []string{} - - for _, name := range clusterNames { - for _, existing := range existingClusters { - if existing == name { - clustersToDelete = append(clustersToDelete, name) - break - } - } - } - - if len(clustersToDelete) == 0 { - return - } - - args := append([]string{"delete", "clusters"}, clustersToDelete...) - cmd := exec.Command(util.ExecutablePaths["kind"], args...) - if err := cmd.Run(); err != nil { - t.Logf("Warning: Failed to cleanup: %v", err) - } -} diff --git a/pkg/internal/verify-executables_integration_test.go b/pkg/internal/verify-executables_integration_test.go index ec4c1ea..d85e4ef 100644 --- a/pkg/internal/verify-executables_integration_test.go +++ b/pkg/internal/verify-executables_integration_test.go @@ -13,15 +13,7 @@ import ( "github.com/stretchr/testify/require" ) -// isBinaryAvailable is a helper to check if a binary exists for skipping tests. -func isBinaryAvailable(t *testing.T, name string) bool { - t.Helper() - _, err := exec.LookPath(name) - return err == nil -} - func TestVerifyExecutablesIntegration(t *testing.T) { - // Setup: Check for binaries required by the test suite itself requiredBinaries := []string{"kubectl", "helm"} for _, bin := range requiredBinaries { if !isBinaryAvailable(t, bin) { @@ -54,10 +46,6 @@ func TestVerifyExecutablesIntegration(t *testing.T) { assert.Contains(t, util.ExecutablePaths, "kubectl") assert.Contains(t, util.ExecutablePaths, "docker") assert.Contains(t, util.ExecutablePaths, "helm") - assert.NotEmpty(t, util.ExecutablePaths["kind"]) - assert.NotEmpty(t, util.ExecutablePaths["kubectl"]) - assert.NotEmpty(t, util.ExecutablePaths["docker"]) - assert.NotEmpty(t, util.ExecutablePaths["helm"]) }, }, { @@ -76,8 +64,6 @@ func TestVerifyExecutablesIntegration(t *testing.T) { assert.NotContains(t, util.ExecutablePaths, "docker") assert.Contains(t, util.ExecutablePaths, "kubectl") assert.Contains(t, util.ExecutablePaths, "helm") - assert.NotEmpty(t, util.ExecutablePaths["kubectl"]) - assert.NotEmpty(t, util.ExecutablePaths["helm"]) }, }, { @@ -151,14 +137,10 @@ func TestVerifyExecutablesIntegration(t *testing.T) { VerifyExecutables(tt.config) if tt.expectFatal { - require.NotEmpty(t, fakeOutput.FatalCalls, - "Expected VerifyExecutables to call Fatalf") + require.NotEmpty(t, fakeOutput.FatalCalls) assert.Contains(t, fakeOutput.FatalCalls[0], tt.fatalContains) } else { - require.Empty(t, fakeOutput.FatalCalls, - "VerifyExecutables should not have called Fatalf. Errors: %v", - fakeOutput.FatalCalls) - + require.Empty(t, fakeOutput.FatalCalls) if tt.validatePaths != nil { tt.validatePaths(t) } From 4a912efeb98d0bf5d35c08ad2ab7b87cf195ff81 Mon Sep 17 00:00:00 2001 From: Alokzh Date: Tue, 18 Nov 2025 10:20:31 +0530 Subject: [PATCH 27/29] Added integration tests for GatherNetworkInformation functionality Signed-off-by: Alokzh --- .../get-network-info_integration_test.go | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 pkg/internal/get-network-info_integration_test.go diff --git a/pkg/internal/get-network-info_integration_test.go b/pkg/internal/get-network-info_integration_test.go new file mode 100644 index 0000000..1ed6cf7 --- /dev/null +++ b/pkg/internal/get-network-info_integration_test.go @@ -0,0 +1,147 @@ +//go:build integration +// +build integration + +package internal + +import ( + "os" + "os/exec" + "strings" + "testing" + + "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGatherNetworkInformation_KindPath(t *testing.T) { + if !isBinaryAvailable(t, "kind") || !isBinaryAvailable(t, "docker") { + t.SkipNow() + } + + const clusterName = "kubeslice-itest-net-kind" + + createTestKindClusters(t, []string{clusterName}) + t.Cleanup(func() { cleanupKindClusters(t, []string{clusterName}) }) + + config := &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ClusterType: "kind", + ControllerCluster: Cluster{Name: clusterName}, + }, + }, + } + + originalExecutor := util.CommandExecutor + cleanupEnv := util.NewTestEnvironment() + util.CommandExecutor = originalExecutor + fakeOutput := util.Output.(*util.FakeOutput) + defer cleanupEnv() + + GatherNetworkInformation(config) + + require.Empty(t, fakeOutput.FatalCalls, "GatherNetworkInformation should not fail") + + ip := config.Configuration.ClusterConfiguration.ControllerCluster.NodeIP + addr := config.Configuration.ClusterConfiguration.ControllerCluster.ControlPlaneAddress + + assert.NotEmpty(t, ip, "NodeIP should be populated") + assert.True(t, strings.HasPrefix(ip, "172.") || strings.HasPrefix(ip, "10."), "Kind IP should be valid (172.x or 10.x), got: %s", ip) + assert.Equal(t, "https://"+ip+":6443", addr, "ControlPlaneAddress should be derived from NodeIP") +} + +func TestGatherNetworkInformation_NonKindPath(t *testing.T) { + if !isBinaryAvailable(t, "kind") || !isBinaryAvailable(t, "kubectl") { + t.SkipNow() + } + + const clusterName = "kubeslice-itest-net-nonkind" + const contextName = "kind-" + clusterName + + createTestKindClusters(t, []string{clusterName}) + + originalExecutor := util.CommandExecutor + originalFileSystem := util.FileSystem + originalSetEnv := setEnvFunc + + cleanupEnv := util.NewTestEnvironment() + fakeOutput := util.Output.(*util.FakeOutput) + + util.CommandExecutor = originalExecutor + util.FileSystem = originalFileSystem + setEnvFunc = os.Setenv + + t.Cleanup(func() { + cleanupEnv() + setEnvFunc = originalSetEnv + os.Unsetenv("KUBECONFIG") + os.RemoveAll(kubesliceDirectory) + cleanupKindClusters(t, []string{clusterName}) + }) + + util.CreateDirectoryPath(kubesliceDirectory) + CreateKubeConfig() + SetKubeConfigPath() + + cmd := exec.Command(util.ExecutablePaths["kind"], "get", "kubeconfig", "--name", clusterName) + kubeconfigBytes, err := cmd.Output() + require.NoError(t, err, "Failed to get kubeconfig from kind") + + err = os.WriteFile(KubeconfigPath, kubeconfigBytes, 0644) + require.NoError(t, err, "Failed to write real kubeconfig to test file") + + config := &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ClusterType: "eks", + ControllerCluster: Cluster{ + Name: "controller", + ContextName: contextName, + KubeConfigPath: KubeconfigPath, + }, + }, + }, + } + + GatherNetworkInformation(config) + + require.Empty(t, fakeOutput.FatalCalls, "GatherNetworkInformation should not fail") + + ip := config.Configuration.ClusterConfiguration.ControllerCluster.NodeIP + addr := config.Configuration.ClusterConfiguration.ControllerCluster.ControlPlaneAddress + + assert.NotEmpty(t, ip, "NodeIP should be populated by kubectl") + assert.True(t, strings.HasPrefix(ip, "172.") || strings.HasPrefix(ip, "10."), "Kind Node IP should be valid, got: %s", ip) + + assert.NotEmpty(t, addr, "ControlPlaneAddress should be populated by kubectl") + assert.Contains(t, addr, "https://127.0.0.1:", "Kind Control Plane Address should be populated") +} + +func TestGatherNetworkInformation_KindPath_Failure(t *testing.T) { + if !isBinaryAvailable(t, "docker") { + t.SkipNow() + } + + const clusterName = "cluster-does-not-exist" + config := &ConfigurationSpecs{ + Configuration: Configuration{ + ClusterConfiguration: ClusterConfiguration{ + ClusterType: "kind", + ControllerCluster: Cluster{Name: clusterName}, + }, + }, + } + + originalExecutor := util.CommandExecutor + cleanupEnv := util.NewTestEnvironment() + util.CommandExecutor = originalExecutor + fakeOutput := util.Output.(*util.FakeOutput) + defer cleanupEnv() + + GatherNetworkInformation(config) + + require.NotEmpty(t, fakeOutput.FatalCalls, "GatherNetworkInformation should call Fatalf") + assert.Contains(t, fakeOutput.FatalCalls[0], "Failed to run command", "Should fail on docker inspect") + assert.Contains(t, fakeOutput.FatalCalls[0], "Error:", "Error should be from docker") +} From 7f73e095ae4a17ed1f95149b12e8ea76415df564 Mon Sep 17 00:00:00 2001 From: Alokzh Date: Wed, 19 Nov 2025 14:49:56 +0530 Subject: [PATCH 28/29] Added Unit & Integration tests for kubectl operations Signed-off-by: Alokzh --- pkg/internal/integration_helpers_test.go | 23 +- pkg/internal/kubernetes-operation.go | 35 +-- .../kubernetes-operation_integration_test.go | 202 +++++++++++++ pkg/internal/kubernetes-operation_test.go | 279 ++++++++++++++++++ 4 files changed, 517 insertions(+), 22 deletions(-) create mode 100644 pkg/internal/kubernetes-operation_integration_test.go create mode 100644 pkg/internal/kubernetes-operation_test.go diff --git a/pkg/internal/integration_helpers_test.go b/pkg/internal/integration_helpers_test.go index 60ef789..4f34d32 100644 --- a/pkg/internal/integration_helpers_test.go +++ b/pkg/internal/integration_helpers_test.go @@ -5,15 +5,16 @@ package internal import ( "bytes" + "os" "os/exec" "strings" "testing" "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/require" ) // isBinaryAvailable is the helper to check if a binary exists for skipping tests. -// It also populates the util.ExecutablePaths map, which is critical. func isBinaryAvailable(t *testing.T, name string) bool { t.Helper() path, err := exec.LookPath(name) @@ -140,3 +141,23 @@ func cleanupHelmRepo(t *testing.T, repoAlias string) { t.Logf("Failed to cleanup helm repo %s: %v", repoAlias, err) } } + +// setupKubeconfigForCluster configures kubectl to talk to the Kind cluster +func setupKubeconfigForCluster(t *testing.T, clusterName string) { + t.Helper() + + util.CreateDirectoryPath(kubesliceDirectory) + CreateKubeConfig() + SetKubeConfigPath() + + if !isBinaryAvailable(t, "kind") { + t.FailNow() + } + + cmd := exec.Command(util.ExecutablePaths["kind"], "get", "kubeconfig", "--name", clusterName) + kubeconfigBytes, err := cmd.Output() + require.NoError(t, err, "Failed to get kubeconfig from kind") + + err = os.WriteFile(KubeconfigPath, kubeconfigBytes, 0644) + require.NoError(t, err, "Failed to write real kubeconfig to test file") +} diff --git a/pkg/internal/kubernetes-operation.go b/pkg/internal/kubernetes-operation.go index 9842a31..657540e 100644 --- a/pkg/internal/kubernetes-operation.go +++ b/pkg/internal/kubernetes-operation.go @@ -3,8 +3,6 @@ package internal import ( "bytes" "fmt" - "io/ioutil" - "log" "strconv" "strings" "time" @@ -36,7 +34,7 @@ func PodVerification(message string, cluster Cluster, namespace string) { backoffCount = backoffCount + 1 util.Printf("%s %s... Pod(s) in error state, waiting to recover... %d seconds elapsed", util.Wait, message, i*5) if backoffCount > backoffLimit { - log.Fatalf("Pod(s) in error state,\n%s", output) + util.Fatalf("Pod(s) in error state,\n%s", output) } } else { util.Printf("%s %s... %d seconds elapsed", util.Wait, message, i*5) @@ -50,7 +48,7 @@ func LicenseVerification(message string, cluster Cluster, namespace string) { return fetchLicenseSecret(LicenseFileName, cluster, namespace) }) if err != nil { - log.Fatalf("Unable to fetch License\n%s", err) + util.Fatalf("Unable to fetch License\n%s", err) } } @@ -76,7 +74,7 @@ func ApplyKubectlManifest(fileName, namespace string, cluster *Cluster) { cmdArgs = append(cmdArgs, "apply", "-f", fileName, "-n", namespace) err := util.RunCommand("kubectl", cmdArgs...) if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } } @@ -91,12 +89,11 @@ func GetKubectlResources(resourceType string, resourceName string, namespace str cmdArgs = append(cmdArgs, "get", resourceType, resourceName, "-n", namespace) } if outputFormat != "" { - cmdArgs = append(cmdArgs, "-o", outputFormat) } err := util.RunCommandOnStdIO("kubectl", cmdArgs...) if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } } @@ -108,7 +105,7 @@ func DeleteKubectlResources(resourceType string, resourceName string, namespace cmdArgs = append(cmdArgs, "delete", resourceType, resourceName, "-n", namespace) err := util.RunCommandOnStdIO("kubectl", cmdArgs...) if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } } @@ -120,7 +117,7 @@ func EditKubectlResources(resourceType string, resourceName string, namespace st cmdArgs = append(cmdArgs, "edit", resourceType, resourceName, "-n", namespace) err := util.RunCommandOnStdIO("kubectl", cmdArgs...) if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } } @@ -132,7 +129,7 @@ func DescribeKubectlResources(resourceType string, resourceName string, namespac cmdArgs = append(cmdArgs, "describe", resourceType, resourceName, "-n", namespace) err := util.RunCommandOnStdIO("kubectl", cmdArgs...) if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } } @@ -140,7 +137,7 @@ func verifyPods(cluster Cluster, namespace string) (PodVerificationStatus, strin var outB, errB bytes.Buffer err := util.RunCommandCustomIO("kubectl", &outB, &errB, true, "--context="+cluster.ContextName, "--kubeconfig="+cluster.KubeConfigPath, "get", "pods", "-n", namespace) if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } var count = 0 var lines = 0 @@ -175,34 +172,30 @@ func ApplyFile(fileName, namespace string, cluster *Cluster) { cmdArgs = append(cmdArgs, "apply", "-f", fileName, "-n", namespace) err := util.RunCommandOnStdIO("kubectl", cmdArgs...) if err != nil { - log.Fatalf("Process failed %v", err) + util.Fatalf("Process failed %v", err) } } func SetWorker(worker []string, filename string) { - //controllerv1alpha1 jsonByte := getConf(filename) var value string value = string(jsonByte) - log.Println("roshani", len(worker)) + util.Printf("roshani %d", len(worker)) if len(worker) != 0 { for i := 0; i < len(worker); i++ { value, _ = sjson.Set(value, "spec.clusters."+strconv.Itoa(i), worker[i]) } } - err := ioutil.WriteFile(filename, []byte(value), 0644) + err := util.FileSystem.WriteFile(filename, []byte(value), 0644) if err != nil { - log.Fatalf("file writing error #%v ", err) + util.Fatalf("file writing error #%v ", err) } } -// func SetKeys(filename string){ - -// } func getConf(filename string) []byte { - yamlFile, err := ioutil.ReadFile(filename) + yamlFile, err := util.FileSystem.ReadFile(filename) if err != nil { - log.Fatalf("yamlFile.Get err #%v ", err) + util.Fatalf("yamlFile.Get err #%v ", err) } jsonByte, err := YAML.YAMLToJSON(yamlFile) return jsonByte diff --git a/pkg/internal/kubernetes-operation_integration_test.go b/pkg/internal/kubernetes-operation_integration_test.go new file mode 100644 index 0000000..03ff139 --- /dev/null +++ b/pkg/internal/kubernetes-operation_integration_test.go @@ -0,0 +1,202 @@ +//go:build integration +// +build integration + +package internal + +import ( + "os" + "os/exec" + "testing" + + "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestApplyKubectlManifestIntegration(t *testing.T) { + if !isBinaryAvailable(t, "kind") || !isBinaryAvailable(t, "kubectl") { + t.SkipNow() + } + + clusterName := "kubeslice-itest-kubectl" + createTestKindClusters(t, []string{clusterName}) + + originalFileSystem := util.FileSystem + originalSetEnv := setEnvFunc + setupKubeconfigForCluster(t, clusterName) + + originalExecutor := util.CommandExecutor + cleanupEnv := util.NewTestEnvironment() + util.CommandExecutor = originalExecutor + fakeOutput := util.Output.(*util.FakeOutput) + + defer func() { + cleanupEnv() + util.FileSystem = originalFileSystem + setEnvFunc = originalSetEnv + cleanupKindClusters(t, []string{clusterName}) + os.RemoveAll("test-manifest.yaml") + os.RemoveAll(kubesliceDirectory) + os.Unsetenv("KUBECONFIG") + }() + + contextName := "kind-" + clusterName + cluster := &Cluster{ + Name: clusterName, + ContextName: contextName, + KubeConfigPath: KubeconfigPath, + } + + manifest := `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config +data: + key: value` + + err := os.WriteFile("test-manifest.yaml", []byte(manifest), 0644) + require.NoError(t, err) + + ApplyKubectlManifest("test-manifest.yaml", "default", cluster) + + require.Empty(t, fakeOutput.FatalCalls) + + cmd := exec.Command(util.ExecutablePaths["kubectl"], "--context", contextName, + "--kubeconfig", KubeconfigPath, "get", "configmap", "test-config", "-n", "default") + output, err := cmd.CombinedOutput() + require.NoError(t, err, "ConfigMap should exist after apply") + assert.Contains(t, string(output), "test-config") +} + +func TestGetKubectlResourcesIntegration(t *testing.T) { + if !isBinaryAvailable(t, "kind") || !isBinaryAvailable(t, "kubectl") { + t.SkipNow() + } + + clusterName := "kubeslice-itest-get" + createTestKindClusters(t, []string{clusterName}) + + originalFileSystem := util.FileSystem + originalSetEnv := setEnvFunc + setupKubeconfigForCluster(t, clusterName) + + originalExecutor := util.CommandExecutor + cleanupEnv := util.NewTestEnvironment() + util.CommandExecutor = originalExecutor + fakeOutput := util.Output.(*util.FakeOutput) + + defer func() { + cleanupEnv() + util.FileSystem = originalFileSystem + setEnvFunc = originalSetEnv + cleanupKindClusters(t, []string{clusterName}) + os.RemoveAll(kubesliceDirectory) + os.Unsetenv("KUBECONFIG") + }() + + contextName := "kind-" + clusterName + cluster := &Cluster{ + Name: clusterName, + ContextName: contextName, + KubeConfigPath: KubeconfigPath, + } + + GetKubectlResources("pods", "", "kube-system", cluster, "") + + require.Empty(t, fakeOutput.FatalCalls) +} + +func TestDeleteKubectlResourcesIntegration(t *testing.T) { + if !isBinaryAvailable(t, "kind") || !isBinaryAvailable(t, "kubectl") { + t.SkipNow() + } + + clusterName := "kubeslice-itest-delete" + createTestKindClusters(t, []string{clusterName}) + + originalFileSystem := util.FileSystem + originalSetEnv := setEnvFunc + setupKubeconfigForCluster(t, clusterName) + + originalExecutor := util.CommandExecutor + cleanupEnv := util.NewTestEnvironment() + util.CommandExecutor = originalExecutor + fakeOutput := util.Output.(*util.FakeOutput) + + defer func() { + cleanupEnv() + util.FileSystem = originalFileSystem + setEnvFunc = originalSetEnv + cleanupKindClusters(t, []string{clusterName}) + os.RemoveAll("test-delete.yaml") + os.RemoveAll(kubesliceDirectory) + os.Unsetenv("KUBECONFIG") + }() + + contextName := "kind-" + clusterName + cluster := &Cluster{ + Name: clusterName, + ContextName: contextName, + KubeConfigPath: KubeconfigPath, + } + + manifest := `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-delete-config +data: + key: value` + + err := os.WriteFile("test-delete.yaml", []byte(manifest), 0644) + require.NoError(t, err) + + ApplyKubectlManifest("test-delete.yaml", "default", cluster) + require.Empty(t, fakeOutput.FatalCalls) + + DeleteKubectlResources("configmap", "test-delete-config", "default", cluster) + require.Empty(t, fakeOutput.FatalCalls) + + cmd := exec.Command(util.ExecutablePaths["kubectl"], "--context", contextName, + "--kubeconfig", KubeconfigPath, "get", "configmap", "test-delete-config", "-n", "default") + output, err := cmd.CombinedOutput() + assert.Error(t, err) + assert.Contains(t, string(output), "not found") +} + +func TestDescribeKubectlResourcesIntegration(t *testing.T) { + if !isBinaryAvailable(t, "kind") || !isBinaryAvailable(t, "kubectl") { + t.SkipNow() + } + + clusterName := "kubeslice-itest-describe" + createTestKindClusters(t, []string{clusterName}) + + originalFileSystem := util.FileSystem + originalSetEnv := setEnvFunc + setupKubeconfigForCluster(t, clusterName) + + originalExecutor := util.CommandExecutor + cleanupEnv := util.NewTestEnvironment() + util.CommandExecutor = originalExecutor + fakeOutput := util.Output.(*util.FakeOutput) + + defer func() { + cleanupEnv() + util.FileSystem = originalFileSystem + setEnvFunc = originalSetEnv + cleanupKindClusters(t, []string{clusterName}) + os.RemoveAll(kubesliceDirectory) + os.Unsetenv("KUBECONFIG") + }() + + contextName := "kind-" + clusterName + cluster := &Cluster{ + Name: clusterName, + ContextName: contextName, + KubeConfigPath: KubeconfigPath, + } + + DescribeKubectlResources("namespace", "kube-system", "", cluster) + + require.Empty(t, fakeOutput.FatalCalls) +} diff --git a/pkg/internal/kubernetes-operation_test.go b/pkg/internal/kubernetes-operation_test.go new file mode 100644 index 0000000..0d6feaa --- /dev/null +++ b/pkg/internal/kubernetes-operation_test.go @@ -0,0 +1,279 @@ +package internal + +import ( + "errors" + "io" + "testing" + + "github.com/kubeslice/kubeslice-cli/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestApplyKubectlManifest(t *testing.T) { + cluster := &Cluster{ + Name: "test-cluster", + ContextName: "test-ctx", + KubeConfigPath: "/path/to/config", + } + + tests := []struct { + name string + fileName string + namespace string + cluster *Cluster + mockExecutor func(*util.FakeExecutor) + expectFatal bool + fatalContains string + }{ + { + name: "successful apply with cluster", + fileName: "manifest.yaml", + namespace: "default", + cluster: cluster, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + assert.Equal(t, "kubectl", cli) + assert.Contains(t, args, "--context=test-ctx") + assert.Contains(t, args, "--kubeconfig=/path/to/config") + assert.Contains(t, args, "apply") + assert.Contains(t, args, "-f") + assert.Contains(t, args, "manifest.yaml") + assert.Contains(t, args, "-n") + assert.Contains(t, args, "default") + return nil + } + }, + }, + { + name: "successful apply without cluster", + fileName: "manifest.yaml", + namespace: "default", + cluster: nil, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + assert.Equal(t, "kubectl", cli) + assert.NotContains(t, args, "--context=test-ctx") + return nil + } + }, + }, + { + name: "command failure triggers fatal", + fileName: "manifest.yaml", + namespace: "default", + cluster: cluster, + mockExecutor: func(fe *util.FakeExecutor) { + fe.ExecuteFunc = func(cli string, args ...string) error { + return errors.New("connection refused") + } + }, + expectFatal: true, + fatalContains: "Process failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeOutput := util.Output.(*util.FakeOutput) + + if tt.mockExecutor != nil { + tt.mockExecutor(fakeExec) + } + + ApplyKubectlManifest(tt.fileName, tt.namespace, tt.cluster) + + if tt.expectFatal { + require.NotEmpty(t, fakeOutput.FatalCalls) + assert.Contains(t, fakeOutput.FatalCalls[0], tt.fatalContains) + } else { + assert.Empty(t, fakeOutput.FatalCalls) + } + }) + } +} + +func TestGetKubectlResources(t *testing.T) { + cluster := &Cluster{ContextName: "ctx", KubeConfigPath: "conf"} + + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + + // Test with resource name + fakeExec.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + assert.Contains(t, args, "get") + assert.Contains(t, args, "pods") + assert.Contains(t, args, "my-pod") + assert.Contains(t, args, "-o") + assert.Contains(t, args, "yaml") + return nil + } + GetKubectlResources("pods", "my-pod", "default", cluster, "yaml") + + fakeExec.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + assert.Contains(t, args, "get") + assert.Contains(t, args, "pods") + assert.NotContains(t, args, "my-pod") + return nil + } + GetKubectlResources("pods", "", "default", cluster, "") +} + +func TestDeleteKubectlResources(t *testing.T) { + cluster := &Cluster{ContextName: "ctx", KubeConfigPath: "conf"} + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeExec.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + assert.Equal(t, "kubectl", cli) + assert.Contains(t, args, "delete") + assert.Contains(t, args, "pods") + return nil + } + + DeleteKubectlResources("pods", "my-pod", "default", cluster) +} + +func TestDescribeKubectlResources(t *testing.T) { + cluster := &Cluster{ContextName: "ctx", KubeConfigPath: "conf"} + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeExec.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + assert.Equal(t, "kubectl", cli) + assert.Contains(t, args, "describe") + return nil + } + + DescribeKubectlResources("pods", "my-pod", "default", cluster) +} + +func TestVerifyPods(t *testing.T) { + cluster := Cluster{ + ContextName: "test-ctx", + KubeConfigPath: "/path/to/config", + } + + tests := []struct { + name string + mockOutput string + mockError error + expectedStatus PodVerificationStatus + }{ + { + name: "all pods ready", + mockOutput: `NAME READY STATUS RESTARTS AGE +pod-1 2/2 Running 0 1m +pod-2 1/1 Running 0 1m`, + expectedStatus: PodVerificationStatusSuccess, + }, + { + name: "pods still starting", + mockOutput: `NAME READY STATUS RESTARTS AGE +pod-1 1/2 Running 0 1m +pod-2 0/1 Pending 0 1m`, + expectedStatus: PodVerificationStatusInProgress, + }, + { + name: "pod in error state", + mockOutput: `NAME READY STATUS RESTARTS AGE +pod-1 0/1 ImagePullBackOff 0 1m`, + expectedStatus: PodVerificationStatusFailed, + }, + { + name: "pod crashed", + mockOutput: `NAME READY STATUS RESTARTS AGE +pod-1 0/1 CrashLoopBackOff 3 1m`, + expectedStatus: PodVerificationStatusFailed, + }, + { + name: "completed pods ignored", + mockOutput: `NAME READY STATUS RESTARTS AGE +pod-1 0/1 Completed 0 1m +pod-2 1/1 Running 0 1m`, + expectedStatus: PodVerificationStatusSuccess, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeExec.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + stdout.Write([]byte(tt.mockOutput)) + return tt.mockError + } + + status, _ := verifyPods(cluster, "default") + assert.Equal(t, tt.expectedStatus, status) + }) + } +} + +func TestFetchLicenseSecret(t *testing.T) { + cluster := Cluster{ + ContextName: "test-ctx", + KubeConfigPath: "/path/to/config", + } + + tests := []struct { + name string + secretName string + mockOutput string + mockError error + expectError bool + }{ + { + name: "secret found", + secretName: "license", + mockOutput: "NAME TYPE DATA\nlicense Opaque 1", + expectError: false, + }, + { + name: "secret not found", + secretName: "license", + mockOutput: "No resources found", + expectError: true, + }, + { + name: "kubectl command fails", + secretName: "license", + mockError: errors.New("connection refused"), + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := util.NewTestEnvironment() + defer cleanup() + + fakeExec := util.CommandExecutor.(*util.FakeExecutor) + fakeExec.ExecuteWithOutputFunc = func(cli string, stdout, stderr io.Writer, args ...string) error { + if tt.mockError != nil { + return tt.mockError + } + stdout.Write([]byte(tt.mockOutput)) + return nil + } + + err := fetchLicenseSecret(tt.secretName, cluster, "default") + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} From 0d3471707ce5b76b39b8a8c0e76ddeb785eb4294 Mon Sep 17 00:00:00 2001 From: Alokzh Date: Wed, 19 Nov 2025 17:53:35 +0530 Subject: [PATCH 29/29] Added comprehensive testing strategy documentation for kubeslice-cli Signed-off-by: Alokzh --- TESTING.md | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 TESTING.md diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..12a5edf --- /dev/null +++ b/TESTING.md @@ -0,0 +1,73 @@ +# Testing Strategy + +This document outlines the testing strategy for `kubeslice-cli`. We use a two-layered approach: **Unit Tests** for logic isolation & **Integration Tests** for real-world verification. + +## 1. Test Architecture + +| Type | Location | Purpose | Methodology | +| :--- | :--- | :--- | :--- | +| **Unit Tests** | `_test.go` | Verify internal logic, argument parsing & error handling. | Uses **Fakes** (`FakeExecutor`, `FakeFileSystem`) to test in isolation. | +| **Integration Tests** | `_integration_test.go` | Verify interaction with the host system & external binaries. | Uses **Real Binaries** (`kind`, `helm`, `kubectl`, `docker`) and real file system operations. | + +--- + +## 2. Integration Test Scenarios + +The following scenarios are covered by the integration suite in `pkg/internal/`. These tests run against a live environment to ensure end-to-end reliability. + +### A. Environment Verification (`verify-executables`) +* **Kind Profile:** Verifies that `kind`, `kubectl`, `docker`, & `helm` are correctly detected on the host. +* **Non-Kind Profile:** Verifies that only `kubectl` and `helm` are checked when `kind` is not required. +* **Failure Handling:** Simulates a missing binary to ensure the CLI exits gracefully with the correct error message. + +### B. Helm Repo Operations (`helm-repo-add`) +* **Add Repo:** Verifies the CLI can successfully add a public Helm repository using the real `helm` binary. +* **Local Skip:** Verifies that the `UseLocal: true` configuration correctly skips helm operations. +* **Failure Handling:** Verifies that adding a non-existent repository URL triggers a failure. + +### C. Cluster Lifecycle (`kind-clusters`) +* **Create Cluster:** Uses the `kind` binary to spin up real Docker containers for the cluster. +* **Idempotency:** Runs creation twice to ensure the CLI correctly **skips** clusters that already exist. +* **Delete Cluster:** Verifies successful deletion of clusters. +* **Kubeconfig:** Verifies the generation of the `kubeconfig.yaml` file on the real file system. +* **Environment:** Verifies the `KUBECONFIG` environment variable is set correctly. + +### D. Network Discovery (`get-network-info`) +* **Kind Path:** Verifies retrieval of the Control Plane IP using `docker inspect` (used for Kind clusters). +* **Non-Kind Path:** Verifies retrieval of connection info using `kubectl config view` and `kubectl get nodes` (used for Cloud/DC clusters). +* **Failure Handling:** Verifies the CLI fails correctly when inspecting a non-existent cluster. + +### E. Kubernetes Operations (`kubernetes-operation`) +* **Apply Manifest:** Applies a real Kubernetes manifest (ConfigMap) to a running cluster. +* **Get Resources:** Uses `kubectl get` to verify resources were created successfully. +* **Describe Resources:** Uses `kubectl describe` to inspect system resources. +* **Delete Resources:** Deletes a resource and verifies it is removed from the cluster. + +--- + +## 3. How to Run Tests + +### Prerequisites +Ensure the following tools are installed and in your `$PATH`: +* `go` (v1.17+) +* `docker` (Daemon must be running) +* `kind` +* `kubectl` +* `helm` + +### Commands + +**Run Unit Tests Only** (Fast) +```bash +make test-unit +``` + +**Run Integration Tests Only** (Slower) +```bash +make test-integration +``` + +**Run All Tests** +```bash +make test +``` \ No newline at end of file