From 8c7182da0fecc1704d1b82f6488932010d18e050 Mon Sep 17 00:00:00 2001 From: Prasanna Sundaravaradan Date: Sat, 15 Aug 2026 08:18:01 -0500 Subject: [PATCH] Add Azure Container Apps reference deployment (Bicep) Last remaining Phase 1 roadmap item. docker-compose.yml (the other half of that bundled bullet) was already done. Memory-only for this first pass, deliberately -- deploys the existing core image completely as-is, no code changes to src/RiftCache. Key Vault still gets provisioned and still backs RIFTCACHE_API_KEY, because Azure Container Apps has native Key-Vault-backed secret injection: the platform resolves the secret via the app's managed identity before the container starts, so the app itself never needs AzureKeyVaultSecretProvider code for that. Wiring AzureBlobPersistenceProvider into an actual deployment is real follow-up work -- Program.cs's DI wiring is plain top-level statements today, not reusable, so that needs extracting into AddRiftCacheCore()/MapRiftCacheCore() extensions plus a second composed entry point + Dockerfile first. Tracked as an explicit new unchecked ROADMAP line, not silently dropped. Resources: Log Analytics Workspace, Container Apps Environment (Consumption), a user-assigned Managed Identity, Key Vault (identity granted Key Vault Secrets User), the API key as a Key Vault secret, and the Container App itself (external ingress on 8080, matching the Dockerfile's ASPNETCORE_HTTP_PORTS, 1-3 replicas -- no scale-to-zero default, since a cache with zero warm replicas defeats its own purpose). Image reference is a parameter; nothing is published anywhere yet, so this template expects you to build and push your own from deployment/docker/Dockerfile. No live Azure subscription available in this environment, so this wasn't deployment-tested end to end the way the Docker/Podman/Azurite work was -- flagged explicitly rather than claimed. What's real: installed the Bicep CLI locally (az bicep install, no Azure account needed) and ran az bicep build / az bicep build-params, which both compiled clean on the first try -- genuine schema validation against Azure's real resource type definitions, not a syntax guess. Also caught a real correctness point this way: the Container App's implicit dependency inference doesn't pick up the Key Vault role assignment (nothing in its properties references that resource symbolically), so the explicit dependsOn on it is load-bearing, not redundant. Also added .gitignore entries for compiled ARM JSON output and local .bicepparam copies with real values filled in, and updated README.md/ROADMAP.md now that this and Docker Compose cover the full "reference deployment" bullet. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 7 ++ README.md | 3 +- ROADMAP.md | 3 +- deployment/azure/bicep/README.md | 79 ++++++++++++ deployment/azure/bicep/main.bicep | 159 +++++++++++++++++++++++++ deployment/azure/bicep/main.bicepparam | 19 +++ 6 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 deployment/azure/bicep/README.md create mode 100644 deployment/azure/bicep/main.bicep create mode 100644 deployment/azure/bicep/main.bicepparam diff --git a/.gitignore b/.gitignore index d5a18de..c28c06a 100644 --- a/.gitignore +++ b/.gitignore @@ -427,3 +427,10 @@ FodyWeavers.xsd *.msix *.msm *.msp + +# Compiled ARM JSON from `az bicep build` / `az bicep build-params` -- generated from the .bicep +# / .bicepparam sources under deployment/, never hand-edited or meant to be committed. +deployment/**/*.json + +# Local copies of example .bicepparam files with real values filled in. +deployment/**/*.local.bicepparam diff --git a/README.md b/README.md index 1826f66..a541843 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ RiftCache is an open-source, Redis-inspired distributed cache built for containe - **Self-hosters**: one container, one API key, no cloud lock-in required. - **Enterprise teams**: multi-tenancy, pluggable secrets/persistence providers, OpenTelemetry (traces + metrics), and Azure reference providers (Key Vault, Blob Storage — see [docs/providers/azure.md](docs/providers/azure.md)) today. AWS and GCP implementations of the same interfaces are designed for (see [ARCHITECTURE.md](ARCHITECTURE.md)) but not built yet — contributions welcome, see [CONTRIBUTING.md](CONTRIBUTING.md). -> Reference deployment today: **Docker / Podman** — see [Building From Source](#building-from-source-docker--podman) below. Azure Container Apps, AWS (Fargate/ECS), and GCP (Cloud Run) reference deployments are planned, not built yet. Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). +> Reference deployments today: **Docker / Podman** — see [Building From Source](#building-from-source-docker--podman) below — and **Azure Container Apps** (Bicep) — see [deployment/azure/bicep](deployment/azure/bicep). AWS (Fargate/ECS) and GCP (Cloud Run) reference deployments are planned, not built yet. Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). --- @@ -167,6 +167,7 @@ provider. Observability goes through OpenTelemetry directly, no custom abstracti |---|---| | [ARCHITECTURE.md](ARCHITECTURE.md) | Provider abstraction pattern, deployment layout, multi-tenancy design | | [docs/providers/azure.md](docs/providers/azure.md) | Configuring `AzureKeyVaultSecretProvider` and `AzureBlobPersistenceProvider` | +| [deployment/azure/bicep](deployment/azure/bicep) | Azure Container Apps reference deployment (Bicep) | | [ENTERPRISE_DEPLOYMENT.md](ENTERPRISE_DEPLOYMENT.md) | Running RiftCache as shared, centrally-owned infrastructure for multiple teams | | [ROADMAP.md](ROADMAP.md) | Phased plan — core, Azure reference, AWS/GCP providers, enterprise features | | [CONTRIBUTING.md](CONTRIBUTING.md) | How to add a cloud provider, submit PRs, project conventions | diff --git a/ROADMAP.md b/ROADMAP.md index bf7561c..d7e37bc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,7 +7,8 @@ - [x] Default providers: `EnvironmentSecretProvider`, `NullPersistenceProvider` (self-host path) - [x] Azure reference provider: Key Vault (`ISecretProvider`) — see [docs/providers/azure.md](docs/providers/azure.md) - [x] Azure reference provider: Blob Storage (`IPersistenceProvider`) — see [docs/providers/azure.md](docs/providers/azure.md) -- [ ] Azure Container Apps reference deployment (Bicep) + Docker Compose for local dev +- [x] Azure Container Apps reference deployment (Bicep) + Docker Compose for local dev — see [deployment/azure/bicep](deployment/azure/bicep); memory-only (`NullPersistenceProvider`) in this template +- [ ] Wire `AzureBlobPersistenceProvider` into the Azure Container Apps reference deployment — needs `Program.cs` extracted into reusable `AddRiftCacheCore()`/`MapRiftCacheCore()` extensions first, plus a second composed entry point + Dockerfile that layers the Azure providers on top of the default - [x] Single-tenant mode as default; multi-tenant mode as opt-in config - [x] v0.1.0 release diff --git a/deployment/azure/bicep/README.md b/deployment/azure/bicep/README.md new file mode 100644 index 0000000..bc81cc1 --- /dev/null +++ b/deployment/azure/bicep/README.md @@ -0,0 +1,79 @@ +# Azure Container Apps Reference Deployment + +Deploys the core RiftCache image behind Azure Container Apps — no code changes, the exact same +image `deployment/docker/Dockerfile` builds. Key Vault backs `RIFTCACHE_API_KEY` via Container +Apps' native Key-Vault-backed secret injection: the platform resolves the secret using the app's +managed identity before the container ever starts, so the app itself never needs +`AzureKeyVaultSecretProvider` code for this. + +**Persistence is memory-only in this template** (`NullPersistenceProvider`, the core service's +default). Wiring `AzureBlobPersistenceProvider` into a live deployment needs `Program.cs` extracted +into reusable `AddRiftCacheCore()`/`MapRiftCacheCore()` extensions first, plus a second composed +entry point that layers the Azure providers on top — real follow-up work, tracked in +[ROADMAP.md](../../../ROADMAP.md), not done here. + +## What gets created + +- Log Analytics Workspace (Container Apps Environment requires one for logging) +- Container Apps Environment (Consumption plan) +- A user-assigned Managed Identity +- Key Vault, with the identity granted **Key Vault Secrets User** (read-only) +- A Key Vault secret holding `RIFTCACHE_API_KEY` +- The Container App itself — external ingress on port 8080, 1–3 replicas by default + +## Prerequisites + +- An Azure subscription and a resource group to deploy into. +- The [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli), logged in + (`az login`) with that subscription selected. +- **A container image already built and pushed somewhere you control.** Nothing is published + anywhere yet (see the main [README](../../../README.md)), so this template takes the image + reference as a parameter — build `deployment/docker/Dockerfile` and push it to your own registry + (Azure Container Registry, Docker Hub, GHCR, etc.) first. + +## Deploy + +```bash +cp main.bicepparam main.local.bicepparam +# edit main.local.bicepparam: set containerImage and riftCacheApiKey at minimum + +az deployment group create \ + --resource-group \ + --template-file main.bicep \ + --parameters main.local.bicepparam +``` + +## Verify + +```bash +fqdn=$(az deployment group show \ + --resource-group \ + --name main \ + --query properties.outputs.containerAppFqdn.value -o tsv) + +curl "https://$fqdn/healthz" +curl -H "X-RiftCache-Api-Key: " "https://$fqdn/api/v1/cache/hello" +``` + +## Cost + +Consumption-plan Container Apps, Key Vault, and Log Analytics are all pay-per-use with no fixed +minimum, but this isn't free to leave running indefinitely — `minReplicas` defaults to `1` (a cache +with zero warm replicas defeats its own purpose, unlike a typical stateless web app where +scale-to-zero makes sense), so there's always at least one replica running. Delete the resource +group when you're done evaluating this template: + +```bash +az group delete --name +``` + +## Local validation without deploying + +```bash +az bicep build --file main.bicep +az bicep build-params --file main.bicepparam +``` + +Both check the templates compile and every resource type/API version/property is valid against +Azure's real schemas — no subscription or credentials needed, since this is pure local +compilation. diff --git a/deployment/azure/bicep/main.bicep b/deployment/azure/bicep/main.bicep new file mode 100644 index 0000000..db5ff62 --- /dev/null +++ b/deployment/azure/bicep/main.bicep @@ -0,0 +1,159 @@ +// Azure Container Apps reference deployment for RiftCache. +// +// Deploys the core RiftCache image (Azure-SDK-free, no code changes) behind Azure Container +// Apps. Key Vault backs RIFTCACHE_API_KEY via Container Apps' native Key-Vault-backed secret +// injection -- the app itself never needs AzureKeyVaultSecretProvider code for that, since the +// platform resolves the secret using the app's managed identity before the container starts. +// +// Persistence is memory-only in this template (NullPersistenceProvider, the core service's +// default) -- wiring AzureBlobPersistenceProvider into a live deployment needs Program.cs +// extracted into reusable extensions first. See ROADMAP.md. +// +// Deploy with (see README.md in this directory for the full walkthrough): +// az deployment group create --resource-group --template-file main.bicep --parameters main.bicepparam + +@description('Azure region for all resources.') +param location string = resourceGroup().location + +@description('Name prefix applied to all resources.') +param namePrefix string = 'riftcache' + +@description('Container image to deploy, e.g. myregistry.azurecr.io/riftcache:latest. No image is published anywhere yet -- build and push your own from deployment/docker/Dockerfile.') +param containerImage string + +@description('The API key clients must send via the X-RiftCache-Api-Key header. Stored in Key Vault, never in a plain app setting.') +@secure() +param riftCacheApiKey string + +@description('Minimum number of replicas. A cache with zero warm replicas defeats its own purpose, so this defaults to 1 rather than allowing scale-to-zero.') +param minReplicas int = 1 + +@description('Maximum number of replicas.') +param maxReplicas int = 3 + +var logAnalyticsName = '${namePrefix}-logs' +var containerAppsEnvironmentName = '${namePrefix}-env' +var identityName = '${namePrefix}-identity' +var keyVaultName = '${namePrefix}-kv-${uniqueString(resourceGroup().id)}' +var containerAppName = '${namePrefix}-app' +var apiKeySecretName = 'RIFTCACHE-API-KEY' // Key Vault secret names don't allow underscores. +var keyVaultSecretsUserRoleId = '4633458b-17de-408a-b874-0445c86b69e6' + +resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { + name: logAnalyticsName + location: location + properties: { + sku: { + name: 'PerGB2018' + } + retentionInDays: 30 + } +} + +resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2024-03-01' = { + name: containerAppsEnvironmentName + location: location + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: logAnalytics.properties.customerId + sharedKey: logAnalytics.listKeys().primarySharedKey + } + } + } +} + +resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: identityName + location: location +} + +resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = { + name: keyVaultName + location: location + properties: { + sku: { + family: 'A' + name: 'standard' + } + tenantId: subscription().tenantId + enableRbacAuthorization: true + } +} + +resource apiKeySecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { + parent: keyVault + name: apiKeySecretName + properties: { + value: riftCacheApiKey + } +} + +resource keyVaultSecretsUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(keyVault.id, identity.id, keyVaultSecretsUserRoleId) + scope: keyVault + properties: { + principalId: identity.properties.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', keyVaultSecretsUserRoleId) + } +} + +resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { + name: containerAppName + location: location + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${identity.id}': {} + } + } + properties: { + managedEnvironmentId: containerAppsEnvironment.id + configuration: { + ingress: { + external: true + targetPort: 8080 + } + secrets: [ + { + name: 'riftcache-api-key' + keyVaultUrl: apiKeySecret.properties.secretUri + identity: identity.id + } + ] + } + template: { + containers: [ + { + name: 'riftcache' + image: containerImage + resources: { + cpu: json('0.25') + memory: '0.5Gi' + } + env: [ + { + name: 'RIFTCACHE_API_KEY' + secretRef: 'riftcache-api-key' + } + ] + } + ] + scale: { + minReplicas: minReplicas + maxReplicas: maxReplicas + } + } + } + dependsOn: [ + keyVaultSecretsUserRoleAssignment + ] +} + +@description('Public URL of the deployed RiftCache Container App.') +output containerAppFqdn string = containerApp.properties.configuration.ingress.fqdn + +@description('Key Vault name, for adding/rotating RIFTCACHE_API_KEY or configuring AzureKeyVaultSecretProvider later.') +output keyVaultName string = keyVault.name diff --git a/deployment/azure/bicep/main.bicepparam b/deployment/azure/bicep/main.bicepparam new file mode 100644 index 0000000..50bb724 --- /dev/null +++ b/deployment/azure/bicep/main.bicepparam @@ -0,0 +1,19 @@ +using 'main.bicep' + +// Copy this file to main.local.bicepparam (already covered by .gitignore, so a filled-in copy +// with real values never gets committed by accident) and fill in the values below before +// deploying. See README.md in this directory for the full walkthrough. + +param namePrefix = 'riftcache' + +// Build and push your own image from deployment/docker/Dockerfile first -- nothing is published +// anywhere yet. e.g.: 'myregistry.azurecr.io/riftcache:latest' +param containerImage = '' + +// The value callers must send back via the X-RiftCache-Api-Key header. Pick something real -- +// this becomes a Key Vault secret, not a plaintext app setting, but the value you put here is +// still visible in your shell history / this file, so don't reuse a key from anywhere else. +param riftCacheApiKey = '' + +param minReplicas = 1 +param maxReplicas = 3