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/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 new file mode 100644 index 0000000..da44c9c --- /dev/null +++ b/.azure/main.bicep @@ -0,0 +1,83 @@ +@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 + } +} + +module acrPullRoleAssignment 'acr-role.bicep' = { + name: 'acrPullRoleAssignment' + scope: resourceGroup(acrResourceGroup) + params: { + acrName: acrName + principalId: webApp.identity.principalId + roleDefinitionId: acrPullRoleDefinitionId + webAppId: webApp.id + } +} + +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' 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 }}