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
7 changes: 7 additions & 0 deletions infra/azure/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
12 changes: 12 additions & 0 deletions infra/azure/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -101,13 +102,23 @@ 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: {
name: envName
location: location
logAnalyticsCustomerId: logAnalytics.outputs.customerId
logAnalyticsSharedKey: logAnalytics.outputs.primarySharedKey
storageAccountName: storage.outputs.accountName
fileShareName: storage.outputs.shareName
}
}

Expand All @@ -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
Expand Down
23 changes: 23 additions & 0 deletions infra/azure/modules/container-app.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions infra/azure/modules/container-apps-environment.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
44 changes: 44 additions & 0 deletions infra/azure/modules/storage.bicep
Original file line number Diff line number Diff line change
@@ -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
Loading