From 0fefcecef42bdb55267b3113c568ca823aa8bd0b Mon Sep 17 00:00:00 2001 From: Paymaun Heidari Date: Thu, 5 Mar 2026 14:47:49 -0800 Subject: [PATCH] ci: add Azure DevOps pipeline equivalents for GitHub Actions workflows - Add .pipelines/ci.yaml (test + validate), deploy.yaml (manual trigger), templates/setup-siteops.yaml, and templates/siteops-deploy.yaml - Use AzureCLI@2 for scoped auth (no token refresh or explicit logout) - Scaffold per-environment isolation via object parameter lookup tables - Switch GHA deploy.yaml to secrets: inherit for environment-scoped secrets - Add name: property to deploy.yaml for descriptive run titles - Document ADO setup, Arc proxy RBAC, and deployment instructions in ci-cd-setup.md --- .github/workflows/deploy.yaml | 6 +- .pipelines/ci.yaml | 103 +++++++ .pipelines/deploy.yaml | 119 ++++++++ .pipelines/templates/setup-siteops.yaml | 44 +++ .pipelines/templates/siteops-deploy.yaml | 194 +++++++++++++ docs/ci-cd-setup.md | 336 ++++++++++++++++++++--- 6 files changed, 756 insertions(+), 46 deletions(-) create mode 100644 .pipelines/ci.yaml create mode 100644 .pipelines/deploy.yaml create mode 100644 .pipelines/templates/setup-siteops.yaml create mode 100644 .pipelines/templates/siteops-deploy.yaml diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 8961627..85a8f96 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -153,8 +153,4 @@ jobs: dry-run: ${{ inputs.dry-run }} environment: ${{ needs.prepare.outputs.github-environment }} ref: ${{ needs.prepare.outputs.ref }} - secrets: - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - SITE_OVERRIDES: ${{ secrets.SITE_OVERRIDES }} + secrets: inherit diff --git a/.pipelines/ci.yaml b/.pipelines/ci.yaml new file mode 100644 index 0000000..a10e32c --- /dev/null +++ b/.pipelines/ci.yaml @@ -0,0 +1,103 @@ +# CI pipeline: Unit tests and manifest validation +# Equivalent to .github/workflows/ci.yaml + +trigger: + branches: + include: [main] + paths: + include: + - siteops/** + - workspaces/** + - tests/** + - pyproject.toml + +pr: + branches: + include: [main] + paths: + include: + - siteops/** + - workspaces/** + - tests/** + - pyproject.toml + +pool: + vmImage: ubuntu-latest + +jobs: + - job: test + displayName: Unit Tests + steps: + - checkout: self + fetchDepth: 1 + persistCredentials: false + + - template: templates/setup-siteops.yaml + parameters: + installDev: true + + - script: > + pytest tests/ -v + --cov=siteops + --cov-report=term-missing + --cov-report=xml:coverage.xml + --junitxml=test-results.xml + displayName: Run unit tests + + - task: PublishTestResults@2 + displayName: Publish test results + condition: succeededOrFailed() + inputs: + testResultsFormat: JUnit + testResultsFiles: test-results.xml + testRunTitle: Site Ops Unit Tests + + - task: PublishCodeCoverageResults@2 + displayName: Publish code coverage + condition: succeededOrFailed() + inputs: + summaryFileLocation: coverage.xml + + - job: validate + displayName: Validate Manifests + steps: + - checkout: self + fetchDepth: 1 + persistCredentials: false + + - template: templates/setup-siteops.yaml + + - script: | + WORKSPACE_DIR="workspaces/iot-operations" + MANIFESTS=$(find "$WORKSPACE_DIR/manifests" -name "*.yaml" -o -name "*.yml" 2>/dev/null | tr '\n' ' ' || echo "") + + if [[ -z "$MANIFESTS" ]]; then + echo "##vso[task.logissue type=warning]No manifests found in $WORKSPACE_DIR/manifests" + exit 0 + fi + + echo "Found manifests: $MANIFESTS" + + SUMMARY_FILE="$(Build.ArtifactStagingDirectory)/validation-summary.md" + echo "## Deployment Plans" > "$SUMMARY_FILE" + EXIT_CODE=0 + + for manifest in $MANIFESTS; do + manifest_rel="${manifest#$WORKSPACE_DIR/}" + echo "##[group]Validating $manifest" + echo "### $(basename $manifest)" >> "$SUMMARY_FILE" + echo '```' >> "$SUMMARY_FILE" + + if siteops -w "$WORKSPACE_DIR" validate "$manifest_rel" -v 2>&1 | tee -a "$SUMMARY_FILE"; then + echo "✓ Valid" + else + EXIT_CODE=1 + fi + + echo '```' >> "$SUMMARY_FILE" + echo "##[endgroup]" + done + + echo "##vso[task.uploadsummary]$SUMMARY_FILE" + exit $EXIT_CODE + displayName: Validate manifests and show plans diff --git a/.pipelines/deploy.yaml b/.pipelines/deploy.yaml new file mode 100644 index 0000000..d9e0f9f --- /dev/null +++ b/.pipelines/deploy.yaml @@ -0,0 +1,119 @@ +# Deploy pipeline: Manual deployment trigger +# Equivalent to .github/workflows/deploy.yaml + +name: $(Date:yyyyMMdd)$(Rev:.r) · ${{ parameters.environment }} · ${{ parameters.manifest }}${{ iif(parameters.dryRun, ' (dry-run)', '') }} + +trigger: none +pr: none + +parameters: + - name: workspace + displayName: Workspace + type: string + default: iot-operations + values: [iot-operations] + + - name: manifest + displayName: Manifest + type: string + default: aio-install + values: [aio-install, opc-ua-solution] + + - name: environment + displayName: Target environment + type: string + default: dev + values: [dev, staging, prod] + + - name: selector + displayName: Additional site selector (e.g., country=US,name=seattle-dev) + type: string + default: ' ' + + - name: dryRun + displayName: Dry run (preview only) + type: boolean + default: false + + # Per-environment lookup tables. To split resources per environment, + # change the values below — no structural changes needed. + - name: serviceConnections + displayName: Service connection per environment + type: object + default: + dev: azure-siteops + staging: azure-siteops + prod: azure-siteops + + - name: secretGroups + displayName: Variable group per environment + type: object + default: + dev: siteops-secrets + staging: siteops-secrets + prod: siteops-secrets + +variables: + - group: ${{ parameters.secretGroups[parameters.environment] }} + - name: PYTHONUNBUFFERED + value: '1' + +pool: + vmImage: ubuntu-latest + +stages: + - stage: prepare + displayName: Validate and Resolve + jobs: + - job: resolve + displayName: Prepare Deployment + steps: + - checkout: self + fetchDepth: 1 + persistCredentials: false + + - template: templates/setup-siteops.yaml + + - script: | + MANIFEST_PATH="workspaces/${{ parameters.workspace }}/manifests/${{ parameters.manifest }}.yaml" + if [[ ! -f "$MANIFEST_PATH" ]]; then + echo "##vso[task.logissue type=error]Manifest not found: $MANIFEST_PATH" + echo "" + echo "Available manifests:" + find "workspaces/${{ parameters.workspace }}/manifests" -name "*.yaml" -o -name "*.yml" 2>/dev/null || echo " (none found)" + exit 1 + fi + echo "✓ Manifest found: $MANIFEST_PATH" + displayName: Validate manifest exists + + - script: | + SELECTOR="$(echo "${{ parameters.selector }}" | xargs)" + if [ -n "$SELECTOR" ]; then + COMBINED="environment=${{ parameters.environment }},$SELECTOR" + else + COMBINED="environment=${{ parameters.environment }}" + fi + + SUMMARY_FILE="$(Build.ArtifactStagingDirectory)/deploy-config.md" + echo "### Deployment Configuration" > "$SUMMARY_FILE" + echo "| Setting | Value |" >> "$SUMMARY_FILE" + echo "|---------|-------|" >> "$SUMMARY_FILE" + echo "| Workspace | \`workspaces/${{ parameters.workspace }}\` |" >> "$SUMMARY_FILE" + echo "| Manifest | \`manifests/${{ parameters.manifest }}.yaml\` |" >> "$SUMMARY_FILE" + echo "| Environment | \`${{ parameters.environment }}\` |" >> "$SUMMARY_FILE" + echo "| Selector | \`$COMBINED\` |" >> "$SUMMARY_FILE" + echo "| Dry Run | \`${{ parameters.dryRun }}\` |" >> "$SUMMARY_FILE" + echo "##vso[task.uploadsummary]$SUMMARY_FILE" + displayName: Write deployment summary + + - template: templates/siteops-deploy.yaml + parameters: + serviceConnection: ${{ parameters.serviceConnections[parameters.environment] }} + workspace: workspaces/${{ parameters.workspace }} + manifest: manifests/${{ parameters.manifest }}.yaml + environment: ${{ parameters.environment }} + ${{ if not(in(parameters.selector, '', ' ')) }}: + selector: environment=${{ parameters.environment }},${{ parameters.selector }} + ${{ else }}: + selector: environment=${{ parameters.environment }} + dryRun: ${{ parameters.dryRun }} diff --git a/.pipelines/templates/setup-siteops.yaml b/.pipelines/templates/setup-siteops.yaml new file mode 100644 index 0000000..ab49dce --- /dev/null +++ b/.pipelines/templates/setup-siteops.yaml @@ -0,0 +1,44 @@ +# Steps template: Install Python and Site Ops +# Equivalent to .github/actions/setup-siteops/action.yaml + +parameters: + - name: pythonVersion + displayName: Python version + type: string + default: '3.11' + - name: installDev + displayName: Include dev dependencies (pytest, pytest-cov) + type: boolean + default: false + +steps: + - task: UsePythonVersion@0 + displayName: Set up Python ${{ parameters.pythonVersion }} + inputs: + versionSpec: ${{ parameters.pythonVersion }} + + - script: echo "##vso[task.setvariable variable=PIP_CACHE_DIR]$(pip cache dir)" + displayName: Resolve pip cache directory + + - task: Cache@2 + displayName: Cache pip packages + inputs: + key: pip | "$(Agent.OS)" | ${{ parameters.installDev }} | pyproject.toml + path: $(PIP_CACHE_DIR) + restoreKeys: | + pip | "$(Agent.OS)" | ${{ parameters.installDev }} + pip | "$(Agent.OS)" + + - script: | + pip install --upgrade pip + if [ "$INSTALL_DEV" = "True" ]; then + pip install -e ".[dev]" + else + pip install -e . + fi + displayName: Install Site Ops + env: + INSTALL_DEV: ${{ parameters.installDev }} + + - script: siteops --version + displayName: Verify Site Ops installation diff --git a/.pipelines/templates/siteops-deploy.yaml b/.pipelines/templates/siteops-deploy.yaml new file mode 100644 index 0000000..7664eb5 --- /dev/null +++ b/.pipelines/templates/siteops-deploy.yaml @@ -0,0 +1,194 @@ +# Stage template: Site Ops deployment +# Equivalent to .github/workflows/_siteops-deploy.yaml + +parameters: + - name: serviceConnection + displayName: Azure service connection name (WIF) + type: string + - name: workspace + displayName: Workspace directory + type: string + default: workspaces/iot-operations + - name: manifest + displayName: Manifest path (relative to workspace) + type: string + - name: environment + displayName: ADO environment name (approval gates) + type: string + default: '' + - name: selector + displayName: Site selector + type: string + default: '' + - name: dryRun + displayName: Preview only + type: boolean + default: false + +stages: + - stage: deploy + variables: + PYTHONUNBUFFERED: '1' + jobs: + - deployment: siteops_deploy + displayName: Deploy + environment: ${{ parameters.environment }} + strategy: + runOnce: + deploy: + steps: + - checkout: self + fetchDepth: 1 + persistCredentials: false + + - template: setup-siteops.yaml + + - script: | + # Prevent path traversal attacks + if [[ "$WORKSPACE" == *".."* ]] || [[ "$MANIFEST" == *".."* ]]; then + echo "##vso[task.logissue type=error]Path traversal not allowed" + exit 1 + fi + + if [[ -n "$SELECTOR" && ! "$SELECTOR" =~ ^[a-zA-Z0-9_=,./:-]+$ ]]; then + echo "##vso[task.logissue type=error]Invalid selector format" + exit 1 + fi + + if [[ ! -d "$WORKSPACE" ]]; then + echo "##vso[task.logissue type=error]Workspace directory not found: $WORKSPACE" + exit 1 + fi + + echo "✓ Inputs validated" + displayName: Validate inputs + env: + WORKSPACE: ${{ parameters.workspace }} + MANIFEST: ${{ parameters.manifest }} + SELECTOR: ${{ parameters.selector }} + + - script: | + # Generate sites.local/ override files from SITE_OVERRIDES JSON secret. + # Supports nested paths using dot notation for parameters: + # { + # "munich-dev": { + # "subscription": "...", + # "resourceGroup": "...", + # "parameters.clusterName": "actual-cluster-name", + # "parameters.customLocationName": "actual-custom-location" + # } + # } + + if [[ -z "$SITE_OVERRIDES" ]]; then + echo "No site overrides configured, using committed sites" + exit 0 + fi + + if ! echo "$SITE_OVERRIDES" | jq empty 2>/dev/null; then + echo "##vso[task.logissue type=error]SITE_OVERRIDES secret is not valid JSON" + exit 1 + fi + + mkdir -p "$WORKSPACE/sites.local" + + for site_name in $(echo "$SITE_OVERRIDES" | jq -r 'keys[]'); do + # Sanitize site name - only allow alphanumeric, dash, underscore + if [[ ! "$site_name" =~ ^[a-zA-Z0-9_-]+$ ]]; then + echo "##vso[task.logissue type=error]Invalid site name in SITE_OVERRIDES: $site_name" + exit 1 + fi + + output_file="$WORKSPACE/sites.local/${site_name}.yaml" + + # Build the YAML file with proper nesting + { + # Write top-level keys first + echo "$SITE_OVERRIDES" | jq -r --arg site "$site_name" \ + '.[$site] | to_entries[] | select(.key | contains(".") | not) | "\(.key): \"\(.value)\""' + + # Then write nested keys grouped by parent + for parent in $(echo "$SITE_OVERRIDES" | jq -r --arg site "$site_name" \ + '.[$site] | keys[] | select(contains(".")) | split(".")[0]' | sort -u); do + echo "${parent}:" + echo "$SITE_OVERRIDES" | jq -r --arg site "$site_name" --arg parent "$parent" \ + '.[$site] | to_entries[] | select(.key | startswith($parent + ".")) | " \(.key | split(".")[1]): \"\(.value)\""' + done + } > "$output_file" + + echo "✓ Generated override for: $site_name" + done + + # Mask each override value in ADO logs. + # ADO masking is additive — once a value is registered as secret, + # it stays masked for the remainder of the job. + echo "$SITE_OVERRIDES" | jq -r '.. | strings' | while IFS= read -r val; do + [[ -n "$val" ]] && echo "##vso[task.setvariable variable=masked;issecret=true]${val}" + done + + count=$(ls "$WORKSPACE/sites.local/"*.yaml 2>/dev/null | wc -l) + echo "✓ Generated ${count} site override(s)" + displayName: Setup site overrides + env: + WORKSPACE: ${{ parameters.workspace }} + SITE_OVERRIDES: $(SITE_OVERRIDES) + + - script: | + CMD_ARGS=(-w "$WORKSPACE" validate "$MANIFEST") + [[ -n "$SELECTOR" ]] && CMD_ARGS+=(-l "$SELECTOR") + CMD_ARGS+=(-v) + + SUMMARY_FILE="$(Build.ArtifactStagingDirectory)/deployment-plan.md" + echo "### Deployment Plan" > "$SUMMARY_FILE" + echo '```' >> "$SUMMARY_FILE" + siteops "${CMD_ARGS[@]}" 2>&1 | tee -a "$SUMMARY_FILE" + echo '```' >> "$SUMMARY_FILE" + echo "##vso[task.uploadsummary]$SUMMARY_FILE" + displayName: Validate and show target sites + env: + WORKSPACE: ${{ parameters.workspace }} + MANIFEST: ${{ parameters.manifest }} + SELECTOR: ${{ parameters.selector }} + + - task: AzureCLI@2 + displayName: Deploy + inputs: + azureSubscription: ${{ parameters.serviceConnection }} + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + START_TIME=$(date +%s) + + CMD_ARGS=(-w "$WORKSPACE" deploy "$MANIFEST") + + if [[ "$DRY_RUN" == "True" ]]; then + CMD_ARGS+=(--dry-run) + echo "##vso[task.logissue type=warning]Running in DRY-RUN mode - no actual deployment" + fi + + [[ -n "$SELECTOR" ]] && CMD_ARGS+=(-l "$SELECTOR") + + echo "Executing: siteops ${CMD_ARGS[*]}" + if siteops "${CMD_ARGS[@]}" 2>&1; then + DEPLOY_EXIT_CODE=0 + else + DEPLOY_EXIT_CODE=$? + fi + + END_TIME=$(date +%s) + DURATION=$((END_TIME - START_TIME)) + + RESULT_FILE="$(Build.ArtifactStagingDirectory)/deployment-result.md" + echo "### Deployment Result" > "$RESULT_FILE" + if [[ $DEPLOY_EXIT_CODE -eq 0 ]]; then + echo "✅ **Success** in ${DURATION}s" >> "$RESULT_FILE" + else + echo "❌ **Failed** after ${DURATION}s" >> "$RESULT_FILE" + fi + echo "##vso[task.uploadsummary]$RESULT_FILE" + + exit $DEPLOY_EXIT_CODE + env: + WORKSPACE: ${{ parameters.workspace }} + MANIFEST: ${{ parameters.manifest }} + SELECTOR: ${{ parameters.selector }} + DRY_RUN: ${{ parameters.dryRun }} diff --git a/docs/ci-cd-setup.md b/docs/ci-cd-setup.md index 95e3633..818c5cf 100644 --- a/docs/ci-cd-setup.md +++ b/docs/ci-cd-setup.md @@ -1,14 +1,21 @@ # CI/CD Setup -This guide covers GitHub Actions configuration for automated deployments. +This guide covers CI/CD configuration for automated testing and deployments. Site Ops is CI/CD-platform agnostic — it runs anywhere Python and `az` CLI are available. This project provides reference implementations for both GitHub Actions (primary) and Azure DevOps (MVP). + +| Platform | Location | Status | +|----------|----------|--------| +| [GitHub Actions](#github-actions) | `.github/workflows/` | Primary | +| [Azure DevOps](#azure-devops) | `.pipelines/` | Reference implementation | ## Prerequisites 1. Azure subscription with resources to deploy -2. GitHub repository with Actions enabled -3. Azure AD application for OIDC authentication +2. GitHub repository with Actions enabled **or** Azure DevOps project with Pipelines enabled +3. Azure AD application for OIDC / Workload Identity Federation + +## GitHub Actions -## Workflows +### Workflows | Workflow | Trigger | Purpose | |----------|---------|---------| @@ -16,11 +23,11 @@ This guide covers GitHub Actions configuration for automated deployments. | `deploy.yaml` | Manual (`workflow_dispatch`) | Deploy infrastructure to Azure | | `_siteops-deploy.yaml` | Called by deploy.yaml | Reusable deployment logic | -## Azure OIDC Configuration +### Azure OIDC Configuration OIDC (OpenID Connect) allows GitHub Actions to authenticate to Azure without storing secrets. Examples use bash syntax. -### 1. Create Azure AD application +#### 1. Create Azure AD application ```bash # Create app registration @@ -33,7 +40,7 @@ APP_ID=$(az ad app list --display-name "siteops-github-actions" --query "[0].app az ad sp create --id $APP_ID ``` -### 2. Create federated credentials +#### 2. Create federated credentials ```bash # For main branch deployments @@ -61,7 +68,7 @@ done Alternatively, configure the subject to match a branch (`ref:refs/heads/main`), pull request (`pull_request`), or tag (`ref:refs/tags/v*`) instead of an environment. -### 3. Assign Azure roles +#### 3. Assign Azure roles For basic deployments, Contributor is sufficient: @@ -91,7 +98,34 @@ This condition allows creating and deleting role assignments but blocks these pr | `18d7d88d-d35e-4fb5-a5c3-7773c20a72d9` | User Access Administrator | | `f58310d9-a9f6-439a-9e8d-f62e7b41a168` | Role Based Access Control Administrator | -### 4. Configure GitHub secrets +#### Kubernetes RBAC for Arc proxy operations + +If your manifests include `kubectl` steps that execute via Arc proxy (Cluster Connect), the CI/CD service principal needs authorization to perform operations inside the Kubernetes cluster. The Azure roles above control access to Azure resources — they do not grant permissions within Kubernetes itself. + +There are two approaches to grant this access: + +- **Azure RBAC for Arc-enabled Kubernetes** — Assign Azure roles like `Azure Arc Kubernetes Cluster Admin` or a custom role to the service principal, scoped to the cluster resource. This is managed entirely through Azure and requires [Azure RBAC to be enabled on the cluster](https://learn.microsoft.com/azure/azure-arc/kubernetes/azure-rbac). +- **Kubernetes-native RBAC** — Create a `RoleBinding` or `ClusterRoleBinding` on the cluster itself, referencing the service principal's object ID. + +The following is a Kubernetes-native example that grants broad access for development. Replace with a least-privilege role for production: + +```bash +# Replace with the service principal's object ID +# Replace with the target namespace (e.g., azure-iot-operations) + +kubectl create namespace --dry-run=client -o yaml | kubectl apply -f - + +kubectl create rolebinding ci-cluster-admin \ + --clusterrole=cluster-admin \ + --user= \ + --namespace= +``` + +> **Note:** `cluster-admin` is convenient for getting started but grants full access to the namespace. For production, create a custom `ClusterRole` scoped to the specific resources your manifests manage, or use Azure RBAC with a narrowly scoped role. + +This configuration is per-cluster and must be repeated for each Arc-enabled cluster that the CI/CD pipeline targets. + +#### 4. Configure GitHub secrets Go to **Settings → Secrets and variables → Actions** and add: @@ -102,7 +136,7 @@ Go to **Settings → Secrets and variables → Actions** and add: | `AZURE_SUBSCRIPTION_ID` | Yes | Default subscription for OIDC login | | `SITE_OVERRIDES` | No | JSON object with per-site overrides (see below) | -### 5. Configure GitHub environments +#### 5. Configure GitHub environments Go to **Settings → Environments** and create: @@ -121,9 +155,16 @@ Go to **Settings → Environments** and create: - Deployment branches: `main` only - Wait timer: 5 minutes (optional) -## SITE_OVERRIDES (Optional) +## SITE_OVERRIDES + +Use `SITE_OVERRIDES` when you prefer not to commit configuration values (subscriptions, resource groups, credentials) to the repository. Both GHA and ADO pipelines generate `sites.local/*.yaml` files at runtime from this value using identical logic. -Use `SITE_OVERRIDES` when you prefer not to commit configuration values (subscriptions, resource groups, credentials) to the repository. The workflow generates `sites.local/*.yaml` files at runtime from this secret. +| Platform | Where to store | Type | +|----------|---------------|------| +| GitHub Actions | Repository secret (`Settings → Secrets → Actions`) | Secret | +| Azure DevOps | Variable group `siteops-secrets` (`Pipelines → Library`) | Secret variable | + +The JSON format is identical on both platforms. **When to use:** @@ -171,11 +212,11 @@ Override subscription, resource group, and parameters per site. Supports nested ``` > **Note:** `SITE_OVERRIDES` is stored as a secret for access control (admin-only modification). -> Individual override values are registered with `::add-mask::` to prevent exposure in workflow logs. +> Individual override values are masked in pipeline logs to prevent exposure (`::add-mask::` on GHA, `##vso[task.setvariable issecret=true]` on ADO). ## Running Deployments -### CI (automatic) +### CI (automatic — both platforms) CI runs automatically on pushes to main and PRs that modify: @@ -184,7 +225,7 @@ CI runs automatically on pushes to main and PRs that modify: - `tests/**` - `pyproject.toml` -Can also be triggered manually from **Actions → CI → Run workflow**. +Can also be triggered manually from **Actions → CI → Run workflow** (GHA) or **Pipelines → CI → Run pipeline** (ADO). ### Deploy via GitHub UI @@ -225,6 +266,7 @@ curl -X POST \ -d '{ "ref": "main", "inputs": { + "workspace": "iot-operations", "manifest": "aio-install", "environment": "dev", "selector": "", @@ -272,6 +314,8 @@ gh workflow run deploy.yaml -f workspace="iot-operations" -f manifest="opc-ua-so ## Workflow Architecture +### GitHub Actions + ``` ┌─────────────────────────────────────────────────────────────┐ │ Trigger Sources │ @@ -302,38 +346,52 @@ gh workflow run deploy.yaml -f workspace="iot-operations" -f manifest="opc-ua-so └─────────────────────────────────────────────────────────────┘ ``` +See [ADO architecture](#ado-architecture) for the Azure DevOps equivalent. + ## Security -| Feature | Description | -|---------|-------------| -| **OIDC Authentication** | No stored Azure credentials; tokens are short-lived | -| **Environment Protection** | Required approvals for staging/prod | -| **Input Validation** | Prevents path traversal and injection attacks | -| **Site Name Sanitization** | SITE_OVERRIDES keys validated against `^[a-zA-Z0-9_-]+$` | -| **Override Value Masking** | Individual SITE_OVERRIDES values registered with `::add-mask::` to prevent log exposure | -| **Concurrency Control** | One deployment per environment at a time | -| **Least Privilege Permissions** | Workflows request minimal GitHub token scopes | -| **Token Refresh** | Background service refreshes OIDC token for long deployments | -| **Audit Trail** | All runs logged with triggering user | +| Feature | GitHub Actions | Azure DevOps | +|---------|---------------|--------------| +| **Authentication** | OIDC (no stored credentials, short-lived tokens) | WIF service connection (token managed by `AzureCLI@2`) | +| **Environment Protection** | Required approvals for staging/prod | Approval checks on ADO environments | +| **Input Validation** | Prevents path traversal and injection attacks | Same validation logic in pipeline scripts | +| **Site Name Sanitization** | `SITE_OVERRIDES` keys validated against `^[a-zA-Z0-9_-]+$` | Same | +| **Override Value Masking** | `::add-mask::` per value | `##vso[task.setvariable issecret=true]` per value | +| **Concurrency Control** | `concurrency` groups (one deploy per env) | Exclusive lock on ADO environments | +| **Least Privilege** | `permissions:` block scopes GitHub token | Service connection authorization scopes access | +| **Token Refresh** | Background OIDC refresh every 4 min | Not needed (`AzureCLI@2` manages lifecycle) | +| **Credential Isolation** | `persist-credentials: false` on checkout | `persistCredentials: false` on checkout | +| **Audit Trail** | All runs logged with triggering user | Same | ### Security model ``` ┌─────────────────────────────────────────────────────────────┐ -│ Layer 1: GitHub │ +│ Layer 1: CI/CD Platform │ +│ │ +│ GitHub Actions: │ │ • Environment protection rules (approvals, branch gates) │ │ • Concurrency prevents parallel deploys to same env │ -│ • Input validation blocks path traversal │ │ • Minimal permissions (contents: read, id-token: write) │ +│ │ +│ Azure DevOps: │ +│ • Environment approval checks and exclusive locks │ +│ • Service connection authorization (admin-controlled) │ +│ • Variable groups with role-based access │ +│ │ +│ Both: │ +│ • Input validation blocks path traversal │ +│ • SITE_OVERRIDES values masked in logs │ +│ • Credential persistence disabled on checkout │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ -│ Layer 2: OIDC Federation │ -│ • No stored Azure credentials │ -│ • Token scoped to specific environment │ -│ • Federated credential must match subject claim │ -│ • Automatic token refresh for long-running deployments │ +│ Layer 2: Identity Federation │ +│ • No stored Azure credentials on either platform │ +│ • GHA: OIDC token + federated credential subject matching │ +│ • ADO: WIF service connection (automatic token exchange) │ +│ • Token scoped to specific environment/context │ └─────────────────────────────────────────────────────────────┘ │ ▼ @@ -341,6 +399,7 @@ gh workflow run deploy.yaml -f workspace="iot-operations" -f manifest="opc-ua-so │ Layer 3: Azure RBAC │ │ • Service principal has scoped permissions │ │ • Can further restrict by subscription/resource group │ +│ • Same identity and roles for both platforms │ └─────────────────────────────────────────────────────────────┘ ``` @@ -348,11 +407,12 @@ gh workflow run deploy.yaml -f workspace="iot-operations" -f manifest="opc-ua-so ### Adding new manifests -To add a new manifest to the deployment workflow: +To add a new manifest to the deployment workflows: 1. Create your manifest in `workspaces//manifests/` -2. Update `.github/workflows/deploy.yaml` to add it to the `manifest` dropdown: +2. Update the workflow/pipeline to add it to the dropdown: +**GitHub Actions** — `.github/workflows/deploy.yaml`: ```yaml manifest: description: "Manifest to deploy" @@ -364,13 +424,23 @@ manifest: - my-new-manifest # Add here (without .yaml extension) ``` +**Azure DevOps** — `.pipelines/deploy.yaml`: +```yaml +- name: manifest + displayName: Manifest + type: string + default: aio-install + values: [aio-install, opc-ua-solution, my-new-manifest] # Add here +``` + ### Adding new workspaces To add a new workspace (e.g., `iot-hub`): 1. Create `workspaces/iot-hub/` with `manifests/`, `sites/`, `parameters/`, `templates/` -2. Update `.github/workflows/deploy.yaml` to add it to the `workspace` dropdown: +2. Update the workflow/pipeline to add it to the dropdown: +**GitHub Actions** — `.github/workflows/deploy.yaml`: ```yaml workspace: description: "Workspace to deploy" @@ -381,9 +451,18 @@ workspace: - iot-hub # Add here ``` +**Azure DevOps** — `.pipelines/deploy.yaml`: +```yaml +- name: workspace + displayName: Workspace + type: string + default: iot-operations + values: [iot-operations, iot-hub] # Add here +``` + ### Custom deployment workflow -Create a new workflow that calls the reusable workflow: +**GitHub Actions** — Create a new workflow that calls the reusable workflow: ```yaml name: Deploy My Service @@ -402,19 +481,194 @@ jobs: secrets: inherit ``` -### Setup Site Ops action +**Azure DevOps** — Create a new pipeline that uses the stage template: + +```yaml +trigger: + branches: + include: [main] + paths: + include: [services/my-service/**] + +pr: none + +variables: + - group: siteops-secrets + +stages: + - template: templates/siteops-deploy.yaml + parameters: + serviceConnection: azure-siteops + manifest: manifests/my-service.yaml + environment: dev +``` + +### Setup templates -The `setup-siteops` composite action installs Python and Site Ops: +**GitHub Actions** — The `setup-siteops` composite action: | Input | Default | Description | |-------|---------|-------------| | `python-version` | `3.11` | Python version to install | | `install-dev` | `false` | Include dev dependencies (pytest, pytest-cov) | -Example with dev dependencies (for running tests): - ```yaml - uses: ./.github/actions/setup-siteops with: install-dev: "true" ``` + +**Azure DevOps** — The `setup-siteops.yaml` steps template: + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `pythonVersion` | `'3.11'` | Python version to install | +| `installDev` | `false` | Include dev dependencies (pytest, pytest-cov) | + +```yaml +- template: templates/setup-siteops.yaml + parameters: + installDev: true +``` + +--- + +## Azure DevOps + +### Pipelines + +| Pipeline file | Purpose | Trigger | +|---------------|---------|---------| +| `.pipelines/ci.yaml` | 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 | + +### ADO project setup + +#### 1. Create service connection (Workload Identity Federation) + +In ADO → **Project settings → Service connections → New → Azure Resource Manager → Workload Identity federation**. + +- **Automatic** — creates the Entra app registration and federated credential for you +- **Manual** — reuse the existing app registration from GitHub Actions OIDC setup (same `APP_ID`) + +The service connection name is referenced in the deploy pipeline. Default: `azure-siteops`. + +> **Reusing the GitHub Actions app registration:** If you already configured OIDC for GitHub Actions (section above), you can reuse that same app registration. Create a new federated credential for ADO — the issuer and subject claims are different from GitHub's. The Azure roles are shared. + +#### 2. Create variable group + +In ADO → **Pipelines → Library → + Variable group**: + +| Variable group | Variable | Type | Description | +|----------------|----------|------|-------------| +| `siteops-secrets` | `SITE_OVERRIDES` | Secret | JSON object — same format as the GitHub secret (see [SITE_OVERRIDES](#site_overrides)) | + +#### 3. Create environments + +In ADO → **Pipelines → Environments** → create `dev`, `staging`, `prod`. + +| Environment | Approvals | Exclusive lock | +|-------------|-----------|----------------| +| `dev` | None | Yes | +| `staging` | 1 approver | Yes | +| `prod` | 2 approvers | Yes | + +Exclusive lock ensures one deployment per environment at a time (equivalent to GitHub `concurrency` groups). + +To configure: **Environments → (select env) → Approvals and checks → + → Exclusive lock** and **+ → Approvals**. + +#### 4. Create pipelines + +In ADO → **Pipelines → New pipeline** → **Azure Repos Git** (or GitHub, if the repo is hosted there) → select repository → **Existing Azure Pipelines YAML file**: + +- `.pipelines/ci.yaml` → name it **"CI"** +- `.pipelines/deploy.yaml` → name it **"Deploy Infrastructure"** + +#### 5. Assign Azure roles + +Same as GitHub Actions — see [Assign Azure roles](#3-assign-azure-roles). The service connection's managed identity needs the same Contributor (or Owner with conditions) role assignment. + +### Running ADO deployments + +#### Deploy via ADO UI + +1. Go to **Pipelines** → select **"Deploy Infrastructure"** +2. Click **"Run pipeline"** +3. Select branch/tag from the branch picker +4. Fill in parameters: + - **Workspace**: `iot-operations` + - **Manifest**: `aio-install` or `opc-ua-solution` + - **Target environment**: `dev`, `staging`, or `prod` + - **Additional site selector**: e.g., `country=US,name=seattle-dev` (optional) + - **Dry run**: Check to preview without deploying +5. Click **"Run"** + +#### Deploy via Azure CLI + +```bash +az pipelines run \ + --name "Deploy Infrastructure" \ + --parameters workspace=iot-operations manifest=aio-install environment=dev + +# With additional options +az pipelines run \ + --name "Deploy Infrastructure" \ + --parameters workspace=iot-operations manifest=aio-install environment=dev \ + selector="country=US" dryRun=true +``` + +### ADO architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Trigger Sources │ +├──────────────┬──────────────┬──────────────────────────────┤ +│ ADO UI │ az CLI │ Push / PR │ +└──────┬───────┴──────┬───────┴──────────────┬───────────────┘ + │ │ │ + ▼ ▼ ▼ +┌──────────────────────────┐ ┌─────────────────────────────┐ +│ deploy.yaml │ │ ci.yaml │ +│ (manual trigger) │ │ (push + pull_request) │ +└───────────┬──────────────┘ ├─────────────────────────────┤ + │ │ • Unit Tests │ + │ │ • Manifest Validation │ + │ │ • Deployment Plan Preview │ + ▼ └─────────────────────────────┘ +┌─────────────────────────────────────────────────────────────┐ +│ siteops-deploy.yaml (stage template) │ +├─────────────────────────────────────────────────────────────┤ +│ 1. Setup Site Ops (steps template) │ +│ 2. Validate inputs (path traversal protection) │ +│ 3. Generate sites.local/ from SITE_OVERRIDES │ +│ 4. Validate and show deployment plan │ +│ 5. AzureCLI@2: siteops deploy (auth scoped to this step) │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Key difference from GitHub Actions:** `AzureCLI@2` handles authentication, token lifecycle, and cleanup in a single task — no separate login, token refresh, or logout steps needed. + +### Per-environment migration + +The deploy pipeline uses object parameter lookup tables for service connections and variable groups. To split per-environment (separate identities and secrets): + +```yaml +# .pipelines/deploy.yaml — edit these defaults: +- name: serviceConnections + type: object + default: + dev: azure-siteops-dev # ← separate service connection + staging: azure-siteops-staging + prod: azure-siteops-prod + +- name: secretGroups + type: object + default: + dev: siteops-secrets-dev # ← separate variable group + staging: siteops-secrets-staging + prod: siteops-secrets-prod +``` + +No structural pipeline changes needed — just edit defaults and create the corresponding ADO resources.