diff --git a/.changeset/fresh-coins-like.md b/.changeset/fresh-coins-like.md new file mode 100644 index 00000000..e2fa2e73 --- /dev/null +++ b/.changeset/fresh-coins-like.md @@ -0,0 +1,5 @@ +--- +'@gradientedge/cdk-utils-azure': patch +--- + +Feat: Add Azure web app slots diff --git a/packages/azure/src/services/app-service/main.ts b/packages/azure/src/services/app-service/main.ts index 3feafc55..afe76bcf 100644 --- a/packages/azure/src/services/app-service/main.ts +++ b/packages/azure/src/services/app-service/main.ts @@ -3,12 +3,13 @@ import { ManagedServiceIdentityType, SupportedTlsVersions, WebApp, + WebAppSlot, } from '@pulumi/azure-native/web/index.js' import { ResourceOptions } from '@pulumi/pulumi' import { CommonAzureConstruct } from '../../common/index.js' -import { LinuxWebAppProps, ServicePlanProps } from './types.js' +import { LinuxWebAppProps, ServicePlanProps, WebAppSlotProps } from './types.js' /** * Provides operations on Azure App Service using Pulumi @@ -125,4 +126,51 @@ export class AzureAppServiceManager { { parent: scope, ...resourceOptions } ) } + + /** + * @summary Method to create a new web app slot + * @param id scoped id of the resource + * @param scope scope in which this resource is defined + * @param props web app slot properties + * @param resourceOptions Optional settings to control resource behaviour + * @see [Pulumi Azure Native Web App Slot]{@link https://www.pulumi.com/registry/packages/azure-native/api-docs/web/webappslot/} + */ + public createWebAppSlot( + id: string, + scope: CommonAzureConstruct, + props: WebAppSlotProps, + resourceOptions?: ResourceOptions + ) { + if (!props) throw new Error(`Props undefined for ${id}`) + + // Get resource group name + const resourceGroupName = + props.resourceGroupName ?? scope.resourceNameFormatter.format(scope.props.resourceGroupName) + + return new WebAppSlot( + `${id}-was`, + { + ...props, + name: scope.resourceNameFormatter.format(props.name?.toString(), scope.props.resourceNameOptions?.linuxWebApp), + resourceGroupName, + location: props.location ?? scope.props.location, + httpsOnly: props.httpsOnly ?? true, + kind: props.kind ?? 'app,linux', + identity: props.identity ?? { + type: ManagedServiceIdentityType.SystemAssigned, + }, + siteConfig: props.siteConfig ?? { + alwaysOn: true, + linuxFxVersion: 'NODE|22-lts', + minTlsVersion: SupportedTlsVersions.SupportedTlsVersions_1_3, + }, + tags: { + environment: scope.props.stage, + ...scope.props.defaultTags, + ...props.tags, + }, + }, + { parent: scope, ...resourceOptions } + ) + } } diff --git a/packages/azure/src/services/app-service/types.ts b/packages/azure/src/services/app-service/types.ts index 8e94d6ae..d34a75b2 100644 --- a/packages/azure/src/services/app-service/types.ts +++ b/packages/azure/src/services/app-service/types.ts @@ -1,4 +1,4 @@ -import { AppServicePlanArgs, WebAppArgs } from '@pulumi/azure-native/web/index.js' +import { AppServicePlanArgs, WebAppArgs, WebAppSlotArgs } from '@pulumi/azure-native/web/index.js' /** * Properties for creating an Azure App Service Plan @@ -13,3 +13,10 @@ export interface ServicePlanProps extends AppServicePlanArgs {} * @category Interface */ export interface LinuxWebAppProps extends WebAppArgs {} + +/** + * Properties for creating an Azure Web App Slot + * @see [Pulumi Azure Native Web App Slot]{@link https://www.pulumi.com/registry/packages/azure-native/api-docs/web/webappslot/} + * @category Interface + */ +export interface WebAppSlotProps extends WebAppSlotArgs {} diff --git a/packages/azure/test/services/app-service-manager.test.ts b/packages/azure/test/services/app-service-manager.test.ts index ff6b2ade..ad774a43 100644 --- a/packages/azure/test/services/app-service-manager.test.ts +++ b/packages/azure/test/services/app-service-manager.test.ts @@ -1,4 +1,4 @@ -import { AppServicePlan, WebApp } from '@pulumi/azure-native/web/index.js' +import { AppServicePlan, WebApp, WebAppSlot } from '@pulumi/azure-native/web/index.js' import * as pulumi from '@pulumi/pulumi' import { CommonAzureConstruct, @@ -6,6 +6,7 @@ import { CommonAzureStackProps, LinuxWebAppProps, ServicePlanProps, + WebAppSlotProps, } from '../../src/index.js' import { outputToPromise } from '../helpers.js' @@ -13,6 +14,7 @@ interface TestAzureStackProps extends CommonAzureStackProps { testAppServicePlan: ServicePlanProps testAttribute?: string testLinuxWebApp: LinuxWebAppProps + testWebAppSlot?: WebAppSlotProps } const testStackProps: any = { @@ -57,6 +59,7 @@ class TestCommonConstruct extends CommonAzureConstruct { declare props: TestAzureStackProps appServicePlan: AppServicePlan linuxWebApp: WebApp + webAppSlot: WebAppSlot constructor(name: string, props: TestAzureStackProps) { super(name, props) @@ -69,6 +72,16 @@ class TestCommonConstruct extends CommonAzureConstruct { this.linuxWebApp = this.appServiceManager.createLinuxWebApp(`test-linux-web-app-${this.props.stage}`, this, { ...this.props.testLinuxWebApp, }) + + this.webAppSlot = this.appServiceManager.createWebAppSlot(`test-web-app-slot-${this.props.stage}`, this, { + ...(this.props.testWebAppSlot ?? { + name: 'test-linux-web-app', + slot: 'staging', + resourceGroupName: 'test-rg-dev', + serverFarmId: + '/subscriptions/test-sub/resourceGroups/test-rg-dev/providers/Microsoft.Web/serverfarms/test-app-service-plan-dev', + }), + }) } } @@ -87,6 +100,8 @@ pulumi.runtime.setMocks({ name = args.inputs.name } else if (args.type === 'azure-native:web:WebApp') { name = args.inputs.name + } else if (args.type === 'azure-native:web:WebAppSlot') { + name = args.inputs.name } return { @@ -121,6 +136,7 @@ describe('TestAzureAppServiceConstruct', () => { expect(stack.construct).toBeDefined() expect(stack.construct.appServicePlan).toBeDefined() expect(stack.construct.linuxWebApp).toBeDefined() + expect(stack.construct.webAppSlot).toBeDefined() }) }) @@ -180,12 +196,39 @@ describe('TestAzureLinuxWebAppConstruct', () => { }) }) +describe('TestAzureWebAppSlotConstruct', () => { + test('provisions web app slot as expected', async () => { + await outputToPromise( + pulumi + .all([ + stack.construct.webAppSlot.id, + stack.construct.webAppSlot.urn, + stack.construct.webAppSlot.name, + stack.construct.webAppSlot.location, + stack.construct.webAppSlot.httpsOnly, + stack.construct.webAppSlot.tags, + ]) + .apply(([id, urn, name, location, httpsOnly, tags]) => { + expect(id).toEqual('test-web-app-slot-dev-was-id') + expect(urn).toEqual( + 'urn:pulumi:stack::project::construct:test-common-stack$azure-native:web:WebAppSlot::test-web-app-slot-dev-was' + ) + expect(name).toEqual('test-linux-web-app-dev') + expect(location).toEqual('eastus') + expect(httpsOnly).toEqual(true) + expect(tags?.environment).toEqual('dev') + }) + ) + }) +}) + /* --- Tests for default value fallback branches in createLinuxWebApp --- */ class TestMinimalWebAppConstruct extends CommonAzureConstruct { declare props: TestAzureStackProps appServicePlan: AppServicePlan linuxWebApp: WebApp + webAppSlot: WebAppSlot constructor(name: string, props: TestAzureStackProps) { super(name, props) @@ -201,6 +244,13 @@ class TestMinimalWebAppConstruct extends CommonAzureConstruct { resourceGroupName: 'test-rg-dev', serverFarmId: '/subscriptions/test-sub/resourceGroups/test-rg-dev/providers/Microsoft.Web/serverfarms/test', } as any) + + this.webAppSlot = this.appServiceManager.createWebAppSlot(`test-minimal-was-${this.props.stage}`, this, { + name: 'test-minimal-web-app', + slot: 'green', + resourceGroupName: 'test-rg-dev', + serverFarmId: '/subscriptions/test-sub/resourceGroups/test-rg-dev/providers/Microsoft.Web/serverfarms/test', + } as any) } } @@ -248,6 +298,38 @@ describe('TestAzureLinuxWebAppConstruct - Default Values', () => { }) ) }) + + test('web app slot uses default httpsOnly when not provided', async () => { + await outputToPromise( + pulumi.all([minimalWebAppStack.construct.webAppSlot.httpsOnly]).apply(([httpsOnly]) => { + expect(httpsOnly).toEqual(true) + }) + ) + }) + + test('web app slot uses default kind when not provided', async () => { + await outputToPromise( + pulumi.all([minimalWebAppStack.construct.webAppSlot.kind]).apply(([kind]) => { + expect(kind).toEqual('app,linux') + }) + ) + }) + + test('web app slot uses default identity when not provided', async () => { + await outputToPromise( + pulumi.all([minimalWebAppStack.construct.webAppSlot.identity]).apply(([identity]) => { + expect(identity?.type).toEqual('SystemAssigned') + }) + ) + }) + + test('web app slot uses default tags when not provided', async () => { + await outputToPromise( + pulumi.all([minimalWebAppStack.construct.webAppSlot.tags]).apply(([tags]) => { + expect(tags?.environment).toEqual('dev') + }) + ) + }) }) describe('TestAzureLinuxWebAppConstruct - Error Handling', () => { @@ -256,4 +338,10 @@ describe('TestAzureLinuxWebAppConstruct - Error Handling', () => { stack.construct.appServiceManager.createLinuxWebApp('test-lwa-err', stack.construct, undefined as any) }).toThrow('Props undefined for test-lwa-err') }) + + test('createWebAppSlot throws when props are undefined', () => { + expect(() => { + stack.construct.appServiceManager.createWebAppSlot('test-was-err', stack.construct, undefined as any) + }).toThrow('Props undefined for test-was-err') + }) })