diff --git a/.github/workflows/deploy-azure.yml b/.github/workflows/deploy-azure.yml new file mode 100644 index 00000000..33b8a494 --- /dev/null +++ b/.github/workflows/deploy-azure.yml @@ -0,0 +1,219 @@ +name: Deploy to Azure Functions + +on: + push: + branches: [master] + workflow_dispatch: + inputs: + resource_group: + description: 'Azure Resource Group name (created if absent)' + required: true + default: 'zpan-rg' + location: + description: 'Azure region (e.g. eastus)' + required: true + default: 'eastus' + version: + description: 'Release tag to deploy (e.g. v2.5.0). Leave empty for latest.' + required: false + +# Prevent overlapping deployments. +concurrency: + group: deploy-azure + cancel-in-progress: false + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + # Only run on forks that have configured Azure credentials. + # The upstream repo uses Cloudflare Workers Builds; Azure is for self-hosters. + if: github.repository != 'saltbo/zpan' + steps: + # ------------------------------------------------------------------ + # Step 1 — Verify all required secrets are present before doing work. + # ------------------------------------------------------------------ + - name: Check required secrets + env: + HAS_AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS != '' }} + HAS_TURSO_URL: ${{ secrets.TURSO_DATABASE_URL != '' }} + HAS_TURSO_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN != '' }} + run: | + MISSING=() + [ "$HAS_AZURE_CREDENTIALS" != "true" ] && MISSING+=("AZURE_CREDENTIALS") + [ "$HAS_TURSO_URL" != "true" ] && MISSING+=("TURSO_DATABASE_URL") + [ "$HAS_TURSO_TOKEN" != "true" ] && MISSING+=("TURSO_AUTH_TOKEN") + if [ ${#MISSING[@]} -gt 0 ]; then + echo "::error::Missing required secrets: ${MISSING[*]}" + echo "Go to Settings → Secrets and variables → Actions and add the missing secrets." + exit 1 + fi + + # ------------------------------------------------------------------ + # Step 2 — Resolve release tag and check out that version. + # ------------------------------------------------------------------ + - name: Resolve release tag + id: release + env: + GH_TOKEN: ${{ github.token }} + INPUT_VERSION: ${{ inputs.version }} + run: | + if [ -n "$INPUT_VERSION" ]; then + TAG="$INPUT_VERSION" + else + TAG=$(gh api repos/saltbo/zpan/releases/latest --jq '.tag_name') + fi + if [ -z "$TAG" ]; then + echo "::error::No release found in saltbo/zpan" + exit 1 + fi + echo "version=$TAG" >> "$GITHUB_OUTPUT" + echo "### 🚀 Deploying $TAG to Azure Functions" >> "$GITHUB_STEP_SUMMARY" + + - uses: actions/checkout@v4 + with: + repository: saltbo/zpan + ref: ${{ steps.release.outputs.version }} + + # ------------------------------------------------------------------ + # Step 3 — Node.js setup + install dependencies. + # ------------------------------------------------------------------ + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + # ------------------------------------------------------------------ + # Step 4 — Log in to Azure using the service-principal credentials. + # ------------------------------------------------------------------ + - name: Azure login + uses: azure/login@v2 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + + # ------------------------------------------------------------------ + # Step 5 — Provision infrastructure (idempotent create-or-update). + # ------------------------------------------------------------------ + - name: Resolve inputs (push vs. dispatch) + id: params + run: | + echo "resource_group=${{ inputs.resource_group || 'zpan-rg' }}" >> "$GITHUB_OUTPUT" + echo "location=${{ inputs.location || 'eastus' }}" >> "$GITHUB_OUTPUT" + + - name: Ensure Resource Group exists + run: | + az group create \ + --name "${{ steps.params.outputs.resource_group }}" \ + --location "${{ steps.params.outputs.location }}" \ + --output none + + - name: Deploy Bicep template + id: bicep + env: + TURSO_DATABASE_URL: ${{ secrets.TURSO_DATABASE_URL }} + TURSO_AUTH_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN }} + run: | + OUTPUT=$(az deployment group create \ + --resource-group "${{ steps.params.outputs.resource_group }}" \ + --template-file deploy/azure-functions/main.bicep \ + --parameters \ + tursoDatabaseUrl="$TURSO_DATABASE_URL" \ + tursoAuthToken="$TURSO_AUTH_TOKEN" \ + --query "properties.outputs" \ + --output json) + + FUNC_NAME=$(echo "$OUTPUT" | jq -r '.functionAppName.value') + FUNC_URL=$(echo "$OUTPUT" | jq -r '.functionAppUrl.value') + echo "functionAppName=$FUNC_NAME" >> "$GITHUB_OUTPUT" + echo "functionAppUrl=$FUNC_URL" >> "$GITHUB_OUTPUT" + echo "Function App: $FUNC_NAME ($FUNC_URL)" >> "$GITHUB_STEP_SUMMARY" + + # ------------------------------------------------------------------ + # Step 6a — Set BETTER_AUTH_SECRET before publish. + # The Function App exists after Bicep; setting the secret now means + # the very first invocation after publish already has it configured. + # bootstrap.ts throws 'BETTER_AUTH_SECRET is required' if it is absent, + # so publishing before this step would cause 500s until the step ran. + # ------------------------------------------------------------------ + - name: Set BETTER_AUTH_SECRET (generate once, never overwrite) + env: + USER_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} + run: | + FUNC_NAME="${{ steps.bicep.outputs.functionAppName }}" + RG="${{ steps.params.outputs.resource_group }}" + + EXISTS=$(az functionapp config appsettings list \ + --name "$FUNC_NAME" \ + --resource-group "$RG" \ + --query "[?name=='BETTER_AUTH_SECRET'].value" \ + --output tsv) + + if [ -n "$USER_SECRET" ]; then + az functionapp config appsettings set \ + --name "$FUNC_NAME" \ + --resource-group "$RG" \ + --settings "BETTER_AUTH_SECRET=$USER_SECRET" \ + --output none + echo "Set BETTER_AUTH_SECRET from GitHub secret." + elif [ -z "$EXISTS" ]; then + GENERATED=$(openssl rand -base64 32) + az functionapp config appsettings set \ + --name "$FUNC_NAME" \ + --resource-group "$RG" \ + --settings "BETTER_AUTH_SECRET=$GENERATED" \ + --output none + echo "Auto-generated BETTER_AUTH_SECRET." + else + echo "BETTER_AUTH_SECRET already set — skipping." + fi + + # ------------------------------------------------------------------ + # Step 6b — Patch APP_URL / BETTER_AUTH_URL to the real hostname. + # Bicep sets both from the `appUrl` parameter (defaults to an empty + # placeholder when not provided). Finalise before publish so auth + # redirects are correct from the first request. + # ------------------------------------------------------------------ + - name: Update APP_URL to real function app URL + run: | + FUNC_NAME="${{ steps.bicep.outputs.functionAppName }}" + FUNC_URL="${{ steps.bicep.outputs.functionAppUrl }}" + RG="${{ steps.params.outputs.resource_group }}" + az functionapp config appsettings set \ + --name "$FUNC_NAME" \ + --resource-group "$RG" \ + --settings "APP_URL=$FUNC_URL" "BETTER_AUTH_URL=$FUNC_URL" \ + --output none + echo "APP_URL set to $FUNC_URL" + + # ------------------------------------------------------------------ + # Step 7 — Build frontend + Azure Functions bundle. + # ------------------------------------------------------------------ + - name: Build + run: npm run build:azure + + # ------------------------------------------------------------------ + # Step 8 — Run database migrations against Turso. + # ------------------------------------------------------------------ + - name: Run database migrations + env: + TURSO_DATABASE_URL: ${{ secrets.TURSO_DATABASE_URL }} + TURSO_AUTH_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN }} + run: npm run db:migrate + + # ------------------------------------------------------------------ + # Step 9 — Install Azure Functions Core Tools and publish. + # All required app settings (BETTER_AUTH_SECRET, APP_URL, Turso creds) + # are already in place before this step runs. + # ------------------------------------------------------------------ + - name: Install Azure Functions Core Tools + run: npm install -g azure-functions-core-tools@4 --unsafe-perm true + + - name: Publish to Azure Functions + working-directory: azure-functions + run: func azure functionapp publish "${{ steps.bicep.outputs.functionAppName }}" --node + + - name: Deployment summary + run: | + echo "### ✅ Deployed: ${{ steps.bicep.outputs.functionAppUrl }}" >> "$GITHUB_STEP_SUMMARY" diff --git a/deploy/azure-functions/main.bicep b/deploy/azure-functions/main.bicep new file mode 100644 index 00000000..6e0129a0 --- /dev/null +++ b/deploy/azure-functions/main.bicep @@ -0,0 +1,84 @@ +@description('Application name — used as a prefix for all resources.') +param appName string = 'zpan' + +@description('Azure region. Defaults to the resource group location.') +param location string = resourceGroup().location + +@description('Turso (libSQL) database URL, e.g. libsql://your-db.turso.io') +param tursoDatabaseUrl string + +@secure() +@description('Turso auth token.') +param tursoAuthToken string + +// Unique suffix derived from the resource group so re-runs produce the same names (idempotent). +var suffix = uniqueString(resourceGroup().id) +var storageAccountName = take('${toLower(replace(appName, '-', ''))}${suffix}', 24) +var hostingPlanName = '${appName}-plan-${suffix}' +var functionAppName = '${appName}-func-${suffix}' + +// --------------------------------------------------------------------------- +// Storage Account — required by the Azure Functions runtime. +// --------------------------------------------------------------------------- +resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = { + name: storageAccountName + location: location + sku: { name: 'Standard_LRS' } + kind: 'StorageV2' + properties: { + supportsHttpsTrafficOnly: true + minimumTlsVersion: 'TLS1_2' + } +} + +// --------------------------------------------------------------------------- +// Consumption plan (Y1 / Dynamic SKU). +// --------------------------------------------------------------------------- +resource hostingPlan 'Microsoft.Web/serverfarms@2023-01-01' = { + name: hostingPlanName + location: location + sku: { + name: 'Y1' + tier: 'Dynamic' + } + properties: {} +} + +// --------------------------------------------------------------------------- +// Function App — Node 22, programming model v4. +// BETTER_AUTH_SECRET is intentionally absent here; it is managed by the +// deploy workflow so that it is generated once and never overwritten on +// subsequent runs. +// --------------------------------------------------------------------------- +resource functionApp 'Microsoft.Web/sites@2023-01-01' = { + name: functionAppName + location: location + kind: 'functionapp' + properties: { + serverFarmId: hostingPlan.id + httpsOnly: true + siteConfig: { + nodeVersion: '~22' + appSettings: [ + { + name: 'AzureWebJobsStorage' + value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}' + } + { name: 'FUNCTIONS_EXTENSION_VERSION', value: '~4' } + { name: 'FUNCTIONS_WORKER_RUNTIME', value: 'node' } + { name: 'WEBSITE_RUN_FROM_PACKAGE', value: '1' } + { name: 'TURSO_DATABASE_URL', value: tursoDatabaseUrl } + { name: 'TURSO_AUTH_TOKEN', value: tursoAuthToken } + ] + } + } +} + +// --------------------------------------------------------------------------- +// Outputs consumed by the deploy workflow. +// --------------------------------------------------------------------------- +@description('Name of the deployed Function App.') +output functionAppName string = functionApp.name + +@description('Default HTTPS URL of the Function App.') +output functionAppUrl string = 'https://${functionApp.properties.defaultHostName}' diff --git a/docs/deploy/azure-functions.md b/docs/deploy/azure-functions.md new file mode 100644 index 00000000..a13f5196 --- /dev/null +++ b/docs/deploy/azure-functions.md @@ -0,0 +1,144 @@ +# Azure Functions Deployment + +ZPan supports deployment to [Azure Functions](https://learn.microsoft.com/en-us/azure/azure-functions/) (programming model v4, Node.js 22) as an alternative to Cloudflare Workers or Docker. The function app serves both the Hono API and the React SPA from a single Consumption-plan function. + +> **S3-compatible storage required** — Azure Blob Storage is _not_ adapted as an object backend. You must bring an external S3-compatible bucket (AWS S3, Cloudflare R2, MinIO, etc.) and configure it as a storage provider inside ZPan after deployment. + +--- + +## Prerequisites + +| Requirement | Notes | +|---|---| +| Azure subscription | Consumption-plan Functions are free up to 1 M invocations/month | +| Azure CLI | `az` ≥ 2.50 — [install](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) | +| Azure Functions Core Tools | v4 — installed automatically by the workflow | +| Turso account | [turso.tech](https://turso.tech) — free tier covers most self-hosted use cases | +| S3-compatible bucket | Any provider; configured inside ZPan post-deploy | + +--- + +## 1 — Create a Turso database + +```sh +turso db create zpan +turso db show zpan # note the URL (libsql://...) +turso db tokens create zpan # note the auth token +``` + +--- + +## 2 — Create an Azure service principal + +The GitHub Actions workflow authenticates to Azure with a service principal whose credentials are stored as a single JSON secret (`AZURE_CREDENTIALS`). + +```sh +# Replace with your Azure subscription ID. +az ad sp create-for-rbac \ + --name "zpan-deploy" \ + --role Contributor \ + --scopes /subscriptions/ \ + --sdk-auth +``` + +The command outputs a JSON block. Copy the **entire JSON object** — it is the value for the `AZURE_CREDENTIALS` secret. + +### Service-principal JSON format + +```json +{ + "clientId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "clientSecret": "your-client-secret", + "subscriptionId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "tenantId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "activeDirectoryEndpointUrl": "https://login.microsoftonline.com", + "resourceManagerEndpointUrl": "https://management.azure.com/", + "activeDirectoryGraphResourceId": "https://graph.windows.net/", + "sqlManagementEndpointUrl": "https://management.core.windows.net:8443/", + "galleryEndpointUrl": "https://gallery.azure.com/", + "managementEndpointUrl": "https://management.core.windows.net/" +} +``` + +--- + +## 3 — Add GitHub repository secrets + +In your fork go to **Settings → Secrets and variables → Actions** and add: + +| Secret name | Value | +|---|---| +| `AZURE_CREDENTIALS` | Full JSON object from `az ad sp create-for-rbac --sdk-auth` (see above) | +| `TURSO_DATABASE_URL` | `libsql://your-db-name-orgname.turso.io` | +| `TURSO_AUTH_TOKEN` | Token from `turso db tokens create zpan` | +| `BETTER_AUTH_SECRET` | _(optional)_ Pre-generated secret — `openssl rand -base64 32`. If absent the workflow generates one automatically on first deploy. | + +--- + +## 4 — Run the workflow + +The workflow triggers automatically on every push to `master` **and** can be triggered manually via **Actions → Deploy to Azure Functions → Run workflow**. + +> The workflow includes `if: github.repository != 'saltbo/zpan'` so it is a no-op in the upstream repo. It only runs in your fork. + +| Manual dispatch input | Description | +|---|---| +| `resource_group` | Azure Resource Group name — created automatically if it does not exist (default: `zpan-rg`) | +| `location` | Azure region (default: `eastus`) | +| `version` | Release tag (e.g. `v2.5.0`). Leave empty to use the latest release. | + +> When triggered by a push, defaults are used for `resource_group` (`zpan-rg`) and `location` (`eastus`). + +### What the workflow does + +1. **Check secrets** — fails fast if any required secret is missing. +2. **Resolve release tag** — pins to a specific ZPan release. +3. **Set up Node 22** and install dependencies. +4. **Azure login** — uses the `AZURE_CREDENTIALS` service principal. +5. **Provision infrastructure** via `deploy/azure-functions/main.bicep`: + - Resource Group (idempotent `az group create`) + - Storage Account (required by the Functions runtime) + - Consumption plan (Y1 / Dynamic SKU) + - Function App (Node 22, runtime v4) +6. **Build** — `npm run build:azure` produces the `azure-functions/` publish directory. +7. **Migrate** — `npm run db:migrate` applies Drizzle migrations to Turso. +8. **Publish** — `func azure functionapp publish ` uploads the bundle. +9. **Set `BETTER_AUTH_SECRET`** — checks whether the setting already exists; generates and sets it if missing. +10. **Update `APP_URL`** — patches the real function-app URL into its own app settings. + +Re-running the workflow is safe — Bicep uses create-or-update semantics and the secret step skips if the setting is already present. + +--- + +## 5 — Post-deploy: configure S3 storage + +1. Open your function app URL in a browser and complete the ZPan setup wizard. +2. Navigate to **Admin → Storages** and add your S3-compatible bucket credentials. + +### Verify the deployment + +```sh +curl https://.azurewebsites.net/api/health +# → {"status":"ok"} +``` + +--- + +## Local development against a Turso database + +```sh +TURSO_DATABASE_URL=libsql://your-db.turso.io \ +TURSO_AUTH_TOKEN=your-token \ +BETTER_AUTH_SECRET=$(openssl rand -base64 32) \ +npm run dev:node +``` + +### Local Azure Functions emulation + +```sh +npm run build:azure +cd azure-functions +func start +``` + +Requires the [Azure Functions Core Tools v4](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local) and a local `.env` file (or environment variables) with `TURSO_DATABASE_URL`, `TURSO_AUTH_TOKEN`, and `BETTER_AUTH_SECRET`. diff --git a/package-lock.json b/package-lock.json index 18072e01..1ee4bf90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1022.0", "@aws-sdk/s3-request-presigner": "^3.1022.0", + "@azure/functions": "^4.12.0", "@better-auth/api-key": "^1.6.2", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -1017,6 +1018,37 @@ "node": ">=18.0.0" } }, + "node_modules/@azure/functions": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@azure/functions/-/functions-4.12.0.tgz", + "integrity": "sha512-aHBSvEDHOUhLhkivPiotoYfE6WPZdutv9OnXEkSqYtyWjbf1k/DoQ/Z9swqIbyaBSvh2xOR8ARKj5CR/2jqEOw==", + "license": "MIT", + "dependencies": { + "@azure/functions-extensions-base": "0.2.0", + "cookie": "^0.7.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@azure/functions-extensions-base": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@azure/functions-extensions-base/-/functions-extensions-base-0.2.0.tgz", + "integrity": "sha512-ncCkHBNQYJa93dBIh+toH0v1iSgCzSo9tr94s6SMBe7DPWREkaWh8cq33A5P4rPSFX1g5W+3SPvIzDr/6/VOWQ==", + "license": "MIT", + "engines": { + "node": ">=18.0" + } + }, + "node_modules/@azure/functions/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", diff --git a/package.json b/package.json index bf148ebe..8c85f213 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "build:lambda": "tsup server/entry-lambda.ts --format cjs --outDir dist-lambda --external @libsql/client", "build:vercel": "vite build --mode node && tsup server/entry-vercel.ts --format esm --outDir api --external @libsql/client", "build:netlify": "tsup server/entry-netlify.ts --format esm --outDir netlify/functions --external @libsql/client", + "build:azure": "vite build && tsup server/entry-azure.ts --format esm --outDir azure-functions --external @azure/functions --external @libsql/client && cp server/azure-host.json azure-functions/host.json && cp -r dist azure-functions/dist", "deploy": "npm run db:migrate:d1:prod && wrangler deploy", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", @@ -36,6 +37,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1022.0", "@aws-sdk/s3-request-presigner": "^3.1022.0", + "@azure/functions": "^4.12.0", "@better-auth/api-key": "^1.6.2", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/server/azure-host.json b/server/azure-host.json new file mode 100644 index 00000000..03839f46 --- /dev/null +++ b/server/azure-host.json @@ -0,0 +1,15 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + }, + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + } +} diff --git a/server/entry-azure.ts b/server/entry-azure.ts new file mode 100644 index 00000000..25f9aa94 --- /dev/null +++ b/server/entry-azure.ts @@ -0,0 +1,43 @@ +import type { HttpRequest, InvocationContext } from '@azure/functions' +import { app } from '@azure/functions' +import { serveStatic } from '@hono/node-server/serve-static' +import { Hono } from 'hono' +import { createBootstrap } from './bootstrap' +import { createLibsqlPlatform } from './platform/libsql' + +const TURSO_DATABASE_URL = process.env.TURSO_DATABASE_URL +if (!TURSO_DATABASE_URL) { + throw new Error('TURSO_DATABASE_URL is required for the Azure Functions deployment.') +} + +const platform = await createLibsqlPlatform({ + TURSO_DATABASE_URL, + TURSO_AUTH_TOKEN: process.env.TURSO_AUTH_TOKEN, +}) + +const apiApp = await createBootstrap(platform) + +const server = new Hono() +server.route('/', apiApp) +server.use('/*', serveStatic({ root: './dist' })) +server.get('/*', serveStatic({ root: './dist', path: 'index.html' })) + +app.http('zpan', { + methods: ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT'], + authLevel: 'anonymous', + route: '{*path}', + handler: async (request: HttpRequest, _context: InvocationContext): Promise => { + // Construct a standards-compliant Request so Hono body-reading works correctly + // on POST/PUT/PATCH routes. Azure HttpRequest v4 has additional properties and + // a single-read stream; wrapping it avoids silent breakage on body-reading routes. + // The body cast and duplex option are required for Node 22's undici-based fetch. + const hasBody = request.method !== 'GET' && request.method !== 'HEAD' + const webReq = new Request(request.url, { + method: request.method, + headers: request.headers, + body: hasBody ? (request.body as BodyInit) : undefined, + ...(hasBody ? { duplex: 'half' } : {}), + } as RequestInit) + return server.fetch(webReq) + }, +})