From d2631a38b22c199260fcd6848631221937287421 Mon Sep 17 00:00:00 2001 From: Paymaun Heidari Date: Tue, 31 Mar 2026 18:17:04 -0700 Subject: [PATCH] feat(iot-ops): add secret sync enablement templates and CI bicep validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add declarative IaC replacement for `az iot ops secretsync enable` using the resolve-step output chaining pattern. - resolve-aio.bicep: read-only instance → CL → cluster resolution chain - enable-secretsync.bicep: MI, KV (conditional/cross-RG), FIC, SPC, instance update - sync-secret.bicep: generic @secure() secret sync example - Reusable modules: resolve-custom-location, resolve-cluster, keyvault-roles, update-instance - Standalone secretsync.yaml manifest + integrated steps in aio-install.yaml - base-site.yaml: enableSecretSync toggle (default false) - Storage account networkAcls hardening for schema registry - scripts/validate-bicep.ps1 + CI step for all workspace Bicep files - docs/secret-sync.md: feature guide covering enablement, BYOKV, and usage --- .github/workflows/ci.yaml | 6 + .pipelines/ci.yaml | 5 + README.md | 9 +- docs/ci-cd-setup.md | 4 +- docs/secret-sync.md | 234 +++++++++++++++ docs/site-configuration.md | 1 + scripts/validate-bicep.ps1 | 64 ++++ .../iot-operations/manifests/aio-install.yaml | 16 + .../iot-operations/manifests/secretsync.yaml | 30 ++ .../parameters/secretsync-chaining.yaml | 19 ++ .../iot-operations/sites/base-site.yaml | 1 + .../common/modules/resolve-cluster.bicep | 30 ++ .../modules/resolve-custom-location.bicep | 33 +++ .../iot-ops/common/resolve-aio.bicep | 109 +++++++ .../iot-ops/deps/schema-registry.bicep | 14 + .../secretsync/enable-secretsync.bicep | 275 ++++++++++++++++++ .../secretsync/modules/keyvault-roles.bicep | 49 ++++ .../secretsync/modules/update-instance.bicep | 82 ++++++ .../iot-ops/secretsync/sync-secret.bicep | 127 ++++++++ 19 files changed, 1103 insertions(+), 5 deletions(-) create mode 100644 docs/secret-sync.md create mode 100644 scripts/validate-bicep.ps1 create mode 100644 workspaces/iot-operations/manifests/secretsync.yaml create mode 100644 workspaces/iot-operations/parameters/secretsync-chaining.yaml create mode 100644 workspaces/iot-operations/templates/iot-ops/common/modules/resolve-cluster.bicep create mode 100644 workspaces/iot-operations/templates/iot-ops/common/modules/resolve-custom-location.bicep create mode 100644 workspaces/iot-operations/templates/iot-ops/common/resolve-aio.bicep create mode 100644 workspaces/iot-operations/templates/iot-ops/secretsync/enable-secretsync.bicep create mode 100644 workspaces/iot-operations/templates/iot-ops/secretsync/modules/keyvault-roles.bicep create mode 100644 workspaces/iot-operations/templates/iot-ops/secretsync/modules/update-instance.bicep create mode 100644 workspaces/iot-operations/templates/iot-ops/secretsync/sync-secret.bicep diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a0b03a6..bd1f0ec 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -9,6 +9,7 @@ on: - "siteops/**" - "workspaces/**" - "tests/**" + - "scripts/**" - "pyproject.toml" pull_request: branches: [main] @@ -16,6 +17,7 @@ on: - "siteops/**" - "workspaces/**" - "tests/**" + - "scripts/**" - "pyproject.toml" workflow_dispatch: @@ -114,6 +116,10 @@ jobs: - name: Setup Site Ops uses: ./.github/actions/setup-siteops + - name: Validate Bicep templates + shell: pwsh + run: ./scripts/validate-bicep.ps1 + - name: Find manifests id: find shell: bash diff --git a/.pipelines/ci.yaml b/.pipelines/ci.yaml index a10e32c..1927da8 100644 --- a/.pipelines/ci.yaml +++ b/.pipelines/ci.yaml @@ -9,6 +9,7 @@ trigger: - siteops/** - workspaces/** - tests/** + - scripts/** - pyproject.toml pr: @@ -19,6 +20,7 @@ pr: - siteops/** - workspaces/** - tests/** + - scripts/** - pyproject.toml pool: @@ -67,6 +69,9 @@ jobs: - template: templates/setup-siteops.yaml + - pwsh: ./scripts/validate-bicep.ps1 + displayName: Validate Bicep templates + - script: | WORKSPACE_DIR="workspaces/iot-operations" MANIFESTS=$(find "$WORKSPACE_DIR/manifests" -name "*.yaml" -o -name "*.yml" 2>/dev/null | tr '\n' ' ' || echo "") diff --git a/README.md b/README.md index 4ac3317..bd596e8 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ digital-ops-scale-kit/ │ ├── orchestrator.py # Core orchestration logic │ └── executor.py # Azure CLI and kubectl execution ├── tests/ # Test suite +├── scripts/ # Utility scripts (Bicep validation, etc.) ├── workspaces/ │ └── iot-operations/ # Reference implementation │ ├── sites/ # Site definitions @@ -156,12 +157,13 @@ digital-ops-scale-kit/ │ ├── parameters/ # Parameter files │ └── templates/ # Bicep templates ├── docs/ # Extended documentation -│ ├── ci-cd-setup.md # GitHub Actions, OIDC, secrets +│ ├── ci-cd-setup.md # GitHub Actions, Azure DevOps, OIDC, secrets │ ├── manifest-reference.md # Manifest syntax, step types │ ├── parameter-resolution.md # Variables, output chaining │ ├── site-configuration.md # Sites, inheritance, overlays │ └── troubleshooting.md # Common issues and solutions -└── .github/ # CI/CD workflows +├── .github/ # GitHub Actions workflows +└── .pipelines/ # Azure DevOps pipeline definitions ``` ### Workspace anatomy @@ -406,7 +408,8 @@ See [docs/ci-cd-setup.md](docs/ci-cd-setup.md) for detailed configuration. | [docs/site-configuration.md](docs/site-configuration.md) | Site definitions, inheritance, overlays | | [docs/manifest-reference.md](docs/manifest-reference.md) | Manifest syntax, step types, conditions | | [docs/parameter-resolution.md](docs/parameter-resolution.md) | Template variables, output chaining, auto-filtering | -| [docs/ci-cd-setup.md](docs/ci-cd-setup.md) | GitHub Actions, OIDC, secrets configuration | +| [docs/secret-sync.md](docs/secret-sync.md) | Secret sync enablement and usage | +| [docs/ci-cd-setup.md](docs/ci-cd-setup.md) | GitHub Actions, Azure DevOps, OIDC, secrets configuration | | [docs/troubleshooting.md](docs/troubleshooting.md) | Common issues and solutions | --- diff --git a/docs/ci-cd-setup.md b/docs/ci-cd-setup.md index 818c5cf..01331f7 100644 --- a/docs/ci-cd-setup.md +++ b/docs/ci-cd-setup.md @@ -19,7 +19,7 @@ This guide covers CI/CD configuration for automated testing and deployments. Sit | Workflow | Trigger | Purpose | |----------|---------|---------| -| `ci.yaml` | Push, pull request, manual | Run unit tests and validate manifests | +| `ci.yaml` | Push, pull request, manual | Validate Bicep templates, run unit tests, and validate manifests | | `deploy.yaml` | Manual (`workflow_dispatch`) | Deploy infrastructure to Azure | | `_siteops-deploy.yaml` | Called by deploy.yaml | Reusable deployment logic | @@ -539,7 +539,7 @@ stages: | Pipeline file | Purpose | Trigger | |---------------|---------|---------| -| `.pipelines/ci.yaml` | Unit tests + manifest validation | Push to main, PRs | +| `.pipelines/ci.yaml` | Bicep validation, unit tests, manifest validation | Push to main, PRs | | `.pipelines/deploy.yaml` | Manual deploy with environment selection | Manual only | | `.pipelines/templates/siteops-deploy.yaml` | Stage template: deployment logic | Called by deploy.yaml | | `.pipelines/templates/setup-siteops.yaml` | Steps template: install Python + siteops | Called by all pipelines | diff --git a/docs/secret-sync.md b/docs/secret-sync.md new file mode 100644 index 0000000..4590249 --- /dev/null +++ b/docs/secret-sync.md @@ -0,0 +1,234 @@ +# Secret Sync + +Enable [secret synchronization](https://learn.microsoft.com/azure/iot-operations/secure-iot-ops/howto-manage-secrets) for Azure IoT Operations instances, fully declarative with no CLI commands required. + +Secret sync bridges Azure Key Vault and your Arc-enabled Kubernetes cluster. Once enabled, you can synchronize Key Vault secrets to Kubernetes secrets that AIO workloads consume directly. + +## What gets deployed + +The enablement template (`enable-secretsync.bicep`) creates: + +| Resource | Purpose | +|----------|---------| +| User-Assigned Managed Identity | Authenticates the cluster to Key Vault | +| Key Vault (optional) | Stores secrets; skipped if you bring your own | +| Key Vault role assignments | Grants the MI `Key Vault Secrets User` + `Key Vault Reader` | +| Federated Identity Credential | Binds the MI to the cluster's secret sync service account via OIDC | +| SecretProviderClass (SPC) | Cluster-side resource linking the MI, Key Vault, and tenant | +| Instance update | Sets the SPC as the instance's default secret provider | + +## Prerequisites + +- Azure IoT Operations instance deployed and running +- Connected cluster with **OIDC issuer** and **workload identity** enabled +- Contributor + Key Vault Administrator (or equivalent) permissions on the target resource group + +## How it works + +Secret sync enablement uses a two-step pipeline: + +``` +resolve-aio enable-secretsync +┌──────────────────────────┐ ┌──────────────────────────────────┐ +│ Read-only instance lookup │────────▶│ Create MI, KV, FIC, SPC, │ +│ │ output │ role assignments, instance update│ +│ Outputs: │ chain │ │ +│ • CL name, namespace │ │ Receives all values as params; │ +│ • Cluster name, OIDC │ │ no cross-directory dependencies │ +│ • Instance properties │ │ │ +└──────────────────────────┘ └──────────────────────────────────┘ +``` + +**Step 1, Resolve**: `resolve-aio.bicep` reads the existing IoT Operations instance and resolves the full infrastructure chain (instance → custom location → connected cluster) without creating or modifying any resources. It outputs everything downstream templates need. + +**Step 2, Enable**: `enable-secretsync.bicep` receives all resolved values via [output chaining](parameter-resolution.md#output-chaining) and provisions the secret sync resources. + +This pattern keeps templates portable. `enable-secretsync.bicep` never makes assumptions about naming conventions or directory layout. + +### Output chaining + +The parameter file `parameters/secretsync-chaining.yaml` maps outputs from the resolve step to the enablement step's inputs: + +```yaml +# Resolved infrastructure names +customLocationId: "{{ steps.resolve-aio.outputs.customLocationId }}" +customLocationName: "{{ steps.resolve-aio.outputs.customLocationName }}" +customLocationNamespace: "{{ steps.resolve-aio.outputs.customLocationNamespace }}" +connectedClusterName: "{{ steps.resolve-aio.outputs.connectedClusterName }}" +oidcIssuerUrl: "{{ steps.resolve-aio.outputs.oidcIssuerUrl }}" + +# Instance properties for safe PUT forwarding +instanceLocation: "{{ steps.resolve-aio.outputs.instanceLocation }}" +schemaRegistryResourceId: "{{ steps.resolve-aio.outputs.schemaRegistryResourceId }}" +# ... additional properties forwarded for safe instance update +``` + +## Enabling secret sync + +### Option 1: Integrated deployment (new instances) + +Set `enableSecretSync: true` in your site configuration: + +```yaml +# sites/my-site.yaml (or base-site.yaml for all sites) +properties: + deployOptions: + enableSecretSync: true +``` + +Then deploy with `aio-install.yaml` as usual. The resolve-aio and secretsync steps run automatically after the AIO instance is configured: + +```bash +siteops -w workspaces/iot-operations deploy manifests/aio-install.yaml -l "name=my-site" +``` + +Both steps are gated by a `when` condition and only run for sites that have `enableSecretSync: true`. + +### Option 2: Standalone day-2 enablement (existing instances) + +Use the standalone manifest to enable secret sync on instances that are already deployed: + +```bash +siteops -w workspaces/iot-operations deploy manifests/secretsync.yaml -l "name=my-site" +``` + +The standalone `secretsync.yaml` manifest runs the same two steps (resolve-aio → enable-secretsync) without the full AIO installation pipeline. + +### CI/CD + +In CI, enable secret sync per-site via the `SITE_OVERRIDES` secret: + +```json +{ + "munich-dev": { + "subscription": "...", + "resourceGroup": "...", + "properties.deployOptions.enableSecretSync": true + } +} +``` + +## Bringing your own Key Vault + +By default, the enablement template creates a new Key Vault in the deployment resource group. To use an existing Key Vault, including one in a different resource group, pass its resource ID: + +```yaml +# parameters/secretsync-overrides.yaml (or in sites.local/) +existingKeyVaultResourceId: "/subscriptions/.../resourceGroups/shared-rg/providers/Microsoft.KeyVault/vaults/my-keyvault" +``` + +When an existing Key Vault is provided: +- No new Key Vault is created +- Role assignments are scoped to the Key Vault's resource group (cross-RG supported) +- The Key Vault must have RBAC authorization enabled (`enableRbacAuthorization: true`) + +## Syncing secrets to the cluster + +After enablement, use `sync-secret.bicep` to synchronize individual Key Vault secrets to Kubernetes secrets: + +``` +az deployment group create -g \ + -f templates/iot-ops/secretsync/sync-secret.bicep \ + -p keyVaultName= customLocationName= spcName= \ + secretName=my-secret secretValue= +``` + +### Parameters + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `keyVaultName` | Yes | Key Vault name (from enablement outputs) | +| `customLocationName` | Yes | Custom location name | +| `spcName` | Yes | Default SPC name (from enablement outputs) | +| `secretName` | Yes | Name of the Key Vault secret to create | +| `secretValue` | Yes | **`@secure()`**, provided at deploy time, never in git | +| `kubernetesSecretName` | No | K8s secret name (defaults to `secretName`) | +| `kubernetesSecretKey` | No | Key within the K8s secret (defaults to `secretName`) | + +### Security model + +The `secretValue` parameter is decorated with `@secure()` so ARM never logs it in deployment history or outputs. Provide secret values via: + +- **`sites.local/`** parameter overrides (gitignored), the standard siteops pattern for local development +- **CI/CD secrets** such as GitHub Actions secrets or Azure DevOps variable groups +- **CLI `--parameters`** at deployment time + +### Adding as a manifest step + +To sync secrets as part of a manifest, add a step after enablement: + +```yaml +- name: sync-my-secret + template: templates/iot-ops/secretsync/sync-secret.bicep + scope: resourceGroup + parameters: + - parameters/secretsync-chaining.yaml + # secretValue comes from sites.local/ or CI secrets + when: "{{ site.properties.deployOptions.enableSecretSync }}" +``` + +## Template reference + +``` +templates/iot-ops/ +├── common/ +│ ├── resolve-aio.bicep # Read-only instance → CL → cluster resolution +│ └── modules/ +│ ├── resolve-custom-location.bicep # CL resource ID → name, namespace, hostResourceId +│ └── resolve-cluster.bicep # Cluster resource ID → name, OIDC issuer URLs +├── secretsync/ +│ ├── enable-secretsync.bicep # Creates MI, KV, roles, FIC, SPC, instance update +│ ├── sync-secret.bicep # Syncs a KV secret to a K8s secret +│ └── modules/ +│ ├── update-instance.bicep # Safe instance PUT with identity forwarding +│ └── keyvault-roles.bicep # KV role assignments (cross-RG capable) +``` + +### Resolve modules + +The `common/` directory contains reusable resolution templates. `resolve-aio.bicep` is the entry point and chains through co-located modules: + +| Module | Input | Outputs | +|--------|-------|---------| +| `resolve-aio.bicep` | `aioInstanceName` | All infrastructure names + instance properties | +| `resolve-custom-location.bicep` | CL resource ID | `name`, `namespace`, `hostResourceId` | +| `resolve-cluster.bicep` | Cluster resource ID | `name`, `oidcIssuerUrl`, `selfHostedIssuerUrl` | + +These modules use Bicep's **module boundary** pattern: runtime resource IDs passed as module parameters become compile-time values inside the module, enabling chained `existing` resource lookups. + +### Enablement modules + +| Module | Purpose | +|--------|---------| +| `update-instance.bicep` | Safe instance PUT that forwards all writable properties for the pinned API version, with conditional identity handling | +| `keyvault-roles.bicep` | Key Vault role assignments via module scope, supporting cross-resource-group Key Vaults | + +## Troubleshooting + +### "condition not met" (steps skipped) + +The resolve-aio and secretsync steps have `when: "{{ site.properties.deployOptions.enableSecretSync }}"`. Ensure your site (or its base template) sets this to `true`: + +```yaml +properties: + deployOptions: + enableSecretSync: true +``` + +For CI, set it in `SITE_OVERRIDES`: + +```json +{ "my-site": { "properties.deployOptions.enableSecretSync": true } } +``` + +### DeploymentOutputEvaluationFailed + +If `resolve-aio` fails with an error about a property not existing on the instance resource, this is an ARM limitation with `existing` resource references. Properties accessed via safe navigation (`instance.?tags ?? {}`) handle this correctly. If you see this error on a new API version, check that the resolve template uses `?.` for optional properties. + +### Role assignment conflicts + +Role assignments use deterministic names via `guid(keyVault.id, principalId, roleId)`. Re-running the deployment is idempotent; existing assignments are confirmed in place, not duplicated. + +### Key Vault RBAC not enabled + +The enablement template creates Key Vaults with `enableRbacAuthorization: true`. If you bring your own Key Vault, role assignments will still be created successfully regardless of the Key Vault's authorization mode, but they will not take effect until RBAC authorization is enabled. Ensure `enableRbacAuthorization: true` is set on the Key Vault for the managed identity to authenticate. diff --git a/docs/site-configuration.md b/docs/site-configuration.md index 099c9ab..8d14c4c 100644 --- a/docs/site-configuration.md +++ b/docs/site-configuration.md @@ -113,6 +113,7 @@ properties: deployOptions: # Control deployment behavior includeSolution: true includeOpcPlcSimulator: false + enableSecretSync: false tags: costCenter: operations team: platform diff --git a/scripts/validate-bicep.ps1 b/scripts/validate-bicep.ps1 new file mode 100644 index 0000000..52027a9 --- /dev/null +++ b/scripts/validate-bicep.ps1 @@ -0,0 +1,64 @@ +#!/usr/bin/env pwsh +# Validate Bicep templates compile without errors. +# +# Usage: +# ./scripts/validate-bicep.ps1 # All .bicep files under workspaces/ +# ./scripts/validate-bicep.ps1 path/to/template.bicep # Specific file(s) +# ./scripts/validate-bicep.ps1 workspaces/iot-operations/templates/iot-ops/secretsync/*.bicep + +param( + [Parameter(ValueFromRemainingArguments)] + [string[]]$Files +) + +$ErrorActionPreference = 'Continue' +$repoRoot = Split-Path $PSScriptRoot -Parent + +# Discover files: use provided paths or find all .bicep files +if ($Files.Count -gt 0) { + $bicepFiles = @() + foreach ($pattern in $Files) { + $resolved = if ([System.IO.Path]::IsPathRooted($pattern)) { $pattern } else { Join-Path $repoRoot $pattern } + $bicepFiles += Get-Item $resolved -ErrorAction SilentlyContinue + } +} else { + $bicepFiles = Get-ChildItem -Path (Join-Path $repoRoot 'workspaces') -Filter '*.bicep' -Recurse +} + +if ($bicepFiles.Count -eq 0) { + Write-Host 'No .bicep files found.' -ForegroundColor Yellow + exit 0 +} + +Write-Host "Validating $($bicepFiles.Count) Bicep file(s)..." -ForegroundColor Cyan +Write-Host '' + +$failed = @() +$passed = 0 + +foreach ($file in $bicepFiles) { + $relPath = [System.IO.Path]::GetRelativePath($repoRoot, $file.FullName) + + # Build to stdout (discarded) — errors go to stderr + $output = az bicep build --file $file.FullName --stdout 2>&1 + if ($LASTEXITCODE -eq 0) { + Write-Host " OK $relPath" -ForegroundColor Green + $passed++ + } else { + Write-Host " FAIL $relPath" -ForegroundColor Red + # Show error details indented + $output | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] } | ForEach-Object { + Write-Host " $_" -ForegroundColor Red + } + $failed += $relPath + } +} + +Write-Host '' +if ($failed.Count -eq 0) { + Write-Host "All $passed file(s) compiled successfully." -ForegroundColor Green + exit 0 +} else { + Write-Host "$($failed.Count) file(s) failed, $passed passed." -ForegroundColor Red + exit 1 +} diff --git a/workspaces/iot-operations/manifests/aio-install.yaml b/workspaces/iot-operations/manifests/aio-install.yaml index f528fb9..3298f5e 100644 --- a/workspaces/iot-operations/manifests/aio-install.yaml +++ b/workspaces/iot-operations/manifests/aio-install.yaml @@ -73,6 +73,22 @@ steps: parameters: - parameters/post-instance.yaml + # ───────────────────────────────────────────────────────────── + # Secret Sync Enablement (conditional) + # ───────────────────────────────────────────────────────────── + + - name: resolve-aio + template: templates/iot-ops/common/resolve-aio.bicep + scope: resourceGroup + when: "{{ site.properties.deployOptions.enableSecretSync }}" + + - name: secretsync + template: templates/iot-ops/secretsync/enable-secretsync.bicep + scope: resourceGroup + parameters: + - parameters/secretsync-chaining.yaml + when: "{{ site.properties.deployOptions.enableSecretSync }}" + # ───────────────────────────────────────────────────────────── # Solution Layer (conditional) # ───────────────────────────────────────────────────────────── diff --git a/workspaces/iot-operations/manifests/secretsync.yaml b/workspaces/iot-operations/manifests/secretsync.yaml new file mode 100644 index 0000000..b75a43d --- /dev/null +++ b/workspaces/iot-operations/manifests/secretsync.yaml @@ -0,0 +1,30 @@ +apiVersion: siteops/v1 +kind: Manifest +name: secretsync +description: | + Enable secret sync on existing Azure IoT Operations instances. + + This is a standalone manifest for day-2 enablement. For new deployments, + use aio-install.yaml with deployOptions.enableSecretSync = true. + + Prerequisites: + - AIO instance must be deployed and running + - Connected cluster must have OIDC issuer and workload identity enabled + +parallel: 3 + +siteSelector: "environment=dev" + +parameters: + - parameters/common.yaml + +steps: + - name: resolve-aio + template: templates/iot-ops/common/resolve-aio.bicep + scope: resourceGroup + + - name: secretsync + template: templates/iot-ops/secretsync/enable-secretsync.bicep + scope: resourceGroup + parameters: + - parameters/secretsync-chaining.yaml diff --git a/workspaces/iot-operations/parameters/secretsync-chaining.yaml b/workspaces/iot-operations/parameters/secretsync-chaining.yaml new file mode 100644 index 0000000..26f5cd8 --- /dev/null +++ b/workspaces/iot-operations/parameters/secretsync-chaining.yaml @@ -0,0 +1,19 @@ +# Output chaining from resolve-aio step to enable-secretsync +# Used by: secretsync step (in both aio-install.yaml and standalone secretsync.yaml) + +# Resolved infrastructure names +customLocationId: "{{ steps.resolve-aio.outputs.customLocationId }}" +customLocationName: "{{ steps.resolve-aio.outputs.customLocationName }}" +customLocationNamespace: "{{ steps.resolve-aio.outputs.customLocationNamespace }}" +connectedClusterName: "{{ steps.resolve-aio.outputs.connectedClusterName }}" +oidcIssuerUrl: "{{ steps.resolve-aio.outputs.oidcIssuerUrl }}" + +# Instance properties for safe PUT forwarding +instanceLocation: "{{ steps.resolve-aio.outputs.instanceLocation }}" +instanceTags: "{{ steps.resolve-aio.outputs.instanceTags }}" +identityType: "{{ steps.resolve-aio.outputs.identityType }}" +userAssignedIdentities: "{{ steps.resolve-aio.outputs.userAssignedIdentities }}" +schemaRegistryResourceId: "{{ steps.resolve-aio.outputs.schemaRegistryResourceId }}" +adrNamespaceResourceId: "{{ steps.resolve-aio.outputs.adrNamespaceResourceId }}" +features: "{{ steps.resolve-aio.outputs.features }}" +instanceDescription: "{{ steps.resolve-aio.outputs.instanceDescription }}" diff --git a/workspaces/iot-operations/sites/base-site.yaml b/workspaces/iot-operations/sites/base-site.yaml index fa828a4..c0c217c 100644 --- a/workspaces/iot-operations/sites/base-site.yaml +++ b/workspaces/iot-operations/sites/base-site.yaml @@ -15,6 +15,7 @@ properties: includeEdgeSite: true # RG-scoped edge site (default for RG-level sites) includeSolution: false includeOpcPlcSimulator: false + enableSecretSync: false parameters: # AIO broker configuration defaults diff --git a/workspaces/iot-operations/templates/iot-ops/common/modules/resolve-cluster.bicep b/workspaces/iot-operations/templates/iot-ops/common/modules/resolve-cluster.bicep new file mode 100644 index 0000000..a1e86ee --- /dev/null +++ b/workspaces/iot-operations/templates/iot-ops/common/modules/resolve-cluster.bicep @@ -0,0 +1,30 @@ +// resolve-cluster.bicep +// ------------------------------------------------------------------------------------- +// Reusable module: resolves a connected cluster from its full ARM resource ID. +// +// Accepts the full resource ID (e.g., from customLocation.properties.hostResourceId), +// parses the name, declares the cluster as an existing resource, and outputs +// its OIDC issuer URLs for workload identity federation. +// +// The module boundary converts the runtime resource ID into a compile-time +// parameter, allowing the existing resource lookup that Bicep otherwise +// prohibits on runtime values. +// ------------------------------------------------------------------------------------- + +@description('Full ARM resource ID of the Arc-connected cluster.') +param connectedClusterResourceId string + +var connectedClusterName = last(split(connectedClusterResourceId, '/')) + +resource connectedCluster 'Microsoft.Kubernetes/connectedClusters@2024-07-15-preview' existing = { + name: connectedClusterName +} + +@description('Connected cluster name (parsed from resource ID).') +output name string = connectedCluster.name + +@description('Public OIDC issuer URL for workload identity federation.') +output oidcIssuerUrl string = connectedCluster.properties.oidcIssuerProfile.issuerUrl + +@description('Self-hosted OIDC issuer URL (empty string if not configured).') +output selfHostedIssuerUrl string = connectedCluster.properties.oidcIssuerProfile.?selfHostedIssuerUrl ?? '' diff --git a/workspaces/iot-operations/templates/iot-ops/common/modules/resolve-custom-location.bicep b/workspaces/iot-operations/templates/iot-ops/common/modules/resolve-custom-location.bicep new file mode 100644 index 0000000..6d28438 --- /dev/null +++ b/workspaces/iot-operations/templates/iot-ops/common/modules/resolve-custom-location.bicep @@ -0,0 +1,33 @@ +// resolve-custom-location.bicep +// ------------------------------------------------------------------------------------- +// Reusable module: resolves a custom location from its full ARM resource ID. +// +// Accepts the full resource ID (e.g., from instance.extendedLocation.name), +// parses the name, declares the custom location as an existing resource, and +// outputs its key properties. +// +// The module boundary converts the runtime resource ID into a compile-time +// parameter, allowing the existing resource lookup that Bicep otherwise +// prohibits on runtime values. +// ------------------------------------------------------------------------------------- + +@description('Full ARM resource ID of the custom location.') +param customLocationResourceId string + +var customLocationName = last(split(customLocationResourceId, '/')) + +resource customLocation 'Microsoft.ExtendedLocation/customLocations@2021-08-31-preview' existing = { + name: customLocationName +} + +@description('Custom location name (parsed from resource ID).') +output name string = customLocation.name + +@description('Full resource ID of the custom location.') +output id string = customLocation.id + +@description('Kubernetes namespace associated with the custom location.') +output namespace string = customLocation.properties.namespace + +@description('Full ARM resource ID of the host connected cluster.') +output hostResourceId string = customLocation.properties.hostResourceId diff --git a/workspaces/iot-operations/templates/iot-ops/common/resolve-aio.bicep b/workspaces/iot-operations/templates/iot-ops/common/resolve-aio.bicep new file mode 100644 index 0000000..b4804d8 --- /dev/null +++ b/workspaces/iot-operations/templates/iot-ops/common/resolve-aio.bicep @@ -0,0 +1,109 @@ +// resolve-aio.bicep +// ------------------------------------------------------------------------------------- +// Read-only template: resolves an Azure IoT Operations instance and its associated +// infrastructure (custom location, connected cluster) into a complete set of outputs. +// +// This template performs no resource creation or modification. It reads existing +// resources and outputs their properties for consumption by downstream steps via +// output chaining. +// +// Resolution chain (using module boundaries for runtime → compile-time conversion): +// 1. Instance (name is a parameter → compile-time) +// 2. Custom Location (parsed from instance.extendedLocation.name via module) +// 3. Connected Cluster (parsed from CL.hostResourceId via module) +// +// Usage: +// This template is designed as a siteops manifest step. Its outputs feed +// downstream steps (e.g., enable-secretsync) via parameter chaining files. +// +// Standalone: +// az deployment group create -g -f resolve-aio.bicep \ +// -p aioInstanceName= +// ------------------------------------------------------------------------------------- + +// ===================================================================================== +// Parameters +// ===================================================================================== + +@description('Name of the existing IoT Operations instance.') +param aioInstanceName string + +@description('Use the self-hosted OIDC issuer URL instead of the public one.') +param useSelfHostedIssuer bool = false + +// ===================================================================================== +// Existing Instance +// ===================================================================================== + +resource instance 'Microsoft.IoTOperations/instances@2025-10-01' existing = { + name: aioInstanceName +} + +// ===================================================================================== +// Chained Resolution via Modules +// Each module boundary converts a runtime resource ID into a compile-time +// parameter, enabling the next existing resource lookup. +// ===================================================================================== + +module resolvedCl 'modules/resolve-custom-location.bicep' = { + name: 'resolve-cl-${uniqueString(aioInstanceName)}' + params: { + customLocationResourceId: instance.extendedLocation.name + } +} + +module resolvedCluster 'modules/resolve-cluster.bicep' = { + name: 'resolve-cluster-${uniqueString(aioInstanceName)}' + params: { + connectedClusterResourceId: resolvedCl.outputs.hostResourceId + } +} + +// ===================================================================================== +// Outputs — resolved infrastructure +// ===================================================================================== + +@description('Full ARM resource ID of the custom location.') +output customLocationId string = instance.extendedLocation.name + +@description('Custom location name.') +output customLocationName string = resolvedCl.outputs.name + +@description('Kubernetes namespace associated with the custom location.') +output customLocationNamespace string = resolvedCl.outputs.namespace + +@description('Connected cluster name.') +output connectedClusterName string = resolvedCluster.outputs.name + +@description('OIDC issuer URL for workload identity federation.') +output oidcIssuerUrl string = useSelfHostedIssuer + ? resolvedCluster.outputs.selfHostedIssuerUrl + : resolvedCluster.outputs.oidcIssuerUrl + +// ===================================================================================== +// Outputs — instance properties (for safe PUT forwarding by downstream templates) +// ===================================================================================== + +@description('Instance location.') +output instanceLocation string = instance.location + +@description('Instance tags. Note: ARM does not expose tags on existing resource references, so this output requires the instance to have tags set. Defaults to empty object if unavailable.') +output instanceTags object = instance.?tags ?? {} + +@description('Instance identity type.') +output identityType string = instance.?identity.?type ?? 'None' + +@description('Instance user-assigned identities map.') +output userAssignedIdentities object = instance.?identity.?userAssignedIdentities ?? {} + +@description('Schema registry resource ID.') +output schemaRegistryResourceId string = instance.properties.schemaRegistryRef.resourceId + +@description('ADR namespace resource ID.') +output adrNamespaceResourceId string = instance.properties.?adrNamespaceRef.?resourceId ?? '' + +@description('Instance features map.') +output features object = instance.properties.?features ?? {} + +@description('Instance description.') +output instanceDescription string = instance.properties.?description ?? '' diff --git a/workspaces/iot-operations/templates/iot-ops/deps/schema-registry.bicep b/workspaces/iot-operations/templates/iot-ops/deps/schema-registry.bicep index 5ce390b..bdab2d0 100644 --- a/workspaces/iot-operations/templates/iot-ops/deps/schema-registry.bicep +++ b/workspaces/iot-operations/templates/iot-ops/deps/schema-registry.bicep @@ -27,6 +27,10 @@ var generatedStorageAccountName = !empty(storageAccountName) ? storageAccountName : take('sr${uniqueString(resourceGroup().id, schemaRegistryName)}', 24) +// Uses resourceId() instead of schemaRegistry.id to avoid a circular dependency +// (storage account → schema registry → storage account) +var schemaRegistryResourceId = resourceId('Microsoft.DeviceRegistry/schemaRegistries', schemaRegistryName) + resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = { name: generatedStorageAccountName location: location @@ -42,6 +46,16 @@ resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = { supportsHttpsTrafficOnly: true allowBlobPublicAccess: false allowSharedKeyAccess: false + networkAcls: { + defaultAction: 'Deny' + bypass: 'AzureServices' + resourceAccessRules: [ + { + resourceId: schemaRegistryResourceId + tenantId: tenant().tenantId + } + ] + } } } diff --git a/workspaces/iot-operations/templates/iot-ops/secretsync/enable-secretsync.bicep b/workspaces/iot-operations/templates/iot-ops/secretsync/enable-secretsync.bicep new file mode 100644 index 0000000..b058231 --- /dev/null +++ b/workspaces/iot-operations/templates/iot-ops/secretsync/enable-secretsync.bicep @@ -0,0 +1,275 @@ +// enable-secretsync.bicep +// ------------------------------------------------------------------------------------- +// Enables secret synchronization for an Azure IoT Operations instance. +// Mirrors the behavior of `az iot ops secretsync enable`. +// +// All resolved infrastructure values (CL name, cluster name, OIDC issuer, namespace, +// instance properties) are received as parameters — typically via output chaining from +// the resolve-aio step. This template has no cross-directory module dependencies. +// +// Resources provisioned/managed: +// 1. User-Assigned Managed Identity (idempotent PUT) +// 2. Key Vault with RBAC authorization (idempotent PUT) +// 3. Key Vault role assignments — Key Vault Secrets User + Key Vault Reader +// 4. Federated Identity Credential on the managed identity +// 5. AzureKeyVaultSecretProviderClass (SPC) on the custom location +// 6. IoT Operations instance update — sets defaultSecretProviderClassRef to the SPC +// +// Usage (with siteops output chaining from resolve-aio): +// The resolve-aio step outputs all required values. The secretsync-chaining.yaml +// parameter file maps those outputs to this template's parameters. +// +// Usage (standalone): +// az deployment group create -g -f enable-secretsync.bicep \ +// -p aioInstanceName= customLocationId= customLocationName= \ +// customLocationNamespace= connectedClusterName= \ +// oidcIssuerUrl= instanceLocation= \ +// schemaRegistryResourceId= +// ------------------------------------------------------------------------------------- + +// ===================================================================================== +// Parameters — resolved infrastructure (from resolve-aio output chaining) +// ===================================================================================== + +@description('Name of the existing IoT Operations instance.') +param aioInstanceName string + +@description('Full ARM resource ID of the custom location.') +param customLocationId string + +@description('Custom location name.') +param customLocationName string + +@description('Kubernetes namespace associated with the custom location.') +param customLocationNamespace string + +@description('Name of the Arc-connected cluster.') +param connectedClusterName string + +@description('OIDC issuer URL for workload identity federation.') +param oidcIssuerUrl string + +// ===================================================================================== +// Parameters — instance properties (from resolve-aio, forwarded to instance update) +// ===================================================================================== + +@description('Instance location.') +param instanceLocation string + +@description('Instance tags (forwarded to instance update).') +param instanceTags object = {} + +@description('Instance identity type (forwarded to instance update).') +param identityType string = 'None' + +@description('Instance user-assigned identities map (forwarded to instance update).') +param userAssignedIdentities object = {} + +@description('Schema registry resource ID (forwarded to instance update).') +param schemaRegistryResourceId string + +@description('ADR namespace resource ID (forwarded to instance update).') +param adrNamespaceResourceId string = '' + +@description('Instance features map (forwarded to instance update).') +param features object = {} + +@description('Instance description (forwarded to instance update).') +param instanceDescription string = '' + +// ===================================================================================== +// Parameters — secret sync configuration +// ===================================================================================== + +@description('Name for the user-assigned managed identity. Auto-generated if empty.') +param managedIdentityName string = '' + +@description('Resource ID of an existing Key Vault. If provided, no Key Vault is created and this one is used instead. Supports cross-resource-group references.') +param existingKeyVaultResourceId string = '' + +@description('Name for a new Key Vault. Ignored if existingKeyVaultResourceId is provided. Auto-generated if empty.') +param keyVaultName string = '' + +@description('Name override for the Secret Provider Class. Auto-generated if empty.') +param spcName string = '' + +@description('Skip Key Vault role assignments (use when roles are already configured).') +param skipRoleAssignments bool = false + +@description('Tags to apply to created resources.') +param tags object = {} + +// ===================================================================================== +// Variables +// ===================================================================================== + +var resolvedMiName = !empty(managedIdentityName) + ? managedIdentityName + : 'mi-aio-${uniqueString(resourceGroup().id, aioInstanceName)}' + +// Key Vault: use existing or create new +var useExistingKv = !empty(existingKeyVaultResourceId) +var kvRgName = useExistingKv ? split(existingKeyVaultResourceId, '/')[4] : resourceGroup().name +var resolvedKvName = useExistingKv + ? last(split(existingKeyVaultResourceId, '/')) + : !empty(keyVaultName) ? keyVaultName : 'kvaio${uniqueString(resourceGroup().id, aioInstanceName)}' + +var resolvedSpcName = !empty(spcName) + ? spcName + : 'spc-ops-${uniqueString(connectedClusterName, resourceGroup().name, aioInstanceName)}' + +var fedCredName = 'fc-${uniqueString(connectedClusterName, customLocationName, aioInstanceName)}' + +// Kubernetes service account subject for the secret sync controller +var credSubject = 'system:serviceaccount:${customLocationNamespace}:aio-ssc-sa' + +// ===================================================================================== +// Existing Resources +// ===================================================================================== + +resource customLocation 'Microsoft.ExtendedLocation/customLocations@2021-08-31-preview' existing = { + name: customLocationName +} + +// ===================================================================================== +// User-Assigned Managed Identity +// Idempotent PUT — if an MI with this name already exists, it is confirmed in place. +// ===================================================================================== + +resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: resolvedMiName + location: instanceLocation + tags: tags +} + +// ===================================================================================== +// Key Vault +// Conditional: only created when no existing Key Vault is provided. +// Idempotent PUT — if a KV with this name already exists in the RG, it is confirmed +// in place with RBAC authorization enabled. +// ===================================================================================== + +resource newKeyVault 'Microsoft.KeyVault/vaults@2022-07-01' = if (!useExistingKv) { + name: resolvedKvName + location: instanceLocation + tags: tags + properties: { + sku: { + family: 'A' + name: 'standard' + } + tenantId: tenant().tenantId + enableRbacAuthorization: true + } +} + +// ===================================================================================== +// Key Vault Role Assignments +// Deployed as a module to support cross-resource-group Key Vaults. +// The module scope targets the Key Vault's resource group. +// ===================================================================================== + +module kvRoles './modules/keyvault-roles.bicep' = if (!skipRoleAssignments) { + name: 'kv-roles-${uniqueString(resolvedKvName, aioInstanceName)}' + scope: resourceGroup(kvRgName) + params: { + keyVaultName: resolvedKvName + principalId: managedIdentity.properties.principalId + } + dependsOn: [ + newKeyVault + ] +} + +// ===================================================================================== +// Federated Identity Credential +// Links the MI to the connected cluster's OIDC issuer via the aio-ssc-sa +// Kubernetes service account, enabling workload identity federation. +// ===================================================================================== + +resource federatedCredential 'Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentials@2023-01-31' = { + parent: managedIdentity + name: fedCredName + properties: { + issuer: oidcIssuerUrl + subject: credSubject + audiences: [ + 'api://AzureADTokenExchange' + ] + } +} + +// ===================================================================================== +// Secret Provider Class (SPC) +// ===================================================================================== + +resource spc 'Microsoft.SecretSyncController/azureKeyVaultSecretProviderClasses@2024-08-21-preview' = { + name: resolvedSpcName + location: instanceLocation + extendedLocation: { + name: customLocation.id + type: 'CustomLocation' + } + tags: tags + properties: { + clientId: managedIdentity.properties.clientId + keyvaultName: resolvedKvName + tenantId: tenant().tenantId + } + dependsOn: [ + federatedCredential + newKeyVault + kvRoles + ] +} + +// ===================================================================================== +// Instance Update +// Uses a module to reset compile-time constraints. All known writable properties +// for the pinned API version (2025-10-01) are forwarded to prevent data loss. +// ===================================================================================== + +module instanceUpdate './modules/update-instance.bicep' = { + name: 'update-instance-spc-${uniqueString(aioInstanceName, spc.id)}' + params: { + instanceName: aioInstanceName + instanceLocation: instanceLocation + extendedLocationName: customLocationId + instanceTags: instanceTags + identityType: identityType + userAssignedIdentities: userAssignedIdentities + schemaRegistryResourceId: schemaRegistryResourceId + adrNamespaceResourceId: adrNamespaceResourceId + features: features + instanceDescription: instanceDescription + spcResourceId: spc.id + } +} + +// ===================================================================================== +// Outputs +// ===================================================================================== + +@description('Resource ID of the created Secret Provider Class.') +output spcResourceId string = spc.id + +@description('Name of the created Secret Provider Class.') +output spcResourceName string = spc.name + +@description('Principal ID of the managed identity.') +output managedIdentityPrincipalId string = managedIdentity.properties.principalId + +@description('Client ID of the managed identity.') +output managedIdentityClientId string = managedIdentity.properties.clientId + +@description('Resource ID of the managed identity.') +output managedIdentityResourceId string = managedIdentity.id + +@description('Name of the Key Vault.') +output keyVaultName string = resolvedKvName + +@description('Resource ID of the Key Vault.') +output keyVaultResourceId string = useExistingKv ? existingKeyVaultResourceId : newKeyVault!.id + +@description('Name of the federated identity credential.') +output federatedCredentialName string = fedCredName diff --git a/workspaces/iot-operations/templates/iot-ops/secretsync/modules/keyvault-roles.bicep b/workspaces/iot-operations/templates/iot-ops/secretsync/modules/keyvault-roles.bicep new file mode 100644 index 0000000..a5d545e --- /dev/null +++ b/workspaces/iot-operations/templates/iot-ops/secretsync/modules/keyvault-roles.bicep @@ -0,0 +1,49 @@ +// keyvault-roles.bicep +// ------------------------------------------------------------------------------------- +// Module: Key Vault role assignments for secret sync. +// +// Declares the Key Vault as an existing resource and assigns the required roles +// to a managed identity principal. Deployed as a module so that cross-resource-group +// Key Vaults are supported — the parent template sets the module scope to the +// Key Vault's resource group. +// ------------------------------------------------------------------------------------- + +@description('Name of the Key Vault.') +param keyVaultName string + +@description('Principal ID of the managed identity to grant access.') +param principalId string + +// Well-known role definition IDs +var kvSecretsUserRoleId = '4633458b-17de-408a-b874-0445c86b69e6' +var kvReaderRoleId = '21090545-7ca7-4776-b22c-e363652d74d2' + +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { + name: keyVaultName +} + +resource kvSecretsUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(keyVault.id, principalId, kvSecretsUserRoleId) + scope: keyVault + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', kvSecretsUserRoleId) + principalId: principalId + principalType: 'ServicePrincipal' + } +} + +resource kvReader 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(keyVault.id, principalId, kvReaderRoleId) + scope: keyVault + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', kvReaderRoleId) + principalId: principalId + principalType: 'ServicePrincipal' + } +} + +@description('Key Vault name.') +output name string = keyVault.name + +@description('Key Vault resource ID.') +output id string = keyVault.id diff --git a/workspaces/iot-operations/templates/iot-ops/secretsync/modules/update-instance.bicep b/workspaces/iot-operations/templates/iot-ops/secretsync/modules/update-instance.bicep new file mode 100644 index 0000000..8529648 --- /dev/null +++ b/workspaces/iot-operations/templates/iot-ops/secretsync/modules/update-instance.bicep @@ -0,0 +1,82 @@ +// update-instance.bicep +// ------------------------------------------------------------------------------------- +// Module: IoT Operations Instance Update +// +// Updates an existing IoT Operations instance to set the defaultSecretProviderClassRef. +// Uses a module boundary to convert runtime values from the parent template into +// compile-time parameters, satisfying Bicep's requirement that location and +// extendedLocation be calculable at deployment start. +// +// WARNING: This module re-declares the full instance via PUT. All writable properties +// for the pinned API version (2025-10-01) must be forwarded from the parent template +// to prevent data loss. ARM API versions are immutable — the property set is fixed +// for this version. +// ------------------------------------------------------------------------------------- + +@description('IoT Operations instance name.') +param instanceName string + +@description('Instance location (from existing instance).') +param instanceLocation string + +@description('Extended location resource ID — the custom location ID.') +param extendedLocationName string + +@description('Instance tags.') +param instanceTags object = {} + +@description('Identity type (None, UserAssigned, SystemAssigned, SystemAssigned,UserAssigned).') +param identityType string = 'None' + +@description('User-assigned managed identities map (resource ID to empty object).') +param userAssignedIdentities object = {} + +@description('Schema registry resource ID (required by the IoT Operations instance).') +param schemaRegistryResourceId string + +@description('ADR namespace resource ID.') +param adrNamespaceResourceId string = '' + +@description('Instance features map (component mode/settings). Forwarded to prevent data loss.') +param features object = {} + +@description('Instance description.') +param instanceDescription string = '' + +@description('Secret Provider Class resource ID to set as the default.') +param spcResourceId string + +resource instance 'Microsoft.IoTOperations/instances@2025-10-01' = { + name: instanceName + location: instanceLocation + extendedLocation: { + name: extendedLocationName + type: 'CustomLocation' + } + tags: instanceTags + identity: identityType == 'None' + ? { + type: 'None' + } + : { + type: identityType + userAssignedIdentities: userAssignedIdentities + } + properties: { + schemaRegistryRef: { + resourceId: schemaRegistryResourceId + } + adrNamespaceRef: !empty(adrNamespaceResourceId) + ? { + resourceId: adrNamespaceResourceId + } + : null + features: features + description: instanceDescription + defaultSecretProviderClassRef: { + resourceId: spcResourceId + } + } +} + +output instanceResourceId string = instance.id diff --git a/workspaces/iot-operations/templates/iot-ops/secretsync/sync-secret.bicep b/workspaces/iot-operations/templates/iot-ops/secretsync/sync-secret.bicep new file mode 100644 index 0000000..beaaafe --- /dev/null +++ b/workspaces/iot-operations/templates/iot-ops/secretsync/sync-secret.bicep @@ -0,0 +1,127 @@ +// sync-secret.bicep +// ------------------------------------------------------------------------------------- +// Generic template: syncs a Key Vault secret to a Kubernetes secret via SecretSync. +// +// Creates a secret in the Key Vault and a SecretSync resource that maps it to a +// Kubernetes secret on the cluster. The SecretSync references the default SPC +// created by enable-secretsync. +// +// Security: The secret value is a @secure() parameter — it is never logged in ARM +// deployment history or Bicep outputs. Provide the value via: +// - sites.local/ parameter overrides (gitignored) +// - CI/CD pipeline secrets +// - CLI --parameters at deployment time +// +// Usage: +// az deployment group create -g -f sync-secret.bicep \ +// -p keyVaultName= customLocationName= spcName= \ +// secretName= secretValue= +// ------------------------------------------------------------------------------------- + +// ===================================================================================== +// Parameters +// ===================================================================================== + +@description('Name of the Key Vault (from enable-secretsync outputs).') +param keyVaultName string + +@description('Name of the custom location.') +param customLocationName string + +@description('Name of the default Secret Provider Class (from enable-secretsync outputs).') +param spcName string + +@description('Name of the Key Vault secret to create.') +param secretName string + +@secure() +@description('Secret value. Provide at deployment time — never store in source control.') +param secretValue string + +@description('Name for the Kubernetes secret. Defaults to the Key Vault secret name.') +param kubernetesSecretName string = '' + +@description('Key within the Kubernetes secret. Defaults to the Key Vault secret name.') +#disable-next-line secure-secrets-in-params // This is a key name, not a secret value +param kubernetesSecretKey string = '' + +@description('Location for the SecretSync resource. Defaults to the resource group location.') +param location string = resourceGroup().location + +// ===================================================================================== +// Variables +// ===================================================================================== + +var resolvedK8sSecretName = !empty(kubernetesSecretName) ? kubernetesSecretName : secretName +var resolvedK8sSecretKey = !empty(kubernetesSecretKey) ? kubernetesSecretKey : secretName + +// ===================================================================================== +// Existing Resources +// ===================================================================================== + +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { + name: keyVaultName +} + +resource customLocation 'Microsoft.ExtendedLocation/customLocations@2021-08-31-preview' existing = { + name: customLocationName +} + +resource spc 'Microsoft.SecretSyncController/azureKeyVaultSecretProviderClasses@2024-08-21-preview' existing = { + name: spcName +} + +// ===================================================================================== +// Key Vault Secret +// ===================================================================================== + +resource kvSecret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { + parent: keyVault + name: secretName + properties: { + value: secretValue + } +} + +// ===================================================================================== +// SecretSync Resource +// Maps the Key Vault secret to a Kubernetes secret via the default SPC. +// The resource name becomes the Kubernetes secret name on the cluster. +// ===================================================================================== + +resource secretSync 'Microsoft.SecretSyncController/secretSyncs@2024-08-21-preview' = { + name: resolvedK8sSecretName + location: location + extendedLocation: { + name: customLocation.id + type: 'CustomLocation' + } + properties: { + secretProviderClassName: spc.name + serviceAccountName: 'aio-ssc-sa' + kubernetesSecretType: 'Opaque' + objectSecretMapping: [ + { + sourcePath: secretName + targetKey: resolvedK8sSecretKey + } + ] + forceSynchronization: 'no' + } + dependsOn: [ + kvSecret + ] +} + +// ===================================================================================== +// Outputs +// ===================================================================================== + +@description('Name of the Kubernetes secret that will be created on the cluster.') +output kubernetesSecretName string = resolvedK8sSecretName + +@description('Key within the Kubernetes secret.') +output kubernetesSecretKey string = resolvedK8sSecretKey + +@description('Name of the SecretSync resource.') +output secretSyncName string = secretSync.name