diff --git a/infra/azure/README.md b/infra/azure/README.md index febc908c5..ad06b19ac 100644 --- a/infra/azure/README.md +++ b/infra/azure/README.md @@ -73,6 +73,13 @@ secrets in GitHub rather than writing them to Key Vault by hand: every deploy re-applies the Key Vault secrets from the workflow inputs and would overwrite a value set manually. +The data directory `/var/lib/sop` (config.json, stores, billing state) is an Azure +Files share mounted into the container, so it survives restarts and new revisions. +The app stays at one replica because there must only ever be one writer. The mount +uses `nobrl` because Azure Files does not honor SMB byte-range locks for this +access pattern. Standard LRS files bill on used capacity, so an empty share is +close to free. + The first deploy provisions the ACR before an image exists in it; build and push the image, then re-run `az deployment group create` with the resulting `containerImage` value (this is exactly what `deploy-azure.yml` automates). diff --git a/infra/azure/main.bicep b/infra/azure/main.bicep index 7e512b0dc..5261f0360 100644 --- a/infra/azure/main.bicep +++ b/infra/azure/main.bicep @@ -62,6 +62,7 @@ var envName = '${appName}-env-${resourceToken}' var containerAppName = '${appName}-app' var uamiName = '${appName}-acr-pull-identity' var actionGroupName = '${appName}-alerts' +var storageAccountName = take(replace('${appName}data${resourceToken}', '-', ''), 24) module logAnalytics 'modules/log-analytics.bicep' = { name: 'logAnalytics' @@ -101,6 +102,14 @@ module keyVault 'modules/key-vault.bicep' = { } } +module storage 'modules/storage.bicep' = { + name: 'dataStorage' + params: { + name: storageAccountName + location: location + } +} + module containerAppsEnv 'modules/container-apps-environment.bicep' = { name: 'containerAppsEnvironment' params: { @@ -108,6 +117,8 @@ module containerAppsEnv 'modules/container-apps-environment.bicep' = { location: location logAnalyticsCustomerId: logAnalytics.outputs.customerId logAnalyticsSharedKey: logAnalytics.outputs.primarySharedKey + storageAccountName: storage.outputs.accountName + fileShareName: storage.outputs.shareName } } @@ -117,6 +128,7 @@ module containerApp 'modules/container-app.bicep' = { name: containerAppName location: location environmentId: containerAppsEnv.outputs.id + dataStorageName: containerAppsEnv.outputs.dataStorageName containerImage: containerImage acrLoginServer: acr.outputs.loginServer userAssignedIdentityId: identity.outputs.id diff --git a/infra/azure/modules/container-app.bicep b/infra/azure/modules/container-app.bicep index df8a44ec3..34d657d3d 100644 --- a/infra/azure/modules/container-app.bicep +++ b/infra/azure/modules/container-app.bicep @@ -45,6 +45,9 @@ var optionalEnv = concat( @description('True when real Stripe secrets were supplied to the deploy. While false the app gets no Key Vault secret references and runs in simulation mode.') param stripeEnabled bool = false +@description('Name of the managed environment storage (Azure Files share) mounted as the app data directory.') +param dataStorageName string + // Pinned to 1 replica: joltrin's embedded B-Tree engine has no documented // multi-process write-safety guarantee, and this deployment optimizes for // lowest cost over horizontal scale. CPU/memory/concurrency limits below @@ -137,6 +140,18 @@ resource containerApp 'Microsoft.App/containerApps@2023-11-02-preview' = { // 0.5 vCPU / 1.0 GiB: matches the requested cost-containment // sizing. Combined GB-CPU pairing is one of ACA's valid // combinations (0.5 vCPU pairs with 1Gi). + // The data directory (config.json, stores, billing state) lives on + // the Azure Files share so it survives restarts and new revisions. + // uid/gid 65532 is the image's nonroot user. nobrl skips SMB + // byte-range locks, which Azure Files does not honor for this + // access pattern; the app is pinned to one replica, so there is + // only ever one writer. + volumeMounts: [ + { + volumeName: 'data' + mountPath: '/var/lib/sop' + } + ] resources: { cpu: json('0.5') memory: '1Gi' @@ -175,6 +190,14 @@ resource containerApp 'Microsoft.App/containerApps@2023-11-02-preview' = { ] } ] + volumes: [ + { + name: 'data' + storageType: 'AzureFile' + storageName: dataStorageName + mountOptions: 'uid=65532,gid=65532,dir_mode=0770,file_mode=0660,nobrl' + } + ] scale: { minReplicas: minReplicas maxReplicas: maxReplicas diff --git a/infra/azure/modules/container-apps-environment.bicep b/infra/azure/modules/container-apps-environment.bicep index 3b394cf71..0006c00eb 100644 --- a/infra/azure/modules/container-apps-environment.bicep +++ b/infra/azure/modules/container-apps-environment.bicep @@ -8,6 +8,16 @@ param logAnalyticsCustomerId string @secure() param logAnalyticsSharedKey string +param storageAccountName string +param fileShareName string + +@description('Name the container app uses to reference the mounted share.') +param storageMountName string = 'joltrin-data' + +resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' existing = { + name: storageAccountName +} + resource env 'Microsoft.App/managedEnvironments@2023-11-02-preview' = { name: name location: location @@ -25,5 +35,19 @@ resource env 'Microsoft.App/managedEnvironments@2023-11-02-preview' = { } } +resource dataStorage 'Microsoft.App/managedEnvironments/storages@2023-11-02-preview' = { + parent: env + name: storageMountName + properties: { + azureFile: { + accountName: storageAccountName + accountKey: storageAccount.listKeys().keys[0].value + shareName: fileShareName + accessMode: 'ReadWrite' + } + } +} + output id string = env.id output name string = env.name +output dataStorageName string = dataStorage.name diff --git a/infra/azure/modules/storage.bicep b/infra/azure/modules/storage.bicep new file mode 100644 index 000000000..07b384738 --- /dev/null +++ b/infra/azure/modules/storage.bicep @@ -0,0 +1,44 @@ +@description('Storage account name (3-24 lowercase letters and digits).') +param name string + +param location string + +@description('Azure Files share that holds the app data directory (config.json, stores, billing state).') +param shareName string = 'joltrin-data' + +@description('Share quota in GiB. Standard files bill on used capacity, the quota is only a ceiling.') +param shareQuotaGiB int = 5 + +resource account 'Microsoft.Storage/storageAccounts@2023-05-01' = { + name: name + location: location + kind: 'StorageV2' + sku: { + name: 'Standard_LRS' + } + properties: { + minimumTlsVersion: 'TLS1_2' + supportsHttpsTrafficOnly: true + allowBlobPublicAccess: false + // Container Apps mounts Azure Files with the account key, so shared key + // access has to stay on. Nothing else in this stack uses the account. + allowSharedKeyAccess: true + } +} + +resource fileService 'Microsoft.Storage/storageAccounts/fileServices@2023-05-01' = { + parent: account + name: 'default' +} + +resource share 'Microsoft.Storage/storageAccounts/fileServices/shares@2023-05-01' = { + parent: fileService + name: shareName + properties: { + shareQuota: shareQuotaGiB + enabledProtocols: 'SMB' + } +} + +output accountName string = account.name +output shareName string = share.name