From 1efe3d78b4978e8064942d03a29415126522639e Mon Sep 17 00:00:00 2001 From: Liviu Frateanu Date: Fri, 3 Jul 2026 14:47:47 +0300 Subject: [PATCH 1/6] feat: add managed identity auth support for azure-native and azuread providers Co-Authored-By: Claude Sonnet 4.6 --- .../provisioners/pulumi/pulumi.go | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/internal/controllers/provisioning/provisioners/pulumi/pulumi.go b/internal/controllers/provisioning/provisioners/pulumi/pulumi.go index fcb4234..a164934 100644 --- a/internal/controllers/provisioning/provisioners/pulumi/pulumi.go +++ b/internal/controllers/provisioning/provisioners/pulumi/pulumi.go @@ -26,6 +26,7 @@ import ( var ( EnvPulumiSkipRefresh = "PULUMI_SKIP_REFRESH" EnvAzureEnabled = "AZURE_ENABLED" + EnvAzureUseMsi = "AZURE_USE_MSI" ) type provisionedResourceMap = map[provisioningv1.ProvisioningResourceIdendtifier]pulumi.Resource @@ -223,16 +224,31 @@ func createOrSelectStack(ctx context.Context, stackName, projectName string, dep } if azureEnabled { + useMsi, _ := strconv.ParseBool(os.Getenv(EnvAzureUseMsi)) + azureConfigValues := map[string]auto.ConfigValue{ "azure-native:location": {Value: os.Getenv("AZURE_LOCATION")}, - "azure-native:clientId": {Value: os.Getenv("AZURE_CLIENT_ID")}, "azure-native:subscriptionId": {Value: os.Getenv("AZURE_SUBSCRIPTION_ID")}, "azure-native:tenantId": {Value: os.Getenv("AZURE_TENANT_ID")}, - "azure-native:clientSecret": {Value: os.Getenv("AZURE_CLIENT_SECRET"), Secret: true}, - "azuread:clientId": {Value: os.Getenv("ARM_CLIENT_ID")}, "azuread:tenantId": {Value: os.Getenv("ARM_TENANT_ID")}, - "azuread:clientSecret": {Value: os.Getenv("ARM_CLIENT_SECRET"), Secret: true}, } + + if useMsi { + azureConfigValues["azure-native:useMsi"] = auto.ConfigValue{Value: "true"} + azureConfigValues["azuread:useMsi"] = auto.ConfigValue{Value: "true"} + if clientId := os.Getenv("AZURE_CLIENT_ID"); clientId != "" { + azureConfigValues["azure-native:clientId"] = auto.ConfigValue{Value: clientId} + } + if armClientId := os.Getenv("ARM_CLIENT_ID"); armClientId != "" { + azureConfigValues["azuread:clientId"] = auto.ConfigValue{Value: armClientId} + } + } else { + azureConfigValues["azure-native:clientId"] = auto.ConfigValue{Value: os.Getenv("AZURE_CLIENT_ID")} + azureConfigValues["azure-native:clientSecret"] = auto.ConfigValue{Value: os.Getenv("AZURE_CLIENT_SECRET"), Secret: true} + azureConfigValues["azuread:clientId"] = auto.ConfigValue{Value: os.Getenv("ARM_CLIENT_ID")} + azureConfigValues["azuread:clientSecret"] = auto.ConfigValue{Value: os.Getenv("ARM_CLIENT_SECRET"), Secret: true} + } + for key, value := range azureConfigValues { configValues[key] = value } From ade94b886eea1feac9e6660c11403678429397da Mon Sep 17 00:00:00 2001 From: Liviu Frateanu Date: Fri, 3 Jul 2026 14:56:29 +0300 Subject: [PATCH 2/6] feat: expose AZURE_USE_MSI in Helm chart, gate secret refs when MSI is enabled Co-Authored-By: Claude Sonnet 4.6 --- helm/templates/provisioner-deployment.yaml | 8 +++++++- helm/values.yaml | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/helm/templates/provisioner-deployment.yaml b/helm/templates/provisioner-deployment.yaml index 63dd511..5e6006a 100644 --- a/helm/templates/provisioner-deployment.yaml +++ b/helm/templates/provisioner-deployment.yaml @@ -39,12 +39,16 @@ spec: fieldPath: metadata.namespace - name: AZURE_ENABLED value: {{ .Values.global.azure.enabled | quote }} + - name: AZURE_USE_MSI + value: {{ .Values.global.azure.useMsi | quote }} {{- if .Values.global.azure.enabled }} + {{- if not .Values.global.azure.useMsi }} - name: AZURE_CLIENT_SECRET valueFrom: secretKeyRef: name: azure-secret key: clientSecret + {{- end }} - name: AZURE_CLIENT_ID valueFrom: configMapKeyRef: @@ -79,12 +83,14 @@ spec: valueFrom: configMapKeyRef: name: azure-config - key: clientId + key: clientId + {{- if not .Values.global.azure.useMsi }} - name: ARM_CLIENT_SECRET valueFrom: secretKeyRef: name: azure-secret key: clientSecret + {{- end }} - name: ARM_TENANT_ID valueFrom: configMapKeyRef: diff --git a/helm/values.yaml b/helm/values.yaml index 6a7cb57..cf49705 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -11,6 +11,7 @@ global: enabled: true azure: enabled: true + useMsi: false backend: type: cloud # cloud | custom customBackedUrl: s3://my-bucket?region=ro&endpoint=http://my-minio-server:9000&disableSSL=true&s3ForcePathStyle=true From a0f1bf05a42027b1fbd72c1cc7aa22837931c019 Mon Sep 17 00:00:00 2001 From: Liviu Frateanu Date: Fri, 3 Jul 2026 15:16:09 +0300 Subject: [PATCH 3/6] fix: handle error when parsing AZURE_USE_MSI environment variable --- .../controllers/provisioning/provisioners/pulumi/pulumi.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/controllers/provisioning/provisioners/pulumi/pulumi.go b/internal/controllers/provisioning/provisioners/pulumi/pulumi.go index 5b44e34..baf2183 100644 --- a/internal/controllers/provisioning/provisioners/pulumi/pulumi.go +++ b/internal/controllers/provisioning/provisioners/pulumi/pulumi.go @@ -231,7 +231,10 @@ func createOrSelectStack(ctx context.Context, stackName, projectName string, dep } if azureEnabled { - useMsi, _ := strconv.ParseBool(os.Getenv(EnvAzureUseMsi)) + useMsi, err := strconv.ParseBool(os.Getenv(EnvAzureUseMsi)) + if err != nil { + return auto.Stack{}, fmt.Errorf("invalid value for %s: %w", EnvAzureUseMsi, err) + } azureConfigValues := map[string]auto.ConfigValue{ "azure-native:location": {Value: os.Getenv("AZURE_LOCATION")}, From f69e971218b0b73c7fa4ac30b5561897bb51dd61 Mon Sep 17 00:00:00 2001 From: Liviu Frateanu Date: Fri, 3 Jul 2026 17:34:25 +0300 Subject: [PATCH 4/6] feat: add support for Azure Workload Identity in provisioning controller --- helm/templates/provisioner-deployment.yaml | 7 + helm/templates/rbac.yaml | 4 + helm/values.yaml | 1 + internal/controllers/provisioning/README.md | 261 ++++++++++++++++++ .../provisioners/pulumi/pulumi.go | 12 +- 5 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 internal/controllers/provisioning/README.md diff --git a/helm/templates/provisioner-deployment.yaml b/helm/templates/provisioner-deployment.yaml index 520337a..c4a97b2 100644 --- a/helm/templates/provisioner-deployment.yaml +++ b/helm/templates/provisioner-deployment.yaml @@ -21,6 +21,9 @@ spec: app.kubernetes.io/name: {{ .Release.Name }} app.kubernetes.io/version: {{ .Values.global.tag }} app.kubernetes.io/managed-by: "helm" + {{- if and .Values.global.azure.useMsi .Values.global.azure.workloadIdentityClientId }} + azure.workload.identity/use: "true" + {{- end }} spec: {{- if .Values.global.s3.enabled }} volumes: @@ -49,11 +52,13 @@ spec: name: azure-secret key: clientSecret {{- end }} + {{- if not .Values.global.azure.useMsi }} - name: AZURE_CLIENT_ID valueFrom: configMapKeyRef: name: azure-config key: clientId + {{- end }} - name: AZURE_LOCATION valueFrom: configMapKeyRef: @@ -79,11 +84,13 @@ spec: configMapKeyRef: name: azure-config key: managedIdentityName + {{- if not .Values.global.azure.useMsi }} - name: ARM_CLIENT_ID valueFrom: configMapKeyRef: name: azure-config key: clientId + {{- end }} {{- if not .Values.global.azure.useMsi }} - name: ARM_CLIENT_SECRET valueFrom: diff --git a/helm/templates/rbac.yaml b/helm/templates/rbac.yaml index a3d906e..63a8eaa 100644 --- a/helm/templates/rbac.yaml +++ b/helm/templates/rbac.yaml @@ -2,6 +2,10 @@ apiVersion: v1 kind: ServiceAccount metadata: name: provisioning-controller + {{- if and .Values.global.azure.useMsi .Values.global.azure.workloadIdentityClientId }} + annotations: + azure.workload.identity/client-id: {{ .Values.global.azure.workloadIdentityClientId | quote }} + {{- end }} --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/helm/values.yaml b/helm/values.yaml index 46c7504..b184287 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -12,6 +12,7 @@ global: azure: enabled: true useMsi: false + workloadIdentityClientId: "" backend: type: cloud # cloud | custom customBackedUrl: s3://my-bucket?region=ro&endpoint=http://my-minio-server:9000&disableSSL=true&s3ForcePathStyle=true diff --git a/internal/controllers/provisioning/README.md b/internal/controllers/provisioning/README.md new file mode 100644 index 0000000..e1771e3 --- /dev/null +++ b/internal/controllers/provisioning/README.md @@ -0,0 +1,261 @@ +# Provisioning Controller — Configuration Guide + +The provisioning controller watches `provisioning.totalsoft.ro` custom resources and drives infrastructure provisioning via Pulumi for every platform/tenant combination. + +## Helm installation + +```bash +helm upgrade --install platform-controllers ./helm \ + --namespace platform-system \ + --create-namespace \ + -f my-values.yaml +``` + +## Helm values reference + +```yaml +global: + registry: ghcr.io/osstotalsoft/platform-controllers + tag: "0.0.1" + imagePullPolicy: IfNotPresent + imagePullSecrets: "registrykey" + logLevel: 4 # verbosity (klog) + workersCount: 5 # parallel reconcile workers + migrationJobTTLSecondsAfterFinished: 86400 + + azure: + enabled: false + useMsi: false + workloadIdentityClientId: "" # set to enable Workload Identity (see below) + + backend: + type: cloud # "cloud" | "custom" + customBackedUrl: "" # required when type=custom + + vault: + enabled: false + address: http://vault.vault:8200 + + minio: + enabled: false + endpoint: http://minio:9000 + + keycloak: + enabled: false + url: http://keycloak:8080 + clientId: pulumi + + s3: + enabled: false + profile: default + + rusi: + enabled: false +``` + +## Required Kubernetes resources + +### Secret: `provisioner-secrets` + +| Key | Required when | Description | +|-----|--------------|-------------| +| `pulumiAccessToken` | `backend.type=cloud` | Pulumi Cloud access token | +| `pulumiConfigPassphrase` | `backend.type=custom` | State encryption passphrase | +| `vaultAccessToken` | `vault.enabled=true` | Vault token | +| `githubAccessToken` | optional | GitHub token for private Helm chart repos | +| `minioAccessKey` | `minio.enabled=true` | Minio access key | +| `minioSecretKey` | `minio.enabled=true` | Minio secret key | +| `keycloakClientSecret` | `keycloak.enabled=true` | Keycloak client secret | + +```bash +kubectl create secret generic provisioner-secrets \ + --from-literal=pulumiAccessToken=pul-xxx \ + --from-literal=vaultAccessToken=hvs.xxx \ + -n platform-system +``` + +### ConfigMap: `azure-config` (when `azure.enabled=true`) + +| Key | Description | +|-----|-------------| +| `clientId` | Service principal or user-assigned managed identity client ID | +| `location` | Default Azure region (e.g. `West Europe`) | +| `subscriptionId` | Azure subscription ID | +| `tenantId` | Azure AD tenant ID | +| `managedIdentityRG` | Resource group of the managed identity | +| `managedIdentityName` | Name of the managed identity | + +```bash +kubectl create configmap azure-config \ + --from-literal=clientId=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ + --from-literal=location="West Europe" \ + --from-literal=subscriptionId=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ + --from-literal=tenantId=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ + --from-literal=managedIdentityRG=my-rg \ + --from-literal=managedIdentityName=my-identity \ + -n platform-system +``` + +### Secret: `azure-secret` (service principal mode only) + +```bash +kubectl create secret generic azure-secret \ + --from-literal=clientSecret= \ + -n platform-system +``` + +--- + +## Azure authentication modes + +### 1. Service Principal + +Set `azure.useMsi=false` (default). Requires `azure-config` ConfigMap and `azure-secret` Secret. + +```yaml +global: + azure: + enabled: true + useMsi: false +``` + +### 2. Workload Identity + +Scopes Azure credentials to the `provisioning-controller` ServiceAccount rather than the entire node. Requires three one-time Azure setup steps, then a single Helm value. + +**Step 1 — Enable OIDC issuer and Workload Identity on the cluster:** + +```bash +az aks update -g -n \ + --enable-oidc-issuer \ + --enable-workload-identity +``` + +**Step 2 — Create a federated credential on the managed identity:** + +```bash +OIDC_ISSUER=$(az aks show -g -n \ + --query oidcIssuerProfile.issuerUrl -o tsv) + +az identity federated-credential create \ + --name provisioning-controller-fedcred \ + --identity-name \ + --resource-group \ + --issuer $OIDC_ISSUER \ + --subject "system:serviceaccount::provisioning-controller" \ + --audiences api://AzureADTokenExchange +``` + +**Step 3 — Set `workloadIdentityClientId` in Helm values:** + +```yaml +global: + azure: + enabled: true + useMsi: true + workloadIdentityClientId: "" +``` + +The Helm chart will: +- Annotate the `provisioning-controller` ServiceAccount with `azure.workload.identity/client-id` +- Label the pod with `azure.workload.identity/use: "true"` so the webhook injects credentials +- Skip the `AZURE_CLIENT_ID` and `ARM_CLIENT_ID` env vars (the webhook and the controller's fallback logic handle them) + +With Workload Identity active, the `azure-config` ConfigMap is still required for `location`, `subscriptionId`, `tenantId`, `managedIdentityRG`, and `managedIdentityName`. The `clientId` key in that ConfigMap is not used — the client ID comes from `workloadIdentityClientId` in Helm values and is injected by the webhook. + +--- + +## Pulumi backend + +### Pulumi Cloud (default) + +```yaml +global: + backend: + type: cloud +``` + +Requires `pulumiAccessToken` in `provisioner-secrets`. + +### Self-hosted / custom backend (e.g. S3-compatible) + +```yaml +global: + backend: + type: custom + customBackedUrl: "s3://my-bucket?region=ro&endpoint=http://minio:9000&disableSSL=true&s3ForcePathStyle=true" +``` + +Requires `pulumiConfigPassphrase` in `provisioner-secrets` for state encryption. + +--- + +## Environment variable reference + +All values below are injected by the Helm chart. This table is useful when running the controller outside of Helm. + +| Variable | Source | Description | +|----------|--------|-------------| +| `NAMESPACE` | pod field | Kubernetes namespace the controller runs in | +| `AZURE_ENABLED` | `azure.enabled` | Enable Azure Pulumi providers | +| `AZURE_USE_MSI` | `azure.useMsi` | Use managed identity instead of service principal | +| `AZURE_CLIENT_ID` | `azure-config` / webhook | Client ID of the service principal or user-assigned MI | +| `AZURE_CLIENT_SECRET` | `azure-secret` | Service principal secret (non-MSI only) | +| `AZURE_LOCATION` | `azure-config` | Default Azure region | +| `AZURE_SUBSCRIPTION_ID` | `azure-config` | Azure subscription | +| `AZURE_TENANT_ID` | `azure-config` | Azure AD tenant | +| `AZURE_MANAGED_IDENTITY_RG` | `azure-config` | Resource group of the managed identity | +| `AZURE_MANAGED_IDENTITY_NAME` | `azure-config` | Name of the managed identity | +| `ARM_CLIENT_ID` | `azure-config` or fallback | Client ID for the `azuread` Pulumi provider; falls back to `AZURE_CLIENT_ID` when Workload Identity is active | +| `ARM_CLIENT_SECRET` | `azure-secret` | Secret for the `azuread` provider (non-MSI only) | +| `ARM_TENANT_ID` | `azure-config` | Tenant for the `azuread` provider | +| `VAULT_ENABLED` | `vault.enabled` | Enable Vault integration | +| `VAULT_ADDR` | `vault.address` | Vault server URL | +| `VAULT_TOKEN` | `provisioner-secrets` | Vault access token | +| `MINIO_ENDPOINT` | `minio.endpoint` | Minio server URL | +| `MINIO_ACCESS_KEY` | `provisioner-secrets` | Minio access key | +| `MINIO_SECRET_KEY` | `provisioner-secrets` | Minio secret key | +| `KEYCLOAK_URL` | `keycloak.url` | Keycloak server URL | +| `KEYCLOAK_CLIENT_ID` | `keycloak.clientId` | Keycloak client ID | +| `KEYCLOAK_CLIENT_SECRET` | `provisioner-secrets` | Keycloak client secret | +| `PULUMI_ACCESS_TOKEN` | `provisioner-secrets` | Pulumi Cloud token (cloud backend) | +| `PULUMI_BACKEND_URL` | `backend.customBackedUrl` | Custom backend URL | +| `PULUMI_CONFIG_PASSPHRASE` | `provisioner-secrets` | State encryption passphrase (custom backend) | +| `GITHUB_TOKEN` | `provisioner-secrets` | GitHub token for private Helm chart sources | +| `AWS_PROFILE` | `s3.profile` | AWS/S3 credentials profile | +| `PULUMI_SKIP_REFRESH` | — | Set to `true` to skip the Pulumi refresh step on each reconcile (faster, less safe) | +| `RUSI_ENABLED` | `rusi.enabled` | Enable RUSI sidecar for event publishing | +| `RUSI_GRPC_PORT` | — | RUSI sidecar gRPC port (when RUSI enabled) | +| `WORKERS_COUNT` | `workersCount` | Number of parallel reconcile workers | +| `MIGRATION_JOB_TTL_SECONDS_AFTER_FINISHED` | `migrationJobTTLSecondsAfterFinished` | TTL for completed migration jobs | + +--- + +## Pulumi providers + +The controller installs the following Pulumi plugins at startup: + +| Provider | Version | Used for | +|----------|---------|---------| +| `azure-native` | v2.92.2 | Azure Resource Manager provisioning | +| `azuread` | v5.53.8 | Azure Entra ID (users, service principals) | +| `kubernetes` | v4.30.0 | Helm releases, Kubernetes resources | +| `vault` | v5.20.0 | HashiCorp Vault secret storage | +| `minio` | v0.16.9 | Minio / S3-compatible buckets | +| `keycloak` | v6.10.1 | Keycloak OpenID Connect clients | +| `mssql` | v0.0.8 | Microsoft SQL Server databases | +| `random` | v4.19.2 | Random value generation | +| `command` | v1.2.1 | Local script execution | + +--- + +## RUSI events + +When `RUSI_ENABLED=true` the controller publishes events to the following topics after each reconcile: + +| Topic | Fired when | +|-------|-----------| +| `PlatformControllers.ProvisioningController.TenantProvisionedSuccessfully` | Tenant provisioning succeeded | +| `PlatformControllers.ProvisioningController.TenantProvisioningFailed` | Tenant provisioning failed | +| `PlatformControllers.ProvisioningController.PlatformProvisionedSuccessfully` | Platform provisioning succeeded | +| `PlatformControllers.ProvisioningController.PlatformProvisioningFailed` | Platform provisioning failed | diff --git a/internal/controllers/provisioning/provisioners/pulumi/pulumi.go b/internal/controllers/provisioning/provisioners/pulumi/pulumi.go index baf2183..2e3aef8 100644 --- a/internal/controllers/provisioning/provisioners/pulumi/pulumi.go +++ b/internal/controllers/provisioning/provisioners/pulumi/pulumi.go @@ -244,14 +244,14 @@ func createOrSelectStack(ctx context.Context, stackName, projectName string, dep } if useMsi { + clientId := os.Getenv("AZURE_CLIENT_ID") + if clientId == "" { + return auto.Stack{}, fmt.Errorf("MSI is enabled but AZURE_CLIENT_ID is not set; ensure Workload Identity is configured correctly") + } azureConfigValues["azure-native:useMsi"] = auto.ConfigValue{Value: "true"} + azureConfigValues["azure-native:clientId"] = auto.ConfigValue{Value: clientId} azureConfigValues["azuread:useMsi"] = auto.ConfigValue{Value: "true"} - if clientId := os.Getenv("AZURE_CLIENT_ID"); clientId != "" { - azureConfigValues["azure-native:clientId"] = auto.ConfigValue{Value: clientId} - } - if armClientId := os.Getenv("ARM_CLIENT_ID"); armClientId != "" { - azureConfigValues["azuread:clientId"] = auto.ConfigValue{Value: armClientId} - } + azureConfigValues["azuread:clientId"] = auto.ConfigValue{Value: clientId} } else { azureConfigValues["azure-native:clientId"] = auto.ConfigValue{Value: os.Getenv("AZURE_CLIENT_ID")} azureConfigValues["azure-native:clientSecret"] = auto.ConfigValue{Value: os.Getenv("AZURE_CLIENT_SECRET"), Secret: true} From 07a59aa432400ea5829500d9efd48844de185d2c Mon Sep 17 00:00:00 2001 From: Liviu Frateanu Date: Mon, 6 Jul 2026 09:52:46 +0300 Subject: [PATCH 5/6] switch to oidc --- helm/templates/provisioner-deployment.yaml | 14 ++---- helm/templates/rbac.yaml | 2 +- helm/values.yaml | 2 +- internal/controllers/provisioning/README.md | 47 ++++++++++--------- .../provisioners/pulumi/pulumi.go | 23 ++++++--- 5 files changed, 48 insertions(+), 40 deletions(-) diff --git a/helm/templates/provisioner-deployment.yaml b/helm/templates/provisioner-deployment.yaml index c4a97b2..1460274 100644 --- a/helm/templates/provisioner-deployment.yaml +++ b/helm/templates/provisioner-deployment.yaml @@ -21,7 +21,7 @@ spec: app.kubernetes.io/name: {{ .Release.Name }} app.kubernetes.io/version: {{ .Values.global.tag }} app.kubernetes.io/managed-by: "helm" - {{- if and .Values.global.azure.useMsi .Values.global.azure.workloadIdentityClientId }} + {{- if and .Values.global.azure.useWorkloadIdentity .Values.global.azure.workloadIdentityClientId }} azure.workload.identity/use: "true" {{- end }} spec: @@ -42,17 +42,15 @@ spec: fieldPath: metadata.namespace - name: AZURE_ENABLED value: {{ .Values.global.azure.enabled | quote }} - - name: AZURE_USE_MSI - value: {{ .Values.global.azure.useMsi | quote }} + - name: AZURE_USE_WORKLOAD_IDENTITY + value: {{ .Values.global.azure.useWorkloadIdentity | quote }} {{- if .Values.global.azure.enabled }} - {{- if not .Values.global.azure.useMsi }} + {{- if not .Values.global.azure.useWorkloadIdentity }} - name: AZURE_CLIENT_SECRET valueFrom: secretKeyRef: name: azure-secret key: clientSecret - {{- end }} - {{- if not .Values.global.azure.useMsi }} - name: AZURE_CLIENT_ID valueFrom: configMapKeyRef: @@ -84,14 +82,12 @@ spec: configMapKeyRef: name: azure-config key: managedIdentityName - {{- if not .Values.global.azure.useMsi }} + {{- if not .Values.global.azure.useWorkloadIdentity }} - name: ARM_CLIENT_ID valueFrom: configMapKeyRef: name: azure-config key: clientId - {{- end }} - {{- if not .Values.global.azure.useMsi }} - name: ARM_CLIENT_SECRET valueFrom: secretKeyRef: diff --git a/helm/templates/rbac.yaml b/helm/templates/rbac.yaml index 63a8eaa..7c1ecfb 100644 --- a/helm/templates/rbac.yaml +++ b/helm/templates/rbac.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: ServiceAccount metadata: name: provisioning-controller - {{- if and .Values.global.azure.useMsi .Values.global.azure.workloadIdentityClientId }} + {{- if and .Values.global.azure.useWorkloadIdentity .Values.global.azure.workloadIdentityClientId }} annotations: azure.workload.identity/client-id: {{ .Values.global.azure.workloadIdentityClientId | quote }} {{- end }} diff --git a/helm/values.yaml b/helm/values.yaml index b184287..a3522db 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -11,7 +11,7 @@ global: enabled: true azure: enabled: true - useMsi: false + useWorkloadIdentity: false workloadIdentityClientId: "" backend: type: cloud # cloud | custom diff --git a/internal/controllers/provisioning/README.md b/internal/controllers/provisioning/README.md index e1771e3..3e10f9d 100644 --- a/internal/controllers/provisioning/README.md +++ b/internal/controllers/provisioning/README.md @@ -25,8 +25,8 @@ global: azure: enabled: false - useMsi: false - workloadIdentityClientId: "" # set to enable Workload Identity (see below) + useWorkloadIdentity: false # set to true to use Workload Identity instead of service principal + workloadIdentityClientId: "" # client ID of the user-assigned managed identity (required when useWorkloadIdentity=true) backend: type: cloud # "cloud" | "custom" @@ -76,14 +76,14 @@ kubectl create secret generic provisioner-secrets \ ### ConfigMap: `azure-config` (when `azure.enabled=true`) -| Key | Description | -|-----|-------------| -| `clientId` | Service principal or user-assigned managed identity client ID | -| `location` | Default Azure region (e.g. `West Europe`) | -| `subscriptionId` | Azure subscription ID | -| `tenantId` | Azure AD tenant ID | -| `managedIdentityRG` | Resource group of the managed identity | -| `managedIdentityName` | Name of the managed identity | +| Key | Used in | Description | +|-----|---------|-------------| +| `clientId` | service principal only | Azure AD application client ID | +| `location` | both modes | Default Azure region (e.g. `West Europe`) | +| `subscriptionId` | both modes | Azure subscription ID | +| `tenantId` | both modes | Azure AD tenant ID | +| `managedIdentityRG` | both modes | Resource group of the managed identity | +| `managedIdentityName` | both modes | Name of the managed identity | ```bash kubectl create configmap azure-config \ @@ -110,18 +110,18 @@ kubectl create secret generic azure-secret \ ### 1. Service Principal -Set `azure.useMsi=false` (default). Requires `azure-config` ConfigMap and `azure-secret` Secret. +Set `azure.useWorkloadIdentity=false` (default). Requires `azure-config` ConfigMap and `azure-secret` Secret. ```yaml global: azure: enabled: true - useMsi: false + useWorkloadIdentity: false ``` ### 2. Workload Identity -Scopes Azure credentials to the `provisioning-controller` ServiceAccount rather than the entire node. Requires three one-time Azure setup steps, then a single Helm value. +Scopes Azure credentials to the `provisioning-controller` ServiceAccount via OIDC federation. The Workload Identity webhook injects `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, and `AZURE_FEDERATED_TOKEN_FILE` into the pod. The controller reads the token from that file and passes it to both Pulumi providers (`azure-native` and `azuread`) using `useOidc`/`oidcToken`. **Step 1 — Enable OIDC issuer and Workload Identity on the cluster:** @@ -146,22 +146,22 @@ az identity federated-credential create \ --audiences api://AzureADTokenExchange ``` -**Step 3 — Set `workloadIdentityClientId` in Helm values:** +**Step 3 — Set values in Helm:** ```yaml global: azure: enabled: true - useMsi: true + useWorkloadIdentity: true workloadIdentityClientId: "" ``` The Helm chart will: - Annotate the `provisioning-controller` ServiceAccount with `azure.workload.identity/client-id` - Label the pod with `azure.workload.identity/use: "true"` so the webhook injects credentials -- Skip the `AZURE_CLIENT_ID` and `ARM_CLIENT_ID` env vars (the webhook and the controller's fallback logic handle them) +- Omit `AZURE_CLIENT_ID` and `ARM_CLIENT_ID` from the pod spec (the webhook injects `AZURE_CLIENT_ID` directly) -With Workload Identity active, the `azure-config` ConfigMap is still required for `location`, `subscriptionId`, `tenantId`, `managedIdentityRG`, and `managedIdentityName`. The `clientId` key in that ConfigMap is not used — the client ID comes from `workloadIdentityClientId` in Helm values and is injected by the webhook. +The `azure-config` ConfigMap is still required for `location`, `subscriptionId`, `tenantId`, `managedIdentityRG`, and `managedIdentityName`. The `clientId` key is not used in this mode. --- @@ -198,16 +198,17 @@ All values below are injected by the Helm chart. This table is useful when runni |----------|--------|-------------| | `NAMESPACE` | pod field | Kubernetes namespace the controller runs in | | `AZURE_ENABLED` | `azure.enabled` | Enable Azure Pulumi providers | -| `AZURE_USE_MSI` | `azure.useMsi` | Use managed identity instead of service principal | -| `AZURE_CLIENT_ID` | `azure-config` / webhook | Client ID of the service principal or user-assigned MI | -| `AZURE_CLIENT_SECRET` | `azure-secret` | Service principal secret (non-MSI only) | +| `AZURE_USE_WORKLOAD_IDENTITY` | `azure.useWorkloadIdentity` | Use Workload Identity (OIDC) instead of service principal | +| `AZURE_CLIENT_ID` | `azure-config` (SP) / webhook (WI) | Azure AD application or managed identity client ID | +| `AZURE_CLIENT_SECRET` | `azure-secret` | Service principal secret (service principal mode only) | +| `AZURE_FEDERATED_TOKEN_FILE` | webhook | Path to the projected ServiceAccount token file (Workload Identity only, injected automatically) | | `AZURE_LOCATION` | `azure-config` | Default Azure region | | `AZURE_SUBSCRIPTION_ID` | `azure-config` | Azure subscription | | `AZURE_TENANT_ID` | `azure-config` | Azure AD tenant | | `AZURE_MANAGED_IDENTITY_RG` | `azure-config` | Resource group of the managed identity | | `AZURE_MANAGED_IDENTITY_NAME` | `azure-config` | Name of the managed identity | -| `ARM_CLIENT_ID` | `azure-config` or fallback | Client ID for the `azuread` Pulumi provider; falls back to `AZURE_CLIENT_ID` when Workload Identity is active | -| `ARM_CLIENT_SECRET` | `azure-secret` | Secret for the `azuread` provider (non-MSI only) | +| `ARM_CLIENT_ID` | `azure-config` | Client ID for the `azuread` Pulumi provider (service principal mode only) | +| `ARM_CLIENT_SECRET` | `azure-secret` | Secret for the `azuread` provider (service principal mode only) | | `ARM_TENANT_ID` | `azure-config` | Tenant for the `azuread` provider | | `VAULT_ENABLED` | `vault.enabled` | Enable Vault integration | | `VAULT_ADDR` | `vault.address` | Vault server URL | @@ -221,9 +222,9 @@ All values below are injected by the Helm chart. This table is useful when runni | `PULUMI_ACCESS_TOKEN` | `provisioner-secrets` | Pulumi Cloud token (cloud backend) | | `PULUMI_BACKEND_URL` | `backend.customBackedUrl` | Custom backend URL | | `PULUMI_CONFIG_PASSPHRASE` | `provisioner-secrets` | State encryption passphrase (custom backend) | +| `PULUMI_SKIP_REFRESH` | — | Set to `true` to skip the Pulumi refresh step on each reconcile (faster, less safe) | | `GITHUB_TOKEN` | `provisioner-secrets` | GitHub token for private Helm chart sources | | `AWS_PROFILE` | `s3.profile` | AWS/S3 credentials profile | -| `PULUMI_SKIP_REFRESH` | — | Set to `true` to skip the Pulumi refresh step on each reconcile (faster, less safe) | | `RUSI_ENABLED` | `rusi.enabled` | Enable RUSI sidecar for event publishing | | `RUSI_GRPC_PORT` | — | RUSI sidecar gRPC port (when RUSI enabled) | | `WORKERS_COUNT` | `workersCount` | Number of parallel reconcile workers | diff --git a/internal/controllers/provisioning/provisioners/pulumi/pulumi.go b/internal/controllers/provisioning/provisioners/pulumi/pulumi.go index 2e3aef8..93427b5 100644 --- a/internal/controllers/provisioning/provisioners/pulumi/pulumi.go +++ b/internal/controllers/provisioning/provisioners/pulumi/pulumi.go @@ -26,7 +26,7 @@ import ( var ( EnvPulumiSkipRefresh = "PULUMI_SKIP_REFRESH" EnvAzureEnabled = "AZURE_ENABLED" - EnvAzureUseMsi = "AZURE_USE_MSI" + EnvAzureUseWorkloadIdentity = "AZURE_USE_WORKLOAD_IDENTITY" ) type provisionedResourceMap = map[provisioningv1.ProvisioningResourceIdendtifier]pulumi.Resource @@ -231,9 +231,9 @@ func createOrSelectStack(ctx context.Context, stackName, projectName string, dep } if azureEnabled { - useMsi, err := strconv.ParseBool(os.Getenv(EnvAzureUseMsi)) + useWorkloadIdentity, err := strconv.ParseBool(os.Getenv(EnvAzureUseWorkloadIdentity)) if err != nil { - return auto.Stack{}, fmt.Errorf("invalid value for %s: %w", EnvAzureUseMsi, err) + return auto.Stack{}, fmt.Errorf("invalid value for %s: %w", EnvAzureUseWorkloadIdentity, err) } azureConfigValues := map[string]auto.ConfigValue{ @@ -243,14 +243,25 @@ func createOrSelectStack(ctx context.Context, stackName, projectName string, dep "azuread:tenantId": {Value: os.Getenv("ARM_TENANT_ID")}, } - if useMsi { + if useWorkloadIdentity { clientId := os.Getenv("AZURE_CLIENT_ID") if clientId == "" { return auto.Stack{}, fmt.Errorf("MSI is enabled but AZURE_CLIENT_ID is not set; ensure Workload Identity is configured correctly") } - azureConfigValues["azure-native:useMsi"] = auto.ConfigValue{Value: "true"} + tokenFile := os.Getenv("AZURE_FEDERATED_TOKEN_FILE") + if tokenFile == "" { + return auto.Stack{}, fmt.Errorf("MSI is enabled but AZURE_FEDERATED_TOKEN_FILE is not set; ensure the Workload Identity webhook is active") + } + tokenBytes, err := os.ReadFile(tokenFile) + if err != nil { + return auto.Stack{}, fmt.Errorf("failed to read OIDC token file %s: %w", tokenFile, err) + } + token := string(tokenBytes) + azureConfigValues["azure-native:useOidc"] = auto.ConfigValue{Value: "true"} + azureConfigValues["azure-native:oidcToken"] = auto.ConfigValue{Value: token, Secret: true} azureConfigValues["azure-native:clientId"] = auto.ConfigValue{Value: clientId} - azureConfigValues["azuread:useMsi"] = auto.ConfigValue{Value: "true"} + azureConfigValues["azuread:useOidc"] = auto.ConfigValue{Value: "true"} + azureConfigValues["azuread:oidcToken"] = auto.ConfigValue{Value: token, Secret: true} azureConfigValues["azuread:clientId"] = auto.ConfigValue{Value: clientId} } else { azureConfigValues["azure-native:clientId"] = auto.ConfigValue{Value: os.Getenv("AZURE_CLIENT_ID")} From eb4f8ce994d38bc763286263371c7d4fcb600d18 Mon Sep 17 00:00:00 2001 From: Liviu Frateanu Date: Mon, 6 Jul 2026 10:54:24 +0300 Subject: [PATCH 6/6] fix --- .../provisioning/provisioners/pulumi/pulumi.go | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/internal/controllers/provisioning/provisioners/pulumi/pulumi.go b/internal/controllers/provisioning/provisioners/pulumi/pulumi.go index 93427b5..178e64c 100644 --- a/internal/controllers/provisioning/provisioners/pulumi/pulumi.go +++ b/internal/controllers/provisioning/provisioners/pulumi/pulumi.go @@ -252,16 +252,11 @@ func createOrSelectStack(ctx context.Context, stackName, projectName string, dep if tokenFile == "" { return auto.Stack{}, fmt.Errorf("MSI is enabled but AZURE_FEDERATED_TOKEN_FILE is not set; ensure the Workload Identity webhook is active") } - tokenBytes, err := os.ReadFile(tokenFile) - if err != nil { - return auto.Stack{}, fmt.Errorf("failed to read OIDC token file %s: %w", tokenFile, err) - } - token := string(tokenBytes) azureConfigValues["azure-native:useOidc"] = auto.ConfigValue{Value: "true"} - azureConfigValues["azure-native:oidcToken"] = auto.ConfigValue{Value: token, Secret: true} + azureConfigValues["azure-native:oidcTokenFilePath"] = auto.ConfigValue{Value: tokenFile} azureConfigValues["azure-native:clientId"] = auto.ConfigValue{Value: clientId} azureConfigValues["azuread:useOidc"] = auto.ConfigValue{Value: "true"} - azureConfigValues["azuread:oidcToken"] = auto.ConfigValue{Value: token, Secret: true} + azureConfigValues["azuread:oidcTokenFilePath"] = auto.ConfigValue{Value: tokenFile} azureConfigValues["azuread:clientId"] = auto.ConfigValue{Value: clientId} } else { azureConfigValues["azure-native:clientId"] = auto.ConfigValue{Value: os.Getenv("AZURE_CLIENT_ID")}