mirror of
https://github.com/nexu-io/open-design.git
synced 2026-07-03 12:27:55 +08:00
feat(deploy): Azure deployment support (App Service + ACI) (#3387)
* feat(deploy): add Azure deployment templates (App Service + ACI) Adds a deploy/azure/ lane so Open Design can be deployed to Microsoft Azure from the published runtime image, alongside the existing Docker Compose and Helm options. Covers the App Service + ACI scope of #1028. Two Bicep templates run the same single-port Alpine image used by deploy/docker-compose.yml and charts/open-design: - app-service.bicep: App Service for Containers with managed HTTPS, Always On, and health checks on /api/health. - aci.bicep: a single Azure Container Instances group with a public FQDN and an /api/health liveness probe. These are evaluation deployments: state lives on the container's local disk and is ephemeral. Open Design stores SQLite under OD_DATA_DIR, and SQLite needs real file locking, which the Azure Files (SMB) storage behind both App Service and ACI persistence cannot provide without corruption. App Service sets WEBSITES_ENABLE_APP_SERVICE_STORAGE=false to keep the data dir on local disk. Durable self-hosting stays on the Compose named volume or the Helm PVC. Both wire the daemon's env contract (OD_BIND_HOST/OD_PORT/OD_WEB_PORT/ OD_DATA_DIR/OD_PUBLIC_BASE_URL/OD_ALLOWED_ORIGINS/OD_API_TOKEN) and take the API token as a secure parameter so it never appears in deployment outputs. deploy-azure.sh wraps `az` to create the resource group, generate a token when one isn't supplied, deploy a lane, and print the URL. README.md documents both lanes, the ephemeral-data caveat, and the security trade-offs. deploy/tests/azure-bicep.test.ts guards the runtime contract and that the data dir is never mounted to Azure Files; when the bicep CLI is present it also compiles both templates. * docs(deploy): add ACI health-check example to Azure quick start
This commit is contained in:
140
deploy/azure/README.md
Normal file
140
deploy/azure/README.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# Azure deployment (evaluation)
|
||||
|
||||
Deploy Open Design to Microsoft Azure from the published runtime image — the
|
||||
same single Alpine image used by [`deploy/docker-compose.yml`](../docker-compose.yml)
|
||||
and the [Helm chart](../../charts/open-design). The daemon serves both the API
|
||||
and the built web UI on one port, so there is no separate web container.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **These lanes are for evaluation and demos, not durable data.** Open Design
|
||||
> stores its state in a SQLite database under `/app/.od`, and SQLite needs real
|
||||
> file locking. The persistent-storage options on both App Service and ACI are
|
||||
> backed by **Azure Files (SMB)**, where SQLite WAL/locking is unsupported and
|
||||
> will corrupt the database. These templates therefore keep the data dir on the
|
||||
> container's **local disk**, which has correct locking but is **ephemeral** —
|
||||
> data is reset on restart, redeploy, or scale.
|
||||
>
|
||||
> For durable self-hosting today, use [`deploy/docker-compose.yml`](../docker-compose.yml)
|
||||
> (named volume) or the [Helm chart](../../charts/open-design) (PVC with
|
||||
> `ReadWriteOnce`). A durable Azure lane needs block storage (e.g. a VM with a
|
||||
> managed disk) and is out of scope here.
|
||||
|
||||
Two lanes are provided:
|
||||
|
||||
| Lane | Template | Best for | Public endpoint |
|
||||
| --- | --- | --- | --- |
|
||||
| **App Service for Containers** | [`app-service.bicep`](./app-service.bicep) | Always-on eval with managed HTTPS | `https://<app>.azurewebsites.net` |
|
||||
| **Azure Container Instances (ACI)** | [`aci.bicep`](./aci.bicep) | Quick, pay-per-second eval | `http://<dns>.<region>.azurecontainer.io:7456` |
|
||||
|
||||
Both run as a single instance (Open Design uses single-writer SQLite).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) (`az`), logged in with `az login`
|
||||
- An API token. Generate one with `openssl rand -hex 32`; the daemon requires
|
||||
`OD_API_TOKEN` and will not start without it.
|
||||
|
||||
## Quick start
|
||||
|
||||
The wrapper script creates the resource group, generates a token if you don't
|
||||
supply one, deploys the chosen template, and prints the URL:
|
||||
|
||||
```bash
|
||||
# App Service (managed HTTPS, always on)
|
||||
deploy/azure/deploy-azure.sh \
|
||||
--target app-service \
|
||||
--resource-group open-design-rg \
|
||||
--location eastus
|
||||
|
||||
# Azure Container Instances (serverless, pay-per-second)
|
||||
deploy/azure/deploy-azure.sh \
|
||||
--target aci \
|
||||
--resource-group open-design-rg \
|
||||
--location eastus
|
||||
```
|
||||
|
||||
First container start takes a couple of minutes while Azure pulls the image.
|
||||
`deploy-azure.sh` prints the exact `Health:` URL when it finishes — poll that
|
||||
until it returns `ok`:
|
||||
|
||||
```bash
|
||||
# App Service
|
||||
curl -fsS https://<app>.azurewebsites.net/api/health
|
||||
|
||||
# ACI (note the :7456 port)
|
||||
curl -fsS http://<dns>.<region>.azurecontainer.io:7456/api/health
|
||||
```
|
||||
|
||||
## Deploy with `az` directly
|
||||
|
||||
The templates are standard Bicep, so you can skip the wrapper and call `az`:
|
||||
|
||||
```bash
|
||||
az group create --name open-design-rg --location eastus
|
||||
|
||||
az deployment group create \
|
||||
--resource-group open-design-rg \
|
||||
--template-file deploy/azure/app-service.bicep \
|
||||
--parameters apiToken="$(openssl rand -hex 32)"
|
||||
```
|
||||
|
||||
Swap `app-service.bicep` for `aci.bicep` to use the ACI lane.
|
||||
|
||||
## Parameters
|
||||
|
||||
Both templates share these parameters (defaults in parentheses):
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| `name` (`open-design`) | Base name; a unique suffix is appended to globally-scoped resources |
|
||||
| `location` (resource group location) | Azure region |
|
||||
| `image` (`docker.io/vanjayak/open-design:latest`) | Container image; pin to a digest for production |
|
||||
| `apiToken` (**required**, secure) | API token guarding the daemon |
|
||||
| `nodeOptions` (`--max-old-space-size=192`) | Node.js heap cap |
|
||||
| `extraAllowedOrigins` (empty) | Extra comma-separated browser origins allowed to call `/api` |
|
||||
|
||||
App Service adds `appServicePlanSku` (`B1`). ACI adds `cpuCores` (`1`) and
|
||||
`memoryInGb` (`1.5`).
|
||||
|
||||
The deployed app's own origin is always added to `OD_ALLOWED_ORIGINS`, and
|
||||
`OD_PUBLIC_BASE_URL` is set to that origin so OAuth callbacks resolve correctly.
|
||||
Use `extraAllowedOrigins` only if you serve the UI from an additional hostname.
|
||||
|
||||
## Pin a specific image
|
||||
|
||||
Use a digest instead of the mutable `latest` tag for reproducible deployments:
|
||||
|
||||
```bash
|
||||
deploy/azure/deploy-azure.sh \
|
||||
--target app-service \
|
||||
--resource-group open-design-rg \
|
||||
--image docker.io/vanjayak/open-design@sha256:<digest>
|
||||
```
|
||||
|
||||
## Security notes
|
||||
|
||||
- **App Service** terminates HTTPS for you (`httpsOnly` is enabled) and is the
|
||||
recommended lane for any internet-facing deployment. To restrict access
|
||||
further, layer [App Service Authentication](https://learn.microsoft.com/azure/app-service/overview-authentication-authorization)
|
||||
("Easy Auth") or IP restrictions on top.
|
||||
- **ACI** exposes the daemon's port directly over plain HTTP with no managed
|
||||
TLS. Access is still gated by `OD_API_TOKEN`, and browser traffic by
|
||||
`OD_ALLOWED_ORIGINS`, but treat this lane as evaluation / trusted-network use.
|
||||
For production with HTTPS, use App Service or place ACI behind Application
|
||||
Gateway / Front Door.
|
||||
- Keep `OD_API_TOKEN` secret. It is passed as a secure parameter (and a secure
|
||||
environment variable on ACI), so it is not returned in deployment outputs.
|
||||
|
||||
## Updating
|
||||
|
||||
Re-run the same command with a newer `--image` (or the same tag after a new
|
||||
push). Note that App Service / ACI store data on ephemeral local disk, so a
|
||||
redeploy starts from an empty data dir.
|
||||
|
||||
## Tearing down
|
||||
|
||||
Delete the whole resource group when you're done:
|
||||
|
||||
```bash
|
||||
az group delete --name open-design-rg --yes --no-wait
|
||||
```
|
||||
147
deploy/azure/aci.bicep
Normal file
147
deploy/azure/aci.bicep
Normal file
@@ -0,0 +1,147 @@
|
||||
// Open Design on Azure Container Instances (ACI) — evaluation deployment.
|
||||
//
|
||||
// Single serverless container group with a public FQDN. State lives on the
|
||||
// container's local disk, which is EPHEMERAL — it is reset whenever the group
|
||||
// is restarted or recreated. This lane is for evaluation and demos.
|
||||
//
|
||||
// Why no persistent volume: Open Design stores SQLite under OD_DATA_DIR, and
|
||||
// SQLite needs real file locking. ACI's only persistent volume type is Azure
|
||||
// Files (SMB), where SQLite WAL/locking is unsupported and corrupts. So we
|
||||
// keep the data dir on the container's local filesystem.
|
||||
//
|
||||
// SECURITY: ACI exposes the port over plain HTTP (no managed TLS). Access is
|
||||
// gated by OD_API_TOKEN and browser traffic by OD_ALLOWED_ORIGINS. For
|
||||
// internet-facing HTTPS use app-service.bicep.
|
||||
|
||||
@description('Base name for the deployment. A globally-unique suffix is appended to the DNS label.')
|
||||
param name string = 'open-design'
|
||||
|
||||
@description('Azure region. Defaults to the resource group location.')
|
||||
param location string = resourceGroup().location
|
||||
|
||||
@description('Container image to run. Pin to a digest (image@sha256:...) for production.')
|
||||
param image string = 'docker.io/vanjayak/open-design:latest'
|
||||
|
||||
@secure()
|
||||
@minLength(32)
|
||||
@description('Required API token guarding the daemon. Generate one with: openssl rand -hex 32')
|
||||
param apiToken string
|
||||
|
||||
@description('vCPU cores allocated to the container.')
|
||||
param cpuCores int = 1
|
||||
|
||||
@description('Memory (GiB) allocated to the container. Raise for large exports or concurrent agent runs.')
|
||||
param memoryInGb string = '1.5'
|
||||
|
||||
@description('Node.js heap cap passed to the container via NODE_OPTIONS.')
|
||||
param nodeOptions string = '--max-old-space-size=192'
|
||||
|
||||
@description('Extra browser origins allowed to call /api, comma-separated. The container FQDN is always allowed.')
|
||||
param extraAllowedOrigins string = ''
|
||||
|
||||
// Runtime invariants of the image, not user-facing knobs.
|
||||
var containerPort = 7456
|
||||
var dataDir = '/app/.od'
|
||||
|
||||
// ACI exposes the port verbatim (no 80 -> 7456 mapping), so the base URL
|
||||
// carries the container port.
|
||||
var dnsNameLabel = '${name}-${uniqueString(resourceGroup().id)}'
|
||||
var fqdn = '${dnsNameLabel}.${toLower(location)}.azurecontainer.io'
|
||||
var publicBaseUrl = 'http://${fqdn}:${containerPort}'
|
||||
var allowedOrigins = empty(extraAllowedOrigins) ? publicBaseUrl : '${publicBaseUrl},${extraAllowedOrigins}'
|
||||
|
||||
resource containerGroup 'Microsoft.ContainerInstance/containerGroups@2023-05-01' = {
|
||||
name: '${name}-aci'
|
||||
location: location
|
||||
properties: {
|
||||
osType: 'Linux'
|
||||
restartPolicy: 'Always'
|
||||
ipAddress: {
|
||||
type: 'Public'
|
||||
dnsNameLabel: dnsNameLabel
|
||||
ports: [
|
||||
{
|
||||
protocol: 'TCP'
|
||||
port: containerPort
|
||||
}
|
||||
]
|
||||
}
|
||||
containers: [
|
||||
{
|
||||
name: 'open-design'
|
||||
properties: {
|
||||
image: image
|
||||
ports: [
|
||||
{
|
||||
protocol: 'TCP'
|
||||
port: containerPort
|
||||
}
|
||||
]
|
||||
resources: {
|
||||
requests: {
|
||||
cpu: cpuCores
|
||||
memoryInGB: json(memoryInGb)
|
||||
}
|
||||
}
|
||||
environmentVariables: [
|
||||
{
|
||||
name: 'OD_BIND_HOST'
|
||||
value: '0.0.0.0'
|
||||
}
|
||||
{
|
||||
name: 'OD_PORT'
|
||||
value: '${containerPort}'
|
||||
}
|
||||
{
|
||||
name: 'OD_WEB_PORT'
|
||||
value: '${containerPort}'
|
||||
}
|
||||
{
|
||||
name: 'OD_DATA_DIR'
|
||||
value: dataDir
|
||||
}
|
||||
{
|
||||
name: 'OD_PUBLIC_BASE_URL'
|
||||
value: publicBaseUrl
|
||||
}
|
||||
{
|
||||
name: 'OD_ALLOWED_ORIGINS'
|
||||
value: allowedOrigins
|
||||
}
|
||||
{
|
||||
name: 'NODE_ENV'
|
||||
value: 'production'
|
||||
}
|
||||
{
|
||||
name: 'NODE_OPTIONS'
|
||||
value: nodeOptions
|
||||
}
|
||||
{
|
||||
name: 'OD_API_TOKEN'
|
||||
secureValue: apiToken
|
||||
}
|
||||
]
|
||||
livenessProbe: {
|
||||
httpGet: {
|
||||
path: '/api/health'
|
||||
port: containerPort
|
||||
scheme: 'HTTP'
|
||||
}
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 30
|
||||
failureThreshold: 3
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@description('Public URL of the deployed container (plain HTTP, port included).')
|
||||
output appUrl string = publicBaseUrl
|
||||
|
||||
@description('Health endpoint to poll after deployment.')
|
||||
output healthUrl string = '${publicBaseUrl}/api/health'
|
||||
|
||||
@description('Fully-qualified domain name assigned to the container group.')
|
||||
output fqdn string = fqdn
|
||||
136
deploy/azure/app-service.bicep
Normal file
136
deploy/azure/app-service.bicep
Normal file
@@ -0,0 +1,136 @@
|
||||
// Open Design on Azure App Service for Containers (evaluation deployment).
|
||||
//
|
||||
// Single-instance Linux web app behind Azure's managed HTTPS. State lives on
|
||||
// the container's local disk, which is EPHEMERAL — it is reset on restart,
|
||||
// redeploy, or scale. This lane is for evaluation and demos, not durable data.
|
||||
//
|
||||
// Why no persistent volume: Open Design stores SQLite under OD_DATA_DIR, and
|
||||
// SQLite needs real file locking. App Service's persistent storage is backed
|
||||
// by Azure Files (SMB), where SQLite WAL/locking is unsupported and corrupts.
|
||||
// So we deliberately keep the data dir on the container's local filesystem.
|
||||
|
||||
@description('Base name for the deployment. A globally-unique suffix is appended to the web app.')
|
||||
param name string = 'open-design'
|
||||
|
||||
@description('Azure region. Defaults to the resource group location.')
|
||||
param location string = resourceGroup().location
|
||||
|
||||
@description('Container image to run. Pin to a digest (image@sha256:...) for production.')
|
||||
param image string = 'docker.io/vanjayak/open-design:latest'
|
||||
|
||||
@description('App Service plan SKU. B1 is the smallest tier that supports Always On and health checks.')
|
||||
param appServicePlanSku string = 'B1'
|
||||
|
||||
@secure()
|
||||
@minLength(32)
|
||||
@description('Required API token guarding the daemon. Generate one with: openssl rand -hex 32')
|
||||
param apiToken string
|
||||
|
||||
@description('Node.js heap cap passed to the container via NODE_OPTIONS.')
|
||||
param nodeOptions string = '--max-old-space-size=192'
|
||||
|
||||
@description('Extra browser origins allowed to call /api, comma-separated. The App Service default hostname is always allowed.')
|
||||
param extraAllowedOrigins string = ''
|
||||
|
||||
// Runtime invariants of the image, not user-facing knobs.
|
||||
var containerPort = 7456
|
||||
var dataDir = '/app/.od'
|
||||
|
||||
// Default App Service hostname is deterministic, so we derive the public URL up
|
||||
// front and avoid a circular reference on the site's own appSettings.
|
||||
var webAppName = '${name}-${uniqueString(resourceGroup().id)}'
|
||||
var publicBaseUrl = 'https://${webAppName}.azurewebsites.net'
|
||||
var allowedOrigins = empty(extraAllowedOrigins) ? publicBaseUrl : '${publicBaseUrl},${extraAllowedOrigins}'
|
||||
|
||||
resource plan 'Microsoft.Web/serverfarms@2023-12-01' = {
|
||||
name: '${name}-plan'
|
||||
location: location
|
||||
kind: 'linux'
|
||||
sku: {
|
||||
name: appServicePlanSku
|
||||
capacity: 1
|
||||
}
|
||||
properties: {
|
||||
reserved: true // required for Linux plans
|
||||
}
|
||||
}
|
||||
|
||||
resource webApp 'Microsoft.Web/sites@2023-12-01' = {
|
||||
name: webAppName
|
||||
location: location
|
||||
kind: 'app,linux,container'
|
||||
properties: {
|
||||
serverFarmId: plan.id
|
||||
httpsOnly: true
|
||||
siteConfig: {
|
||||
linuxFxVersion: 'DOCKER|${image}'
|
||||
alwaysOn: true
|
||||
ftpsState: 'Disabled'
|
||||
minTlsVersion: '1.2'
|
||||
http20Enabled: true
|
||||
healthCheckPath: '/api/health'
|
||||
numberOfWorkers: 1 // SQLite is single-writer: keep one worker.
|
||||
appSettings: [
|
||||
{
|
||||
name: 'WEBSITES_PORT' // route inbound traffic to the container port
|
||||
value: '${containerPort}'
|
||||
}
|
||||
{
|
||||
// Keep the data dir on the container's local disk (real file
|
||||
// locking). Azure Files-backed storage corrupts SQLite.
|
||||
name: 'WEBSITES_ENABLE_APP_SERVICE_STORAGE'
|
||||
value: 'false'
|
||||
}
|
||||
{
|
||||
name: 'WEBSITES_CONTAINER_START_TIME_LIMIT'
|
||||
value: '230'
|
||||
}
|
||||
{
|
||||
name: 'OD_BIND_HOST'
|
||||
value: '0.0.0.0'
|
||||
}
|
||||
{
|
||||
name: 'OD_PORT'
|
||||
value: '${containerPort}'
|
||||
}
|
||||
{
|
||||
name: 'OD_WEB_PORT'
|
||||
value: '${containerPort}'
|
||||
}
|
||||
{
|
||||
name: 'OD_DATA_DIR'
|
||||
value: dataDir
|
||||
}
|
||||
{
|
||||
name: 'OD_PUBLIC_BASE_URL'
|
||||
value: publicBaseUrl
|
||||
}
|
||||
{
|
||||
name: 'OD_ALLOWED_ORIGINS'
|
||||
value: allowedOrigins
|
||||
}
|
||||
{
|
||||
name: 'OD_API_TOKEN'
|
||||
value: apiToken
|
||||
}
|
||||
{
|
||||
name: 'NODE_ENV'
|
||||
value: 'production'
|
||||
}
|
||||
{
|
||||
name: 'NODE_OPTIONS'
|
||||
value: nodeOptions
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@description('Public HTTPS URL of the deployed app.')
|
||||
output appUrl string = publicBaseUrl
|
||||
|
||||
@description('Health endpoint to poll after deployment.')
|
||||
output healthUrl string = '${publicBaseUrl}/api/health'
|
||||
|
||||
@description('Generated web app name (includes the unique suffix).')
|
||||
output webAppName string = webAppName
|
||||
106
deploy/azure/deploy-azure.sh
Executable file
106
deploy/azure/deploy-azure.sh
Executable file
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Deploy Open Design to Azure from the Bicep templates in this directory.
|
||||
#
|
||||
# deploy/azure/deploy-azure.sh --target app-service --resource-group od-rg --location eastus
|
||||
# deploy/azure/deploy-azure.sh --target aci --resource-group od-rg --location eastus
|
||||
#
|
||||
# Requires the Azure CLI (`az`) and an authenticated session (`az login`).
|
||||
# If no --api-token is given, a 32-byte hex token is generated and printed so
|
||||
# you can save it for client configuration.
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
TARGET="${TARGET:-app-service}"
|
||||
RESOURCE_GROUP="${RESOURCE_GROUP:-open-design-rg}"
|
||||
LOCATION="${LOCATION:-eastus}"
|
||||
NAME="${NAME:-open-design}"
|
||||
IMAGE="${IMAGE:-docker.io/vanjayak/open-design:latest}"
|
||||
API_TOKEN="${API_TOKEN:-}"
|
||||
EXTRA_ALLOWED_ORIGINS="${EXTRA_ALLOWED_ORIGINS:-}"
|
||||
DEPLOYMENT_NAME="${DEPLOYMENT_NAME:-open-design}"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: deploy/azure/deploy-azure.sh [options]
|
||||
|
||||
Options:
|
||||
--target <app-service|aci> deployment lane (default: app-service)
|
||||
--resource-group <name> resource group to deploy into (default: open-design-rg)
|
||||
--location <region> Azure region, used to create the group (default: eastus)
|
||||
--name <name> base name for resources (default: open-design)
|
||||
--image <image-ref> container image (default: docker.io/vanjayak/open-design:latest)
|
||||
--api-token <token> API token; generated with `openssl rand -hex 32` if omitted
|
||||
--extra-allowed-origins <list> extra comma-separated browser origins for /api
|
||||
--deployment-name <name> ARM deployment name (default: open-design)
|
||||
-h, --help
|
||||
|
||||
Examples:
|
||||
deploy/azure/deploy-azure.sh --target app-service --resource-group od-rg --location westeurope
|
||||
deploy/azure/deploy-azure.sh --target aci --resource-group od-rg --image docker.io/vanjayak/open-design@sha256:<digest>
|
||||
EOF
|
||||
}
|
||||
|
||||
die() {
|
||||
echo "error: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--target) TARGET="$2"; shift 2 ;;
|
||||
--resource-group) RESOURCE_GROUP="$2"; shift 2 ;;
|
||||
--location) LOCATION="$2"; shift 2 ;;
|
||||
--name) NAME="$2"; shift 2 ;;
|
||||
--image) IMAGE="$2"; shift 2 ;;
|
||||
--api-token) API_TOKEN="$2"; shift 2 ;;
|
||||
--extra-allowed-origins) EXTRA_ALLOWED_ORIGINS="$2"; shift 2 ;;
|
||||
--deployment-name) DEPLOYMENT_NAME="$2"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) usage; die "unknown argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$TARGET" in
|
||||
app-service) TEMPLATE="$ROOT_DIR/app-service.bicep" ;;
|
||||
aci) TEMPLATE="$ROOT_DIR/aci.bicep" ;;
|
||||
*) die "--target must be 'app-service' or 'aci' (got: $TARGET)" ;;
|
||||
esac
|
||||
|
||||
command -v az >/dev/null 2>&1 || die "Azure CLI ('az') not found. Install it: https://learn.microsoft.com/cli/azure/install-azure-cli"
|
||||
az account show >/dev/null 2>&1 || die "not logged in. Run 'az login' first."
|
||||
|
||||
if [[ -z "$API_TOKEN" ]]; then
|
||||
command -v openssl >/dev/null 2>&1 || die "openssl not found; pass --api-token explicitly."
|
||||
API_TOKEN="$(openssl rand -hex 32)"
|
||||
echo "Generated OD_API_TOKEN: $API_TOKEN"
|
||||
echo "Save this token — clients need it to call the API."
|
||||
fi
|
||||
|
||||
echo "Ensuring resource group '$RESOURCE_GROUP' in '$LOCATION'..."
|
||||
az group create --name "$RESOURCE_GROUP" --location "$LOCATION" --output none
|
||||
|
||||
echo "Deploying '$TARGET' template..."
|
||||
az deployment group create \
|
||||
--resource-group "$RESOURCE_GROUP" \
|
||||
--name "$DEPLOYMENT_NAME" \
|
||||
--template-file "$TEMPLATE" \
|
||||
--parameters \
|
||||
name="$NAME" \
|
||||
image="$IMAGE" \
|
||||
apiToken="$API_TOKEN" \
|
||||
extraAllowedOrigins="$EXTRA_ALLOWED_ORIGINS" \
|
||||
--output none
|
||||
|
||||
APP_URL="$(az deployment group show --resource-group "$RESOURCE_GROUP" --name "$DEPLOYMENT_NAME" --query 'properties.outputs.appUrl.value' --output tsv)"
|
||||
HEALTH_URL="$(az deployment group show --resource-group "$RESOURCE_GROUP" --name "$DEPLOYMENT_NAME" --query 'properties.outputs.healthUrl.value' --output tsv)"
|
||||
|
||||
echo
|
||||
echo "Deployment complete."
|
||||
echo " App URL: $APP_URL"
|
||||
echo " Health: $HEALTH_URL"
|
||||
echo
|
||||
echo "First container start can take a couple of minutes while the image is pulled."
|
||||
echo "Poll the health endpoint until it returns ok:"
|
||||
echo " curl -fsS $HEALTH_URL"
|
||||
121
deploy/tests/azure-bicep.test.ts
Normal file
121
deploy/tests/azure-bicep.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// Guards the Azure Bicep templates against drifting from the Open Design
|
||||
// runtime contract. Source-level checks run without the Azure CLI; the extra
|
||||
// `bicep build` pass runs only when the bicep binary is on PATH.
|
||||
|
||||
const repoRoot = join(import.meta.dirname, '../..');
|
||||
const azureDir = join(repoRoot, 'deploy/azure');
|
||||
const appServicePath = join(azureDir, 'app-service.bicep');
|
||||
const aciPath = join(azureDir, 'aci.bicep');
|
||||
|
||||
// Must match deploy/docker-compose.yml and charts/open-design.
|
||||
const CONTAINER_PORT = '7456';
|
||||
const DATA_DIR = '/app/.od';
|
||||
const HEALTH_PATH = '/api/health';
|
||||
|
||||
// Env vars the daemon reads (apps/daemon/src).
|
||||
const REQUIRED_ENV = [
|
||||
'OD_BIND_HOST',
|
||||
'OD_PORT',
|
||||
'OD_WEB_PORT',
|
||||
'OD_DATA_DIR',
|
||||
'OD_PUBLIC_BASE_URL',
|
||||
'OD_ALLOWED_ORIGINS',
|
||||
'OD_API_TOKEN',
|
||||
'NODE_ENV',
|
||||
'NODE_OPTIONS',
|
||||
];
|
||||
|
||||
async function read(path: string): Promise<string> {
|
||||
return readFile(path, 'utf8');
|
||||
}
|
||||
|
||||
for (const [label, path] of [
|
||||
['app-service.bicep', appServicePath],
|
||||
['aci.bicep', aciPath],
|
||||
] as const) {
|
||||
test(`${label}: pins the runtime port and data dir`, async () => {
|
||||
const src = await read(path);
|
||||
assert.match(src, new RegExp(`var containerPort = ${CONTAINER_PORT}\\b`), 'container port must be 7456');
|
||||
assert.match(src, new RegExp(`var dataDir = '${DATA_DIR}'`), 'data dir must be /app/.od');
|
||||
});
|
||||
|
||||
test(`${label}: requires a secure API token of at least 32 chars`, async () => {
|
||||
const src = await read(path);
|
||||
assert.match(src, /@secure\(\)\s*\n\s*@minLength\(32\)\s*\n\s*@description\([^)]*\)\s*\nparam apiToken string/);
|
||||
});
|
||||
|
||||
test(`${label}: wires every required daemon env var`, async () => {
|
||||
const src = await read(path);
|
||||
for (const name of REQUIRED_ENV) {
|
||||
assert.ok(src.includes(`'${name}'`), `${label} is missing env var ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${label}: probes the health endpoint`, async () => {
|
||||
const src = await read(path);
|
||||
assert.ok(src.includes(HEALTH_PATH), `${label} must reference ${HEALTH_PATH}`);
|
||||
});
|
||||
|
||||
test(`${label}: keeps SQLite off Azure Files (SMB breaks SQLite locking)`, async () => {
|
||||
const src = await read(path);
|
||||
// The data dir holds app.sqlite + its WAL files; an Azure Files (SMB) mount
|
||||
// corrupts SQLite. The data dir must stay on the container's local disk.
|
||||
assert.doesNotMatch(src, /azureFile|azureStorageAccounts/i, `${label} must not mount the data dir to Azure Files`);
|
||||
assert.doesNotMatch(src, /Microsoft\.Storage\/storageAccounts\/fileServices\/shares/, `${label} must not declare a SQLite data file share`);
|
||||
});
|
||||
|
||||
test(`${label}: derives allowed origins from the public base URL`, async () => {
|
||||
const src = await read(path);
|
||||
assert.match(src, /var allowedOrigins = empty\(extraAllowedOrigins\)/);
|
||||
});
|
||||
}
|
||||
|
||||
test('app-service.bicep: enforces HTTPS and a single SQLite writer', async () => {
|
||||
const src = await read(appServicePath);
|
||||
assert.match(src, /httpsOnly:\s*true/, 'App Service must be HTTPS-only');
|
||||
assert.match(src, /numberOfWorkers:\s*1/, 'App Service must run a single worker for SQLite');
|
||||
assert.match(src, /capacity:\s*1/, 'App Service plan must be pinned to one instance');
|
||||
assert.ok(src.includes("'WEBSITES_PORT'"), 'App Service must set WEBSITES_PORT so traffic reaches the container');
|
||||
// Forces the data dir onto the container's local disk (Azure Files breaks SQLite).
|
||||
assert.match(src, /'WEBSITES_ENABLE_APP_SERVICE_STORAGE'\s*\n\s*value:\s*'false'/, 'App Service must disable Azure Files-backed storage');
|
||||
});
|
||||
|
||||
test('aci.bicep: passes the API token as a secure value and always restarts', async () => {
|
||||
const src = await read(aciPath);
|
||||
assert.match(src, /name:\s*'OD_API_TOKEN'\s*\n\s*secureValue:\s*apiToken/, 'ACI must pass OD_API_TOKEN as a secureValue');
|
||||
assert.match(src, /restartPolicy:\s*'Always'/, 'ACI container should restart on failure');
|
||||
});
|
||||
|
||||
// Compile the templates when a bicep binary is on PATH; skipped otherwise.
|
||||
function findBicep(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
execFile('bicep', ['--version'], { timeout: 10_000 }, (err) => resolve(err ? null : 'bicep'));
|
||||
});
|
||||
}
|
||||
|
||||
const bicepBin = await findBicep();
|
||||
|
||||
for (const [label, path] of [
|
||||
['app-service.bicep', appServicePath],
|
||||
['aci.bicep', aciPath],
|
||||
] as const) {
|
||||
test(`${label}: compiles with bicep build`, { skip: bicepBin ? false : 'bicep CLI not installed' }, async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
execFile('bicep', ['build', path, '--stdout'], { timeout: 60_000 }, (err, _stdout, stderr) => {
|
||||
if (err) {
|
||||
reject(new Error(`bicep build failed for ${label}: ${stderr || err.message}`));
|
||||
return;
|
||||
}
|
||||
// bicep prints lint warnings to stderr; treat any as a failure.
|
||||
assert.equal(stderr.trim(), '', `bicep emitted warnings for ${label}: ${stderr}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user