diff --git a/cli/azd/.vscode/cspell-azd-dictionary.txt b/cli/azd/.vscode/cspell-azd-dictionary.txt index b7a6c573b73..cdf0041e1ea 100644 --- a/cli/azd/.vscode/cspell-azd-dictionary.txt +++ b/cli/azd/.vscode/cspell-azd-dictionary.txt @@ -103,6 +103,7 @@ javac jmes jquery keychain +kubelogin LASTEXITCODE ldflags lechnerc77 diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index d1259c2ad9e..4c57397f143 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -33,6 +33,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/ioc" + "github.com/azure/azure-dev/cli/azd/pkg/kubelogin" "github.com/azure/azure-dev/cli/azd/pkg/lazy" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/pipeline" @@ -464,6 +465,7 @@ func registerCommonDependencies(container *ioc.NestedContainer) { container.MustRegisterSingleton(javac.NewCli) container.MustRegisterSingleton(kubectl.NewKubectl) container.MustRegisterSingleton(maven.NewMavenCli) + container.MustRegisterSingleton(kubelogin.NewCli) container.MustRegisterSingleton(npm.NewNpmCli) container.MustRegisterSingleton(python.NewPythonCli) container.MustRegisterSingleton(swa.NewSwaCli) diff --git a/cli/azd/pkg/kubelogin/cli.go b/cli/azd/pkg/kubelogin/cli.go new file mode 100644 index 00000000000..697f75362bc --- /dev/null +++ b/cli/azd/pkg/kubelogin/cli.go @@ -0,0 +1,82 @@ +package kubelogin + +import ( + "context" + "fmt" + + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/tools" +) + +// Cli is a wrapper around the kubelogin CLI +type Cli struct { + commandRunner exec.CommandRunner +} + +// NewCli creates a new instance of the kubelogin CLI wrapper +func NewCli(commandRunner exec.CommandRunner) *Cli { + return &Cli{ + commandRunner: commandRunner, + } +} + +// Gets the name of the Tool +func (cli *Cli) Name() string { + return "kubelogin" +} + +// Returns the installation URL to install the kubelogin CLI +func (cli *Cli) InstallUrl() string { + return "https://aka.ms/azure-dev/kubelogin-install" +} + +// Checks whether or not the kubelogin CLI is installed and available within the PATH +func (cli *Cli) CheckInstalled(ctx context.Context) error { + if err := tools.ToolInPath("kubelogin"); err != nil { + return err + } + + return nil +} + +// ConvertKubeConfig converts a kubeconfig file to use the exec auth module +func (c *Cli) ConvertKubeConfig(ctx context.Context, options *ConvertOptions) error { + if options == nil { + options = &ConvertOptions{} + } + + if options.Login == "" { + options.Login = "azd" + } + + runArgs := exec.NewRunArgs("kubelogin", "convert-kubeconfig", "--login", options.Login) + if options.KubeConfig != "" { + runArgs = runArgs.AppendParams("--kubeconfig", options.KubeConfig) + } + + if options.TenantId != "" { + runArgs = runArgs.AppendParams("--tenant-id", options.TenantId) + } + + if options.Context != "" { + runArgs = runArgs.AppendParams("--context", options.Context) + } + + if _, err := c.commandRunner.Run(ctx, runArgs); err != nil { + return fmt.Errorf("converting kubeconfig: %w", err) + } + + return nil +} + +// ConvertOptions are the options for converting a kubeconfig file +type ConvertOptions struct { + // Login method to use (defaults to azd) + Login string + // AAD tenant ID + TenantId string + // The name of the kubeconfig context to use + Context string + // KubeConfig is the path to the kube config file + KubeConfig string +} diff --git a/cli/azd/pkg/project/service_target_aks.go b/cli/azd/pkg/project/service_target_aks.go index c14636736c4..f89f635074d 100644 --- a/cli/azd/pkg/project/service_target_aks.go +++ b/cli/azd/pkg/project/service_target_aks.go @@ -9,12 +9,13 @@ import ( "path/filepath" "strings" - "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v2" "github.com/azure/azure-dev/cli/azd/pkg/async" "github.com/azure/azure-dev/cli/azd/pkg/azure" + "github.com/azure/azure-dev/cli/azd/pkg/convert" "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/ext" "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/kubelogin" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/tools" "github.com/azure/azure-dev/cli/azd/pkg/tools/azcli" @@ -62,6 +63,7 @@ type aksTarget struct { managedClustersService azcli.ManagedClustersService resourceManager ResourceManager kubectl kubectl.KubectlCli + kubeLoginCli *kubelogin.Cli containerHelper *ContainerHelper } @@ -73,6 +75,7 @@ func NewAksTarget( managedClustersService azcli.ManagedClustersService, resourceManager ResourceManager, kubectlCli kubectl.KubectlCli, + kubeLoginCli *kubelogin.Cli, containerHelper *ContainerHelper, ) ServiceTarget { return &aksTarget{ @@ -82,6 +85,7 @@ func NewAksTarget( managedClustersService: managedClustersService, resourceManager: resourceManager, kubectl: kubectlCli, + kubeLoginCli: kubeLoginCli, containerHelper: containerHelper, } } @@ -298,25 +302,84 @@ func (t *aksTarget) ensureClusterContext( ) if err != nil { return "", fmt.Errorf( - "failed retrieving cluster admin credentials. Ensure your cluster has been configured to support admin credentials, %w", + //nolint:lll + "failed retrieving cluster user credentials. Ensure the current principal has been granted rights to the AKS cluster, %w", err, ) } if len(clusterCreds.Kubeconfigs) == 0 { return "", fmt.Errorf( - "cluster credentials is empty. Ensure your cluster has been configured to support admin credentials. , %w", + "cluster credentials is empty. Ensure the current principal has been granted rights to the AKS cluster. , %w", err, ) } // The kubeConfig that we care about will also be at position 0 // I don't know if there is a valid use case where this credential results would container multiple configs - kubeConfigPath, err = t.configureK8sContext(ctx, clusterName, defaultNamespace, clusterCreds.Kubeconfigs[0]) + kubeConfig, err := kubectl.ParseKubeConfig(ctx, clusterCreds.Kubeconfigs[0].Value) + if err != nil { + return "", fmt.Errorf( + "failed parsing kube config. Ensure your configuration is valid yaml. %w", + err, + ) + } + + // Set default namespace for the context + // This avoids having to specify the namespace for every kubectl command + kubeConfig.Contexts[0].Context.Namespace = defaultNamespace + kubeConfigManager, err := kubectl.NewKubeConfigManager(t.kubectl) + if err != nil { + return "", err + } + + // Create or update the kube config/context for the AKS cluster + kubeConfigPath, err = kubeConfigManager.AddOrUpdateContext(ctx, clusterName, kubeConfig) + if err != nil { + return "", fmt.Errorf("failed adding/updating kube context, %w", err) + } + + // Get the provisioned cluster properties to inspect configuration + managedCluster, err := t.managedClustersService.Get( + ctx, + targetResource.SubscriptionId(), + targetResource.ResourceGroupName(), + clusterName, + ) + if err != nil { + return "", fmt.Errorf("failed retrieving managed cluster, %w", err) + } + + azureRbacEnabled := managedCluster.Properties.AADProfile != nil && + convert.ToValueWithDefault(managedCluster.Properties.AADProfile.EnableAzureRBAC, false) + localAccountsDisabled := convert.ToValueWithDefault(managedCluster.Properties.DisableLocalAccounts, false) + + // If we're connecting to a cluster with RBAC enabled and local accounts disabled + // then we need to convert the kube config to use the exec auth module with azd auth + if azureRbacEnabled || localAccountsDisabled { + convertOptions := &kubelogin.ConvertOptions{ + Login: "azd", + KubeConfig: kubeConfigPath, + } + if err := t.kubeLoginCli.ConvertKubeConfig(ctx, convertOptions); err != nil { + return "", err + } + } + + // Merge the cluster config/context into the default kube config + kubeConfigPath, err = kubeConfigManager.MergeConfigs(ctx, "config", clusterName) if err != nil { return "", err } + // Setup the default kube context to use the AKS cluster context + if _, err := t.kubectl.ConfigUseContext(ctx, clusterName, nil); err != nil { + return "", fmt.Errorf( + "failed setting kube context '%s'. Ensure the specified context exists. %w", clusterName, + err, + ) + } + return kubeConfigPath, nil } @@ -342,43 +405,6 @@ func (t *aksTarget) ensureNamespace(ctx context.Context, namespace string) error return nil } -func (t *aksTarget) configureK8sContext( - ctx context.Context, - clusterName string, - namespace string, - credentialResult *armcontainerservice.CredentialResult, -) (string, error) { - kubeConfigManager, err := kubectl.NewKubeConfigManager(t.kubectl) - if err != nil { - return "", err - } - - kubeConfig, err := kubectl.ParseKubeConfig(ctx, credentialResult.Value) - if err != nil { - return "", fmt.Errorf( - "failed parsing kube config. Ensure your configuration is valid yaml. %w", - err, - ) - } - - // Set default namespace for the context - // This avoids having to specify the namespace for every kubectl command - kubeConfig.Contexts[0].Context.Namespace = namespace - kubeConfigPath, err := kubeConfigManager.AddOrUpdateContext(ctx, clusterName, kubeConfig) - if err != nil { - return "", fmt.Errorf("failed adding/updating kube context, %w", err) - } - - if _, err := t.kubectl.ConfigUseContext(ctx, clusterName, nil); err != nil { - return "", fmt.Errorf( - "failed setting kube context '%s'. Ensure the specified context exists. %w", clusterName, - err, - ) - } - - return kubeConfigPath, nil -} - // Finds a deployment using the specified deploymentNameFilter string // Waits until the deployment rollout is complete and all replicas are accessible // Additionally confirms rollout is complete by checking the rollout status diff --git a/cli/azd/pkg/project/service_target_aks_test.go b/cli/azd/pkg/project/service_target_aks_test.go index 7ec387fab8a..d370e94add6 100644 --- a/cli/azd/pkg/project/service_target_aks_test.go +++ b/cli/azd/pkg/project/service_target_aks_test.go @@ -19,6 +19,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/infra" + "github.com/azure/azure-dev/cli/azd/pkg/kubelogin" "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/pkg/tools/azcli" "github.com/azure/azure-dev/cli/azd/pkg/tools/docker" @@ -143,7 +144,7 @@ func Test_Resolve_Cluster_Name(t *testing.T) { require.NoError(t, err) serviceConfig := createTestServiceConfig(tempDir, AksTarget, ServiceLanguageTypeScript) - serviceConfig.ResourceName = NewExpandableString("MY_AKS_CLUSTER") + serviceConfig.ResourceName = NewExpandableString("AKS_CLUSTER") env := createEnv() // Remove default AKS cluster name from env file @@ -160,9 +161,9 @@ func Test_Resolve_Cluster_Name(t *testing.T) { require.NoError(t, err) serviceConfig := createTestServiceConfig(tempDir, AksTarget, ServiceLanguageTypeScript) - serviceConfig.ResourceName = NewExpandableString("$MY_CUSTOM_ENV_VAR") + serviceConfig.ResourceName = NewExpandableString("${MY_CUSTOM_ENV_VAR}") env := createEnv() - env.DotenvSet("MY_CUSTOM_ENV_VAR", "MY_AKS_CLUSTER") + env.DotenvSet("MY_CUSTOM_ENV_VAR", "AKS_CLUSTER") // Remove default AKS cluster name from env file env.DotenvDelete(environment.AksClusterEnvVarName) @@ -212,7 +213,7 @@ func Test_Deploy_No_Credentials(t *testing.T) { serviceTarget := createAksServiceTarget(mockContext, serviceConfig, env) err = simulateInitliaze(*mockContext.Context, serviceTarget, serviceConfig) require.Error(t, err) - require.ErrorContains(t, err, "failed retrieving cluster admin credentials") + require.ErrorContains(t, err, "failed retrieving cluster user credentials") } func setupK8sManifests(t *testing.T, serviceConfig *ServiceConfig) error { @@ -241,6 +242,7 @@ func setupMocksForAksTarget(mockContext *mocks.MockContext) error { return err } + setupGetClusterMock(mockContext, http.StatusOK) setupMocksForAcr(mockContext) setupMocksForKubectl(mockContext) setupMocksForDocker(mockContext) @@ -248,6 +250,34 @@ func setupMocksForAksTarget(mockContext *mocks.MockContext) error { return nil } +func setupGetClusterMock(mockContext *mocks.MockContext, statusCode int) { + // Get cluster configuration + mockContext.HttpClient.When(func(request *http.Request) bool { + return request.Method == http.MethodGet && strings.Contains( + request.URL.Path, + "Microsoft.ContainerService/managedClusters/AKS_CLUSTER", + ) + }).RespondFn(func(request *http.Request) (*http.Response, error) { + managedCluster := armcontainerservice.ManagedClustersClientGetResponse{ + ManagedCluster: armcontainerservice.ManagedCluster{ + ID: convert.RefOf("cluster1"), + Location: convert.RefOf("eastus2"), + Type: convert.RefOf("Microsoft.ContainerService/managedClusters"), + Properties: &armcontainerservice.ManagedClusterProperties{ + EnableRBAC: convert.RefOf(true), + DisableLocalAccounts: convert.RefOf(false), + }, + }, + } + + if statusCode == http.StatusOK { + return mocks.CreateHttpResponseWithBody(request, statusCode, managedCluster) + } else { + return mocks.CreateEmptyHttpResponse(request, statusCode) + } + }) +} + func setupListClusterAdminCredentialsMock(mockContext *mocks.MockContext, statusCode int) error { kubeConfig := createTestCluster("cluster1", "user1") kubeConfigBytes, err := yaml.Marshal(kubeConfig) @@ -550,6 +580,7 @@ func createAksServiceTarget( ) ServiceTarget { kubeCtl := kubectl.NewKubectl(mockContext.CommandRunner) dockerCli := docker.NewDocker(mockContext.CommandRunner) + kubeLoginCli := kubelogin.NewCli(mockContext.CommandRunner) credentialProvider := mockaccount.SubscriptionCredentialProviderFunc( func(_ context.Context, _ string) (azcore.TokenCredential, error) { return mockContext.Credentials, nil @@ -580,6 +611,7 @@ func createAksServiceTarget( managedClustersService, resourceManager, kubeCtl, + kubeLoginCli, containerHelper, ) } diff --git a/cli/azd/pkg/tools/azcli/managed_clusters.go b/cli/azd/pkg/tools/azcli/managed_clusters.go index 8507b21f56a..5e82b976f79 100644 --- a/cli/azd/pkg/tools/azcli/managed_clusters.go +++ b/cli/azd/pkg/tools/azcli/managed_clusters.go @@ -12,13 +12,13 @@ import ( // ManagedClustersService provides actions on top of Azure Kubernetes Service (AKS) Managed Clusters type ManagedClustersService interface { - // Gets the admin credentials for the specified resource - GetAdminCredentials( + // Gets the managed cluster resource by name + Get( ctx context.Context, subscriptionId string, resourceGroupName string, resourceName string, - ) (*armcontainerservice.CredentialResults, error) + ) (*armcontainerservice.ManagedCluster, error) // Gets the user credentials for the specified resource GetUserCredentials( ctx context.Context, @@ -46,28 +46,28 @@ func NewManagedClustersService( } } -// Gets the user credentials for the specified resource -func (cs *managedClustersService) GetUserCredentials( +// Gets the managed cluster resource by name +func (cs *managedClustersService) Get( ctx context.Context, subscriptionId string, resourceGroupName string, resourceName string, -) (*armcontainerservice.CredentialResults, error) { +) (*armcontainerservice.ManagedCluster, error) { client, err := cs.createManagedClusterClient(ctx, subscriptionId) if err != nil { return nil, err } - credResult, err := client.ListClusterUserCredentials(ctx, resourceGroupName, resourceName, nil) + managedCluster, err := client.Get(ctx, resourceGroupName, resourceName, nil) if err != nil { return nil, err } - return &credResult.CredentialResults, nil + return &managedCluster.ManagedCluster, nil } -// Gets the admin credentials for the specified resource -func (cs *managedClustersService) GetAdminCredentials( +// Gets the user credentials for the specified resource +func (cs *managedClustersService) GetUserCredentials( ctx context.Context, subscriptionId string, resourceGroupName string, @@ -78,7 +78,7 @@ func (cs *managedClustersService) GetAdminCredentials( return nil, err } - credResult, err := client.ListClusterAdminCredentials(ctx, resourceGroupName, resourceName, nil) + credResult, err := client.ListClusterUserCredentials(ctx, resourceGroupName, resourceName, nil) if err != nil { return nil, err } diff --git a/cli/azd/pkg/tools/kubectl/kube_config.go b/cli/azd/pkg/tools/kubectl/kube_config.go index 37d1deb2ef5..c674d1f2373 100644 --- a/cli/azd/pkg/tools/kubectl/kube_config.go +++ b/cli/azd/pkg/tools/kubectl/kube_config.go @@ -105,16 +105,11 @@ func (kcm *KubeConfigManager) AddOrUpdateContext( contextName string, newKubeConfig *KubeConfig, ) (string, error) { - _, err := kcm.SaveKubeConfig(ctx, contextName, newKubeConfig) + configPath, err := kcm.SaveKubeConfig(ctx, contextName, newKubeConfig) if err != nil { return "", fmt.Errorf("failed write new kube context file: %w", err) } - configPath, err := kcm.MergeConfigs(ctx, "config", contextName) - if err != nil { - return "", fmt.Errorf("failed merging KUBE configs: %w", err) - } - return configPath, nil }