diff --git a/docs/usage.md b/docs/usage.md index a8b96c9..2cef0d1 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,24 +1,25 @@ # AKS Flex Node Usage Guide -This guide provides two complete deployment paths for AKS Flex Node: +This guide provides three complete setup paths for AKS Flex Node: 1. **[Setup with Azure Arc](#setup-with-azure-arc)** - Easier setup for quick start, plug and play -2. **[Setup with Service Principal](#setup-with-service-principal)** - More scalable for production deployments +2. **[Setup with Service Principal](#setup-with-service-principal)** - More scalable for secure production environment +3. **[Setup with Bootstrap Token](#setup-with-bootstrap-token)** - Simplest setup with minimum dependancy for dynamic hyperscale environments -## Comparison: Arc vs Service Principal +## Comparison: Arc vs Service Principal vs Bootstrap Token Use this comparison to choose the deployment path that best fits your requirements: -| Feature | With Azure Arc | With Service Principal | -|---------|---------------|----------------------| -| **Setup Complexity** | Simple (plug and play) | Moderate (requires SP setup) | -| **Scalability** | Limited (Arc overhead per node) | High (lightweight, efficient) | -| **Credential Management** | Automatic (managed identity) | Manual (SP rotation) | -| **Azure Visibility** | Full (Arc resource in portal) | Limited (just node) | -| **Authentication** | Managed identity + auto-rotation | Static SP credentials | -| **Required Permissions** | More (Arc + RBAC + AKS) | Less (AKS only) | -| **Performance** | Higher overhead (Arc agent) | Lower overhead (direct auth) | -| **Use Case** | Quick start, demos, small scale | Production, large scale | +| Feature | With Azure Arc | With Service Principal | With Bootstrap Token | +|---------|---------------|----------------------|---------------------| +| **Setup Complexity** | Simple (plug and play) | Moderate (requires SP setup) | Very simple (just token) | +| **Scalability** | Low (Arc overhead per node) | High (lightweight, efficient) | Highest (minimal overhead) | +| **Credential Management** | Automatic (managed identity) | Manual (SP rotation) | Manual (token rotation) | +| **Azure Visibility** | Full (Arc resource in portal) | Limited (just node) | Limited (just node) | +| **Authentication** | Managed identity + auto-rotation | Static SP credentials | Bootstrap token (time-limited) | +| **Required Permissions** | More (Arc + RBAC + AKS) | Less (AKS only) | Minimal (token creation) | +| **Performance** | Higher overhead (Arc agent) | Lower overhead (direct auth) | Minimum overhead | +| **Use Case** | Quick start, demos, small scale | Production, large scale | Dynamic, hyperscale | --- @@ -367,6 +368,238 @@ kubectl describe node --- +## Setup with Bootstrap Token + +Use this approach for temporary setup, hyperscale environments. Bootstrap tokens are native Kubernetes authentication tokens with configurable time-to-live (TTL), making them ideal for short-lived nodes. + +**Why Bootstrap Tokens?** +- **Simplest setup** - No Azure Arc registration needed +- **Native Kubernetes** - Uses standard K8s authentication (TLS bootstrapping) +- **Time-limited** - Tokens expire automatically after configured TTL +- **Quick provisioning** - Generate tokens with minimal dependencies + +### Cluster Setup + +Create an AKS cluster with Azure AD enabled: + +```bash +# Create AKS cluster +MY_USER_ID=$(az ad signed-in-user show --query id -o tsv) +RESOURCE_GROUP="your-resource-group" +CLUSTER_NAME="your-cluster-name" +az aks create \ + --resource-group "$RESOURCE_GROUP" \ + --name "$CLUSTER_NAME" \ + --enable-aad \ + --aad-admin-group-object-ids "$MY_USER_ID" +``` + +### Bootstrap Token Creation + +Create a bootstrap token by creating a Kubernetes secret: + +```bash +# Get cluster credentials +az aks get-credentials \ + --resource-group "$RESOURCE_GROUP" \ + --name "$CLUSTER_NAME" \ + --admin \ + --overwrite-existing + +# Generate a valid bootstrap token (format: 6 chars . 16 chars) +TOKEN_ID=$(openssl rand -hex 3) +TOKEN_SECRET=$(openssl rand -hex 8) +BOOTSTRAP_TOKEN="${TOKEN_ID}.${TOKEN_SECRET}" + +# Set expiration (e.g., 24 hours from now) +EXPIRATION=$(date -u -d "+24 hours" +"%Y-%m-%dT%H:%M:%SZ") + +# Create the bootstrap token secret +kubectl apply -f - <` +- Bootstrap tokens follow the [Kubernetes bootstrap token specification](https://kubernetes.io/docs/reference/access-authn-authz/bootstrap-tokens/) + +### Configure RBAC Roles + +Apply the necessary Kubernetes RBAC roles for bootstrap token authentication: + +```bash +# Create node bootstrapper role binding +kubectl apply -f - < /dev/null < +``` + +### How It Works + +Bootstrap token authentication follows the [Kubernetes TLS Bootstrapping](https://kubernetes.io/docs/reference/access-authn-authz/kubelet-tls-bootstrapping/) process: + +1. **Token Creation**: A bootstrap token secret is created in the `kube-system` namespace with proper RBAC bindings +2. **Initial Authentication**: The node uses the bootstrap token to authenticate to the Kubernetes API server +3. **Certificate Request**: Kubelet generates a private key and submits a Certificate Signing Request (CSR) to the cluster +4. **Certificate Approval**: The CSR is automatically approved through the configured RBAC roles +5. **Certificate Issuance**: The cluster issues a signed client certificate for the kubelet +6. **Ongoing Authentication**: After bootstrap, kubelet uses its client certificate for all future API requests +7. **Token Expiration**: The bootstrap token expires after the configured TTL, but the node continues to use its certificate + +This approach is more secure than long-lived credentials because: +- Bootstrap tokens are short-lived (typically 24 hours to 7 days) +- After successful bootstrap, the token is no longer needed +- Ongoing authentication uses auto-rotating kubelet certificates (rotated by kubelet automatically) +- Each node gets its own unique client certificate +- No manual credential rotation required after initial bootstrap + +### Token vs Certificate Lifecycle + +#### Bootstrap Token (One-Time Use) +- **Purpose**: Initial node authentication only +- **Usage**: Used once during node bootstrap +- **Expiration**: Token expires after configured TTL +- **Rotation**: Not needed - token is no longer used after successful bootstrap + +#### Kubelet Certificates (Auto-Rotated) +- **Purpose**: Ongoing node authentication after bootstrap +- **Usage**: Used for all API server communication +- **Expiration**: Typically valid for ~1 year +- **Rotation**: **Automatically rotated by kubelet** before expiration + +**Key Point:** Once a node successfully bootstraps: +1. The bootstrap token is no longer needed (can expire safely) +2. The node uses a client certificate for all future authentication +3. Kubelet automatically rotates this certificate (built-in feature since Kubernetes 1.8+) + ## Common Operations ### Available Commands @@ -453,6 +686,26 @@ az aks show \ --name $CLUSTER_NAME ``` +### Bootstrap Token Mode Issues + +```bash +# Verify bootstrap token exists and is valid +kubectl get secret -n kube-system | grep bootstrap-token + +# Check bootstrap token details (decode base64 values) +kubectl get secret bootstrap-token- -n kube-system -o yaml + +# Verify RBAC bindings +kubectl get clusterrolebinding aks-flex-node-bootstrapper +kubectl get clusterrolebinding aks-flex-node-auto-approve-csr + +# Check certificate signing requests +kubectl get csr + +# Approve pending CSR if needed +kubectl certificate approve +``` + ### Kubelet Issues ```bash diff --git a/pkg/components/kubelet/kubelet_installer.go b/pkg/components/kubelet/kubelet_installer.go index 9e2d55f..3239a9d 100644 --- a/pkg/components/kubelet/kubelet_installer.go +++ b/pkg/components/kubelet/kubelet_installer.go @@ -3,6 +3,7 @@ package kubelet import ( "context" "fmt" + "os" "path/filepath" "strings" @@ -38,6 +39,7 @@ func (i *Installer) GetName() string { // Execute installs and configures kubelet service func (i *Installer) Execute(ctx context.Context) error { i.logger.Info("Installing and configuring kubelet") + // Set up mc client for getting cluster info if err := i.setUpClients(); err != nil { return fmt.Errorf("failed to set up Azure SDK clients: %w", err) @@ -91,14 +93,23 @@ func (i *Installer) configure(ctx context.Context) error { return err } - // Create token script for exec credential authentication (Arc or Service Principal) - if err := i.createTokenScript(); err != nil { - return err - } + // Create authentication configuration based on auth method + if i.config.IsBootstrapTokenConfigured() { + // Bootstrap token authentication uses a simple token-based kubeconfig + if err := i.createKubeconfigWithBootstrapToken(ctx); err != nil { + return err + } + } else { + // Arc or Service Principal authentication uses exec credential provider + // Create token script for exec credential authentication (Arc or Service Principal) + if err := i.createTokenScript(); err != nil { + return err + } - // Create kubeconfig with exec credential provider - if err := i.createKubeconfigWithExecCredential(ctx); err != nil { - return err + // Create kubeconfig with exec credential provider + if err := i.createKubeconfigWithExecCredential(ctx); err != nil { + return err + } } // Create kubelet containerd configuration @@ -222,6 +233,7 @@ KUBELET_FLAGS="\ --read-only-port=0 \ --resolv-conf=/run/systemd/resolve/resolv.conf \ --streaming-connection-idle-timeout=4h \ + --rotate-certificates=true \ --tls-cipher-suites=TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,TLS_RSA_WITH_AES_256_GCM_SHA384,TLS_RSA_WITH_AES_128_GCM_SHA256 \ "`, strings.Join(labels, ","), @@ -319,8 +331,11 @@ func (i *Installer) createTokenScript() error { return i.createMSITokenScript() } else if i.config.IsSPConfigured() { return i.createServicePrincipalTokenScript() + } else if i.config.IsBootstrapTokenConfigured() { + // Bootstrap token doesn't need a token script + return nil } else { - return fmt.Errorf("no valid authentication method configured - either Arc, MSI, or Service Principal must be explicitly configured") + return fmt.Errorf("no valid authentication method configured - either Arc, MSI, Service Principal, or Bootstrap Token must be configured") } } @@ -492,11 +507,14 @@ func (i *Installer) writeTokenScript(tokenScript string) error { // createKubeconfigWithExecCredential creates kubeconfig with exec credential provider for authentication func (i *Installer) createKubeconfigWithExecCredential(ctx context.Context) error { + // Fetch cluster credentials from Azure (for Arc/SP/MI modes) + i.logger.Info("Fetching cluster credentials from Azure") kubeconfig, err := i.getClusterCredentials(ctx) if err != nil { return fmt.Errorf("failed to get cluster credentials: %w", err) } + // Extract server URL and CA cert from kubeconfig serverURL, caCertData, err := utils.ExtractClusterInfo(kubeconfig) if err != nil { return fmt.Errorf("failed to extract cluster info from kubeconfig: %w", err) @@ -561,6 +579,72 @@ users: return nil } +// createKubeconfigWithBootstrapToken creates a kubeconfig file with bootstrap token authentication +func (i *Installer) createKubeconfigWithBootstrapToken(ctx context.Context) error { + i.logger.Info("Creating bootstrap token kubeconfig") + + // Use cluster info from kubelet config (required fields validated earlier) + serverURL := i.config.Node.Kubelet.ServerURL + caCertData := i.config.Node.Kubelet.CACertData + bootstrapToken := i.config.Azure.BootstrapToken.Token + + // Get node hostname for audit logging + hostname, err := os.Hostname() + if err != nil { + return fmt.Errorf("failed to get hostname for bootstrap kubeconfig: %w", err) + } + + // Include node name in username for better auditing in Kubernetes API server logs + username := fmt.Sprintf("kubelet-bootstrap-%s", hostname) + + // Create cluster configuration based on whether we have CA cert + var clusterConfig string + if caCertData != "" { + clusterConfig = fmt.Sprintf(`- cluster: + certificate-authority-data: %s + server: %s + name: %s`, caCertData, serverURL, i.config.Azure.TargetCluster.Name) + } else { + clusterConfig = fmt.Sprintf(`- cluster: + insecure-skip-tls-verify: true + server: %s + name: %s`, serverURL, i.config.Azure.TargetCluster.Name) + } + + // Create kubeconfig with bootstrap token authentication + kubeconfigContent := fmt.Sprintf(`apiVersion: v1 +kind: Config +clusters: +%s +contexts: +- context: + cluster: %s + user: %s + name: %s +current-context: %s +users: +- name: %s + user: + token: %s +`, + clusterConfig, + i.config.Azure.TargetCluster.Name, + username, + i.config.Azure.TargetCluster.Name, + i.config.Azure.TargetCluster.Name, + username, + bootstrapToken) + + // Write kubeconfig file to the correct location for kubelet + if err := utils.WriteFileAtomicSystem(KubeletKubeconfigPath, []byte(kubeconfigContent), 0o600); err != nil { + return fmt.Errorf("failed to create bootstrap kubeconfig file: %w", err) + } + + i.logger.Info("Bootstrap token kubeconfig created successfully") + return nil +} + +// setUpClients sets up Azure SDK clients for fetching cluster credentials func (i *Installer) setUpClients() error { cred, err := auth.NewAuthProvider().UserCredential(config.GetConfig()) if err != nil { @@ -575,7 +659,7 @@ func (i *Installer) setUpClients() error { return nil } -// GetClusterCredentials retrieves cluster kube admin credentials using Azure SDK +// getClusterCredentials retrieves cluster kube admin credentials using Azure SDK func (i *Installer) getClusterCredentials(ctx context.Context) ([]byte, error) { cfg := config.GetConfig() clusterResourceGroup := cfg.GetTargetClusterResourceGroup() diff --git a/pkg/config/config.go b/pkg/config/config.go index a64ad8d..498d91e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -196,6 +196,10 @@ func (c *Config) setNpdDefaults() { // Pattern is case insensitive to handle variations in Azure resource path casing var AKSClusterResourceIDPattern = regexp.MustCompile(`(?i)^/subscriptions/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/resourcegroups/([a-zA-Z0-9_\-\.]+)/providers/microsoft\.containerservice/managedclusters/([a-zA-Z0-9_\-\.]+)$`) +// BootstrapTokenPattern is the regex pattern for Kubernetes bootstrap tokens +// Format: . where token-id is 6 chars [a-z0-9] and token-secret is 16 chars [a-z0-9] +var BootstrapTokenPattern = regexp.MustCompile(`^[a-z0-9]{6}\.[a-z0-9]{16}$`) + // validateAzureResourceID validates the format of an AKS cluster resource ID using regex pattern matching func validateAzureResourceID(resourceID string) error { // Check AKS cluster resource ID format @@ -207,6 +211,31 @@ func validateAzureResourceID(resourceID string) error { return nil } +// validateBootstrapToken validates the bootstrap token configuration +func validateBootstrapToken(cfg *Config) error { + tokenCfg := cfg.Azure.BootstrapToken + if tokenCfg == nil { + return fmt.Errorf("bootstrap token configuration is nil") + } + + // Validate token format + if !BootstrapTokenPattern.MatchString(tokenCfg.Token) { + return fmt.Errorf("invalid bootstrap token format. Expected format: . " + + "where token-id is 6 lowercase alphanumeric characters and token-secret is 16 lowercase alphanumeric characters") + } + + // When using bootstrap token, serverURL and caCertData are required in kubelet config + // because there's no Azure authentication to fetch them + if cfg.Node.Kubelet.ServerURL == "" { + return fmt.Errorf("node.kubelet.serverURL is required when using bootstrap token authentication") + } + if cfg.Node.Kubelet.CACertData == "" { + return fmt.Errorf("node.kubelet.caCertData is required when using bootstrap token authentication") + } + + return nil +} + // validLogLevels defines the allowed logging levels for the agent var validLogLevels = map[string]bool{ "debug": true, @@ -252,10 +281,33 @@ func (c *Config) Validate() error { return fmt.Errorf("invalid agent.logLevel: %s. Valid values are: debug, info, warning, error", c.Agent.LogLevel) } - // Validate authentication configuration - Arc and MSI cannot coexist - if c.IsARCEnabled() && c.IsMIConfigured() { - return fmt.Errorf("invalid configuration: Arc and ManagedIdentity cannot be configured together. " + - "Choose either Arc (with arc.enabled: true) or ManagedIdentity (with managedIdentity config), but not both") + // Validate authentication configuration - ensure mutual exclusivity + authMethodCount := 0 + if c.IsARCEnabled() { + authMethodCount++ + } + if c.IsSPConfigured() { + authMethodCount++ + } + if c.IsMIConfigured() { + authMethodCount++ + } + if c.IsBootstrapTokenConfigured() { + authMethodCount++ + } + + if authMethodCount == 0 { + return fmt.Errorf("at least one authentication method must be configured: Arc, Service Principal, Managed Identity, or Bootstrap Token") + } + if authMethodCount > 1 { + return fmt.Errorf("only one authentication method can be enabled at a time: Arc, Service Principal, Managed Identity, or Bootstrap Token") + } + + // Validate bootstrap token if configured + if c.IsBootstrapTokenConfigured() { + if err := validateBootstrapToken(c); err != nil { + return fmt.Errorf("invalid bootstrap token configuration: %w", err) + } } return nil diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index d7026cd..321ef76 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -83,6 +83,9 @@ func TestValidate(t *testing.T) { SubscriptionID: "12345678-1234-1234-1234-123456789012", TenantID: "12345678-1234-1234-1234-123456789012", Cloud: "AzurePublicCloud", + BootstrapToken: &BootstrapTokenConfig{ + Token: "abcdef.0123456789abcdef", + }, TargetCluster: &TargetClusterConfig{ ResourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster", Location: "eastus", @@ -91,6 +94,12 @@ func TestValidate(t *testing.T) { Agent: AgentConfig{ LogLevel: "info", }, + Node: NodeConfig{ + Kubelet: KubeletConfig{ + ServerURL: "https://test-cluster-abc123.hcp.eastus.azmk8s.io:443", + CACertData: "LS0tLS1CRUdJTi1DRVJUSUZJQ0FURS0tLS0tCk1JSUREekNDQWZlZ0F3SUJBZ0lSQU1kbzBZa0R", + }, + }, }, wantErr: false, }, @@ -217,6 +226,7 @@ func TestValidate(t *testing.T) { Location: "eastus", }, Arc: &ArcConfig{ + Enabled: true, ResourceGroup: "test-rg", MachineName: "test-machine", Location: "eastus", @@ -273,6 +283,9 @@ func TestLoadConfig(t *testing.T) { "subscriptionId": "12345678-1234-1234-1234-123456789012", "tenantId": "12345678-1234-1234-1234-123456789012", "cloud": "AzurePublicCloud", + "bootstrapToken": { + "token": "abcdef.0123456789abcdef" + }, "targetCluster": { "resourceId": "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster", "location": "eastus" @@ -280,6 +293,12 @@ func TestLoadConfig(t *testing.T) { }, "agent": { "logLevel": "debug" + }, + "node": { + "kubelet": { + "serverURL": "https://test-cluster-abc123.hcp.eastus.azmk8s.io:443", + "caCertData": "LS0tLS1CRUdJTi1DRVJUSUZJQ0FURS0tLS0tCk1JSUREekNDQWZlZ0F3SUJBZ0lSQU1kbzBZa0R" + } } }`, wantErr: false, @@ -475,11 +494,11 @@ func TestManagedIdentityConfiguration(t *testing.T) { }() tests := []struct { - name string - configJSON string - wantMIConfigured bool - wantMIClientID string - wantValidationErr bool + name string + configJSON string + wantMIConfigured bool + wantMIClientID string + wantValidationErr bool }{ { name: "managedIdentity with empty object", @@ -546,10 +565,19 @@ func TestManagedIdentityConfiguration(t *testing.T) { "subscriptionId": "12345678-1234-1234-1234-123456789012", "tenantId": "12345678-1234-1234-1234-123456789012", "cloud": "AzurePublicCloud", + "bootstrapToken": { + "token": "abcdef.0123456789abcdef" + }, "targetCluster": { "resourceId": "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster", "location": "eastus" } + }, + "node": { + "kubelet": { + "serverURL": "https://test-cluster-abc123.hcp.eastus.azmk8s.io:443", + "caCertData": "LS0tLS1CRUdJTi1DRVJUSUZJQ0FURS0tLS0tCk1JSUREekNDQWZlZ0F3SUJBZ0lSQU1kbzBZa0R" + } } }`, wantMIConfigured: false, @@ -620,3 +648,407 @@ func TestManagedIdentityConfiguration(t *testing.T) { }) } } + +func TestValidateBootstrapToken(t *testing.T) { + tests := []struct { + name string + config *Config + wantErr bool + errString string + }{ + { + name: "valid bootstrap token", + config: &Config{ + Azure: AzureConfig{ + BootstrapToken: &BootstrapTokenConfig{ + Token: "abcdef.0123456789abcdef", + }, + }, + Node: NodeConfig{ + Kubelet: KubeletConfig{ + ServerURL: "https://test-cluster-abc123.hcp.eastus.azmk8s.io:443", + CACertData: "LS0tLS1CRUdJTi1DRVJUSUZJQ0FURS0tLS0tCk1JSUREekNDQWZlZ0F3SUJBZ0lSQU1kbzBZa0R", + }, + }, + }, + wantErr: false, + }, + { + name: "invalid token format - uppercase", + config: &Config{ + Azure: AzureConfig{ + BootstrapToken: &BootstrapTokenConfig{ + Token: "ABCDEF.0123456789ABCDEF", + }, + }, + Node: NodeConfig{ + Kubelet: KubeletConfig{ + ServerURL: "https://test-cluster-abc123.hcp.eastus.azmk8s.io:443", + CACertData: "LS0tLS1CRUdJTi1DRVJUSUZJQ0FURS0tLS0tCk1JSUREekNDQWZlZ0F3SUJBZ0lSQU1kbzBZa0R", + }, + }, + }, + wantErr: true, + errString: "invalid bootstrap token format", + }, + { + name: "invalid token format - wrong token-id length", + config: &Config{ + Azure: AzureConfig{ + BootstrapToken: &BootstrapTokenConfig{ + Token: "abcde.0123456789abcdef", + }, + }, + Node: NodeConfig{ + Kubelet: KubeletConfig{ + ServerURL: "https://test-cluster-abc123.hcp.eastus.azmk8s.io:443", + CACertData: "LS0tLS1CRUdJTi1DRVJUSUZJQ0FURS0tLS0tCk1JSUREekNDQWZlZ0F3SUJBZ0lSQU1kbzBZa0R", + }, + }, + }, + wantErr: true, + errString: "invalid bootstrap token format", + }, + { + name: "invalid token format - wrong token-secret length", + config: &Config{ + Azure: AzureConfig{ + BootstrapToken: &BootstrapTokenConfig{ + Token: "abcdef.0123456789abcde", + }, + }, + Node: NodeConfig{ + Kubelet: KubeletConfig{ + ServerURL: "https://test-cluster-abc123.hcp.eastus.azmk8s.io:443", + CACertData: "LS0tLS1CRUdJTi1DRVJUSUZJQ0FURS0tLS0tCk1JSUREekNDQWZlZ0F3SUJBZ0lSQU1kbzBZa0R", + }, + }, + }, + wantErr: true, + errString: "invalid bootstrap token format", + }, + { + name: "invalid token format - no separator", + config: &Config{ + Azure: AzureConfig{ + BootstrapToken: &BootstrapTokenConfig{ + Token: "abcdef0123456789abcdef", + }, + }, + Node: NodeConfig{ + Kubelet: KubeletConfig{ + ServerURL: "https://test-cluster-abc123.hcp.eastus.azmk8s.io:443", + CACertData: "LS0tLS1CRUdJTi1DRVJUSUZJQ0FURS0tLS0tCk1JSUREekNDQWZlZ0F3SUJBZ0lSQU1kbzBZa0R", + }, + }, + }, + wantErr: true, + errString: "invalid bootstrap token format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateBootstrapToken(tt.config) + if tt.wantErr { + if err == nil { + t.Errorf("validateBootstrapToken() expected error but got none") + } else if tt.errString != "" && !strings.Contains(err.Error(), tt.errString) { + t.Errorf("validateBootstrapToken() error = %v, want error containing %v", err, tt.errString) + } + } else { + if err != nil { + t.Errorf("validateBootstrapToken() unexpected error = %v", err) + } + } + }) + } +} + +func TestAuthenticationMethodValidation(t *testing.T) { + tests := []struct { + name string + config *Config + wantErr bool + errMsg string + }{ + { + name: "bootstrap token authentication enabled", + config: &Config{ + Azure: AzureConfig{ + SubscriptionID: "12345678-1234-1234-1234-123456789012", + TenantID: "12345678-1234-1234-1234-123456789012", + Cloud: "AzurePublicCloud", + BootstrapToken: &BootstrapTokenConfig{ + Token: "abcdef.0123456789abcdef", + }, + TargetCluster: &TargetClusterConfig{ + ResourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster", + Location: "eastus", + }, + }, + Agent: AgentConfig{ + LogLevel: "info", + }, + Node: NodeConfig{ + Kubelet: KubeletConfig{ + ServerURL: "https://test-cluster-abc123.hcp.eastus.azmk8s.io:443", + CACertData: "LS0tLS1CRUdJTi1DRVJUSUZJQ0FURS0tLS0tCk1JSUREekNDQWZlZ0F3SUJBZ0lSQU1kbzBZa0R", + }, + }, + }, + wantErr: false, + }, + { + name: "service principal authentication enabled", + config: &Config{ + Azure: AzureConfig{ + SubscriptionID: "12345678-1234-1234-1234-123456789012", + TenantID: "12345678-1234-1234-1234-123456789012", + Cloud: "AzurePublicCloud", + ServicePrincipal: &ServicePrincipalConfig{ + TenantID: "12345678-1234-1234-1234-123456789012", + ClientID: "12345678-1234-1234-1234-123456789012", + ClientSecret: "test-secret", + }, + TargetCluster: &TargetClusterConfig{ + ResourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster", + Location: "eastus", + }, + }, + Agent: AgentConfig{ + LogLevel: "info", + }, + }, + wantErr: false, + }, + { + name: "managed identity authentication enabled", + config: &Config{ + Azure: AzureConfig{ + SubscriptionID: "12345678-1234-1234-1234-123456789012", + TenantID: "12345678-1234-1234-1234-123456789012", + Cloud: "AzurePublicCloud", + ManagedIdentity: &ManagedIdentityConfig{ + ClientID: "12345678-1234-1234-1234-123456789012", + }, + TargetCluster: &TargetClusterConfig{ + ResourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster", + Location: "eastus", + }, + }, + Agent: AgentConfig{ + LogLevel: "info", + }, + isMIExplicitlySet: true, + }, + wantErr: false, + }, + { + name: "arc authentication enabled", + config: &Config{ + Azure: AzureConfig{ + SubscriptionID: "12345678-1234-1234-1234-123456789012", + TenantID: "12345678-1234-1234-1234-123456789012", + Cloud: "AzurePublicCloud", + Arc: &ArcConfig{ + Enabled: true, + ResourceGroup: "test-rg", + MachineName: "test-machine", + Location: "eastus", + }, + TargetCluster: &TargetClusterConfig{ + ResourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster", + Location: "eastus", + }, + }, + Agent: AgentConfig{ + LogLevel: "info", + }, + }, + wantErr: false, + }, + { + name: "arc and managed identity together fails", + config: &Config{ + Azure: AzureConfig{ + SubscriptionID: "12345678-1234-1234-1234-123456789012", + TenantID: "12345678-1234-1234-1234-123456789012", + Cloud: "AzurePublicCloud", + Arc: &ArcConfig{ + Enabled: true, + ResourceGroup: "test-rg", + MachineName: "test-machine", + Location: "eastus", + }, + ManagedIdentity: &ManagedIdentityConfig{ + ClientID: "12345678-1234-1234-1234-123456789012", + }, + TargetCluster: &TargetClusterConfig{ + ResourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster", + Location: "eastus", + }, + }, + Agent: AgentConfig{ + LogLevel: "info", + }, + isMIExplicitlySet: true, + }, + wantErr: true, + errMsg: "only one authentication method can be enabled at a time", + }, + { + name: "bootstrap token and service principal together fails", + config: &Config{ + Azure: AzureConfig{ + SubscriptionID: "12345678-1234-1234-1234-123456789012", + TenantID: "12345678-1234-1234-1234-123456789012", + Cloud: "AzurePublicCloud", + BootstrapToken: &BootstrapTokenConfig{ + Token: "abcdef.0123456789abcdef", + }, + ServicePrincipal: &ServicePrincipalConfig{ + TenantID: "12345678-1234-1234-1234-123456789012", + ClientID: "12345678-1234-1234-1234-123456789012", + ClientSecret: "test-secret", + }, + TargetCluster: &TargetClusterConfig{ + ResourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster", + Location: "eastus", + }, + }, + Agent: AgentConfig{ + LogLevel: "info", + }, + Node: NodeConfig{ + Kubelet: KubeletConfig{ + ServerURL: "https://test-cluster-abc123.hcp.eastus.azmk8s.io:443", + CACertData: "LS0tLS1CRUdJTi1DRVJUSUZJQ0FURS0tLS0tCk1JSUREekNDQWZlZ0F3SUJBZ0lSQU1kbzBZa0R", + }, + }, + }, + wantErr: true, + errMsg: "only one authentication method can be enabled at a time", + }, + { + name: "arc and service principal together fails", + config: &Config{ + Azure: AzureConfig{ + SubscriptionID: "12345678-1234-1234-1234-123456789012", + TenantID: "12345678-1234-1234-1234-123456789012", + Cloud: "AzurePublicCloud", + Arc: &ArcConfig{ + Enabled: true, + ResourceGroup: "test-rg", + MachineName: "test-machine", + Location: "eastus", + }, + ServicePrincipal: &ServicePrincipalConfig{ + TenantID: "12345678-1234-1234-1234-123456789012", + ClientID: "12345678-1234-1234-1234-123456789012", + ClientSecret: "test-secret", + }, + TargetCluster: &TargetClusterConfig{ + ResourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster", + Location: "eastus", + }, + }, + Agent: AgentConfig{ + LogLevel: "info", + }, + }, + wantErr: true, + errMsg: "only one authentication method can be enabled at a time", + }, + { + name: "no authentication method configured fails", + config: &Config{ + Azure: AzureConfig{ + SubscriptionID: "12345678-1234-1234-1234-123456789012", + TenantID: "12345678-1234-1234-1234-123456789012", + Cloud: "AzurePublicCloud", + TargetCluster: &TargetClusterConfig{ + ResourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster", + Location: "eastus", + }, + }, + Agent: AgentConfig{ + LogLevel: "info", + }, + }, + wantErr: true, + errMsg: "at least one authentication method must be configured", + }, + { + name: "bootstrap token without serverURL fails", + config: &Config{ + Azure: AzureConfig{ + SubscriptionID: "12345678-1234-1234-1234-123456789012", + TenantID: "12345678-1234-1234-1234-123456789012", + Cloud: "AzurePublicCloud", + BootstrapToken: &BootstrapTokenConfig{ + Token: "abcdef.0123456789abcdef", + }, + TargetCluster: &TargetClusterConfig{ + ResourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster", + Location: "eastus", + }, + }, + Agent: AgentConfig{ + LogLevel: "info", + }, + Node: NodeConfig{ + Kubelet: KubeletConfig{ + CACertData: "LS0tLS1CRUdJTi1DRVJUSUZJQ0FURS0tLS0tCk1JSUREekNDQWZlZ0F3SUJBZ0lSQU1kbzBZa0R", + }, + }, + }, + wantErr: true, + errMsg: "node.kubelet.serverURL is required when using bootstrap token authentication", + }, + { + name: "bootstrap token without caCertData fails", + config: &Config{ + Azure: AzureConfig{ + SubscriptionID: "12345678-1234-1234-1234-123456789012", + TenantID: "12345678-1234-1234-1234-123456789012", + Cloud: "AzurePublicCloud", + BootstrapToken: &BootstrapTokenConfig{ + Token: "abcdef.0123456789abcdef", + }, + TargetCluster: &TargetClusterConfig{ + ResourceID: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster", + Location: "eastus", + }, + }, + Agent: AgentConfig{ + LogLevel: "info", + }, + Node: NodeConfig{ + Kubelet: KubeletConfig{ + ServerURL: "https://test-cluster-abc123.hcp.eastus.azmk8s.io:443", + }, + }, + }, + wantErr: true, + errMsg: "node.kubelet.caCertData is required when using bootstrap token authentication", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if tt.wantErr { + if err == nil { + t.Errorf("Validate() expected error but got none") + } else if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("Validate() error = %v, want error containing %v", err, tt.errMsg) + } + } else { + if err != nil { + t.Errorf("Validate() unexpected error = %v", err) + } + } + }) + } +} diff --git a/pkg/config/structs.go b/pkg/config/structs.go index 0bc889d..3c1cc4a 100644 --- a/pkg/config/structs.go +++ b/pkg/config/structs.go @@ -28,6 +28,7 @@ type AzureConfig struct { Cloud string `json:"cloud"` // Azure cloud environment (defaults to AzurePublicCloud) ServicePrincipal *ServicePrincipalConfig `json:"servicePrincipal,omitempty"` // Optional service principal authentication ManagedIdentity *ManagedIdentityConfig `json:"managedIdentity,omitempty"` // Optional managed identity authentication + BootstrapToken *BootstrapTokenConfig `json:"bootstrapToken,omitempty"` // Optional bootstrap token authentication Arc *ArcConfig `json:"arc"` // Azure Arc machine configuration TargetCluster *TargetClusterConfig `json:"targetCluster"` // Target AKS cluster configuration } @@ -46,6 +47,12 @@ type ManagedIdentityConfig struct { ClientID string `json:"clientId,omitempty"` // Client ID of the managed identity (optional, for VMs with multiple identities) } +// BootstrapTokenConfig holds Kubernetes bootstrap token authentication configuration. +// Bootstrap tokens provide a lightweight authentication method for node joining. +type BootstrapTokenConfig struct { + Token string `json:"token"` // Bootstrap token in format: . +} + // TargetClusterConfig holds configuration for the target AKS cluster the ARC machine will connect to. type TargetClusterConfig struct { ResourceID string `json:"resourceId"` // Full resource ID of the target AKS cluster @@ -105,6 +112,8 @@ type KubeletConfig struct { ImageGCHighThreshold int `json:"imageGCHighThreshold"` ImageGCLowThreshold int `json:"imageGCLowThreshold"` DNSServiceIP string `json:"dnsServiceIP"` // Cluster DNS service IP (default: 10.0.0.10 for AKS) + ServerURL string `json:"serverURL"` // Kubernetes API server URL + CACertData string `json:"caCertData"` // Base64-encoded CA certificate data } // PathsConfig holds file system paths used by the agent for Kubernetes and CNI configurations. @@ -145,6 +154,12 @@ func (cfg *Config) IsMIConfigured() bool { return cfg.isMIExplicitlySet } +// IsBootstrapTokenConfigured checks if bootstrap token credentials are provided in the configuration +func (cfg *Config) IsBootstrapTokenConfigured() bool { + return cfg.Azure.BootstrapToken != nil && + cfg.Azure.BootstrapToken.Token != "" +} + // GetArcMachineName returns the Arc machine name from configuration or defaults to the system hostname func (cfg *Config) GetArcMachineName() string { if cfg.Azure.Arc != nil && cfg.Azure.Arc.MachineName != "" {