Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ on:
- "siteops/**"
- "workspaces/**"
- "tests/**"
- "scripts/**"
- "pyproject.toml"
pull_request:
branches: [main]
paths:
- "siteops/**"
- "workspaces/**"
- "tests/**"
- "scripts/**"
- "pyproject.toml"
workflow_dispatch:

Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .pipelines/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ trigger:
- siteops/**
- workspaces/**
- tests/**
- scripts/**
- pyproject.toml

pr:
Expand All @@ -19,6 +20,7 @@ pr:
- siteops/**
- workspaces/**
- tests/**
- scripts/**
- pyproject.toml

pool:
Expand Down Expand Up @@ -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 "")
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,19 +149,21 @@ 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
│ ├── manifests/ # Deployment orchestration
│ ├── 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
Expand Down Expand Up @@ -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 |

---
Expand Down
4 changes: 2 additions & 2 deletions docs/ci-cd-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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 |
Expand Down
234 changes: 234 additions & 0 deletions docs/secret-sync.md
Original file line number Diff line number Diff line change
@@ -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 <rg> \
-f templates/iot-ops/secretsync/sync-secret.bicep \
-p keyVaultName=<kv> customLocationName=<cl> spcName=<spc> \
secretName=my-secret secretValue=<value>
```

### 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.
1 change: 1 addition & 0 deletions docs/site-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ properties:
deployOptions: # Control deployment behavior
includeSolution: true
includeOpcPlcSimulator: false
enableSecretSync: false
tags:
costCenter: operations
team: platform
Expand Down
Loading
Loading