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:
youcef zr
2026-06-23 07:53:45 +01:00
committed by GitHub
parent 7229584ae4
commit 6efac95b53
5 changed files with 650 additions and 0 deletions

140
deploy/azure/README.md Normal file
View 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
View 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

View 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
View 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"

View 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();
});
});
});
}