diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8e33db3bb..8232e3f9d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -128,7 +128,7 @@ jobs: run: go install ./... - name: Run Gosec Security Scanner - uses: securego/gosec@6be2b51fd78feca86af91f5186b7964d76cb1256 # v2.22.10 + uses: securego/gosec@424fc4cd9c82ea0fd6bee9cd49c2db2c3cc0c93f # v2.22.11 with: args: ./... env: diff --git a/aqua.yaml b/aqua.yaml index 64454a4f5..6d30679c8 100644 --- a/aqua.yaml +++ b/aqua.yaml @@ -9,25 +9,25 @@ # - all registries: - type: standard - ref: v4.444.2 # renovate: depName=aquaproj/aqua-registry + ref: v4.445.0 # renovate: depName=aquaproj/aqua-registry packages: -- name: hashicorp/terraform@v1.14.1 +- name: hashicorp/terraform@v1.14.2 - name: siderolabs/talos@v1.11.5 - name: siderolabs/omni/omnictl@v1.3.4 - name: siderolabs/omni/omni@v1.3.4 -- name: kubernetes/kubectl@v1.34.2 +- name: kubernetes/kubectl@v1.34.3 - name: go-task/task@v3.45.5 - name: golang/go@go1.25.5 - name: getsops/sops@v3.9.0 - name: abiosoft/colima@v0.9.1 - name: lima-vm/lima@v2.0.2 - name: docker/cli@v27.4.1 -- name: securego/gosec@v2.22.10 +- name: securego/gosec@v2.22.11 - name: docker/compose@v5.0.0 - name: google/go-jsonnet@v0.21.0 - name: mikefarah/yq@v4.49.2 - name: goreleaser/goreleaser@v2.13.1 -- name: helm/helm@v4.0.1 +- name: helm/helm@v4.0.2 - name: 1password/cli@v2.30.3 - name: fluxcd/flux2@v2.7.5 -- name: aws/aws-cli@2.32.12 +- name: aws/aws-cli@2.32.15 diff --git a/go.mod b/go.mod index f62eedcd6..cf9c8bb89 100644 --- a/go.mod +++ b/go.mod @@ -25,9 +25,9 @@ require ( github.com/spf13/pflag v1.0.10 github.com/zclconf/go-cty v1.17.0 golang.org/x/crypto v0.46.0 - k8s.io/api v0.34.2 - k8s.io/apimachinery v0.34.2 - k8s.io/client-go v0.34.2 + k8s.io/api v0.34.3 + k8s.io/apimachinery v0.34.3 + k8s.io/client-go v0.34.3 sigs.k8s.io/controller-runtime v0.22.4 sigs.k8s.io/yaml v1.6.0 ) diff --git a/go.sum b/go.sum index 4c6e4b4a6..129b78e16 100644 --- a/go.sum +++ b/go.sum @@ -625,14 +625,14 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY= -k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw= +k8s.io/api v0.34.3 h1:D12sTP257/jSH2vHV2EDYrb16bS7ULlHpdNdNhEw2S4= +k8s.io/api v0.34.3/go.mod h1:PyVQBF886Q5RSQZOim7DybQjAbVs8g7gwJNhGtY5MBk= k8s.io/apiextensions-apiserver v0.34.2 h1:WStKftnGeoKP4AZRz/BaAAEJvYp4mlZGN0UCv+uvsqo= k8s.io/apiextensions-apiserver v0.34.2/go.mod h1:398CJrsgXF1wytdaanynDpJ67zG4Xq7yj91GrmYN2SE= -k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4= -k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= -k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M= -k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE= +k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= +k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A= +k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= diff --git a/pkg/composer/composer.go b/pkg/composer/composer.go index dd16b51af..ec06f8339 100644 --- a/pkg/composer/composer.go +++ b/pkg/composer/composer.go @@ -176,6 +176,7 @@ func (r *Composer) generateGitignore() error { ".windsor/", ".volumes/", "terraform/**/backend_override.tf", + "terraform/**/providers_override.tf", "contexts/**/.kube/", "contexts/**/.talos/", "contexts/**/.omni/", diff --git a/pkg/composer/composer_test.go b/pkg/composer/composer_test.go index 75a4794bd..12339eccc 100644 --- a/pkg/composer/composer_test.go +++ b/pkg/composer/composer_test.go @@ -1114,7 +1114,7 @@ func TestComposer_generateGitignore(t *testing.T) { } contentStr := string(content) - requiredEntries := []string{".windsor/", ".volumes/", "terraform/**/backend_override.tf"} + requiredEntries := []string{".windsor/", ".volumes/", "terraform/**/backend_override.tf", "terraform/**/providers_override.tf"} for _, entry := range requiredEntries { if !strings.Contains(contentStr, entry) { t.Errorf("Expected .gitignore to contain %s", entry) diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 136f9a301..63cf69caf 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -77,13 +77,13 @@ const DefaultKustomizationWaitPollInterval = 5 * time.Second const DefaultKustomizationWaitMaxFailures = 5 // renovate: datasource=docker depName=localstack/localstack -const DefaultAWSLocalstackImage = "localstack/localstack:4.11.1" +const DefaultAWSLocalstackImage = "localstack/localstack:4.12.0" // renovate: datasource=docker depName=localstack/localstack-pro const DefaultAWSLocalstackProImage = "localstack/localstack-pro:4.11.1" // renovate: datasource=docker depName=coredns/coredns -const DefaultDNSImage = "coredns/coredns:1.13.1" +const DefaultDNSImage = "coredns/coredns:1.13.2" // renovate: datasource=docker depName=registry const RegistryDefaultImage = "registry:3.0.0" @@ -112,6 +112,16 @@ const MinimumVersion1Password = "2.15.0" const MinimumVersionAWSCLI = "2.15.0" +const MinimumVersionKubelogin = "0.1.7" + +// DefaultAKSOIDCServerID is the standard Azure AKS OIDC server ID (application ID of the +// Microsoft-managed enterprise application "Azure Kubernetes Service AAD Server"). +// This is the same for all AKS clusters with AKS-managed Azure AD enabled. +const DefaultAKSOIDCServerID = "6dae42f8-4368-4678-94ff-3960e28e3630" + +// DefaultAKSOIDCClientID is the standard Azure AKS OIDC client ID used for all AKS clusters. +const DefaultAKSOIDCClientID = "80faf920-1908-4b52-b5ef-a8e7bedfc67a" + const DefaultNodeHealthCheckTimeout = 5 * time.Minute const DefaultNodeHealthCheckPollInterval = 10 * time.Second diff --git a/pkg/provisioner/terraform/stack.go b/pkg/provisioner/terraform/stack.go index 3d8517cb2..41804b49d 100644 --- a/pkg/provisioner/terraform/stack.go +++ b/pkg/provisioner/terraform/stack.go @@ -159,6 +159,13 @@ func (s *TerraformStack) Up(blueprint *blueprintv1alpha1.Blueprint) error { return fmt.Errorf("error removing backend override file for %s: %w", component.Path, err) } } + + providersOverridePath := filepath.Join(component.FullPath, "providers_override.tf") + if _, err := s.shims.Stat(providersOverridePath); err == nil { + if err := s.shims.Remove(providersOverridePath); err != nil { + return fmt.Errorf("error removing providers override file for %s: %w", component.Path, err) + } + } } return nil @@ -247,6 +254,10 @@ func (s *TerraformStack) Down(blueprint *blueprintv1alpha1.Blueprint) error { if err := s.shims.Remove(filepath.Join(component.FullPath, "backend_override.tf")); err != nil && !os.IsNotExist(err) { return fmt.Errorf("error removing backend_override.tf from %s: %w", component.Path, err) } + + if err := s.shims.Remove(filepath.Join(component.FullPath, "providers_override.tf")); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("error removing providers_override.tf from %s: %w", component.Path, err) + } } return nil diff --git a/pkg/provisioner/terraform/stack_test.go b/pkg/provisioner/terraform/stack_test.go index 0a328604b..42510f0a8 100644 --- a/pkg/provisioner/terraform/stack_test.go +++ b/pkg/provisioner/terraform/stack_test.go @@ -444,6 +444,32 @@ func TestStack_Up(t *testing.T) { t.Errorf("Expected remove error, got: %v", err) } }) + + t.Run("ErrorRemovingProvidersOverride", func(t *testing.T) { + stack, mocks := setup(t) + projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") + providersOverridePath := filepath.Join(projectRoot, ".windsor", "contexts", "local", "remote", "path", "providers_override.tf") + if err := os.MkdirAll(filepath.Dir(providersOverridePath), 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + if err := os.WriteFile(providersOverridePath, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to create providers override file: %v", err) + } + mocks.Shims.Remove = func(path string) error { + if strings.Contains(path, "providers_override.tf") { + return fmt.Errorf("remove error") + } + return nil + } + blueprint := createTestBlueprint() + err := stack.Up(blueprint) + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error removing providers override file") { + t.Errorf("Expected remove error, got: %v", err) + } + }) } func TestStack_Down(t *testing.T) { @@ -697,6 +723,32 @@ func TestStack_Down(t *testing.T) { t.Errorf("Expected remove error, got: %v", err) } }) + + t.Run("ErrorRemovingProvidersOverride", func(t *testing.T) { + stack, mocks := setup(t) + projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") + providersOverridePath := filepath.Join(projectRoot, ".windsor", "contexts", "local", "remote", "path", "providers_override.tf") + if err := os.MkdirAll(filepath.Dir(providersOverridePath), 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + if err := os.WriteFile(providersOverridePath, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to create providers override file: %v", err) + } + mocks.Shims.Remove = func(path string) error { + if strings.Contains(path, "providers_override.tf") { + return fmt.Errorf("remove error") + } + return nil + } + blueprint := createTestBlueprint() + err := stack.Down(blueprint) + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error removing providers_override.tf") { + t.Errorf("Expected remove error, got: %v", err) + } + }) } func TestNewShims(t *testing.T) { diff --git a/pkg/runtime/env/terraform_env.go b/pkg/runtime/env/terraform_env.go index 457e16273..eae47a2f4 100644 --- a/pkg/runtime/env/terraform_env.go +++ b/pkg/runtime/env/terraform_env.go @@ -15,6 +15,7 @@ import ( "github.com/goccy/go-yaml" blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" + "github.com/windsorcli/cli/pkg/constants" "github.com/windsorcli/cli/pkg/runtime/config" "github.com/windsorcli/cli/pkg/runtime/shell" ) @@ -106,7 +107,10 @@ func (e *TerraformEnvPrinter) GetEnvVars() (map[string]string, error) { // PostEnvHook executes operations after setting the environment variables. func (e *TerraformEnvPrinter) PostEnvHook(directory ...string) error { - return e.generateBackendOverrideTf(directory...) + if err := e.generateBackendOverrideTf(directory...); err != nil { + return err + } + return e.generateProvidersOverrideTf(directory...) } // GenerateTerraformArgs constructs Terraform CLI arguments and environment variables for given project and module paths. @@ -492,6 +496,101 @@ func (e *TerraformEnvPrinter) generateBackendOverrideTf(directory ...string) err return nil } +// generateProvidersOverrideTf creates a providers_override.tf file when using Azure + AKS +// to configure Kubernetes provider authentication via Entra ID using kubelogin with SPN authentication. +// Detects SPN mode when AZURE_CLIENT_SECRET is set and validates required environment variables. +// This enables generic Kubernetes modules to work with AKS clusters using Entra AD authentication +// without requiring provider blocks in the modules themselves. +func (e *TerraformEnvPrinter) generateProvidersOverrideTf(directory ...string) error { + var currentPath string + if len(directory) > 0 { + currentPath = filepath.Clean(directory[0]) + } else { + var err error + currentPath, err = e.shims.Getwd() + if err != nil { + return fmt.Errorf("error getting current directory: %w", err) + } + } + + projectPath, err := e.findRelativeTerraformProjectPath(directory...) + if err != nil { + return fmt.Errorf("error finding project path: %w", err) + } + + if projectPath == "" { + return nil + } + + azureEnabled := e.configHandler.GetBool("azure.enabled", false) + clusterDriver := e.configHandler.GetString("cluster.driver", "") + + if !azureEnabled || clusterDriver != "aks" { + providersOverridePath := filepath.Join(currentPath, "providers_override.tf") + if _, err := e.shims.Stat(providersOverridePath); err == nil { + if err := e.shims.Remove(providersOverridePath); err != nil { + return fmt.Errorf("error removing providers_override.tf: %w", err) + } + } + return nil + } + + config := e.configHandler.GetConfig() + if config == nil || config.Azure == nil { + return nil + } + + azureEnv := "AzurePublicCloud" + if config.Azure.Environment != nil { + azureEnv = *config.Azure.Environment + } + + loginMode := e.detectKubeloginMode() + + providerConfig := fmt.Sprintf(`provider "kubernetes" { + exec { + api_version = "client.authentication.k8s.io/v1beta1" + command = "kubelogin" + args = [ + "get-token", + "--login", "%s", + "--environment", "%s", + "--server-id", "%s", + ] + } +} +`, loginMode, azureEnv, constants.DefaultAKSOIDCServerID) + + providersOverridePath := filepath.Join(currentPath, "providers_override.tf") + + err = e.shims.WriteFile(providersOverridePath, []byte(providerConfig), os.ModePerm) + if err != nil { + return fmt.Errorf("error writing providers_override.tf: %w", err) + } + + return nil +} + +// detectKubeloginMode determines which kubelogin authentication mode to use based on +// environment variables. Priority order: GitHub Actions OIDC, Kubernetes pod workload +// identity, and fallback to Azure CLI for local development. +func (e *TerraformEnvPrinter) detectKubeloginMode() string { + actionsToken := e.shims.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") + actionsURL := e.shims.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL") + if actionsToken != "" && actionsURL != "" { + return "workloadidentity" + } + + federatedTokenFile := e.shims.Getenv("AZURE_FEDERATED_TOKEN_FILE") + if federatedTokenFile != "" { + if _, err := e.shims.Stat(federatedTokenFile); err == nil { + return "workloadidentity" + } + } + + return "azurecli" +} + // generateBackendConfigArgs constructs backend config args for terraform commands. // It reads the backend type from the config and adds relevant key-value pairs. // The function supports local, s3, kubernetes, and azurerm backends. diff --git a/pkg/runtime/env/terraform_env_test.go b/pkg/runtime/env/terraform_env_test.go index 5bb6d471d..57227f449 100644 --- a/pkg/runtime/env/terraform_env_test.go +++ b/pkg/runtime/env/terraform_env_test.go @@ -10,6 +10,9 @@ import ( "testing" blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" + v1alpha1 "github.com/windsorcli/cli/api/v1alpha1" + "github.com/windsorcli/cli/api/v1alpha1/azure" + "github.com/windsorcli/cli/pkg/constants" "github.com/windsorcli/cli/pkg/runtime/config" ) @@ -915,6 +918,561 @@ func TestTerraformEnv_generateBackendOverrideTf(t *testing.T) { }) } +func TestTerraformEnv_generateProvidersOverrideTf(t *testing.T) { + setup := func(t *testing.T) (*TerraformEnvPrinter, *EnvTestMocks) { + t.Helper() + mocks := setupTerraformEnvMocks(t) + printer := NewTerraformEnvPrinter(mocks.Shell, mocks.ConfigHandler) + printer.shims = mocks.Shims + return printer, mocks + } + + t.Run("SuccessWithKubernetesPodWorkloadIdentity", func(t *testing.T) { + printer, mocks := setup(t) + mocks.ConfigHandler.Set("azure.enabled", true) + mocks.ConfigHandler.Set("cluster.driver", "aks") + mocks.Shims.Getenv = func(key string) string { + if key == "AZURE_FEDERATED_TOKEN_FILE" { + return "/path/to/token/file" + } + return "" + } + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + if name == "/path/to/token/file" { + return nil, nil + } + return nil, os.ErrNotExist + } + + var writtenData []byte + mocks.Shims.WriteFile = func(filename string, data []byte, perm os.FileMode) error { + writtenData = data + return nil + } + + err := printer.generateProvidersOverrideTf() + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + expectedContent := fmt.Sprintf(`provider "kubernetes" { + exec { + api_version = "client.authentication.k8s.io/v1beta1" + command = "kubelogin" + args = [ + "get-token", + "--login", "workloadidentity", + "--environment", "AzurePublicCloud", + "--server-id", "%s", + ] + } +} +`, constants.DefaultAKSOIDCServerID) + if string(writtenData) != expectedContent { + t.Errorf("Expected provider config %q, got %q", expectedContent, string(writtenData)) + } + }) + + t.Run("SuccessWithGitHubActionsWorkloadIdentity", func(t *testing.T) { + printer, mocks := setup(t) + mocks.ConfigHandler.Set("azure.enabled", true) + mocks.ConfigHandler.Set("cluster.driver", "aks") + mocks.Shims.Getenv = func(key string) string { + if key == "ACTIONS_ID_TOKEN_REQUEST_TOKEN" { + return "test-token" + } + if key == "ACTIONS_ID_TOKEN_REQUEST_URL" { + return "https://test-url" + } + return "" + } + + var writtenData []byte + mocks.Shims.WriteFile = func(filename string, data []byte, perm os.FileMode) error { + writtenData = data + return nil + } + + err := printer.generateProvidersOverrideTf() + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + expectedContent := fmt.Sprintf(`provider "kubernetes" { + exec { + api_version = "client.authentication.k8s.io/v1beta1" + command = "kubelogin" + args = [ + "get-token", + "--login", "workloadidentity", + "--environment", "AzurePublicCloud", + "--server-id", "%s", + ] + } +} +`, constants.DefaultAKSOIDCServerID) + if string(writtenData) != expectedContent { + t.Errorf("Expected provider config %q, got %q", expectedContent, string(writtenData)) + } + }) + + t.Run("GitHubActionsPriorityOverKubernetesPod", func(t *testing.T) { + printer, mocks := setup(t) + mocks.ConfigHandler.Set("azure.enabled", true) + mocks.ConfigHandler.Set("cluster.driver", "aks") + mocks.Shims.Getenv = func(key string) string { + if key == "ACTIONS_ID_TOKEN_REQUEST_TOKEN" { + return "test-token" + } + if key == "ACTIONS_ID_TOKEN_REQUEST_URL" { + return "https://test-url" + } + if key == "AZURE_FEDERATED_TOKEN_FILE" { + return "/path/to/token/file" + } + return "" + } + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + if name == "/path/to/token/file" { + return nil, nil + } + return nil, os.ErrNotExist + } + + var writtenData []byte + mocks.Shims.WriteFile = func(filename string, data []byte, perm os.FileMode) error { + writtenData = data + return nil + } + + err := printer.generateProvidersOverrideTf() + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + expectedContent := fmt.Sprintf(`provider "kubernetes" { + exec { + api_version = "client.authentication.k8s.io/v1beta1" + command = "kubelogin" + args = [ + "get-token", + "--login", "workloadidentity", + "--environment", "AzurePublicCloud", + "--server-id", "%s", + ] + } +} +`, constants.DefaultAKSOIDCServerID) + if string(writtenData) != expectedContent { + t.Errorf("Expected provider config %q, got %q", expectedContent, string(writtenData)) + } + }) + + t.Run("KubernetesPodTokenFileNotExists", func(t *testing.T) { + printer, mocks := setup(t) + mocks.ConfigHandler.Set("azure.enabled", true) + mocks.ConfigHandler.Set("cluster.driver", "aks") + mocks.Shims.Getenv = func(key string) string { + if key == "AZURE_FEDERATED_TOKEN_FILE" { + return "/path/to/nonexistent/file" + } + return "" + } + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + var writtenData []byte + mocks.Shims.WriteFile = func(filename string, data []byte, perm os.FileMode) error { + writtenData = data + return nil + } + + err := printer.generateProvidersOverrideTf() + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + expectedContent := fmt.Sprintf(`provider "kubernetes" { + exec { + api_version = "client.authentication.k8s.io/v1beta1" + command = "kubelogin" + args = [ + "get-token", + "--login", "azurecli", + "--environment", "AzurePublicCloud", + "--server-id", "%s", + ] + } +} +`, constants.DefaultAKSOIDCServerID) + if string(writtenData) != expectedContent { + t.Errorf("Expected provider config %q, got %q", expectedContent, string(writtenData)) + } + }) + + t.Run("SuccessWithAzureCLI", func(t *testing.T) { + // Given a TerraformEnvPrinter with Azure + AKS enabled but no AZURE_CLIENT_SECRET (fallback to Azure CLI) + printer, mocks := setup(t) + mocks.ConfigHandler.Set("azure.enabled", true) + mocks.ConfigHandler.Set("cluster.driver", "aks") + mocks.Shims.Getenv = func(key string) string { + return "" + } + + // Mock WriteFile to capture the output + var writtenData []byte + mocks.Shims.WriteFile = func(filename string, data []byte, perm os.FileMode) error { + writtenData = data + return nil + } + + // When generateProvidersOverrideTf is called + err := printer.generateProvidersOverrideTf() + + // Then no error should occur and the expected provider config with azurecli should be written + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + expectedContent := fmt.Sprintf(`provider "kubernetes" { + exec { + api_version = "client.authentication.k8s.io/v1beta1" + command = "kubelogin" + args = [ + "get-token", + "--login", "azurecli", + "--environment", "AzurePublicCloud", + "--server-id", "%s", + ] + } +} +`, constants.DefaultAKSOIDCServerID) + if string(writtenData) != expectedContent { + t.Errorf("Expected provider config %q, got %q", expectedContent, string(writtenData)) + } + }) + + t.Run("AzureNotEnabled", func(t *testing.T) { + // Given a TerraformEnvPrinter with Azure disabled + printer, mocks := setup(t) + mocks.ConfigHandler.Set("azure.enabled", false) + mocks.ConfigHandler.Set("cluster.driver", "aks") + + // Mock Stat and Remove to verify file deletion + fileExists := true + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + if strings.Contains(name, "providers_override.tf") { + if fileExists { + return nil, nil + } + return nil, os.ErrNotExist + } + return nil, os.ErrNotExist + } + + var fileRemoved bool + mocks.Shims.Remove = func(name string) error { + if strings.Contains(name, "providers_override.tf") { + fileRemoved = true + fileExists = false + return nil + } + return nil + } + + // When generateProvidersOverrideTf is called + err := printer.generateProvidersOverrideTf() + + // Then no error should occur and the file should be removed + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if !fileRemoved { + t.Error("Expected providers_override.tf to be removed") + } + }) + + t.Run("NotAKS", func(t *testing.T) { + // Given a TerraformEnvPrinter with Azure enabled but not AKS + printer, mocks := setup(t) + mocks.ConfigHandler.Set("azure.enabled", true) + mocks.ConfigHandler.Set("cluster.driver", "eks") + + // Mock Stat and Remove to verify file deletion + fileExists := true + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + if strings.Contains(name, "providers_override.tf") { + if fileExists { + return nil, nil + } + return nil, os.ErrNotExist + } + return nil, os.ErrNotExist + } + + var fileRemoved bool + mocks.Shims.Remove = func(name string) error { + if strings.Contains(name, "providers_override.tf") { + fileRemoved = true + fileExists = false + return nil + } + return nil + } + + // When generateProvidersOverrideTf is called + err := printer.generateProvidersOverrideTf() + + // Then no error should occur and the file should be removed + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if !fileRemoved { + t.Error("Expected providers_override.tf to be removed") + } + }) + + t.Run("NoAzureConfig", func(t *testing.T) { + // Given a TerraformEnvPrinter with Azure enabled but no Azure config + printer, mocks := setup(t) + mocks.ConfigHandler.Set("azure.enabled", true) + mocks.ConfigHandler.Set("cluster.driver", "aks") + + // Mock GetConfig to return nil + mockConfigHandler := config.NewMockConfigHandler() + mockConfigHandler.GetConfigFunc = func() *v1alpha1.Context { + return nil + } + mockConfigHandler.GetBoolFunc = func(key string, defaultValue ...bool) bool { + if key == "azure.enabled" { + return true + } + return false + } + mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { + if key == "cluster.driver" { + return "aks" + } + return "" + } + printer.configHandler = mockConfigHandler + + // When generateProvidersOverrideTf is called + err := printer.generateProvidersOverrideTf() + + // Then no error should occur + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("AZURE_CLIENT_SECRETNotSet", func(t *testing.T) { + // Given a TerraformEnvPrinter with Azure + AKS enabled but no AZURE_CLIENT_SECRET + printer, mocks := setup(t) + mocks.ConfigHandler.Set("azure.enabled", true) + mocks.ConfigHandler.Set("cluster.driver", "aks") + mocks.Shims.Getenv = func(key string) string { + return "" + } + + // Mock WriteFile to capture the output + var writtenData []byte + mocks.Shims.WriteFile = func(filename string, data []byte, perm os.FileMode) error { + writtenData = data + return nil + } + + // When generateProvidersOverrideTf is called + err := printer.generateProvidersOverrideTf() + + // Then no error should occur and the expected provider config with azurecli should be written + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + expectedContent := fmt.Sprintf(`provider "kubernetes" { + exec { + api_version = "client.authentication.k8s.io/v1beta1" + command = "kubelogin" + args = [ + "get-token", + "--login", "azurecli", + "--environment", "AzurePublicCloud", + "--server-id", "%s", + ] + } +} +`, constants.DefaultAKSOIDCServerID) + if string(writtenData) != expectedContent { + t.Errorf("Expected provider config %q, got %q", expectedContent, string(writtenData)) + } + }) + + t.Run("CustomAzureEnvironment", func(t *testing.T) { + printer, mocks := setup(t) + mocks.ConfigHandler.Set("azure.enabled", true) + mocks.ConfigHandler.Set("cluster.driver", "aks") + mocks.Shims.Getenv = func(key string) string { + return "" + } + + mockConfigHandler := config.NewMockConfigHandler() + mockConfigHandler.GetBoolFunc = func(key string, defaultValue ...bool) bool { + if key == "azure.enabled" { + return true + } + return false + } + mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { + if key == "cluster.driver" { + return "aks" + } + return "" + } + azureEnv := "AzureUSGovernment" + mockConfigHandler.GetConfigFunc = func() *v1alpha1.Context { + return &v1alpha1.Context{ + Azure: &azure.AzureConfig{ + Environment: &azureEnv, + }, + } + } + printer.configHandler = mockConfigHandler + + var writtenData []byte + mocks.Shims.WriteFile = func(filename string, data []byte, perm os.FileMode) error { + writtenData = data + return nil + } + + err := printer.generateProvidersOverrideTf() + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + expectedContent := fmt.Sprintf(`provider "kubernetes" { + exec { + api_version = "client.authentication.k8s.io/v1beta1" + command = "kubelogin" + args = [ + "get-token", + "--login", "azurecli", + "--environment", "AzureUSGovernment", + "--server-id", "%s", + ] + } +} +`, constants.DefaultAKSOIDCServerID) + if string(writtenData) != expectedContent { + t.Errorf("Expected provider config %q, got %q", expectedContent, string(writtenData)) + } + }) + + t.Run("NoProjectPath", func(t *testing.T) { + // Given a TerraformEnvPrinter with no Terraform project path + printer, mocks := setup(t) + mocks.Shims.Glob = func(pattern string) ([]string, error) { + return nil, nil + } + + // When generateProvidersOverrideTf is called + err := printer.generateProvidersOverrideTf() + + // Then no error should occur + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("ErrorGettingCurrentDirectory", func(t *testing.T) { + // Given a TerraformEnvPrinter with failing Getwd + printer, mocks := setup(t) + mocks.Shims.Getwd = func() (string, error) { + return "", fmt.Errorf("mock error getting current directory") + } + + // When generateProvidersOverrideTf is called + err := printer.generateProvidersOverrideTf() + + // Then an error should be returned + if err == nil { + t.Errorf("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error getting current directory") { + t.Errorf("Expected error message to contain 'error getting current directory', got %v", err) + } + }) + + t.Run("ErrorWritingFile", func(t *testing.T) { + // Given a TerraformEnvPrinter with Azure + AKS enabled and AZURE_CLIENT_SECRET set + printer, mocks := setup(t) + mocks.ConfigHandler.Set("azure.enabled", true) + mocks.ConfigHandler.Set("cluster.driver", "aks") + mocks.Shims.Getenv = func(key string) string { + if key == "AZURE_CLIENT_SECRET" { + return "test-secret" + } + return "" + } + + // Mock WriteFile to return error + mocks.Shims.WriteFile = func(filename string, data []byte, perm os.FileMode) error { + return fmt.Errorf("mock error writing file") + } + + // When generateProvidersOverrideTf is called + err := printer.generateProvidersOverrideTf() + + // Then an error should be returned + if err == nil { + t.Errorf("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error writing providers_override.tf") { + t.Errorf("Expected error message to contain 'error writing providers_override.tf', got %v", err) + } + }) + + t.Run("ErrorRemovingFile", func(t *testing.T) { + // Given a TerraformEnvPrinter with Azure not enabled and existing file + printer, mocks := setup(t) + mocks.ConfigHandler.Set("azure.enabled", false) + mocks.ConfigHandler.Set("cluster.driver", "aks") + + // Mock Stat to return file exists + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + if strings.Contains(name, "providers_override.tf") { + return nil, nil + } + return nil, os.ErrNotExist + } + + // Mock Remove to return error + mocks.Shims.Remove = func(name string) error { + if strings.Contains(name, "providers_override.tf") { + return fmt.Errorf("mock error removing file") + } + return nil + } + + // When generateProvidersOverrideTf is called + err := printer.generateProvidersOverrideTf() + + // Then an error should be returned + if err == nil { + t.Errorf("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error removing providers_override.tf") { + t.Errorf("Expected error message to contain 'error removing providers_override.tf', got %v", err) + } + }) +} + func TestTerraformEnv_generateBackendConfigArgs(t *testing.T) { setup := func(t *testing.T) (*TerraformEnvPrinter, *EnvTestMocks) { t.Helper() diff --git a/pkg/runtime/tools/tools_manager.go b/pkg/runtime/tools/tools_manager.go index a531f8ac8..9c550d342 100644 --- a/pkg/runtime/tools/tools_manager.go +++ b/pkg/runtime/tools/tools_manager.go @@ -101,6 +101,14 @@ func (t *BaseToolsManager) Check() error { } } + if t.configHandler.GetBool("azure.enabled") { + if err := t.checkKubelogin(); err != nil { + spin.Stop() + fmt.Fprintf(os.Stderr, "\033[31m✗ %s - Failed\033[0m\n", message) + return fmt.Errorf("kubelogin check failed: %v", err) + } + } + spin.Stop() fmt.Fprintf(os.Stderr, "\033[32m✔\033[0m %s - \033[32mDone\033[0m\n", message) return nil @@ -227,6 +235,54 @@ func (t *BaseToolsManager) checkOnePassword() error { return nil } +// checkKubelogin ensures kubelogin is available in the system's PATH using execLookPath. +// It checks for 'kubelogin' in the system's PATH, verifies its version, and validates +// required environment variables for SPN authentication if AZURE_CLIENT_SECRET is set. +// Returns nil if found and meets the minimum version requirement, else an error indicating it is not available or outdated. +func (t *BaseToolsManager) checkKubelogin() error { + if _, err := execLookPath("kubelogin"); err != nil { + return fmt.Errorf("kubelogin is not available in the PATH") + } + + out, err := t.shell.ExecSilent("kubelogin", "--version") + if err != nil { + return fmt.Errorf("kubelogin is not available in the PATH") + } + + version := extractVersion(out) + if version == "" { + return fmt.Errorf("failed to extract kubelogin version") + } + + if compareVersion(version, constants.MinimumVersionKubelogin) < 0 { + return fmt.Errorf("kubelogin version %s is below the minimum required version %s", version, constants.MinimumVersionKubelogin) + } + + validationRules := []struct { + triggerVar string + authMethod string + }{ + {"AZURE_FEDERATED_TOKEN_FILE", "Workload Identity"}, + {"AZURE_CLIENT_SECRET", "SPN"}, + } + + for _, rule := range validationRules { + if os.Getenv(rule.triggerVar) != "" { + azureClientID := os.Getenv("AZURE_CLIENT_ID") + azureTenantID := os.Getenv("AZURE_TENANT_ID") + + if azureClientID == "" { + return fmt.Errorf("%s is set but AZURE_CLIENT_ID is missing - both are required for %s authentication", rule.triggerVar, rule.authMethod) + } + if azureTenantID == "" { + return fmt.Errorf("%s is set but AZURE_TENANT_ID is missing - both are required for %s authentication", rule.triggerVar, rule.authMethod) + } + } + } + + return nil +} + // compareVersion is a helper function to compare two version strings. // It returns -1 if version1 < version2, 0 if version1 == version2, and 1 if version1 > version2. func compareVersion(version1, version2 string) int { diff --git a/pkg/runtime/tools/tools_manager_test.go b/pkg/runtime/tools/tools_manager_test.go index 36adad75c..0c7ce9b91 100644 --- a/pkg/runtime/tools/tools_manager_test.go +++ b/pkg/runtime/tools/tools_manager_test.go @@ -860,6 +860,280 @@ func TestToolsManager_checkOnePassword(t *testing.T) { }) } +// Tests for kubelogin version validation +func TestToolsManager_checkKubelogin(t *testing.T) { + setup := func(t *testing.T) (*Mocks, *BaseToolsManager) { + t.Helper() + mocks := setupMocks(t) + toolsManager := NewToolsManager(mocks.ConfigHandler, mocks.Shell) + return mocks, toolsManager + } + + t.Run("Success", func(t *testing.T) { + // Given kubelogin is available with correct version + mocks, toolsManager := setup(t) + originalExecLookPath := execLookPath + execLookPath = func(name string) (string, error) { + if name == "kubelogin" { + return "/usr/bin/kubelogin", nil + } + return originalExecLookPath(name) + } + mocks.Shell.ExecSilentFunc = func(name string, args ...string) (string, error) { + if name == "kubelogin" && args[0] == "--version" { + return fmt.Sprintf("kubelogin version %s", constants.MinimumVersionKubelogin), nil + } + return "", fmt.Errorf("command not found") + } + // When checking kubelogin version + err := toolsManager.checkKubelogin() + // Then no error should be returned + if err != nil { + t.Errorf("Expected checkKubelogin to succeed, but got error: %v", err) + } + }) + + t.Run("KubeloginNotAvailable", func(t *testing.T) { + // Given kubelogin is not found in PATH + _, toolsManager := setup(t) + originalExecLookPath := execLookPath + execLookPath = func(name string) (string, error) { + if name == "kubelogin" { + return "", exec.ErrNotFound + } + return originalExecLookPath(name) + } + // When checking kubelogin version + err := toolsManager.checkKubelogin() + // Then an error indicating kubelogin is not available should be returned + if err == nil || !strings.Contains(err.Error(), "kubelogin is not available in the PATH") { + t.Errorf("Expected kubelogin not available error, got %v", err) + } + }) + + t.Run("KubeloginVersionInvalidResponse", func(t *testing.T) { + // Given kubelogin version response is invalid + mocks, toolsManager := setup(t) + originalExecLookPath := execLookPath + execLookPath = func(name string) (string, error) { + if name == "kubelogin" { + return "/usr/bin/kubelogin", nil + } + return originalExecLookPath(name) + } + mocks.Shell.ExecSilentFunc = func(name string, args ...string) (string, error) { + if name == "kubelogin" && args[0] == "--version" { + return "Invalid version response", nil + } + return "", fmt.Errorf("command not found") + } + // When checking kubelogin version + err := toolsManager.checkKubelogin() + // Then an error indicating version extraction failed should be returned + if err == nil || !strings.Contains(err.Error(), "failed to extract kubelogin version") { + t.Errorf("Expected failed to extract kubelogin version error, got %v", err) + } + }) + + t.Run("KubeloginVersionTooLow", func(t *testing.T) { + // Given kubelogin version is below minimum required version + mocks, toolsManager := setup(t) + originalExecLookPath := execLookPath + execLookPath = func(name string) (string, error) { + if name == "kubelogin" { + return "/usr/bin/kubelogin", nil + } + return originalExecLookPath(name) + } + mocks.Shell.ExecSilentFunc = func(name string, args ...string) (string, error) { + if name == "kubelogin" && args[0] == "--version" { + return "kubelogin version 0.1.0", nil + } + return "", fmt.Errorf("command not found") + } + // When checking kubelogin version + err := toolsManager.checkKubelogin() + // Then an error indicating version is too low should be returned + if err == nil || !strings.Contains(err.Error(), "kubelogin version 0.1.0 is below the minimum required version") { + t.Errorf("Expected kubelogin version too low error, got %v", err) + } + }) + + t.Run("KubeloginCommandError", func(t *testing.T) { + // Given kubelogin command execution fails + mocks, toolsManager := setup(t) + originalExecLookPath := execLookPath + execLookPath = func(name string) (string, error) { + if name == "kubelogin" { + return "/usr/bin/kubelogin", nil + } + return originalExecLookPath(name) + } + mocks.Shell.ExecSilentFunc = func(name string, args ...string) (string, error) { + if name == "kubelogin" && args[0] == "--version" { + return "", fmt.Errorf("kubelogin is not available in the PATH") + } + return "", fmt.Errorf("command not found") + } + // When checking kubelogin version + err := toolsManager.checkKubelogin() + // Then an error indicating kubelogin is not available should be returned + if err == nil || !strings.Contains(err.Error(), "kubelogin is not available in the PATH") { + t.Errorf("Expected kubelogin is not available in the PATH error, got %v", err) + } + }) + + t.Run("AZURE_FEDERATED_TOKEN_FILESetButAZURE_CLIENT_IDMissing", func(t *testing.T) { + // Given AZURE_FEDERATED_TOKEN_FILE is set but AZURE_CLIENT_ID is missing + mocks, toolsManager := setup(t) + originalExecLookPath := execLookPath + execLookPath = func(name string) (string, error) { + if name == "kubelogin" { + return "/usr/bin/kubelogin", nil + } + return originalExecLookPath(name) + } + mocks.Shell.ExecSilentFunc = func(name string, args ...string) (string, error) { + if name == "kubelogin" && args[0] == "--version" { + return fmt.Sprintf("kubelogin version %s", constants.MinimumVersionKubelogin), nil + } + return "", fmt.Errorf("command not found") + } + os.Setenv("AZURE_FEDERATED_TOKEN_FILE", "/path/to/token") + defer os.Unsetenv("AZURE_FEDERATED_TOKEN_FILE") + os.Unsetenv("AZURE_CLIENT_ID") + os.Unsetenv("AZURE_TENANT_ID") + // When checking kubelogin + err := toolsManager.checkKubelogin() + // Then an error indicating AZURE_CLIENT_ID is missing should be returned + if err == nil || !strings.Contains(err.Error(), "AZURE_FEDERATED_TOKEN_FILE is set but AZURE_CLIENT_ID is missing") { + t.Errorf("Expected AZURE_CLIENT_ID missing error, got %v", err) + } + }) + + t.Run("AZURE_FEDERATED_TOKEN_FILESetButAZURE_TENANT_IDMissing", func(t *testing.T) { + // Given AZURE_FEDERATED_TOKEN_FILE is set but AZURE_TENANT_ID is missing + mocks, toolsManager := setup(t) + originalExecLookPath := execLookPath + execLookPath = func(name string) (string, error) { + if name == "kubelogin" { + return "/usr/bin/kubelogin", nil + } + return originalExecLookPath(name) + } + mocks.Shell.ExecSilentFunc = func(name string, args ...string) (string, error) { + if name == "kubelogin" && args[0] == "--version" { + return fmt.Sprintf("kubelogin version %s", constants.MinimumVersionKubelogin), nil + } + return "", fmt.Errorf("command not found") + } + os.Setenv("AZURE_FEDERATED_TOKEN_FILE", "/path/to/token") + os.Setenv("AZURE_CLIENT_ID", "test-client-id") + defer func() { + os.Unsetenv("AZURE_FEDERATED_TOKEN_FILE") + os.Unsetenv("AZURE_CLIENT_ID") + }() + os.Unsetenv("AZURE_TENANT_ID") + // When checking kubelogin + err := toolsManager.checkKubelogin() + // Then an error indicating AZURE_TENANT_ID is missing should be returned + if err == nil || !strings.Contains(err.Error(), "AZURE_FEDERATED_TOKEN_FILE is set but AZURE_TENANT_ID is missing") { + t.Errorf("Expected AZURE_TENANT_ID missing error, got %v", err) + } + }) + + t.Run("AZURE_CLIENT_SECRETSetButAZURE_CLIENT_IDMissing", func(t *testing.T) { + // Given AZURE_CLIENT_SECRET is set but AZURE_CLIENT_ID is missing + mocks, toolsManager := setup(t) + originalExecLookPath := execLookPath + execLookPath = func(name string) (string, error) { + if name == "kubelogin" { + return "/usr/bin/kubelogin", nil + } + return originalExecLookPath(name) + } + mocks.Shell.ExecSilentFunc = func(name string, args ...string) (string, error) { + if name == "kubelogin" && args[0] == "--version" { + return fmt.Sprintf("kubelogin version %s", constants.MinimumVersionKubelogin), nil + } + return "", fmt.Errorf("command not found") + } + os.Setenv("AZURE_CLIENT_SECRET", "test-secret") + defer os.Unsetenv("AZURE_CLIENT_SECRET") + os.Unsetenv("AZURE_CLIENT_ID") + os.Unsetenv("AZURE_TENANT_ID") + // When checking kubelogin + err := toolsManager.checkKubelogin() + // Then an error indicating AZURE_CLIENT_ID is missing should be returned + if err == nil || !strings.Contains(err.Error(), "AZURE_CLIENT_SECRET is set but AZURE_CLIENT_ID is missing") { + t.Errorf("Expected AZURE_CLIENT_ID missing error, got %v", err) + } + }) + + t.Run("AZURE_CLIENT_SECRETSetButAZURE_TENANT_IDMissing", func(t *testing.T) { + // Given AZURE_CLIENT_SECRET is set but AZURE_TENANT_ID is missing + mocks, toolsManager := setup(t) + originalExecLookPath := execLookPath + execLookPath = func(name string) (string, error) { + if name == "kubelogin" { + return "/usr/bin/kubelogin", nil + } + return originalExecLookPath(name) + } + mocks.Shell.ExecSilentFunc = func(name string, args ...string) (string, error) { + if name == "kubelogin" && args[0] == "--version" { + return fmt.Sprintf("kubelogin version %s", constants.MinimumVersionKubelogin), nil + } + return "", fmt.Errorf("command not found") + } + os.Setenv("AZURE_CLIENT_SECRET", "test-secret") + os.Setenv("AZURE_CLIENT_ID", "test-client-id") + defer func() { + os.Unsetenv("AZURE_CLIENT_SECRET") + os.Unsetenv("AZURE_CLIENT_ID") + }() + os.Unsetenv("AZURE_TENANT_ID") + // When checking kubelogin + err := toolsManager.checkKubelogin() + // Then an error indicating AZURE_TENANT_ID is missing should be returned + if err == nil || !strings.Contains(err.Error(), "AZURE_CLIENT_SECRET is set but AZURE_TENANT_ID is missing") { + t.Errorf("Expected AZURE_TENANT_ID missing error, got %v", err) + } + }) + + t.Run("AZURE_CLIENT_SECRETSetWithAllRequiredVars", func(t *testing.T) { + // Given AZURE_CLIENT_SECRET is set with all required environment variables + mocks, toolsManager := setup(t) + originalExecLookPath := execLookPath + execLookPath = func(name string) (string, error) { + if name == "kubelogin" { + return "/usr/bin/kubelogin", nil + } + return originalExecLookPath(name) + } + mocks.Shell.ExecSilentFunc = func(name string, args ...string) (string, error) { + if name == "kubelogin" && args[0] == "--version" { + return fmt.Sprintf("kubelogin version %s", constants.MinimumVersionKubelogin), nil + } + return "", fmt.Errorf("command not found") + } + os.Setenv("AZURE_CLIENT_SECRET", "test-secret") + os.Setenv("AZURE_CLIENT_ID", "test-client-id") + os.Setenv("AZURE_TENANT_ID", "test-tenant-id") + defer func() { + os.Unsetenv("AZURE_CLIENT_SECRET") + os.Unsetenv("AZURE_CLIENT_ID") + os.Unsetenv("AZURE_TENANT_ID") + }() + // When checking kubelogin + err := toolsManager.checkKubelogin() + // Then no error should be returned + if err != nil { + t.Errorf("Expected checkKubelogin to succeed with all required env vars, but got error: %v", err) + } + }) +} + // ============================================================================= // Test Helpers // =============================================================================