This blueprint ships a full-stack serverless application on Azure: a React single-page app served from a CDN, a single serverless function that runs a Postgres query, and a managed PostgreSQL database. One pulumi up provisions everything and returns a URL you can open in a browser.
The blueprint covers:
- A static React + Vite bundle served from Azure Front Door Standard
- A Azure Functions (Flex Consumption) function with one route,
GET /api/random, that runsSELECT floor(random()*100)::int AS nagainst Azure Database for PostgreSQL Flexible Server - a single Front Door endpoint with two origin groups - the Storage static-website origin for
/*and the Function origin for/api/*so the browser stays on a single origin and never sees CORS - Database credentials generated during
pulumi upand stored in Azure Key Vault - injected into the Function through a Key Vault reference (@Microsoft.KeyVault(SecretUri=...)) - Private networking: the database runs on Azure Functions VNet integration into the landing-zone subnet, pointing at the Flexible Server’s private endpoint attached to the landing-zone private subnets, never exposed to the public internet
The blueprint consumes the Pulumi landing-zone stack through a StackReference so every downstream project reuses the same network and secret conventions.
Architecture on Azure
The Pulumi program is split into two reusable components plus an entrypoint that wires them together:
Database- provisions Azure Database for PostgreSQL Flexible Server at the BurstableStandard_B1mswith a private endpoint tier, attaches it to the landing-zone private network, generates arandom.RandomPassword, and stores the DB URL in Azure Key Vault.Edge- creates the bucket for the SPA, the Azure Functions (Flex Consumption) that runs the API handler, and Azure Front Door Standard with a single Front Door endpoint with two origin groups - the Storage static-website origin for/*and the Function origin for/api/*. The entrypoint wires these together and exports the public site URL.
The function runs Node.js 20 and ships as a bundled handler.js (esbuild) that imports pg and queries the database. The SPA is a plain Vite + React project with one page and one fetch("/api/random") call; because the API is same-origin, there is no CORS setup and no bearer token in the browser.
Scaling: Flex Consumption scales instances to zero between requests. Flexible Server has no auto-pause yet; the blueprint picks the smallest Burstable tier and the cost + cleanup section shows how to stop the server manually.
The Azure variant uses Azure Functions on the Flex Consumption plan for scale-to-zero compute, Azure Database for PostgreSQL Flexible Server on the Burstable Standard_B1ms tier with a private endpoint into the landing-zone VNet, Key Vault for the DB password (referenced from the Function App with @Microsoft.KeyVault(SecretUri=...)), and Azure Front Door Standard for same-origin CDN routing.
Prerequisites
- Pulumi account and CLI
- Node.js 20 or newer and npm
- an Azure subscription where the Pulumi landing-zone stack is already deployed and you have rights to create Storage accounts, Function Apps, Front Door profiles, Key Vault secrets, PostgreSQL Flexible Servers, and related resources
- Node.js 20 or newer and npm for building the React SPA and the API bundle (both ship as Node packages regardless of the Pulumi language you pick)
- A deployed Pulumi landing-zone stack in the same Azure account; see the blueprint prerequisite banner at the top of this page for the link
Landing-zone inputs
The blueprint reads these outputs from your landing-zone/azure stack through pulumi.StackReference:
resourceGroupName- the resource group where the blueprint creates its resourcesnetworkId- the VNet id that hosts the Flexible Server private endpoint and the Function App VNet integrationprivateSubnetIds- the subnet ids used for the Function VNet integration and the Flexible Server DNS zonesecretsStore- the Key Vault name used to store the DB password secret
If the reference is missing or the output keys are not present, pulumi up fails fast at preview time. Point the stack reference at the right name with:
pulumi config set landingZoneStack <your-org>/landing-zone/<stack>
You do not have to re-deploy the landing-zone stack to iterate on this blueprint. Once the outputs exist, every change here is additive.
Download the blueprint
Use the Download blueprint button at the top of this page to grab the Azure zip for the Pulumi language you selected in the chooser. Each zip contains:
index.tsas the Pulumi entrypointcomponents/database.tsandcomponents/edge.tsas the reusable moduleswebsite/(React + Vite) andapi/(Node handler) as the application codepackage.jsonandtsconfig.jsonfor the root Pulumi project
Unzip, change into the directory, and continue with the quickstart below.
Quickstart
Build the SPA and the API bundle, initialize the Pulumi stack, and deploy. The Pulumi program uploads the built artifacts - it does not run the build itself, so you can iterate on the app and redeploy without any Pulumi-side changes.
# 1. Build the React SPA
cd website
npm install
npm run build
cd ..
# 2. Build the API bundle
cd api
npm install
npm run build
cd ..
# 3. Install root Pulumi dependencies
npm install
# 4. Initialize the stack and point it at your landing zone
pulumi stack init dev
pulumi config set azure-native:location eastus
pulumi config set landingZoneStack <your-org>/landing-zone/dev
# 5. Deploy
pulumi up
pulumi up finishes in 5-10 minutes on a cold account, mostly waiting for the database to become available. When it completes, Pulumi prints a siteUrl output; open it in a browser and you should see the SPA showing the random number the API returned from the database.
App walkthrough
The application ships as two Node packages inside the downloadable zip, both independent of the Pulumi language you chose:
website/- a Vite + React project. One page,App.tsx, fetches/api/randomand shows the number. There is no client-side router, no state library, and no auth; it is the smallest possible proof that the frontend reaches the backend.api/- a Node.js 20 TypeScript package. One router insrc/handler.ts, one route (GET /api/random), and onepgpool insrc/db.ts.esbuildbundles the whole thing todist/handler.jsso the Pulumi program uploads a single file.
The handler
// api/src/handler.ts
import { pool } from "./db";
export async function handle(path: string) {
if (path === "/api/random") {
const result = await pool.query<{ n: number }>(
"SELECT floor(random()*100)::int AS n",
);
return { status: 200, body: JSON.stringify({ n: result.rows[0].n }) };
}
return { status: 404, body: JSON.stringify({ error: "not found" }) };
}
Adding more API routes is an edit + npm run build + pulumi up cycle. No function-specific glue; the handler is pure TypeScript.
The SPA
// website/src/App.tsx
import { useEffect, useState } from "react";
export default function App() {
const [n, setN] = useState<number | null>(null);
useEffect(() => {
fetch("/api/random")
.then((r) => r.json())
.then((data) => setN(data.n));
}, []);
return <main>Backend says your lucky number is: {n ?? "…"}</main>;
}
Database and secret wiring
The Database component provisions azure-native:dbforpostgresql:Server at the Burstable Standard_B1ms with a private endpoint tier and a fresh database named after the Pulumi stack. During pulumi up:
- Pulumi generates a
random.RandomPassword(32 characters, no shell-unsafe symbols). - The password, plus the DB host, port, and database name, is assembled into a Postgres connection string and written to Azure Key Vault (
azure-native:keyvault:Secret). - The Azure Functions (Flex Consumption) function is configured so the connection string is injected into the Function through a Key Vault reference (
@Microsoft.KeyVault(SecretUri=...)).
The function reads the secret once on cold start and reuses the pg.Pool across invocations. The password never appears in Pulumi stack outputs, never leaves Azure Key Vault in plaintext, and rotates automatically if you change the config.
Database networking: Azure Functions VNet integration into the landing-zone subnet, pointing at the Flexible Server’s private endpoint. The DB endpoint has no public IP, so the function is the only path into it.
Deploy
Run pulumi up once the SPA and API builds are in place. The preview shows three components’ worth of resources: the Database cluster plus secret, the Edge bucket + function + CDN, and the wiring that routes requests between them. Approve the preview and Pulumi deploys the whole stack.
When the deploy finishes, Pulumi prints:
siteUrl- the public CDN URL; open it to verify the SPA calls the API and renders the random numberapiUrl- the same hostname plus/api, useful when iterating on the SPA locally withnpm run dev- stack-scoped identifiers (DB name, function name, CDN id) that make log tailing and cache invalidation straightforward
Re-running pulumi up after an app change is fast: Pulumi only updates the function code and re-uploads the SPA, leaving the database and CDN alone.
Stack outputs
Every variant exports the same core outputs so downstream Pulumi projects can consume them with StackReference or Pulumi ESC:
siteUrl- the public Azure Front Door Standard URL that serves the SPAapiUrl- the same hostname plus/api, useful for integration testsdbSecretId- the handle to the database secret stored in Azure Key Vault
Cloud-specific outputs on this variant:
dbServerName- the Flexible Server resource name for portal navigation andpsqlconnectivityfunctionAppName- the Function App name so you can tail logs withaz webapp log tailfrontDoorEndpointHostname- the Front Door endpoint hostname, useful for DNS alias records later
Run pulumi stack output to see the full list with values. Secret-typed outputs require --show-secrets.
Operations and cost
Logs and cache
Function logs stream through Application Insights attached to the Function
App. Flexible Server metrics are in the PostgreSQL blade. Front Door request
logs and cache invalidations live under the Front Door profile. Clear the
SPA cache with
az afd endpoint purge --resource-group <rg> --profile-name <profile> --endpoint-name <endpoint> --content-paths "/*"
after each npm run build in website/.
Cost
Flex Consumption charges per GB-second of execution, so idle cost for the
Function is zero. Flexible Server runs continuously on the Burstable tier;
stop it from the portal or with az postgres flexible-server stop when you
are not using it. Front Door Standard has a small monthly minimum plus
per-request and per-GB charges.
Cleanup
pulumi destroy tears the whole stack down. The object-storage bucket is emptied during destroy so the delete succeeds; if you attached extra objects outside Pulumi, remove them first.
The landing-zone stack is a separate project; destroying this blueprint does not touch it.
Blueprint Pulumi program
The entrypoint reads the landing-zone outputs, creates the database component, then the edge component, and exports the site URL you can open in a browser once pulumi up completes.
import * as pulumi from "@pulumi/pulumi";
import { Database } from "./components/database";
import { Edge } from "./components/edge";
const config = new pulumi.Config();
const landingZoneStackName = config.require("landingZoneStack");
const dbEngineVersion = config.get("dbEngineVersion") ?? "16";
const functionMemoryMB = config.getNumber("functionMemoryMB") ?? 2048;
const websiteDistPath = config.get("websiteDistPath") ?? "./website/dist";
const apiHandlerPath = config.get("apiHandlerPath") ?? "./api/dist";
const location = new pulumi.Config("azure-native").require("location");
const landingZone = new pulumi.StackReference(landingZoneStackName);
const resourceGroupName = landingZone.requireOutput("resourceGroupName") as pulumi.Output<string>;
const vnetId = landingZone.requireOutput("networkId") as pulumi.Output<string>;
const privateSubnetIds = landingZone.requireOutput("privateSubnetIds") as pulumi.Output<string[]>;
const keyVaultName = landingZone.requireOutput("secretsStore") as pulumi.Output<string>;
const projectName = `${pulumi.getStack()}-serverless-react-postgres`;
const commonTags: Record<string, string> = {
environment: pulumi.getStack(),
"solution-family": "serverless-react-postgres",
cloud: "azure",
language: "typescript",
};
const database = new Database("db", {
resourceGroupName,
location,
vnetId,
privateSubnetIds,
keyVaultName,
engineVersion: dbEngineVersion,
namePrefix: projectName,
tags: commonTags,
});
const edge = new Edge("edge", {
resourceGroupName,
location,
vnetId,
keyVaultName,
databaseSecretUri: database.secretUri,
websiteDistPath,
apiHandlerPath,
functionMemoryMB,
namePrefix: projectName,
tags: commonTags,
});
export const siteUrl = edge.siteUrl;
export const apiUrl = edge.apiUrl;
export const dbSecretId = database.secretUri;
export const dbServerName = database.serverName;
export const functionAppName = edge.functionAppName;
export const frontDoorEndpointHostname = edge.endpointHostname;
export const escEnvironment = `${pulumi.getStack()}-serverless-react-postgres`;
import pulumi
from components.database import Database, DatabaseArgs
from components.edge import Edge, EdgeArgs
config = pulumi.Config()
landing_zone_stack_name = config.require("landingZoneStack")
db_engine_version = config.get("dbEngineVersion") or "16"
function_memory_mb = config.get_int("functionMemoryMB") or 2048
website_dist_path = config.get("websiteDistPath") or "./website/dist"
api_handler_path = config.get("apiHandlerPath") or "./api/dist"
location = pulumi.Config("azure-native").require("location")
landing_zone = pulumi.StackReference(landing_zone_stack_name)
resource_group_name = landing_zone.require_output("resourceGroupName")
vnet_id = landing_zone.require_output("networkId")
private_subnet_ids = landing_zone.require_output("privateSubnetIds")
key_vault_name = landing_zone.require_output("secretsStore")
project_name = f"{pulumi.get_stack()}-serverless-react-postgres"
common_tags = {
"environment": pulumi.get_stack(),
"solution-family": "serverless-react-postgres",
"cloud": "azure",
"language": "python",
}
database = Database(
"db",
DatabaseArgs(
resource_group_name=resource_group_name,
location=location,
vnet_id=vnet_id,
private_subnet_ids=private_subnet_ids,
key_vault_name=key_vault_name,
engine_version=db_engine_version,
name_prefix=project_name,
tags=common_tags,
),
)
edge = Edge(
"edge",
EdgeArgs(
resource_group_name=resource_group_name,
location=location,
vnet_id=vnet_id,
key_vault_name=key_vault_name,
database_secret_uri=database.secret_uri,
website_dist_path=website_dist_path,
api_handler_path=api_handler_path,
function_memory_mb=function_memory_mb,
name_prefix=project_name,
tags=common_tags,
),
)
pulumi.export("siteUrl", edge.site_url)
pulumi.export("apiUrl", edge.api_url)
pulumi.export("dbSecretId", database.secret_uri)
pulumi.export("dbServerName", database.server_name)
pulumi.export("functionAppName", edge.function_app_name)
pulumi.export("frontDoorEndpointHostname", edge.endpoint_hostname)
pulumi.export("escEnvironment", f"{pulumi.get_stack()}-serverless-react-postgres")
package main
import (
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi/config"
"serverless-react-postgres-azure/database"
"serverless-react-postgres-azure/edge"
)
func main() {
pulumi.Run(Program)
}
func Program(ctx *pulumi.Context) error {
cfg := config.New(ctx, "")
landingZoneStackName := cfg.Require("landingZoneStack")
dbEngineVersion := cfg.Get("dbEngineVersion")
if dbEngineVersion == "" {
dbEngineVersion = "16"
}
functionMemoryMB := cfg.GetInt("functionMemoryMB")
if functionMemoryMB == 0 {
functionMemoryMB = 2048
}
websiteDistPath := cfg.Get("websiteDistPath")
if websiteDistPath == "" {
websiteDistPath = "./website/dist"
}
apiHandlerPath := cfg.Get("apiHandlerPath")
if apiHandlerPath == "" {
apiHandlerPath = "./api/dist"
}
location := config.New(ctx, "azure-native").Require("location")
landingZone, err := pulumi.NewStackReference(ctx, landingZoneStackName, nil)
if err != nil {
return err
}
resourceGroupName := landingZone.GetStringOutput(pulumi.String("resourceGroupName"))
vnetId := landingZone.GetStringOutput(pulumi.String("networkId"))
privateSubnetIds := landingZone.GetOutput(pulumi.String("privateSubnetIds")).ApplyT(func(v interface{}) []string {
values, ok := v.([]interface{})
if !ok {
return []string{}
}
result := make([]string, 0, len(values))
for _, item := range values {
if s, ok := item.(string); ok {
result = append(result, s)
}
}
return result
}).(pulumi.StringArrayOutput)
keyVaultName := landingZone.GetStringOutput(pulumi.String("secretsStore"))
projectName := ctx.Stack() + "-serverless-react-postgres"
commonTags := pulumi.StringMap{
"environment": pulumi.String(ctx.Stack()),
"solution-family": pulumi.String("serverless-react-postgres"),
"cloud": pulumi.String("azure"),
"language": pulumi.String("go"),
}
db, err := database.New(ctx, "db", &database.Args{
ResourceGroupName: resourceGroupName,
Location: pulumi.String(location),
VnetId: vnetId,
PrivateSubnetIds: privateSubnetIds,
KeyVaultName: keyVaultName,
EngineVersion: pulumi.String(dbEngineVersion),
NamePrefix: pulumi.String(projectName),
Tags: commonTags,
})
if err != nil {
return err
}
ed, err := edge.New(ctx, "edge", &edge.Args{
ResourceGroupName: resourceGroupName,
Location: pulumi.String(location),
VnetId: vnetId,
KeyVaultName: keyVaultName,
DatabaseSecretUri: db.SecretUri,
WebsiteDistPath: websiteDistPath,
ApiHandlerPath: apiHandlerPath,
FunctionMemoryMB: pulumi.Int(functionMemoryMB),
NamePrefix: pulumi.String(projectName),
Tags: commonTags,
})
if err != nil {
return err
}
ctx.Export("siteUrl", ed.SiteUrl)
ctx.Export("apiUrl", ed.ApiUrl)
ctx.Export("dbSecretId", db.SecretUri)
ctx.Export("dbServerName", db.ServerName)
ctx.Export("functionAppName", ed.FunctionAppName)
ctx.Export("frontDoorEndpointHostname", ed.EndpointHostname)
ctx.Export("escEnvironment", pulumi.String(ctx.Stack()+"-serverless-react-postgres"))
return nil
}
Reusable components
The database wiring and the CDN + function + bucket wiring each live in a reusable module. Copy them into other Pulumi projects or adapt per team.
components/database.ts
Provisions the PostgreSQL Flexible Server instance on the landing-zone private network, generates a strong database password, and stores it in Azure Key Vault for the function to read.
import * as azure from "@pulumi/azure-native";
import * as pulumi from "@pulumi/pulumi";
import * as random from "@pulumi/random";
export interface DatabaseArgs {
resourceGroupName: pulumi.Input<string>;
location: pulumi.Input<string>;
vnetId: pulumi.Input<string>;
privateSubnetIds: pulumi.Input<pulumi.Input<string>[]>;
keyVaultName: pulumi.Input<string>;
engineVersion: pulumi.Input<string>;
namePrefix: pulumi.Input<string>;
tags: Record<string, string>;
}
const PRIVATE_DNS_ZONE_NAME = "privatepostgres.database.azure.com";
/**
* Azure Database for PostgreSQL Flexible Server wired for private VNet access.
*
* Assumptions:
* - The landing-zone VNet (`args.vnetId`) exposes at least one private subnet that is
* delegated to `Microsoft.DBforPostgreSQL/flexibleServers`. We consume
* `privateSubnetIds[0]` as the DB subnet. If the landing-zone does not delegate that
* subnet, this deploy will fail fast and you should add the delegation there rather
* than here, so the VNet schema remains owned by the landing-zone stack.
* - The landing-zone Key Vault (`args.keyVaultName`) has RBAC enabled. The full DATABASE_URL
* is written as a vault secret; the Function App references it via
* `@Microsoft.KeyVault(SecretUri=...)`.
*/
export class Database extends pulumi.ComponentResource {
public readonly serverName: pulumi.Output<string>;
public readonly secretUri: pulumi.Output<string>;
public readonly fullyQualifiedDomainName: pulumi.Output<string>;
public readonly databaseName: pulumi.Output<string>;
constructor(name: string, args: DatabaseArgs, opts?: pulumi.ComponentResourceOptions) {
super("serverless-react-postgres:azure:Database", name, {}, opts);
const parent = { parent: this };
const databaseName = "appdb";
const masterUsername = "pgadmin";
const password = new random.RandomPassword(`${name}-password`, {
length: 32,
special: false,
}, parent);
// Private DNS zone + VNet link so the Function App's VNet integration can
// resolve `<server>.privatepostgres.database.azure.com` inside the landing-zone VNet.
const privateDnsZone = new azure.privatedns.PrivateZone(`${name}-dns`, {
resourceGroupName: args.resourceGroupName,
privateZoneName: PRIVATE_DNS_ZONE_NAME,
location: "global",
tags: args.tags,
}, parent);
const dnsVnetLink = new azure.privatedns.VirtualNetworkLink(`${name}-dns-link`, {
resourceGroupName: args.resourceGroupName,
privateZoneName: privateDnsZone.name,
location: "global",
registrationEnabled: false,
virtualNetwork: { id: args.vnetId },
tags: args.tags,
}, parent);
const dbSubnetId = pulumi.output(args.privateSubnetIds).apply((ids) => {
if (!ids || ids.length === 0) {
throw new Error("privateSubnetIds must have at least one subnet for the Flexible Server");
}
return ids[0];
});
const serverNameOutput = pulumi
.all([args.namePrefix])
.apply(([prefix]) => prefix.toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 55));
const server = new azure.dbforpostgresql.Server(`${name}-pg`, {
resourceGroupName: args.resourceGroupName,
location: args.location,
serverName: serverNameOutput,
version: args.engineVersion,
administratorLogin: masterUsername,
administratorLoginPassword: password.result,
sku: {
name: "Standard_B1ms",
tier: azure.dbforpostgresql.SkuTier.Burstable,
},
storage: {
storageSizeGB: 32,
},
backup: {
backupRetentionDays: 7,
geoRedundantBackup: azure.dbforpostgresql.GeoRedundantBackup.Disabled,
},
highAvailability: {
mode: azure.dbforpostgresql.PostgreSqlFlexibleServerHighAvailabilityMode.Disabled,
},
network: {
delegatedSubnetResourceId: dbSubnetId,
privateDnsZoneArmResourceId: privateDnsZone.id,
},
createMode: azure.dbforpostgresql.CreateMode.Default,
tags: args.tags,
}, { ...parent, dependsOn: [dnsVnetLink] });
const appDatabase = new azure.dbforpostgresql.Database(`${name}-db`, {
resourceGroupName: args.resourceGroupName,
serverName: server.name,
databaseName: databaseName,
charset: "UTF8",
collation: "en_US.utf8",
}, parent);
const connectionUrl = pulumi.all([
password.result,
server.fullyQualifiedDomainName,
]).apply(([pw, host]) =>
`postgresql://${masterUsername}:${encodeURIComponent(pw)}@${host}:5432/${databaseName}?sslmode=require`,
);
const secretName = pulumi.interpolate`${args.namePrefix}-database-url`.apply((n) =>
n.replace(/[^A-Za-z0-9-]/g, "-"),
);
const secret = new azure.keyvault.Secret(`${name}-secret`, {
resourceGroupName: args.resourceGroupName,
vaultName: args.keyVaultName,
secretName: secretName,
properties: {
value: connectionUrl,
contentType: "text/plain",
},
tags: args.tags,
}, { ...parent, dependsOn: [appDatabase] });
// Key Vault reference syntax requires the secret URI; the resource exposes
// `properties.secretUri` (no version) which is what `@Microsoft.KeyVault(SecretUri=...)`
// expects when we want app settings to always follow the latest version.
this.serverName = server.name;
this.secretUri = secret.properties.apply((p) => p.secretUri);
this.fullyQualifiedDomainName = server.fullyQualifiedDomainName;
this.databaseName = pulumi.output(databaseName);
this.registerOutputs({
serverName: this.serverName,
secretUri: this.secretUri,
fullyQualifiedDomainName: this.fullyQualifiedDomainName,
databaseName: this.databaseName,
});
}
}
components/edge.ts
Provisions the Azure Functions (Flex Consumption) function that runs the API, uploads the SPA to object storage, and wires Azure Front Door Standard so /* serves the SPA and /api/* reaches the function.
import * as fs from "fs";
import * as path from "path";
import * as azure from "@pulumi/azure-native";
import * as pulumi from "@pulumi/pulumi";
export interface EdgeArgs {
resourceGroupName: pulumi.Input<string>;
location: pulumi.Input<string>;
vnetId: pulumi.Input<string>;
keyVaultName: pulumi.Input<string>;
databaseSecretUri: pulumi.Input<string>;
websiteDistPath: string;
apiHandlerPath: string;
functionMemoryMB: pulumi.Input<number>;
namePrefix: pulumi.Input<string>;
tags: Record<string, string>;
}
const MIME_TYPES: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".mjs": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".map": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".ico": "image/x-icon",
".webp": "image/webp",
".txt": "text/plain; charset=utf-8",
".woff": "font/woff",
".woff2": "font/woff2",
};
function contentTypeFor(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
return MIME_TYPES[ext] ?? "application/octet-stream";
}
function walk(dir: string): string[] {
const results: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...walk(full));
} else if (entry.isFile()) {
results.push(full);
}
}
return results;
}
function buildArchive(root: string): pulumi.asset.AssetArchive {
const map: Record<string, pulumi.asset.FileAsset> = {};
for (const full of walk(root)) {
const rel = path.relative(root, full).split(path.sep).join("/");
map[rel] = new pulumi.asset.FileAsset(full);
}
return new pulumi.asset.AssetArchive(map);
}
export class Edge extends pulumi.ComponentResource {
public readonly siteUrl: pulumi.Output<string>;
public readonly apiUrl: pulumi.Output<string>;
public readonly functionAppName: pulumi.Output<string>;
public readonly endpointHostname: pulumi.Output<string>;
public readonly storageAccountName: pulumi.Output<string>;
constructor(name: string, args: EdgeArgs, opts?: pulumi.ComponentResourceOptions) {
super("serverless-react-postgres:azure:Edge", name, {}, opts);
const parent = { parent: this };
const client = azure.authorization.getClientConfigOutput({ parent: this });
// Dedicated storage account for this blueprint (SPA + function zip).
const storageAccountNameInput = pulumi
.output(args.namePrefix)
.apply((prefix) => prefix.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 20));
const storageAccount = new azure.storage.StorageAccount(`${name}-storage`, {
accountName: storageAccountNameInput,
resourceGroupName: args.resourceGroupName,
location: args.location,
sku: { name: azure.storage.SkuName.Standard_LRS },
kind: azure.storage.Kind.StorageV2,
allowBlobPublicAccess: true,
minimumTlsVersion: azure.storage.MinimumTlsVersion.TLS1_2,
tags: args.tags,
}, parent);
// Enable the $web static-website endpoint; this provisions the $web container
// automatically and exposes `primaryEndpoints.web`.
const staticWebsite = new azure.storage.StorageAccountStaticWebsite(`${name}-website`, {
accountName: storageAccount.name,
resourceGroupName: args.resourceGroupName,
indexDocument: "index.html",
error404Document: "index.html",
}, parent);
const codeContainer = new azure.storage.BlobContainer(`${name}-code`, {
accountName: storageAccount.name,
containerName: "function-code",
resourceGroupName: args.resourceGroupName,
publicAccess: azure.storage.PublicAccess.None,
}, parent);
// Per-file SPA upload into $web so each file keeps its correct Content-Type.
const websiteFiles = walk(args.websiteDistPath);
for (const file of websiteFiles) {
const key = path.relative(args.websiteDistPath, file).split(path.sep).join("/");
const safeKey = key.replace(/[^A-Za-z0-9._-]/g, "_");
new azure.storage.Blob(`${name}-site-${safeKey}`, {
accountName: storageAccount.name,
resourceGroupName: args.resourceGroupName,
containerName: staticWebsite.containerName,
blobName: key,
source: new pulumi.asset.FileAsset(file),
contentType: contentTypeFor(file),
type: azure.storage.BlobType.Block,
}, { ...parent, dependsOn: [staticWebsite] });
}
// Function App zip package: bundle `./api/dist` into a single zip blob that
// Flex Consumption pulls from via `functionAppConfig.deployment`.
const functionZip = new azure.storage.Blob(`${name}-fn-zip`, {
accountName: storageAccount.name,
resourceGroupName: args.resourceGroupName,
containerName: codeContainer.name,
blobName: "functionapp.zip",
source: buildArchive(args.apiHandlerPath),
contentType: "application/zip",
type: azure.storage.BlobType.Block,
}, parent);
const storageKeys = azure.storage.listStorageAccountKeysOutput({
accountName: storageAccount.name,
resourceGroupName: args.resourceGroupName,
}, { parent: this });
const storageConnectionString = pulumi
.all([storageAccount.name, storageKeys.keys])
.apply(([accountName, keys]) =>
`DefaultEndpointsProtocol=https;AccountName=${accountName};AccountKey=${keys[0].value};EndpointSuffix=core.windows.net`,
);
// Dedicated subnet for the Function App VNet integration. The Web App platform
// requires a subnet delegated to `Microsoft.Web/serverFarms`, and the landing-zone
// subnets are general-purpose, so we carve out a /26 here. Address prefix is
// conventional for the 10.10.0.0/16 landing-zone VNet; adjust via config if a
// collision is reported at deploy time.
const vnetName = pulumi.output(args.vnetId).apply((id) => {
const parts = id.split("/");
return parts[parts.length - 1];
});
const webSubnet = new azure.network.Subnet(`${name}-web-subnet`, {
subnetName: pulumi.interpolate`${args.namePrefix}-web-sn`,
resourceGroupName: args.resourceGroupName,
virtualNetworkName: vnetName,
addressPrefix: "10.10.240.0/26",
delegations: [{
name: "web",
serviceName: "Microsoft.Web/serverFarms",
}],
}, parent);
const plan = new azure.web.AppServicePlan(`${name}-plan`, {
kind: "functionapp",
location: args.location,
resourceGroupName: args.resourceGroupName,
reserved: true,
sku: { name: "FC1", tier: "FlexConsumption" },
tags: args.tags,
}, parent);
const keyVaultReference = pulumi
.output(args.databaseSecretUri)
.apply((uri) => `@Microsoft.KeyVault(SecretUri=${uri})`);
const functionApp = new azure.web.WebApp(`${name}-fn`, {
kind: "functionapp,linux",
location: args.location,
resourceGroupName: args.resourceGroupName,
serverFarmId: plan.id,
httpsOnly: true,
identity: {
type: azure.web.ManagedServiceIdentityType.SystemAssigned,
},
virtualNetworkSubnetId: webSubnet.id,
functionAppConfig: {
deployment: {
storage: {
type: azure.web.FunctionsDeploymentStorageType.BlobContainer,
value: pulumi.interpolate`${storageAccount.primaryEndpoints.blob}${codeContainer.name}/${functionZip.name}`,
authentication: {
type: azure.web.AuthenticationType.StorageAccountConnectionString,
storageAccountConnectionStringName: "AzureWebJobsStorage",
},
},
},
runtime: {
name: azure.web.RuntimeName.Node,
version: "20",
},
scaleAndConcurrency: {
instanceMemoryMB: args.functionMemoryMB,
maximumInstanceCount: 100,
},
},
siteConfig: {
linuxFxVersion: "Node|20",
appSettings: [
{ name: "AzureWebJobsStorage", value: storageConnectionString },
{ name: "FUNCTIONS_EXTENSION_VERSION", value: "~4" },
{ name: "DATABASE_URL", value: keyVaultReference },
],
},
tags: args.tags,
}, { ...parent, dependsOn: [functionZip] });
// Grant the Function App's system-assigned identity Key Vault Secrets User so
// the Key Vault reference in `DATABASE_URL` resolves at runtime.
const keyVaultSecretsUser = "4633458b-17de-408a-b874-0445c86b69e6";
const keyVaultScope = pulumi.interpolate`/subscriptions/${client.subscriptionId}/resourceGroups/${args.resourceGroupName}/providers/Microsoft.KeyVault/vaults/${args.keyVaultName}`;
new azure.authorization.RoleAssignment(`${name}-fn-kv-access`, {
principalId: functionApp.identity.apply((id) => id!.principalId!),
principalType: "ServicePrincipal",
roleDefinitionId: pulumi.interpolate`/subscriptions/${client.subscriptionId}/providers/Microsoft.Authorization/roleDefinitions/${keyVaultSecretsUser}`,
scope: keyVaultScope,
}, parent);
// Front Door Standard: single endpoint, two origin groups, two routes.
const edgeProfile = new azure.cdn.Profile(`${name}-edge`, {
resourceGroupName: args.resourceGroupName,
location: "global",
sku: { name: azure.cdn.SkuName.Standard_AzureFrontDoor },
tags: args.tags,
}, parent);
const edgeEndpoint = new azure.cdn.AFDEndpoint(`${name}-endpoint`, {
profileName: edgeProfile.name,
resourceGroupName: args.resourceGroupName,
location: "global",
enabledState: azure.cdn.EnabledState.Enabled,
tags: args.tags,
}, parent);
const webOriginHost = storageAccount.primaryEndpoints.web.apply((endpoint) =>
endpoint.replace(/^https?:\/\//, "").replace(/\/$/, ""),
);
const websiteOriginGroup = new azure.cdn.AFDOriginGroup(`${name}-website-og`, {
originGroupName: "website-origins",
profileName: edgeProfile.name,
resourceGroupName: args.resourceGroupName,
loadBalancingSettings: {
sampleSize: 4,
successfulSamplesRequired: 3,
additionalLatencyInMilliseconds: 50,
},
healthProbeSettings: {
probePath: "/",
probeRequestType: azure.cdn.HealthProbeRequestType.HEAD,
probeProtocol: azure.cdn.ProbeProtocol.Https,
probeIntervalInSeconds: 120,
},
}, parent);
const websiteOrigin = new azure.cdn.AFDOrigin(`${name}-website-origin`, {
originName: "website",
originGroupName: websiteOriginGroup.name,
profileName: edgeProfile.name,
resourceGroupName: args.resourceGroupName,
hostName: webOriginHost,
originHostHeader: webOriginHost,
httpsPort: 443,
enabledState: azure.cdn.EnabledState.Enabled,
}, { ...parent, dependsOn: [staticWebsite] });
const apiOriginGroup = new azure.cdn.AFDOriginGroup(`${name}-api-og`, {
originGroupName: "api-origins",
profileName: edgeProfile.name,
resourceGroupName: args.resourceGroupName,
loadBalancingSettings: {
sampleSize: 4,
successfulSamplesRequired: 3,
additionalLatencyInMilliseconds: 50,
},
healthProbeSettings: {
probePath: "/api/health",
probeRequestType: azure.cdn.HealthProbeRequestType.GET,
probeProtocol: azure.cdn.ProbeProtocol.Https,
probeIntervalInSeconds: 120,
},
}, parent);
const apiOrigin = new azure.cdn.AFDOrigin(`${name}-api-origin`, {
originName: "api",
originGroupName: apiOriginGroup.name,
profileName: edgeProfile.name,
resourceGroupName: args.resourceGroupName,
hostName: functionApp.defaultHostName,
originHostHeader: functionApp.defaultHostName,
httpsPort: 443,
enabledState: azure.cdn.EnabledState.Enabled,
}, parent);
new azure.cdn.Route(`${name}-website-route`, {
routeName: "website",
endpointName: edgeEndpoint.name,
profileName: edgeProfile.name,
resourceGroupName: args.resourceGroupName,
originGroup: { id: websiteOriginGroup.id },
supportedProtocols: [
azure.cdn.AFDEndpointProtocols.Http,
azure.cdn.AFDEndpointProtocols.Https,
],
patternsToMatch: ["/*"],
forwardingProtocol: azure.cdn.ForwardingProtocol.HttpsOnly,
httpsRedirect: azure.cdn.HttpsRedirect.Enabled,
linkToDefaultDomain: azure.cdn.LinkToDefaultDomain.Enabled,
enabledState: azure.cdn.EnabledState.Enabled,
}, { ...parent, dependsOn: [websiteOrigin] });
new azure.cdn.Route(`${name}-api-route`, {
routeName: "api",
endpointName: edgeEndpoint.name,
profileName: edgeProfile.name,
resourceGroupName: args.resourceGroupName,
originGroup: { id: apiOriginGroup.id },
supportedProtocols: [
azure.cdn.AFDEndpointProtocols.Http,
azure.cdn.AFDEndpointProtocols.Https,
],
patternsToMatch: ["/api/*"],
forwardingProtocol: azure.cdn.ForwardingProtocol.HttpsOnly,
httpsRedirect: azure.cdn.HttpsRedirect.Enabled,
linkToDefaultDomain: azure.cdn.LinkToDefaultDomain.Enabled,
enabledState: azure.cdn.EnabledState.Enabled,
}, { ...parent, dependsOn: [apiOrigin] });
this.siteUrl = edgeEndpoint.hostName.apply((host) => `https://${host}`);
this.apiUrl = edgeEndpoint.hostName.apply((host) => `https://${host}/api`);
this.functionAppName = functionApp.name;
this.endpointHostname = edgeEndpoint.hostName;
this.storageAccountName = storageAccount.name;
this.registerOutputs({
siteUrl: this.siteUrl,
apiUrl: this.apiUrl,
functionAppName: this.functionAppName,
endpointHostname: this.endpointHostname,
storageAccountName: this.storageAccountName,
});
}
}
components/database.py
Provisions the PostgreSQL Flexible Server instance on the landing-zone private network, generates a strong database password, and stores it in Azure Key Vault for the function to read.
from __future__ import annotations
from dataclasses import dataclass
from typing import Mapping, Optional, Sequence
from urllib.parse import quote
import pulumi
import pulumi_azure_native as azure_native
import pulumi_random as random
PRIVATE_DNS_ZONE_NAME = "privatepostgres.database.azure.com"
@dataclass
class DatabaseArgs:
resource_group_name: pulumi.Input[str]
location: pulumi.Input[str]
vnet_id: pulumi.Input[str]
private_subnet_ids: pulumi.Input[Sequence[pulumi.Input[str]]]
key_vault_name: pulumi.Input[str]
engine_version: pulumi.Input[str]
name_prefix: pulumi.Input[str]
tags: Optional[Mapping[str, str]] = None
class Database(pulumi.ComponentResource):
"""Azure Database for PostgreSQL Flexible Server wired for private VNet access.
Assumptions:
- The landing-zone VNet exposes at least one private subnet delegated to
`Microsoft.DBforPostgreSQL/flexibleServers`. We use `private_subnet_ids[0]` as
the DB subnet. If the landing-zone does not delegate that subnet, deploy will
fail fast and the delegation should be added there so VNet ownership stays
with the landing-zone stack.
- The landing-zone Key Vault is RBAC-enabled. The DATABASE_URL is stored as a
vault secret; the Function App references it via
`@Microsoft.KeyVault(SecretUri=...)`.
"""
def __init__(
self,
name: str,
args: DatabaseArgs,
opts: Optional[pulumi.ResourceOptions] = None,
) -> None:
super().__init__("serverless-react-postgres:azure:Database", name, {}, opts)
tags = dict(args.tags or {})
child = pulumi.ResourceOptions(parent=self)
database_name = "appdb"
master_username = "pgadmin"
password = random.RandomPassword(
f"{name}-password",
length=32,
special=False,
opts=child,
)
private_dns_zone = azure_native.privatedns.PrivateZone(
f"{name}-dns",
resource_group_name=args.resource_group_name,
private_zone_name=PRIVATE_DNS_ZONE_NAME,
location="global",
tags=tags,
opts=child,
)
dns_vnet_link = azure_native.privatedns.VirtualNetworkLink(
f"{name}-dns-link",
resource_group_name=args.resource_group_name,
private_zone_name=private_dns_zone.name,
location="global",
registration_enabled=False,
virtual_network=azure_native.privatedns.SubResourceArgs(id=args.vnet_id),
tags=tags,
opts=child,
)
def _first_subnet(ids):
if not ids:
raise ValueError("private_subnet_ids must have at least one subnet for the Flexible Server")
return ids[0]
db_subnet_id = pulumi.Output.from_input(args.private_subnet_ids).apply(_first_subnet)
server_name_output = pulumi.Output.from_input(args.name_prefix).apply(
lambda prefix: "".join(ch if ch.isalnum() or ch == "-" else "-" for ch in prefix.lower())[:55]
)
server = azure_native.dbforpostgresql.Server(
f"{name}-pg",
resource_group_name=args.resource_group_name,
location=args.location,
server_name=server_name_output,
version=args.engine_version,
administrator_login=master_username,
administrator_login_password=password.result,
sku=azure_native.dbforpostgresql.SkuArgs(
name="Standard_B1ms",
tier=azure_native.dbforpostgresql.SkuTier.BURSTABLE,
),
storage=azure_native.dbforpostgresql.StorageArgs(storage_size_gb=32),
backup=azure_native.dbforpostgresql.BackupArgs(
backup_retention_days=7,
geo_redundant_backup=azure_native.dbforpostgresql.GeoRedundantBackup.DISABLED,
),
high_availability=azure_native.dbforpostgresql.HighAvailabilityArgs(
mode=azure_native.dbforpostgresql.PostgreSqlFlexibleServerHighAvailabilityMode.DISABLED,
),
network=azure_native.dbforpostgresql.NetworkArgs(
delegated_subnet_resource_id=db_subnet_id,
private_dns_zone_arm_resource_id=private_dns_zone.id,
),
create_mode=azure_native.dbforpostgresql.CreateMode.DEFAULT,
tags=tags,
opts=pulumi.ResourceOptions(parent=self, depends_on=[dns_vnet_link]),
)
app_database = azure_native.dbforpostgresql.Database(
f"{name}-db",
resource_group_name=args.resource_group_name,
server_name=server.name,
database_name=database_name,
charset="UTF8",
collation="en_US.utf8",
opts=child,
)
connection_url = pulumi.Output.all(password.result, server.fully_qualified_domain_name).apply(
lambda parts: f"postgresql://{master_username}:{quote(parts[0], safe='')}@{parts[1]}:5432/{database_name}?sslmode=require"
)
secret_name = pulumi.Output.from_input(args.name_prefix).apply(
lambda prefix: "".join(ch if ch.isalnum() or ch == "-" else "-" for ch in prefix) + "-database-url"
)
secret = azure_native.keyvault.Secret(
f"{name}-secret",
resource_group_name=args.resource_group_name,
vault_name=args.key_vault_name,
secret_name=secret_name,
properties=azure_native.keyvault.SecretPropertiesArgs(
value=connection_url,
content_type="text/plain",
),
tags=tags,
opts=pulumi.ResourceOptions(parent=self, depends_on=[app_database]),
)
# `properties.secret_uri` (version-less) is what Key Vault references of the
# form `@Microsoft.KeyVault(SecretUri=...)` expect when app settings should
# always follow the latest secret version.
self.server_name = server.name
self.secret_uri = secret.properties.apply(lambda p: p.secret_uri)
self.fully_qualified_domain_name = server.fully_qualified_domain_name
self.database_name = pulumi.Output.from_input(database_name)
self.register_outputs(
{
"server_name": self.server_name,
"secret_uri": self.secret_uri,
"fully_qualified_domain_name": self.fully_qualified_domain_name,
"database_name": self.database_name,
}
)
components/edge.py
Provisions the Azure Functions (Flex Consumption) function that runs the API, uploads the SPA to object storage, and wires Azure Front Door Standard so /* serves the SPA and /api/* reaches the function.
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Mapping, Optional
import pulumi
import pulumi_azure_native as azure_native
MIME_TYPES = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".mjs": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".map": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".ico": "image/x-icon",
".webp": "image/webp",
".txt": "text/plain; charset=utf-8",
".woff": "font/woff",
".woff2": "font/woff2",
}
def _content_type_for(path: str) -> str:
_, ext = os.path.splitext(path.lower())
return MIME_TYPES.get(ext, "application/octet-stream")
def _walk(root: str):
for dirpath, _, filenames in os.walk(root):
for filename in filenames:
yield os.path.join(dirpath, filename)
def _build_archive(root: str) -> pulumi.AssetArchive:
assets = {}
for full in _walk(root):
rel = os.path.relpath(full, root).replace(os.sep, "/")
assets[rel] = pulumi.FileAsset(full)
return pulumi.AssetArchive(assets)
@dataclass
class EdgeArgs:
resource_group_name: pulumi.Input[str]
location: pulumi.Input[str]
vnet_id: pulumi.Input[str]
key_vault_name: pulumi.Input[str]
database_secret_uri: pulumi.Input[str]
website_dist_path: str
api_handler_path: str
function_memory_mb: pulumi.Input[int]
name_prefix: pulumi.Input[str]
tags: Optional[Mapping[str, str]] = None
class Edge(pulumi.ComponentResource):
def __init__(
self,
name: str,
args: EdgeArgs,
opts: Optional[pulumi.ResourceOptions] = None,
) -> None:
super().__init__("serverless-react-postgres:azure:Edge", name, {}, opts)
tags = dict(args.tags or {})
child = pulumi.ResourceOptions(parent=self)
client = azure_native.authorization.get_client_config_output()
storage_account_name = pulumi.Output.from_input(args.name_prefix).apply(
lambda prefix: "".join(ch for ch in prefix.lower() if ch.isalnum())[:20]
)
storage_account = azure_native.storage.StorageAccount(
f"{name}-storage",
account_name=storage_account_name,
resource_group_name=args.resource_group_name,
location=args.location,
sku=azure_native.storage.SkuArgs(name=azure_native.storage.SkuName.STANDARD_LRS),
kind=azure_native.storage.Kind.STORAGE_V2,
allow_blob_public_access=True,
minimum_tls_version=azure_native.storage.MinimumTlsVersion.TLS1_2,
tags=tags,
opts=child,
)
static_website = azure_native.storage.StorageAccountStaticWebsite(
f"{name}-website",
account_name=storage_account.name,
resource_group_name=args.resource_group_name,
index_document="index.html",
error404_document="index.html",
opts=child,
)
code_container = azure_native.storage.BlobContainer(
f"{name}-code",
account_name=storage_account.name,
container_name="function-code",
resource_group_name=args.resource_group_name,
public_access=azure_native.storage.PublicAccess.NONE,
opts=child,
)
# Per-file SPA upload into $web so each blob keeps its correct Content-Type.
for full in _walk(args.website_dist_path):
key = os.path.relpath(full, args.website_dist_path).replace(os.sep, "/")
safe_key = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in key)
azure_native.storage.Blob(
f"{name}-site-{safe_key}",
account_name=storage_account.name,
resource_group_name=args.resource_group_name,
container_name=static_website.container_name,
blob_name=key,
source=pulumi.FileAsset(full),
content_type=_content_type_for(full),
type=azure_native.storage.BlobType.BLOCK,
opts=pulumi.ResourceOptions(parent=self, depends_on=[static_website]),
)
function_zip = azure_native.storage.Blob(
f"{name}-fn-zip",
account_name=storage_account.name,
resource_group_name=args.resource_group_name,
container_name=code_container.name,
blob_name="functionapp.zip",
source=_build_archive(args.api_handler_path),
content_type="application/zip",
type=azure_native.storage.BlobType.BLOCK,
opts=child,
)
storage_keys = azure_native.storage.list_storage_account_keys_output(
account_name=storage_account.name,
resource_group_name=args.resource_group_name,
)
storage_connection_string = pulumi.Output.all(
storage_account.name, storage_keys.keys
).apply(
lambda parts: f"DefaultEndpointsProtocol=https;AccountName={parts[0]};AccountKey={parts[1][0].value};EndpointSuffix=core.windows.net"
)
# Dedicated subnet for Function App VNet integration (delegated to Microsoft.Web/serverFarms).
# Landing-zone private subnets are general-purpose; carve out a /26 in 10.10.240.0/26.
vnet_name = pulumi.Output.from_input(args.vnet_id).apply(lambda vid: vid.split("/")[-1])
web_subnet_name = pulumi.Output.from_input(args.name_prefix).apply(lambda p: f"{p}-web-sn")
web_subnet = azure_native.network.Subnet(
f"{name}-web-subnet",
subnet_name=web_subnet_name,
resource_group_name=args.resource_group_name,
virtual_network_name=vnet_name,
address_prefix="10.10.240.0/26",
delegations=[
azure_native.network.DelegationArgs(
name="web",
service_name="Microsoft.Web/serverFarms",
),
],
opts=child,
)
plan = azure_native.web.AppServicePlan(
f"{name}-plan",
kind="functionapp",
location=args.location,
resource_group_name=args.resource_group_name,
reserved=True,
sku=azure_native.web.SkuDescriptionArgs(name="FC1", tier="FlexConsumption"),
tags=tags,
opts=child,
)
key_vault_reference = pulumi.Output.from_input(args.database_secret_uri).apply(
lambda uri: f"@Microsoft.KeyVault(SecretUri={uri})"
)
deployment_value = pulumi.Output.all(
storage_account.primary_endpoints.apply(lambda e: e.blob),
code_container.name,
function_zip.name,
).apply(lambda parts: f"{parts[0]}{parts[1]}/{parts[2]}")
function_app = azure_native.web.WebApp(
f"{name}-fn",
kind="functionapp,linux",
location=args.location,
resource_group_name=args.resource_group_name,
server_farm_id=plan.id,
https_only=True,
identity=azure_native.web.ManagedServiceIdentityArgs(
type=azure_native.web.ManagedServiceIdentityType.SYSTEM_ASSIGNED,
),
virtual_network_subnet_id=web_subnet.id,
function_app_config=azure_native.web.FunctionAppConfigArgs(
deployment=azure_native.web.FunctionsDeploymentArgs(
storage=azure_native.web.FunctionsDeploymentStorageArgs(
type=azure_native.web.FunctionsDeploymentStorageType.BLOB_CONTAINER,
value=deployment_value,
authentication=azure_native.web.FunctionsDeploymentAuthenticationArgs(
type=azure_native.web.AuthenticationType.STORAGE_ACCOUNT_CONNECTION_STRING,
storage_account_connection_string_name="AzureWebJobsStorage",
),
),
),
runtime=azure_native.web.FunctionsRuntimeArgs(
name=azure_native.web.RuntimeName.NODE,
version="20",
),
scale_and_concurrency=azure_native.web.FunctionsScaleAndConcurrencyArgs(
instance_memory_mb=args.function_memory_mb,
maximum_instance_count=100,
),
),
site_config=azure_native.web.SiteConfigArgs(
linux_fx_version="Node|20",
app_settings=[
azure_native.web.NameValuePairArgs(name="AzureWebJobsStorage", value=storage_connection_string),
azure_native.web.NameValuePairArgs(name="FUNCTIONS_EXTENSION_VERSION", value="~4"),
azure_native.web.NameValuePairArgs(name="DATABASE_URL", value=key_vault_reference),
],
),
tags=tags,
opts=pulumi.ResourceOptions(parent=self, depends_on=[function_zip]),
)
key_vault_secrets_user = "4633458b-17de-408a-b874-0445c86b69e6"
key_vault_scope = pulumi.Output.all(
client.subscription_id, args.resource_group_name, args.key_vault_name
).apply(
lambda parts: f"/subscriptions/{parts[0]}/resourceGroups/{parts[1]}/providers/Microsoft.KeyVault/vaults/{parts[2]}"
)
role_definition_id = client.subscription_id.apply(
lambda sub: f"/subscriptions/{sub}/providers/Microsoft.Authorization/roleDefinitions/{key_vault_secrets_user}"
)
azure_native.authorization.RoleAssignment(
f"{name}-fn-kv-access",
principal_id=function_app.identity.apply(lambda i: i.principal_id if i else ""),
principal_type="ServicePrincipal",
role_definition_id=role_definition_id,
scope=key_vault_scope,
opts=child,
)
edge_profile = azure_native.cdn.Profile(
f"{name}-edge",
resource_group_name=args.resource_group_name,
location="global",
sku=azure_native.cdn.SkuArgs(name=azure_native.cdn.SkuName.STANDARD_AZURE_FRONT_DOOR),
tags=tags,
opts=child,
)
edge_endpoint = azure_native.cdn.AFDEndpoint(
f"{name}-endpoint",
profile_name=edge_profile.name,
resource_group_name=args.resource_group_name,
location="global",
enabled_state=azure_native.cdn.EnabledState.ENABLED,
tags=tags,
opts=child,
)
web_origin_host = storage_account.primary_endpoints.apply(
lambda e: e.web.replace("https://", "").replace("http://", "").rstrip("/")
)
website_origin_group = azure_native.cdn.AFDOriginGroup(
f"{name}-website-og",
origin_group_name="website-origins",
profile_name=edge_profile.name,
resource_group_name=args.resource_group_name,
load_balancing_settings=azure_native.cdn.LoadBalancingSettingsParametersArgs(
sample_size=4,
successful_samples_required=3,
additional_latency_in_milliseconds=50,
),
health_probe_settings=azure_native.cdn.HealthProbeParametersArgs(
probe_path="/",
probe_request_type=azure_native.cdn.HealthProbeRequestType.HEAD,
probe_protocol=azure_native.cdn.ProbeProtocol.HTTPS,
probe_interval_in_seconds=120,
),
opts=child,
)
website_origin = azure_native.cdn.AFDOrigin(
f"{name}-website-origin",
origin_name="website",
origin_group_name=website_origin_group.name,
profile_name=edge_profile.name,
resource_group_name=args.resource_group_name,
host_name=web_origin_host,
origin_host_header=web_origin_host,
https_port=443,
enabled_state=azure_native.cdn.EnabledState.ENABLED,
opts=pulumi.ResourceOptions(parent=self, depends_on=[static_website]),
)
api_origin_group = azure_native.cdn.AFDOriginGroup(
f"{name}-api-og",
origin_group_name="api-origins",
profile_name=edge_profile.name,
resource_group_name=args.resource_group_name,
load_balancing_settings=azure_native.cdn.LoadBalancingSettingsParametersArgs(
sample_size=4,
successful_samples_required=3,
additional_latency_in_milliseconds=50,
),
health_probe_settings=azure_native.cdn.HealthProbeParametersArgs(
probe_path="/api/health",
probe_request_type=azure_native.cdn.HealthProbeRequestType.GET,
probe_protocol=azure_native.cdn.ProbeProtocol.HTTPS,
probe_interval_in_seconds=120,
),
opts=child,
)
api_origin = azure_native.cdn.AFDOrigin(
f"{name}-api-origin",
origin_name="api",
origin_group_name=api_origin_group.name,
profile_name=edge_profile.name,
resource_group_name=args.resource_group_name,
host_name=function_app.default_host_name,
origin_host_header=function_app.default_host_name,
https_port=443,
enabled_state=azure_native.cdn.EnabledState.ENABLED,
opts=child,
)
azure_native.cdn.Route(
f"{name}-website-route",
route_name="website",
endpoint_name=edge_endpoint.name,
profile_name=edge_profile.name,
resource_group_name=args.resource_group_name,
origin_group=azure_native.cdn.ResourceReferenceArgs(id=website_origin_group.id),
supported_protocols=[
azure_native.cdn.AFDEndpointProtocols.HTTP,
azure_native.cdn.AFDEndpointProtocols.HTTPS,
],
patterns_to_match=["/*"],
forwarding_protocol=azure_native.cdn.ForwardingProtocol.HTTPS_ONLY,
https_redirect=azure_native.cdn.HttpsRedirect.ENABLED,
link_to_default_domain=azure_native.cdn.LinkToDefaultDomain.ENABLED,
enabled_state=azure_native.cdn.EnabledState.ENABLED,
opts=pulumi.ResourceOptions(parent=self, depends_on=[website_origin]),
)
azure_native.cdn.Route(
f"{name}-api-route",
route_name="api",
endpoint_name=edge_endpoint.name,
profile_name=edge_profile.name,
resource_group_name=args.resource_group_name,
origin_group=azure_native.cdn.ResourceReferenceArgs(id=api_origin_group.id),
supported_protocols=[
azure_native.cdn.AFDEndpointProtocols.HTTP,
azure_native.cdn.AFDEndpointProtocols.HTTPS,
],
patterns_to_match=["/api/*"],
forwarding_protocol=azure_native.cdn.ForwardingProtocol.HTTPS_ONLY,
https_redirect=azure_native.cdn.HttpsRedirect.ENABLED,
link_to_default_domain=azure_native.cdn.LinkToDefaultDomain.ENABLED,
enabled_state=azure_native.cdn.EnabledState.ENABLED,
opts=pulumi.ResourceOptions(parent=self, depends_on=[api_origin]),
)
self.site_url = edge_endpoint.host_name.apply(lambda host: f"https://{host}")
self.api_url = edge_endpoint.host_name.apply(lambda host: f"https://{host}/api")
self.function_app_name = function_app.name
self.endpoint_hostname = edge_endpoint.host_name
self.storage_account_name = storage_account.name
self.register_outputs(
{
"site_url": self.site_url,
"api_url": self.api_url,
"function_app_name": self.function_app_name,
"endpoint_hostname": self.endpoint_hostname,
"storage_account_name": self.storage_account_name,
}
)
database/database.go
Provisions the PostgreSQL Flexible Server instance on the landing-zone private network, generates a strong database password, and stores it in Azure Key Vault for the function to read.
// Package database provisions an Azure Database for PostgreSQL Flexible Server
// wired for private VNet access, and stores the connection string in Key Vault.
//
// Assumptions:
// - The landing-zone VNet exposes at least one private subnet delegated to
// `Microsoft.DBforPostgreSQL/flexibleServers`. We use `PrivateSubnetIds[0]`.
// If the subnet is not delegated, deploy will fail fast and the delegation
// should be added in the landing-zone so VNet ownership stays with that stack.
// - The landing-zone Key Vault is RBAC-enabled. The DATABASE_URL is stored as a
// vault secret; the Function App references it via
// `@Microsoft.KeyVault(SecretUri=...)`.
package database
import (
"fmt"
"net/url"
"regexp"
"strings"
dbforpostgresql "github.com/pulumi/pulumi-azure-native-sdk/dbforpostgresql/v3"
keyvault "github.com/pulumi/pulumi-azure-native-sdk/keyvault/v3"
privatedns "github.com/pulumi/pulumi-azure-native-sdk/privatedns/v3"
random "github.com/pulumi/pulumi-random/sdk/v4/go/random"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
const privateDnsZoneName = "privatepostgres.database.azure.com"
type Args struct {
ResourceGroupName pulumi.StringInput
Location pulumi.StringInput
VnetId pulumi.StringInput
PrivateSubnetIds pulumi.StringArrayInput
KeyVaultName pulumi.StringInput
EngineVersion pulumi.StringInput
NamePrefix pulumi.StringInput
Tags pulumi.StringMapInput
}
type Database struct {
pulumi.ResourceState
ServerName pulumi.StringOutput
SecretUri pulumi.StringOutput
FullyQualifiedDomainName pulumi.StringOutput
DatabaseName pulumi.StringOutput
}
func New(ctx *pulumi.Context, name string, args *Args, opts ...pulumi.ResourceOption) (*Database, error) {
d := &Database{}
if err := ctx.RegisterComponentResource("serverless-react-postgres:azure:Database", name, d, opts...); err != nil {
return nil, err
}
parent := pulumi.Parent(d)
databaseName := "appdb"
masterUsername := "pgadmin"
password, err := random.NewRandomPassword(ctx, fmt.Sprintf("%s-password", name), &random.RandomPasswordArgs{
Length: pulumi.Int(32),
Special: pulumi.Bool(false),
}, parent)
if err != nil {
return nil, err
}
privateDnsZone, err := privatedns.NewPrivateZone(ctx, fmt.Sprintf("%s-dns", name), &privatedns.PrivateZoneArgs{
ResourceGroupName: args.ResourceGroupName,
PrivateZoneName: pulumi.String(privateDnsZoneName),
Location: pulumi.String("global"),
Tags: args.Tags,
}, parent)
if err != nil {
return nil, err
}
dnsVnetLink, err := privatedns.NewVirtualNetworkLink(ctx, fmt.Sprintf("%s-dns-link", name), &privatedns.VirtualNetworkLinkArgs{
ResourceGroupName: args.ResourceGroupName,
PrivateZoneName: privateDnsZone.Name,
Location: pulumi.String("global"),
RegistrationEnabled: pulumi.Bool(false),
VirtualNetwork: &privatedns.SubResourceArgs{
Id: args.VnetId,
},
Tags: args.Tags,
}, parent)
if err != nil {
return nil, err
}
dbSubnetId := args.PrivateSubnetIds.ToStringArrayOutput().ApplyT(func(ids []string) (string, error) {
if len(ids) == 0 {
return "", fmt.Errorf("PrivateSubnetIds must have at least one subnet for the Flexible Server")
}
return ids[0], nil
}).(pulumi.StringOutput)
nameRe := regexp.MustCompile("[^a-z0-9-]")
serverNameOutput := args.NamePrefix.ToStringOutput().ApplyT(func(prefix string) string {
cleaned := nameRe.ReplaceAllString(strings.ToLower(prefix), "-")
if len(cleaned) > 55 {
cleaned = cleaned[:55]
}
return cleaned
}).(pulumi.StringOutput)
server, err := dbforpostgresql.NewServer(ctx, fmt.Sprintf("%s-pg", name), &dbforpostgresql.ServerArgs{
ResourceGroupName: args.ResourceGroupName,
Location: args.Location,
ServerName: serverNameOutput,
Version: args.EngineVersion,
AdministratorLogin: pulumi.String(masterUsername),
AdministratorLoginPassword: password.Result,
Sku: &dbforpostgresql.SkuArgs{
Name: pulumi.String("Standard_B1ms"),
Tier: pulumi.String(dbforpostgresql.SkuTierBurstable),
},
Storage: &dbforpostgresql.StorageArgs{
StorageSizeGB: pulumi.Int(32),
},
Backup: &dbforpostgresql.BackupTypeArgs{
BackupRetentionDays: pulumi.Int(7),
GeoRedundantBackup: pulumi.String(dbforpostgresql.GeoRedundantBackupDisabled),
},
HighAvailability: &dbforpostgresql.HighAvailabilityArgs{
Mode: pulumi.String(dbforpostgresql.PostgreSqlFlexibleServerHighAvailabilityModeDisabled),
},
Network: &dbforpostgresql.NetworkArgs{
DelegatedSubnetResourceId: dbSubnetId,
PrivateDnsZoneArmResourceId: privateDnsZone.ID().ToStringOutput(),
},
CreateMode: pulumi.String(dbforpostgresql.CreateModeDefault),
Tags: args.Tags,
}, parent, pulumi.DependsOn([]pulumi.Resource{dnsVnetLink}))
if err != nil {
return nil, err
}
appDatabase, err := dbforpostgresql.NewDatabase(ctx, fmt.Sprintf("%s-db", name), &dbforpostgresql.DatabaseArgs{
ResourceGroupName: args.ResourceGroupName,
ServerName: server.Name,
DatabaseName: pulumi.String(databaseName),
Charset: pulumi.String("UTF8"),
Collation: pulumi.String("en_US.utf8"),
}, parent)
if err != nil {
return nil, err
}
connectionUrl := pulumi.All(password.Result, server.FullyQualifiedDomainName).ApplyT(func(parts []interface{}) string {
pw := parts[0].(string)
host := parts[1].(string)
return fmt.Sprintf("postgresql://%s:%s@%s:5432/%s?sslmode=require",
masterUsername, url.QueryEscape(pw), host, databaseName)
}).(pulumi.StringOutput)
secretNameRe := regexp.MustCompile("[^A-Za-z0-9-]")
secretName := args.NamePrefix.ToStringOutput().ApplyT(func(prefix string) string {
return secretNameRe.ReplaceAllString(prefix, "-") + "-database-url"
}).(pulumi.StringOutput)
secret, err := keyvault.NewSecret(ctx, fmt.Sprintf("%s-secret", name), &keyvault.SecretArgs{
ResourceGroupName: args.ResourceGroupName,
VaultName: args.KeyVaultName,
SecretName: secretName,
Properties: &keyvault.SecretPropertiesArgs{
Value: connectionUrl,
ContentType: pulumi.String("text/plain"),
},
Tags: args.Tags,
}, parent, pulumi.DependsOn([]pulumi.Resource{appDatabase}))
if err != nil {
return nil, err
}
d.ServerName = server.Name
d.SecretUri = secret.Properties.ApplyT(func(p keyvault.SecretPropertiesResponse) string {
return p.SecretUri
}).(pulumi.StringOutput)
d.FullyQualifiedDomainName = server.FullyQualifiedDomainName
d.DatabaseName = pulumi.String(databaseName).ToStringOutput()
if err := ctx.RegisterResourceOutputs(d, pulumi.Map{
"serverName": d.ServerName,
"secretUri": d.SecretUri,
"fullyQualifiedDomainName": d.FullyQualifiedDomainName,
"databaseName": d.DatabaseName,
}); err != nil {
return nil, err
}
return d, nil
}
edge/edge.go
Provisions the Azure Functions (Flex Consumption) function that runs the API, uploads the SPA to object storage, and wires Azure Front Door Standard so /* serves the SPA and /api/* reaches the function.
// Package edge wires the Front Door + Storage static website + Flex Consumption
// Function App that serves the React SPA and Node API for this blueprint.
package edge
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
authorization "github.com/pulumi/pulumi-azure-native-sdk/authorization/v3"
cdn "github.com/pulumi/pulumi-azure-native-sdk/cdn/v3"
network "github.com/pulumi/pulumi-azure-native-sdk/network/v3"
storage "github.com/pulumi/pulumi-azure-native-sdk/storage/v3"
web "github.com/pulumi/pulumi-azure-native-sdk/web/v3"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
var mimeTypes = map[string]string{
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".mjs": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".map": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".ico": "image/x-icon",
".webp": "image/webp",
".txt": "text/plain; charset=utf-8",
".woff": "font/woff",
".woff2": "font/woff2",
}
func contentTypeFor(path string) string {
ext := strings.ToLower(filepath.Ext(path))
if ct, ok := mimeTypes[ext]; ok {
return ct
}
return "application/octet-stream"
}
func walkFiles(root string) ([]string, error) {
var files []string
if err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
files = append(files, p)
}
return nil
}); err != nil {
return nil, err
}
return files, nil
}
func buildArchive(root string) (pulumi.Archive, error) {
files, err := walkFiles(root)
if err != nil {
return nil, err
}
assets := map[string]interface{}{}
for _, full := range files {
rel, err := filepath.Rel(root, full)
if err != nil {
return nil, err
}
key := filepath.ToSlash(rel)
assets[key] = pulumi.NewFileAsset(full)
}
return pulumi.NewAssetArchive(assets), nil
}
var safeKeyRe = regexp.MustCompile("[^A-Za-z0-9._-]")
type Args struct {
ResourceGroupName pulumi.StringInput
Location pulumi.StringInput
VnetId pulumi.StringInput
KeyVaultName pulumi.StringInput
DatabaseSecretUri pulumi.StringInput
WebsiteDistPath string
ApiHandlerPath string
FunctionMemoryMB pulumi.IntInput
NamePrefix pulumi.StringInput
Tags pulumi.StringMapInput
}
type Edge struct {
pulumi.ResourceState
SiteUrl pulumi.StringOutput
ApiUrl pulumi.StringOutput
FunctionAppName pulumi.StringOutput
EndpointHostname pulumi.StringOutput
StorageAccountName pulumi.StringOutput
}
func New(ctx *pulumi.Context, name string, args *Args, opts ...pulumi.ResourceOption) (*Edge, error) {
e := &Edge{}
if err := ctx.RegisterComponentResource("serverless-react-postgres:azure:Edge", name, e, opts...); err != nil {
return nil, err
}
parent := pulumi.Parent(e)
client := authorization.GetClientConfigOutput(ctx, parent)
accountNameRe := regexp.MustCompile("[^a-z0-9]")
accountNameOutput := args.NamePrefix.ToStringOutput().ApplyT(func(prefix string) string {
cleaned := accountNameRe.ReplaceAllString(strings.ToLower(prefix), "")
if len(cleaned) > 20 {
cleaned = cleaned[:20]
}
return cleaned
}).(pulumi.StringOutput)
storageAccount, err := storage.NewStorageAccount(ctx, fmt.Sprintf("%s-storage", name), &storage.StorageAccountArgs{
AccountName: accountNameOutput,
ResourceGroupName: args.ResourceGroupName,
Location: args.Location,
Sku: &storage.SkuArgs{Name: pulumi.String(storage.SkuName_Standard_LRS)},
Kind: pulumi.String(storage.KindStorageV2),
AllowBlobPublicAccess: pulumi.Bool(true),
MinimumTlsVersion: storage.MinimumTlsVersion_TLS1_2,
Tags: args.Tags,
}, parent)
if err != nil {
return nil, err
}
staticWebsite, err := storage.NewStorageAccountStaticWebsite(ctx, fmt.Sprintf("%s-website", name), &storage.StorageAccountStaticWebsiteArgs{
AccountName: storageAccount.Name,
ResourceGroupName: args.ResourceGroupName,
IndexDocument: pulumi.String("index.html"),
Error404Document: pulumi.String("index.html"),
}, parent)
if err != nil {
return nil, err
}
codeContainer, err := storage.NewBlobContainer(ctx, fmt.Sprintf("%s-code", name), &storage.BlobContainerArgs{
AccountName: storageAccount.Name,
ContainerName: pulumi.String("function-code"),
ResourceGroupName: args.ResourceGroupName,
PublicAccess: storage.PublicAccessNone,
}, parent)
if err != nil {
return nil, err
}
siteFiles, err := walkFiles(args.WebsiteDistPath)
if err != nil {
return nil, err
}
for _, full := range siteFiles {
rel, err := filepath.Rel(args.WebsiteDistPath, full)
if err != nil {
return nil, err
}
key := filepath.ToSlash(rel)
safeKey := safeKeyRe.ReplaceAllString(key, "_")
if _, err := storage.NewBlob(ctx, fmt.Sprintf("%s-site-%s", name, safeKey), &storage.BlobArgs{
AccountName: storageAccount.Name,
ResourceGroupName: args.ResourceGroupName,
ContainerName: staticWebsite.ContainerName,
BlobName: pulumi.String(key),
Source: pulumi.NewFileAsset(full),
ContentType: pulumi.String(contentTypeFor(full)),
Type: storage.BlobTypeBlock,
}, parent, pulumi.DependsOn([]pulumi.Resource{staticWebsite})); err != nil {
return nil, err
}
}
archive, err := buildArchive(args.ApiHandlerPath)
if err != nil {
return nil, err
}
functionZip, err := storage.NewBlob(ctx, fmt.Sprintf("%s-fn-zip", name), &storage.BlobArgs{
AccountName: storageAccount.Name,
ResourceGroupName: args.ResourceGroupName,
ContainerName: codeContainer.Name,
BlobName: pulumi.String("functionapp.zip"),
Source: archive,
ContentType: pulumi.String("application/zip"),
Type: storage.BlobTypeBlock,
}, parent)
if err != nil {
return nil, err
}
storageKeys := storage.ListStorageAccountKeysOutput(ctx, storage.ListStorageAccountKeysOutputArgs{
AccountName: storageAccount.Name,
ResourceGroupName: args.ResourceGroupName,
}, parent)
storageConnectionString := pulumi.All(storageAccount.Name, storageKeys.Keys()).ApplyT(func(parts []interface{}) string {
accountName := parts[0].(string)
keys := parts[1].([]storage.StorageAccountKeyResponse)
if len(keys) == 0 {
return ""
}
return fmt.Sprintf("DefaultEndpointsProtocol=https;AccountName=%s;AccountKey=%s;EndpointSuffix=core.windows.net",
accountName, keys[0].Value)
}).(pulumi.StringOutput)
// Dedicated subnet for the Function App VNet integration (delegated to
// Microsoft.Web/serverFarms). Landing-zone private subnets are general-purpose,
// so we carve out a /26 in the 10.10.0.0/16 landing-zone VNet; adjust via
// config if a collision is reported at deploy time.
vnetName := args.VnetId.ToStringOutput().ApplyT(func(vid string) string {
parts := strings.Split(vid, "/")
return parts[len(parts)-1]
}).(pulumi.StringOutput)
subnetName := args.NamePrefix.ToStringOutput().ApplyT(func(prefix string) string {
return prefix + "-web-sn"
}).(pulumi.StringOutput)
webSubnet, err := network.NewSubnet(ctx, fmt.Sprintf("%s-web-subnet", name), &network.SubnetArgs{
SubnetName: subnetName,
ResourceGroupName: args.ResourceGroupName,
VirtualNetworkName: vnetName,
AddressPrefix: pulumi.String("10.10.240.0/26"),
Delegations: network.DelegationArray{
&network.DelegationArgs{
Name: pulumi.String("web"),
ServiceName: pulumi.String("Microsoft.Web/serverFarms"),
},
},
}, parent)
if err != nil {
return nil, err
}
plan, err := web.NewAppServicePlan(ctx, fmt.Sprintf("%s-plan", name), &web.AppServicePlanArgs{
Kind: pulumi.String("functionapp"),
Location: args.Location,
ResourceGroupName: args.ResourceGroupName,
Reserved: pulumi.Bool(true),
Sku: &web.SkuDescriptionArgs{
Name: pulumi.String("FC1"),
Tier: pulumi.String("FlexConsumption"),
},
Tags: args.Tags,
}, parent)
if err != nil {
return nil, err
}
keyVaultReference := args.DatabaseSecretUri.ToStringOutput().ApplyT(func(uri string) string {
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=%s)", uri)
}).(pulumi.StringOutput)
blobEndpoint := storageAccount.PrimaryEndpoints.ApplyT(func(ep storage.EndpointsResponse) string {
return ep.Blob
}).(pulumi.StringOutput)
deploymentValue := pulumi.All(blobEndpoint, codeContainer.Name, functionZip.Name).ApplyT(func(parts []interface{}) string {
return fmt.Sprintf("%s%s/%s", parts[0].(string), parts[1].(string), parts[2].(string))
}).(pulumi.StringOutput)
functionApp, err := web.NewWebApp(ctx, fmt.Sprintf("%s-fn", name), &web.WebAppArgs{
Kind: pulumi.String("functionapp,linux"),
Location: args.Location,
ResourceGroupName: args.ResourceGroupName,
ServerFarmId: plan.ID(),
HttpsOnly: pulumi.Bool(true),
Identity: &web.ManagedServiceIdentityArgs{
Type: web.ManagedServiceIdentityTypeSystemAssigned,
},
VirtualNetworkSubnetId: webSubnet.ID().ToStringOutput(),
FunctionAppConfig: &web.FunctionAppConfigArgs{
Deployment: &web.FunctionsDeploymentArgs{
Storage: &web.FunctionsDeploymentStorageArgs{
Type: pulumi.String(web.FunctionsDeploymentStorageTypeBlobContainer),
Value: deploymentValue,
Authentication: &web.FunctionsDeploymentAuthenticationArgs{
Type: pulumi.String(web.AuthenticationTypeStorageAccountConnectionString),
StorageAccountConnectionStringName: pulumi.String("AzureWebJobsStorage"),
},
},
},
Runtime: &web.FunctionsRuntimeArgs{
Name: pulumi.String(web.RuntimeNameNode),
Version: pulumi.String("20"),
},
ScaleAndConcurrency: &web.FunctionsScaleAndConcurrencyArgs{
InstanceMemoryMB: args.FunctionMemoryMB,
MaximumInstanceCount: pulumi.Int(100),
},
},
SiteConfig: &web.SiteConfigArgs{
LinuxFxVersion: pulumi.String("Node|20"),
AppSettings: web.NameValuePairArray{
&web.NameValuePairArgs{Name: pulumi.String("AzureWebJobsStorage"), Value: storageConnectionString},
&web.NameValuePairArgs{Name: pulumi.String("FUNCTIONS_EXTENSION_VERSION"), Value: pulumi.String("~4")},
&web.NameValuePairArgs{Name: pulumi.String("DATABASE_URL"), Value: keyVaultReference},
},
},
Tags: args.Tags,
}, parent, pulumi.DependsOn([]pulumi.Resource{functionZip}))
if err != nil {
return nil, err
}
const keyVaultSecretsUser = "4633458b-17de-408a-b874-0445c86b69e6"
roleDefinitionId := client.SubscriptionId().ApplyT(func(sub string) string {
return fmt.Sprintf("/subscriptions/%s/providers/Microsoft.Authorization/roleDefinitions/%s", sub, keyVaultSecretsUser)
}).(pulumi.StringOutput)
keyVaultScope := pulumi.All(client.SubscriptionId(), args.ResourceGroupName, args.KeyVaultName).ApplyT(func(parts []interface{}) string {
return fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.KeyVault/vaults/%s",
parts[0], parts[1], parts[2])
}).(pulumi.StringOutput)
principalId := functionApp.Identity.ApplyT(func(id *web.ManagedServiceIdentityResponse) string {
if id == nil {
return ""
}
return id.PrincipalId
}).(pulumi.StringOutput)
if _, err := authorization.NewRoleAssignment(ctx, fmt.Sprintf("%s-fn-kv-access", name), &authorization.RoleAssignmentArgs{
PrincipalId: principalId,
PrincipalType: pulumi.String("ServicePrincipal"),
RoleDefinitionId: roleDefinitionId,
Scope: keyVaultScope,
}, parent); err != nil {
return nil, err
}
edgeProfile, err := cdn.NewProfile(ctx, fmt.Sprintf("%s-edge", name), &cdn.ProfileArgs{
ResourceGroupName: args.ResourceGroupName,
Location: pulumi.String("global"),
Sku: &cdn.SkuArgs{Name: pulumi.String(cdn.SkuName_Standard_AzureFrontDoor)},
Tags: args.Tags,
}, parent)
if err != nil {
return nil, err
}
edgeEndpoint, err := cdn.NewAFDEndpoint(ctx, fmt.Sprintf("%s-endpoint", name), &cdn.AFDEndpointArgs{
ProfileName: edgeProfile.Name,
ResourceGroupName: args.ResourceGroupName,
Location: pulumi.String("global"),
EnabledState: pulumi.String(cdn.EnabledStateEnabled),
Tags: args.Tags,
}, parent)
if err != nil {
return nil, err
}
webOriginHost := storageAccount.PrimaryEndpoints.ApplyT(func(ep storage.EndpointsResponse) string {
return strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(ep.Web, "https://"), "http://"), "/")
}).(pulumi.StringOutput)
websiteOriginGroup, err := cdn.NewAFDOriginGroup(ctx, fmt.Sprintf("%s-website-og", name), &cdn.AFDOriginGroupArgs{
OriginGroupName: pulumi.String("website-origins"),
ProfileName: edgeProfile.Name,
ResourceGroupName: args.ResourceGroupName,
LoadBalancingSettings: &cdn.LoadBalancingSettingsParametersArgs{
SampleSize: pulumi.Int(4),
SuccessfulSamplesRequired: pulumi.Int(3),
AdditionalLatencyInMilliseconds: pulumi.Int(50),
},
HealthProbeSettings: &cdn.HealthProbeParametersArgs{
ProbePath: pulumi.String("/"),
ProbeRequestType: cdn.HealthProbeRequestTypeHEAD,
ProbeProtocol: cdn.ProbeProtocolHttps,
ProbeIntervalInSeconds: pulumi.Int(120),
},
}, parent)
if err != nil {
return nil, err
}
websiteOrigin, err := cdn.NewAFDOrigin(ctx, fmt.Sprintf("%s-website-origin", name), &cdn.AFDOriginArgs{
OriginName: pulumi.String("website"),
OriginGroupName: websiteOriginGroup.Name,
ProfileName: edgeProfile.Name,
ResourceGroupName: args.ResourceGroupName,
HostName: webOriginHost,
OriginHostHeader: webOriginHost,
HttpsPort: pulumi.Int(443),
EnabledState: pulumi.String(cdn.EnabledStateEnabled),
}, parent, pulumi.DependsOn([]pulumi.Resource{staticWebsite}))
if err != nil {
return nil, err
}
apiOriginGroup, err := cdn.NewAFDOriginGroup(ctx, fmt.Sprintf("%s-api-og", name), &cdn.AFDOriginGroupArgs{
OriginGroupName: pulumi.String("api-origins"),
ProfileName: edgeProfile.Name,
ResourceGroupName: args.ResourceGroupName,
LoadBalancingSettings: &cdn.LoadBalancingSettingsParametersArgs{
SampleSize: pulumi.Int(4),
SuccessfulSamplesRequired: pulumi.Int(3),
AdditionalLatencyInMilliseconds: pulumi.Int(50),
},
HealthProbeSettings: &cdn.HealthProbeParametersArgs{
ProbePath: pulumi.String("/api/health"),
ProbeRequestType: cdn.HealthProbeRequestTypeGET,
ProbeProtocol: cdn.ProbeProtocolHttps,
ProbeIntervalInSeconds: pulumi.Int(120),
},
}, parent)
if err != nil {
return nil, err
}
apiOrigin, err := cdn.NewAFDOrigin(ctx, fmt.Sprintf("%s-api-origin", name), &cdn.AFDOriginArgs{
OriginName: pulumi.String("api"),
OriginGroupName: apiOriginGroup.Name,
ProfileName: edgeProfile.Name,
ResourceGroupName: args.ResourceGroupName,
HostName: functionApp.DefaultHostName,
OriginHostHeader: functionApp.DefaultHostName,
HttpsPort: pulumi.Int(443),
EnabledState: pulumi.String(cdn.EnabledStateEnabled),
}, parent)
if err != nil {
return nil, err
}
if _, err := cdn.NewRoute(ctx, fmt.Sprintf("%s-website-route", name), &cdn.RouteArgs{
RouteName: pulumi.String("website"),
EndpointName: edgeEndpoint.Name,
ProfileName: edgeProfile.Name,
ResourceGroupName: args.ResourceGroupName,
OriginGroup: &cdn.ResourceReferenceArgs{Id: websiteOriginGroup.ID().ToStringOutput()},
SupportedProtocols: pulumi.StringArray{
pulumi.String(cdn.AFDEndpointProtocolsHttp),
pulumi.String(cdn.AFDEndpointProtocolsHttps),
},
PatternsToMatch: pulumi.StringArray{pulumi.String("/*")},
ForwardingProtocol: pulumi.String(cdn.ForwardingProtocolHttpsOnly),
HttpsRedirect: pulumi.String(cdn.HttpsRedirectEnabled),
LinkToDefaultDomain: pulumi.String(cdn.LinkToDefaultDomainEnabled),
EnabledState: pulumi.String(cdn.EnabledStateEnabled),
}, parent, pulumi.DependsOn([]pulumi.Resource{websiteOrigin})); err != nil {
return nil, err
}
if _, err := cdn.NewRoute(ctx, fmt.Sprintf("%s-api-route", name), &cdn.RouteArgs{
RouteName: pulumi.String("api"),
EndpointName: edgeEndpoint.Name,
ProfileName: edgeProfile.Name,
ResourceGroupName: args.ResourceGroupName,
OriginGroup: &cdn.ResourceReferenceArgs{Id: apiOriginGroup.ID().ToStringOutput()},
SupportedProtocols: pulumi.StringArray{
pulumi.String(cdn.AFDEndpointProtocolsHttp),
pulumi.String(cdn.AFDEndpointProtocolsHttps),
},
PatternsToMatch: pulumi.StringArray{pulumi.String("/api/*")},
ForwardingProtocol: pulumi.String(cdn.ForwardingProtocolHttpsOnly),
HttpsRedirect: pulumi.String(cdn.HttpsRedirectEnabled),
LinkToDefaultDomain: pulumi.String(cdn.LinkToDefaultDomainEnabled),
EnabledState: pulumi.String(cdn.EnabledStateEnabled),
}, parent, pulumi.DependsOn([]pulumi.Resource{apiOrigin})); err != nil {
return nil, err
}
e.SiteUrl = edgeEndpoint.HostName.ApplyT(func(host string) string {
return "https://" + host
}).(pulumi.StringOutput)
e.ApiUrl = edgeEndpoint.HostName.ApplyT(func(host string) string {
return "https://" + host + "/api"
}).(pulumi.StringOutput)
e.FunctionAppName = functionApp.Name
e.EndpointHostname = edgeEndpoint.HostName
e.StorageAccountName = storageAccount.Name
if err := ctx.RegisterResourceOutputs(e, pulumi.Map{
"siteUrl": e.SiteUrl,
"apiUrl": e.ApiUrl,
"functionAppName": e.FunctionAppName,
"endpointHostname": e.EndpointHostname,
"storageAccountName": e.StorageAccountName,
}); err != nil {
return nil, err
}
return e, nil
}