diff --git a/README.md b/README.md index ca929c9..4c8273b 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,44 @@ Converts between Azure Functions HTTP triggers and Next.js InternalEvent/Interna **Build Process:** Uses OpenNext's AWS build with Azure-specific overrides, then adds Azure Functions metadata (`host.json`, `function.json`) for v3 programming model. +## Database & Service Bindings + +Type-safe bindings for Azure services. Configure once in `azure.config.json`, access everywhere with full type safety. + +```json +{ + "bindings": { + "DB": { + "type": "cosmos-sql", + "databaseName": "mydb", + "throughput": 400 + }, + "CACHE": { + "type": "redis", + "sku": "Basic", + "capacity": 0 + } + } +} +``` + +```typescript +// app/api/users/route.ts +import { getBinding } from "opennextjs-azure/bindings"; + +export async function GET() { + const db = getBinding<"cosmos-sql">("DB"); + + const database = db.database("mydb"); + const container = database.container("users"); + const { resources } = await container.items.readAll().fetchAll(); + + return Response.json(resources); +} +``` + +Infrastructure provisioned automatically. Supports: Cosmos DB, Postgres, MySQL, Redis, Service Bus, Event Hub. + ## CLI Commands ### `init --scaffold` (Recommended for new projects) diff --git a/build.config.ts b/build.config.ts index 27b4956..e384d53 100644 --- a/build.config.ts +++ b/build.config.ts @@ -5,6 +5,8 @@ export default defineBuildConfig({ "src/index", "src/cli/index", "src/config/index", + "src/bindings/index", + "src/infrastructure/bindings", "src/overrides/incrementalCache/azure-blob", "src/overrides/tagCache/azure-table", "src/overrides/queue/azure-queue", @@ -45,6 +47,12 @@ export default defineBuildConfig({ "@azure/data-tables", "@azure/storage-queue", "@azure/functions", + "@azure/cosmos", + "@azure/service-bus", + "@azure/event-hubs", "commander", + "redis", + "pg", + "mysql2", ], }); diff --git a/package.json b/package.json index 5bee3d9..e45773c 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,10 @@ "import": "./dist/config/index.js", "types": "./dist/config/index.d.ts" }, + "./bindings": { + "import": "./dist/bindings/index.js", + "types": "./dist/bindings/index.d.ts" + }, "./adapters/wrappers/azure-functions.js": { "import": "./dist/adapters/wrappers/azure-functions.js", "types": "./dist/adapters/wrappers/azure-functions.d.ts" @@ -78,13 +82,20 @@ "commander": "^11.1.0" }, "devDependencies": { + "@azure/cosmos": "^4.0.0", + "@azure/event-hubs": "^5.11.0", + "@azure/service-bus": "^7.9.0", "@types/node": "^20.11.0", + "@types/pg": "^8.11.0", "@typescript-eslint/eslint-plugin": "^6.20.0", "@typescript-eslint/parser": "^6.20.0", "eslint": "^8.56.0", - "rimraf": "^5.0.5", + "mysql2": "^3.9.0", + "pg": "^8.11.0", "prettier": "^3.5.3", "prettier-plugin-tailwindcss": "^0.5.0", + "redis": "^4.6.0", + "rimraf": "^5.0.5", "typescript": "^5.3.3", "unbuild": "^2.0.0", "vitest": "^1.2.0" @@ -92,6 +103,26 @@ "peerDependencies": { "next": ">=13.4.0" }, + "peerDependenciesMeta": { + "@azure/cosmos": { + "optional": true + }, + "@azure/service-bus": { + "optional": true + }, + "@azure/event-hubs": { + "optional": true + }, + "redis": { + "optional": true + }, + "pg": { + "optional": true + }, + "mysql2": { + "optional": true + } + }, "engines": { "node": ">=18.0.0" } diff --git a/src/bindings/index.ts b/src/bindings/index.ts new file mode 100644 index 0000000..e6b335c --- /dev/null +++ b/src/bindings/index.ts @@ -0,0 +1,197 @@ +import type { CosmosClient } from "@azure/cosmos"; +import type { Pool as PostgresPool } from "pg"; +import type { RedisClientType } from "redis"; +import type { ServiceBusClient } from "@azure/service-bus"; +import type { EventHubProducerClient } from "@azure/event-hubs"; +import type { BindingType } from "../types/index.js"; + +export type BindingClientMap = { + "cosmos-sql": CosmosClient; + "cosmos-nosql": CosmosClient; + "postgres-flexible": PostgresPool; + "mysql-flexible": PostgresPool; + redis: RedisClientType; + "service-bus-queue": ServiceBusClient; + "service-bus-topic": ServiceBusClient; + "event-hub": EventHubProducerClient; +}; + +interface BindingMetadata { + type: BindingType; + envVars: { + connectionString?: string; + endpoint?: string; + host?: string; + primaryKey?: string; + }; +} + +interface BindingFactory { + create(envVars: BindingMetadata["envVars"]): unknown; + shouldCache: boolean; +} + +const BINDING_FACTORIES: Record = { + "cosmos-sql": { + create: envVars => { + const { CosmosClient } = require("@azure/cosmos"); + return new CosmosClient(process.env[envVars.endpoint!]!, { + key: process.env[envVars.primaryKey!], + }); + }, + shouldCache: true, + }, + + "cosmos-nosql": { + create: envVars => { + const { CosmosClient } = require("@azure/cosmos"); + return new CosmosClient(process.env[envVars.connectionString!]!); + }, + shouldCache: true, + }, + + "postgres-flexible": { + create: envVars => { + const { Pool } = require("pg"); + return new Pool({ + connectionString: process.env[envVars.connectionString!], + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, + }); + }, + shouldCache: true, + }, + + "mysql-flexible": { + create: envVars => { + const mysql = require("mysql2/promise"); + return mysql.createPool({ + uri: process.env[envVars.connectionString!], + waitForConnections: true, + connectionLimit: 10, + }); + }, + shouldCache: true, + }, + + redis: { + create: envVars => { + const { createClient } = require("redis"); + return createClient({ + url: process.env[envVars.connectionString!], + socket: { + connectTimeout: 5000, + reconnectStrategy: (retries: number) => Math.min(retries * 50, 500), + }, + }); + }, + shouldCache: true, + }, + + "service-bus-queue": { + create: envVars => { + const { ServiceBusClient } = require("@azure/service-bus"); + return new ServiceBusClient(process.env[envVars.connectionString!]!); + }, + shouldCache: true, + }, + + "service-bus-topic": { + create: envVars => { + const { ServiceBusClient } = require("@azure/service-bus"); + return new ServiceBusClient(process.env[envVars.connectionString!]!); + }, + shouldCache: true, + }, + + "event-hub": { + create: envVars => { + const { EventHubProducerClient } = require("@azure/event-hubs"); + return new EventHubProducerClient(process.env[envVars.connectionString!]!); + }, + shouldCache: true, + }, +}; + +let bindingsMetadata: Record | null = null; +const clientCache = new Map(); + +function loadBindingsMetadata(): Record { + if (bindingsMetadata) { + return bindingsMetadata; + } + + try { + const metadataPath = process.env.BINDINGS_METADATA_PATH || "./.bindings.json"; + bindingsMetadata = require(metadataPath); + return bindingsMetadata!; + } catch { + return {}; + } +} + +/** + * Get a typed binding client by name. + * + * @example + * ```ts + * const db = getBinding<'cosmos-sql'>('DATABASE'); + * const cache = getBinding<'redis'>('CACHE'); + * const pg = getBinding<'postgres-flexible'>('POSTGRES'); + * ``` + */ +export function getBinding(name: string): BindingClientMap[T] { + if (clientCache.has(name)) { + return clientCache.get(name) as BindingClientMap[T]; + } + + const metadata = loadBindingsMetadata(); + + if (!metadata[name]) { + const available = Object.keys(metadata); + throw new Error( + `Binding "${name}" not found.\n` + + (available.length > 0 + ? `Available bindings: ${available.join(", ")}` + : `No bindings configured in azure.config.json`) + + `\n\nAdd to azure.config.json:\n` + + `{\n "bindings": {\n "${name}": { "type": "..." }\n }\n}` + ); + } + + const binding = metadata[name]; + const factory = BINDING_FACTORIES[binding.type]; + + if (!factory) { + throw new Error( + `Unknown binding type "${binding.type}" for binding "${name}".\n` + + `Supported types: ${Object.keys(BINDING_FACTORIES).join(", ")}` + ); + } + + for (const [, envVarName] of Object.entries(binding.envVars)) { + if (envVarName && !process.env[envVarName]) { + throw new Error( + `Missing environment variable "${envVarName}" for binding "${name}".\n` + + `This should have been set during deployment. Try redeploying.` + ); + } + } + + const client = factory.create(binding.envVars); + + if (factory.shouldCache) { + clientCache.set(name, client); + } + + return client as BindingClientMap[T]; +} + +/** + * Clear the binding client cache. + * Useful for testing or when you need to recreate connections. + */ +export function clearBindingsCache(): void { + clientCache.clear(); +} diff --git a/src/deploy/index.ts b/src/deploy/index.ts index bc1c212..449334c 100644 --- a/src/deploy/index.ts +++ b/src/deploy/index.ts @@ -4,6 +4,8 @@ import fs from "node:fs/promises"; import { existsSync } from "node:fs"; import path from "node:path"; import { greenCheck, redX } from "../cli/log.js"; +import { generateBindingsBicep, generateBindingsMetadata } from "../infrastructure/bindings.js"; +import type { BindingConfig } from "../types/index.js"; const execAsync = promisify(exec); @@ -77,13 +79,16 @@ export async function deploy(options: DeployOptions): Promise { await uploadStaticAssets(appName, resourceGroup); console.log(` ${greenCheck()} Assets uploaded`); - // Step 3: Deploy Function App + // Step 3: Generate bindings metadata + await generateBindingsMetadataFile(); + + // Step 4: Deploy Function App console.log("Deploying Function App..."); const functionAppName = deploymentOutputs?.functionApp || `${appName}-func-${environment}`; await deployFunctionApp(functionAppName, resourceGroup); console.log(` ${greenCheck()} Function App deployed`); - // Step 4: Postflight checks and display detailed info + // Step 5: Postflight checks and display detailed info await performPostflightChecks( resourceGroup, functionAppName, @@ -410,18 +415,88 @@ async function performPostflightChecks( } } +async function loadAzureConfig(): Promise<{ bindings?: Record }> { + const configPath = path.join(process.cwd(), "azure.config.json"); + try { + const configContent = await fs.readFile(configPath, "utf-8"); + return JSON.parse(configContent); + } catch { + return {}; + } +} + async function syncBicepTemplate(): Promise { - // Sync infrastructure/main.bicep from package to ensure it matches this version const { fileURLToPath } = await import("node:url"); const currentDir = path.dirname(fileURLToPath(import.meta.url)); const packageBicepPath = path.join(currentDir, "../infrastructure/main.bicep"); const projectBicepPath = path.join(process.cwd(), "infrastructure/main.bicep"); - const bicepContent = await fs.readFile(packageBicepPath, "utf-8"); + let bicepContent = await fs.readFile(packageBicepPath, "utf-8"); + + const config = await loadAzureConfig(); + if (config.bindings && Object.keys(config.bindings).length > 0) { + console.log(` Generating infrastructure for ${Object.keys(config.bindings).length} binding(s)...`); + const { modules, envVars, outputs } = generateBindingsBicep(config.bindings); + + const functionAppSectionEnd = bicepContent.indexOf("// Outputs"); + if (functionAppSectionEnd === -1) { + throw new Error("Could not find '// Outputs' section in main.bicep template"); + } + + bicepContent = + bicepContent.slice(0, functionAppSectionEnd) + + `\n// Bindings\n${modules}\n\n` + + bicepContent.slice(functionAppSectionEnd); + + const appSettingsMatch = bicepContent.match(/appSettings: concat\(\[/); + if (!appSettingsMatch) { + throw new Error("Could not find appSettings concat in main.bicep template"); + } + + const appSettingsStartIndex = appSettingsMatch.index! + appSettingsMatch[0].length; + const beforeAppSettings = bicepContent.slice(0, appSettingsStartIndex); + const afterAppSettings = bicepContent.slice(appSettingsStartIndex); + + const nextSectionIndex = afterAppSettings.indexOf("], enableApplicationInsights"); + if (nextSectionIndex === -1) { + throw new Error("Could not find end of base appSettings in main.bicep template"); + } + + const baseSettings = afterAppSettings.slice(0, nextSectionIndex); + const restOfFile = afterAppSettings.slice(nextSectionIndex); + + bicepContent = beforeAppSettings + baseSettings + "\n" + envVars.join("\n") + restOfFile; + + const outputsSectionEnd = bicepContent.lastIndexOf("}"); + if (outputsSectionEnd === -1) { + throw new Error("Could not find end of outputs section in main.bicep template"); + } + + bicepContent = + bicepContent.slice(0, outputsSectionEnd) + + "\n\n// Binding outputs\n" + + outputs.join("\n") + + "\n" + + bicepContent.slice(outputsSectionEnd); + } + await fs.mkdir(path.dirname(projectBicepPath), { recursive: true }); await fs.writeFile(projectBicepPath, bicepContent); } +async function generateBindingsMetadataFile(): Promise { + const config = await loadAzureConfig(); + if (!config.bindings || Object.keys(config.bindings).length === 0) { + return; + } + + const metadata = generateBindingsMetadata(config.bindings); + const metadataPath = path.join(process.cwd(), ".open-next/server-functions/default/.bindings.json"); + + await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2)); + console.log(` ${greenCheck()} Generated bindings metadata`); +} + async function provisionInfrastructure(options: { appName: string; resourceGroup: string; diff --git a/src/infrastructure/bindings.ts b/src/infrastructure/bindings.ts new file mode 100644 index 0000000..c964cc1 --- /dev/null +++ b/src/infrastructure/bindings.ts @@ -0,0 +1,420 @@ +import type { + BindingConfig, + CosmosBinding, + PostgresBinding, + MySQLBinding, + RedisBinding, + ServiceBusQueueBinding, + ServiceBusTopicBinding, + EventHubBinding, +} from "../types/index.js"; + +interface BicepOutput { + modules: string; + envVars: string[]; + outputs: string[]; +} + +export function generateBindingsBicep(bindings: Record): BicepOutput { + const modules: string[] = []; + const outputs: string[] = []; + const envVars: string[] = []; + + for (const [name, binding] of Object.entries(bindings)) { + switch (binding.type) { + case "cosmos-sql": + case "cosmos-nosql": + modules.push(generateCosmosModule(name, binding as CosmosBinding)); + envVars.push(` { name: '${name}_ENDPOINT', value: cosmos${name}.properties.documentEndpoint }`); + envVars.push( + ` { name: '${name}_PRIMARY_KEY', value: cosmos${name}.listKeys().primaryMasterKey }` + ); + outputs.push( + `output ${name}_ENDPOINT string = cosmos${name}.properties.documentEndpoint`, + `output ${name}_PRIMARY_KEY string = cosmos${name}.listKeys().primaryMasterKey` + ); + break; + + case "postgres-flexible": + modules.push(generatePostgresModule(name, binding as PostgresBinding)); + envVars.push(` { name: '${name}_CONNECTION_STRING', value: postgres${name}ConnectionString }`); + envVars.push( + ` { name: '${name}_HOST', value: postgres${name}.properties.fullyQualifiedDomainName }` + ); + outputs.push( + `output ${name}_CONNECTION_STRING string = postgres${name}ConnectionString`, + `output ${name}_HOST string = postgres${name}.properties.fullyQualifiedDomainName` + ); + break; + + case "mysql-flexible": + modules.push(generateMySQLModule(name, binding as MySQLBinding)); + envVars.push(` { name: '${name}_CONNECTION_STRING', value: mysql${name}ConnectionString }`); + envVars.push( + ` { name: '${name}_HOST', value: mysql${name}.properties.fullyQualifiedDomainName }` + ); + outputs.push( + `output ${name}_CONNECTION_STRING string = mysql${name}ConnectionString`, + `output ${name}_HOST string = mysql${name}.properties.fullyQualifiedDomainName` + ); + break; + + case "redis": + modules.push(generateRedisModule(name, binding as RedisBinding)); + envVars.push( + ` { name: '${name}_CONNECTION_STRING', value: '\\$\\{redis${name}.properties.hostName}:6380,password=\\$\\{redis${name}.listKeys().primaryKey},ssl=True,abortConnect=False' }` + ); + envVars.push(` { name: '${name}_HOST', value: redis${name}.properties.hostName }`); + outputs.push( + `output ${name}_CONNECTION_STRING string = '\\$\\{redis${name}.properties.hostName}:6380,password=\\$\\{redis${name}.listKeys().primaryKey},ssl=True,abortConnect=False'`, + `output ${name}_HOST string = redis${name}.properties.hostName` + ); + break; + + case "service-bus-queue": + modules.push(generateServiceBusQueueModule(name, binding as ServiceBusQueueBinding)); + envVars.push( + ` { name: '${name}_CONNECTION_STRING', value: serviceBus${name}.listKeys().primaryConnectionString }` + ); + outputs.push( + `output ${name}_CONNECTION_STRING string = serviceBus${name}.listKeys().primaryConnectionString` + ); + break; + + case "service-bus-topic": + modules.push(generateServiceBusTopicModule(name, binding as ServiceBusTopicBinding)); + envVars.push( + ` { name: '${name}_CONNECTION_STRING', value: serviceBus${name}.listKeys().primaryConnectionString }` + ); + outputs.push( + `output ${name}_CONNECTION_STRING string = serviceBus${name}.listKeys().primaryConnectionString` + ); + break; + + case "event-hub": + modules.push(generateEventHubModule(name, binding as EventHubBinding)); + envVars.push( + ` { name: '${name}_CONNECTION_STRING', value: eventHub${name}Namespace.listKeys().primaryConnectionString }` + ); + outputs.push( + `output ${name}_CONNECTION_STRING string = eventHub${name}Namespace.listKeys().primaryConnectionString` + ); + break; + } + } + + return { + modules: modules.join("\n\n"), + envVars, + outputs, + }; +} + +function generateCosmosModule(name: string, config: CosmosBinding): string { + const resourceName = config.resourceName || `\${toLower(appName)}-cosmos-${name.toLowerCase()}-\${environment}`; + const databaseName = config.databaseName || "maindb"; + const throughput = config.throughput || 400; + + return `// Cosmos DB for binding: ${name} +resource cosmos${name} 'Microsoft.DocumentDB/databaseAccounts@2023-04-15' = { + name: '${resourceName}' + location: location + kind: 'GlobalDocumentDB' + properties: { + databaseAccountOfferType: 'Standard' + consistencyPolicy: { + defaultConsistencyLevel: 'Session' + } + locations: [ + { + locationName: location + failoverPriority: 0 + } + ] + } + + resource database 'sqlDatabases' = { + name: '${databaseName}' + properties: { + resource: { + id: '${databaseName}' + } + options: { + throughput: ${throughput} + } + } + } +}`; +} + +function generatePostgresModule(name: string, config: PostgresBinding): string { + const resourceName = config.resourceName || `\${toLower(appName)}-pg-${name.toLowerCase()}-\${environment}`; + const databaseName = config.databaseName || "appdb"; + const sku = config.sku || "Standard_B1ms"; + const version = config.version || "16"; + const storageSizeGB = config.storageSizeGB || 32; + const adminUsername = config.adminUsername || "pgadmin"; + + return `// PostgreSQL Flexible Server for binding: ${name} +var postgres${name}Password = 'P@ssw0rd-\${uniqueString(resourceGroup().id, '${name}')}' +var postgres${name}ConnectionString = 'postgresql://${adminUsername}:\${postgres${name}Password}@\${postgres${name}.properties.fullyQualifiedDomainName}:5432/${databaseName}?sslmode=require' + +resource postgres${name} 'Microsoft.DBforPostgreSQL/flexibleServers@2023-03-01-preview' = { + name: '${resourceName}' + location: location + sku: { + name: '${sku}' + tier: 'Burstable' + } + properties: { + version: '${version}' + administratorLogin: '${adminUsername}' + administratorLoginPassword: postgres${name}Password + storage: { + storageSizeGB: ${storageSizeGB} + } + backup: { + backupRetentionDays: 7 + geoRedundantBackup: 'Disabled' + } + highAvailability: { + mode: 'Disabled' + } + } + + resource database 'databases' = { + name: '${databaseName}' + } + + resource firewallRule 'firewallRules' = { + name: 'AllowAzureServices' + properties: { + startIpAddress: '0.0.0.0' + endIpAddress: '0.0.0.0' + } + } +}`; +} + +function generateMySQLModule(name: string, config: MySQLBinding): string { + const resourceName = config.resourceName || `\${toLower(appName)}-mysql-${name.toLowerCase()}-\${environment}`; + const databaseName = config.databaseName || "appdb"; + const sku = config.sku || "Standard_B1ms"; + const version = config.version || "8.0.21"; + const storageSizeGB = config.storageSizeGB || 20; + const adminUsername = config.adminUsername || "mysqladmin"; + + return `// MySQL Flexible Server for binding: ${name} +var mysql${name}Password = 'P@ssw0rd-\${uniqueString(resourceGroup().id, '${name}')}' +var mysql${name}ConnectionString = 'mysql://${adminUsername}:\${mysql${name}Password}@\${mysql${name}.properties.fullyQualifiedDomainName}:3306/${databaseName}?ssl-mode=REQUIRED' + +resource mysql${name} 'Microsoft.DBforMySQL/flexibleServers@2023-06-01-preview' = { + name: '${resourceName}' + location: location + sku: { + name: '${sku}' + tier: 'Burstable' + } + properties: { + version: '${version}' + administratorLogin: '${adminUsername}' + administratorLoginPassword: mysql${name}Password + storage: { + storageSizeGB: ${storageSizeGB} + } + backup: { + backupRetentionDays: 7 + geoRedundantBackup: 'Disabled' + } + highAvailability: { + mode: 'Disabled' + } + } + + resource database 'databases' = { + name: '${databaseName}' + properties: { + charset: 'utf8mb4' + collation: 'utf8mb4_unicode_ci' + } + } + + resource firewallRule 'firewallRules' = { + name: 'AllowAzureServices' + properties: { + startIpAddress: '0.0.0.0' + endIpAddress: '0.0.0.0' + } + } +}`; +} + +function generateRedisModule(name: string, config: RedisBinding): string { + const resourceName = config.resourceName || `\${toLower(appName)}-redis-${name.toLowerCase()}-\${environment}`; + const sku = config.sku || "Basic"; + const capacity = config.capacity ?? 0; + const family = sku === "Premium" ? "P" : "C"; + + return `// Azure Cache for Redis for binding: ${name} +resource redis${name} 'Microsoft.Cache/redis@2023-08-01' = { + name: '${resourceName}' + location: location + properties: { + sku: { + name: '${sku}' + family: '${family}' + capacity: ${capacity} + } + enableNonSslPort: false + minimumTlsVersion: '1.2' + } +}`; +} + +function generateServiceBusQueueModule(name: string, config: ServiceBusQueueBinding): string { + const resourceName = config.resourceName || `\${toLower(appName)}-sb-${name.toLowerCase()}-\${environment}`; + const queueName = config.queueName || name.toLowerCase(); + const maxDeliveryCount = config.maxDeliveryCount || 10; + const lockDuration = config.lockDuration || "PT1M"; + + return `// Service Bus Namespace and Queue for binding: ${name} +resource serviceBus${name}Namespace 'Microsoft.ServiceBus/namespaces@2022-10-01-preview' = { + name: '${resourceName}' + location: location + sku: { + name: 'Standard' + tier: 'Standard' + } +} + +resource serviceBus${name} 'Microsoft.ServiceBus/namespaces/queues@2022-10-01-preview' = { + parent: serviceBus${name}Namespace + name: '${queueName}' + properties: { + maxDeliveryCount: ${maxDeliveryCount} + lockDuration: '${lockDuration}' + requiresDuplicateDetection: false + requiresSession: false + enablePartitioning: false + } +} + +resource serviceBus${name}AuthRule 'Microsoft.ServiceBus/namespaces/authorizationRules@2022-10-01-preview' = { + parent: serviceBus${name}Namespace + name: 'RootManageSharedAccessKey' + properties: { + rights: ['Listen', 'Send', 'Manage'] + } +}`; +} + +function generateServiceBusTopicModule(name: string, config: ServiceBusTopicBinding): string { + const resourceName = config.resourceName || `\${toLower(appName)}-sb-${name.toLowerCase()}-\${environment}`; + const topicName = config.topicName || name.toLowerCase(); + const subscriptionName = config.subscriptionName || "default-subscription"; + + return `// Service Bus Namespace and Topic for binding: ${name} +resource serviceBus${name}Namespace 'Microsoft.ServiceBus/namespaces@2022-10-01-preview' = { + name: '${resourceName}' + location: location + sku: { + name: 'Standard' + tier: 'Standard' + } +} + +resource serviceBus${name} 'Microsoft.ServiceBus/namespaces/topics@2022-10-01-preview' = { + parent: serviceBus${name}Namespace + name: '${topicName}' + properties: { + enablePartitioning: false + } +} + +resource serviceBus${name}Subscription 'Microsoft.ServiceBus/namespaces/topics/subscriptions@2022-10-01-preview' = { + parent: serviceBus${name} + name: '${subscriptionName}' + properties: { + maxDeliveryCount: 10 + lockDuration: 'PT1M' + } +} + +resource serviceBus${name}AuthRule 'Microsoft.ServiceBus/namespaces/authorizationRules@2022-10-01-preview' = { + parent: serviceBus${name}Namespace + name: 'RootManageSharedAccessKey' + properties: { + rights: ['Listen', 'Send', 'Manage'] + } +}`; +} + +function generateEventHubModule(name: string, config: EventHubBinding): string { + const resourceName = config.resourceName || `\${toLower(appName)}-eh-${name.toLowerCase()}-\${environment}`; + const eventHubName = config.eventHubName || name.toLowerCase(); + const partitionCount = config.partitionCount || 2; + const messageRetentionInDays = config.messageRetentionInDays || 1; + + return `// Event Hub Namespace and Event Hub for binding: ${name} +resource eventHub${name}Namespace 'Microsoft.EventHub/namespaces@2023-01-01-preview' = { + name: '${resourceName}' + location: location + sku: { + name: 'Standard' + tier: 'Standard' + capacity: 1 + } +} + +resource eventHub${name} 'Microsoft.EventHub/namespaces/eventhubs@2023-01-01-preview' = { + parent: eventHub${name}Namespace + name: '${eventHubName}' + properties: { + partitionCount: ${partitionCount} + messageRetentionInDays: ${messageRetentionInDays} + } +} + +resource eventHub${name}AuthRule 'Microsoft.EventHub/namespaces/authorizationRules@2023-01-01-preview' = { + parent: eventHub${name}Namespace + name: 'RootManageSharedAccessKey' + properties: { + rights: ['Listen', 'Send', 'Manage'] + } +}`; +} + +export function generateBindingsMetadata(bindings: Record): Record { + const metadata: Record = {}; + + for (const [name, binding] of Object.entries(bindings)) { + switch (binding.type) { + case "cosmos-sql": + case "cosmos-nosql": + metadata[name] = { + type: binding.type, + envVars: { + endpoint: `${name}_ENDPOINT`, + primaryKey: `${name}_PRIMARY_KEY`, + }, + }; + break; + + case "postgres-flexible": + case "mysql-flexible": + case "redis": + case "service-bus-queue": + case "service-bus-topic": + case "event-hub": + metadata[name] = { + type: binding.type, + envVars: { + connectionString: `${name}_CONNECTION_STRING`, + }, + }; + break; + } + } + + return metadata; +} diff --git a/src/types/index.ts b/src/types/index.ts index 9e0ab4f..67d3e2b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -18,6 +18,81 @@ export interface AzureStorageConfig { queueName?: string; } +export type BindingType = + | "cosmos-sql" + | "cosmos-nosql" + | "postgres-flexible" + | "mysql-flexible" + | "redis" + | "service-bus-queue" + | "service-bus-topic" + | "event-hub"; + +export interface BaseBinding { + type: BindingType; + resourceName?: string; +} + +export interface CosmosBinding extends BaseBinding { + type: "cosmos-sql" | "cosmos-nosql"; + databaseName?: string; + throughput?: number; + autoscale?: boolean; +} + +export interface PostgresBinding extends BaseBinding { + type: "postgres-flexible"; + databaseName?: string; + sku?: "Standard_B1ms" | "Standard_B2s" | "Standard_D2s_v3"; + version?: "16" | "15" | "14" | "13"; + storageSizeGB?: 32 | 64 | 128 | 256 | 512; + adminUsername?: string; +} + +export interface MySQLBinding extends BaseBinding { + type: "mysql-flexible"; + databaseName?: string; + sku?: "Standard_B1ms" | "Standard_B2s" | "Standard_D2s_v3"; + version?: "8.0.21" | "5.7"; + storageSizeGB?: 20 | 32 | 64 | 128; + adminUsername?: string; +} + +export interface RedisBinding extends BaseBinding { + type: "redis"; + sku?: "Basic" | "Standard" | "Premium"; + capacity?: 0 | 1 | 2 | 3 | 4 | 5 | 6; +} + +export interface ServiceBusQueueBinding extends BaseBinding { + type: "service-bus-queue"; + queueName?: string; + maxDeliveryCount?: number; + lockDuration?: string; +} + +export interface ServiceBusTopicBinding extends BaseBinding { + type: "service-bus-topic"; + topicName?: string; + subscriptionName?: string; +} + +export interface EventHubBinding extends BaseBinding { + type: "event-hub"; + eventHubName?: string; + partitionCount?: number; + messageRetentionInDays?: number; +} + +export type BindingConfig = + | CosmosBinding + | PostgresBinding + | MySQLBinding + | RedisBinding + | ServiceBusQueueBinding + | ServiceBusTopicBinding + | EventHubBinding; + export interface AzureConfig { incrementalCache?: "azure-blob" | IncrementalCache; tagCache?: "azure-table" | TagCache; @@ -34,4 +109,5 @@ export interface AzureConfig { deployment?: AzureDeploymentConfig; storage?: AzureStorageConfig; applicationInsights?: boolean; + bindings?: Record; }