From ac4eb1196e6f2b42a666efdd338f4f5450ed99b5 Mon Sep 17 00:00:00 2001 From: MW Felker Date: Mon, 2 Mar 2026 13:01:25 -0800 Subject: [PATCH 1/3] feat: add OIDC-based deploy workflow for terrain-gpu-demo - Triggers on push to main - Uses workload identity federation (OIDC), no stored secrets - az acr login for ephemeral ACR token (no username/password) - Pushes image to maxfelkershared.azurecr.io/terrain-gpu - Deploys to terrain-gpu-demo Azure WebApp Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/deploy.yml | 48 ++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..c340dc9 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,48 @@ +name: Build and Deploy to Azure WebApp + +on: + push: + branches: [main] + +permissions: + id-token: write + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Azure login (OIDC) + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: ACR login (ephemeral token via OIDC session) + run: az acr login --name maxfelkershared + + - name: Build and push image to ACR + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + maxfelkershared.azurecr.io/terrain-gpu:${{ github.sha }} + maxfelkershared.azurecr.io/terrain-gpu:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Deploy to Azure WebApp + uses: azure/webapps-deploy@v3 + with: + app-name: terrain-gpu-demo + resource-group-name: maxfelker.com + images: maxfelkershared.azurecr.io/terrain-gpu:${{ github.sha }} From 7357c19b66146596361070e5ab78c6e7d0d7ea9d Mon Sep 17 00:00:00 2001 From: MW Felker Date: Mon, 2 Mar 2026 13:02:02 -0800 Subject: [PATCH 2/3] feat: add Azure Bicep infrastructure for terrain-gpu-demo - App Service Plan ASP-terrain-gpu (B1 Linux) - WebApp terrain-gpu-demo with system-assigned managed identity - AcrPull role assignment for MI on maxfelkershared ACR - No secrets: uses managed identity for ACR auth at runtime Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .azure/README.md | 44 +++++++++++++++++++++++ .azure/main.bicep | 82 ++++++++++++++++++++++++++++++++++++++++++ .azure/main.bicepparam | 8 +++++ 3 files changed, 134 insertions(+) create mode 100644 .azure/README.md create mode 100644 .azure/main.bicep create mode 100644 .azure/main.bicepparam diff --git a/.azure/README.md b/.azure/README.md new file mode 100644 index 0000000..10599c5 --- /dev/null +++ b/.azure/README.md @@ -0,0 +1,44 @@ +# Azure Infrastructure — terrain-gpu-demo + +This directory contains Bicep templates that provision the Azure infrastructure for the `terrain-gpu-demo` Web App. + +## Resources Created + +| Resource | Name | Details | +|---|---|---| +| App Service Plan | `ASP-terrain-gpu` | B1, Linux | +| Web App | `terrain-gpu-demo` | Container-based, system-assigned managed identity | +| Role Assignment | AcrPull on `maxfelkershared` ACR | Grants the webapp's MI pull access to the ACR | + +## Prerequisites + +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) (`az`) +- [Bicep CLI](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/install) (installed automatically with recent `az` versions) +- Contributor access on the `maxfelker.com` resource group +- Owner / User Access Administrator access on the `shared` resource group (required to create the AcrPull role assignment) + +## Deploy + +```bash +az deployment group create \ + --resource-group maxfelker.com \ + --template-file .azure/main.bicep \ + --parameters .azure/main.bicepparam +``` + +## How Managed Identity / No-Secret Auth Works + +1. The Web App is created with a **system-assigned managed identity** (MI). +2. A **AcrPull role assignment** is created on the `maxfelkershared` ACR, scoped to the webapp's MI principal ID. +3. The site config sets `acrUseManagedIdentityCreds: true`, so the App Service host authenticates to ACR using the MI token — **no registry credentials or secrets are stored anywhere**. +4. The GitHub Actions workflow (see below) pushes a new image tag to ACR and then restarts/updates the webapp; the runtime pull still uses the same MI flow. + +## GitHub Actions Auto-Deploy + +The workflow at `.github/workflows/deploy.yml` triggers on every push to `main`: + +1. Logs in to Azure via OIDC federated credentials (no long-lived secrets). +2. Builds and pushes the Docker image to `maxfelkershared.azurecr.io/terrain-gpu:latest`. +3. Calls `az webapp restart` (or `az webapp config container set`) to pick up the new image. + +The OIDC trust is established between the GitHub Actions environment and an Azure AD app registration / federated credential — no passwords or client secrets are used in CI either. diff --git a/.azure/main.bicep b/.azure/main.bicep new file mode 100644 index 0000000..c25ef11 --- /dev/null +++ b/.azure/main.bicep @@ -0,0 +1,82 @@ +@description('Azure region for all resources') +param location string = 'eastus' + +@description('Name of the App Service Plan') +param appServicePlanName string = 'ASP-terrain-gpu' + +@description('Name of the Web App') +param webAppName string = 'terrain-gpu-demo' + +@description('Container image to deploy') +param containerImage string = 'maxfelkershared.azurecr.io/terrain-gpu:latest' + +@description('Name of the Azure Container Registry') +param acrName string = 'maxfelkershared' + +@description('Resource group containing the Azure Container Registry') +param acrResourceGroup string = 'shared' + +// AcrPull built-in role definition ID +var acrPullRoleDefinitionId = '7f951dda-4ed3-4680-a7ca-43fe172d538d' + +resource acr 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = { + name: acrName + scope: resourceGroup(acrResourceGroup) +} + +resource appServicePlan 'Microsoft.Web/serverfarms@2023-01-01' = { + name: appServicePlanName + location: location + kind: 'linux' + sku: { + name: 'B1' + tier: 'Basic' + } + properties: { + reserved: true + } +} + +resource webApp 'Microsoft.Web/sites@2023-01-01' = { + name: webAppName + location: location + identity: { + type: 'SystemAssigned' + } + properties: { + serverFarmId: appServicePlan.id + siteConfig: { + linuxFxVersion: 'DOCKER|${containerImage}' + acrUseManagedIdentityCreds: true + appSettings: [ + { + name: 'WEBSITES_ENABLE_APP_SERVICE_STORAGE' + value: 'false' + } + { + name: 'PORT' + value: '80' + } + { + name: 'DOCKER_REGISTRY_SERVER_URL' + value: 'https://maxfelkershared.azurecr.io' + } + ] + } + httpsOnly: true + } +} + +resource acrPullRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(acr.id, webApp.id, acrPullRoleDefinitionId) + scope: acr + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', acrPullRoleDefinitionId) + principalId: webApp.identity.principalId + principalType: 'ServicePrincipal' + } +} + +output webAppName string = webApp.name +output webAppUrl string = 'https://${webApp.properties.defaultHostName}' +output managedIdentityPrincipalId string = webApp.identity.principalId diff --git a/.azure/main.bicepparam b/.azure/main.bicepparam new file mode 100644 index 0000000..a265773 --- /dev/null +++ b/.azure/main.bicepparam @@ -0,0 +1,8 @@ +using './main.bicep' + +param location = 'eastus' +param appServicePlanName = 'ASP-terrain-gpu' +param webAppName = 'terrain-gpu-demo' +param containerImage = 'maxfelkershared.azurecr.io/terrain-gpu:latest' +param acrName = 'maxfelkershared' +param acrResourceGroup = 'shared' From c8d84a0bad8db93b5277201e27e9b22d56d92eee Mon Sep 17 00:00:00 2001 From: MW Felker Date: Mon, 2 Mar 2026 13:08:38 -0800 Subject: [PATCH 3/3] fix: extract cross-RG AcrPull role assignment into bicep module Azure Bicep BCP139 requires cross-resource-group role assignments to use modules scoped to the target RG. Extracted the AcrPull assignment on maxfelkershared ACR into .azure/acr-role.bicep. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .azure/acr-role.bicep | 25 +++++++++++++++++++++++++ .azure/main.bicep | 13 +++++++------ 2 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 .azure/acr-role.bicep diff --git a/.azure/acr-role.bicep b/.azure/acr-role.bicep new file mode 100644 index 0000000..05abe99 --- /dev/null +++ b/.azure/acr-role.bicep @@ -0,0 +1,25 @@ +@description('Name of the Azure Container Registry') +param acrName string + +@description('Principal ID of the managed identity to assign the role to') +param principalId string + +@description('Role definition ID (e.g. AcrPull: 7f951dda-4ed3-4680-a7ca-43fe172d538d)') +param roleDefinitionId string + +@description('Resource ID of the web app (used for unique role assignment name)') +param webAppId string + +resource acr 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = { + name: acrName +} + +resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(acr.id, webAppId, roleDefinitionId) + scope: acr + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionId) + principalId: principalId + principalType: 'ServicePrincipal' + } +} diff --git a/.azure/main.bicep b/.azure/main.bicep index c25ef11..da44c9c 100644 --- a/.azure/main.bicep +++ b/.azure/main.bicep @@ -67,13 +67,14 @@ resource webApp 'Microsoft.Web/sites@2023-01-01' = { } } -resource acrPullRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(acr.id, webApp.id, acrPullRoleDefinitionId) - scope: acr - properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', acrPullRoleDefinitionId) +module acrPullRoleAssignment 'acr-role.bicep' = { + name: 'acrPullRoleAssignment' + scope: resourceGroup(acrResourceGroup) + params: { + acrName: acrName principalId: webApp.identity.principalId - principalType: 'ServicePrincipal' + roleDefinitionId: acrPullRoleDefinitionId + webAppId: webApp.id } }