Skip to content

Commit

Permalink
AKS Service Target: Adds support for Azure RBAC when local user accou…
Browse files Browse the repository at this point in the history
…nts are disabled (#3211)

When a cluster is provisioned with Azure RBAC and local accounts are disabled azd will leverage kubelogin to use exec auth module with azd authentication.
  • Loading branch information
wbreza committed Feb 7, 2024
1 parent 91d7cde commit 8fe3492
Show file tree
Hide file tree
Showing 7 changed files with 200 additions and 62 deletions.
1 change: 1 addition & 0 deletions cli/azd/.vscode/cspell-azd-dictionary.txt
Expand Up @@ -103,6 +103,7 @@ javac
jmes
jquery
keychain
kubelogin
LASTEXITCODE
ldflags
lechnerc77
Expand Down
2 changes: 2 additions & 0 deletions cli/azd/cmd/container.go
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
82 changes: 82 additions & 0 deletions 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
}
108 changes: 67 additions & 41 deletions cli/azd/pkg/project/service_target_aks.go
Expand Up @@ -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"
Expand Down Expand Up @@ -62,6 +63,7 @@ type aksTarget struct {
managedClustersService azcli.ManagedClustersService
resourceManager ResourceManager
kubectl kubectl.KubectlCli
kubeLoginCli *kubelogin.Cli
containerHelper *ContainerHelper
}

Expand All @@ -73,6 +75,7 @@ func NewAksTarget(
managedClustersService azcli.ManagedClustersService,
resourceManager ResourceManager,
kubectlCli kubectl.KubectlCli,
kubeLoginCli *kubelogin.Cli,
containerHelper *ContainerHelper,
) ServiceTarget {
return &aksTarget{
Expand All @@ -82,6 +85,7 @@ func NewAksTarget(
managedClustersService: managedClustersService,
resourceManager: resourceManager,
kubectl: kubectlCli,
kubeLoginCli: kubeLoginCli,
containerHelper: containerHelper,
}
}
Expand Down Expand Up @@ -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
}

Expand All @@ -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
Expand Down
40 changes: 36 additions & 4 deletions cli/azd/pkg/project/service_target_aks_test.go
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -241,13 +242,42 @@ func setupMocksForAksTarget(mockContext *mocks.MockContext) error {
return err
}

setupGetClusterMock(mockContext, http.StatusOK)
setupMocksForAcr(mockContext)
setupMocksForKubectl(mockContext)
setupMocksForDocker(mockContext)

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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -580,6 +611,7 @@ func createAksServiceTarget(
managedClustersService,
resourceManager,
kubeCtl,
kubeLoginCli,
containerHelper,
)
}
Expand Down

0 comments on commit 8fe3492

Please sign in to comment.