From 4711420efc9bec628a49f84eae9030229dbcd35b Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Fri, 25 Sep 2026 12:09:09 +0100 Subject: [PATCH] Add CloudFormation express mode support --- docs/cli-reference/deploy.md | 2 + docs/cli-reference/remove.md | 2 + docs/cli-reference/rollback.md | 2 + docs/guides/compose.md | 2 + docs/guides/deploying.md | 28 ++++ docs/guides/serverless.yml.md | 3 + lib/plugins/aws/deploy/index.js | 7 + .../aws/lib/get-create-stack-params.js | 15 +- .../aws/lib/get-shared-stack-action-params.js | 7 + .../aws/lib/get-update-stack-params.js | 5 +- lib/plugins/aws/provider.js | 1 + lib/plugins/aws/remove/index.js | 7 + lib/plugins/aws/remove/lib/stack.js | 4 + lib/plugins/aws/rollback.js | 7 + .../unit/lib/plugins/aws/deploy/index.test.js | 153 ++++++++++++++++++ test/unit/lib/plugins/aws/provider.test.js | 15 ++ .../unit/lib/plugins/aws/remove/index.test.js | 28 ++++ .../lib/plugins/aws/remove/lib/stack.test.js | 15 ++ test/unit/lib/plugins/aws/rollback.test.js | 69 ++++++++ types/index.d.ts | 1 + 20 files changed, 365 insertions(+), 8 deletions(-) diff --git a/docs/cli-reference/deploy.md b/docs/cli-reference/deploy.md index d4d692622a..f965790aa9 100644 --- a/docs/cli-reference/deploy.md +++ b/docs/cli-reference/deploy.md @@ -2,6 +2,8 @@ The `osls deploy` command deploys your entire service via CloudFormation. Run this command when you have made infrastructure changes (i.e., you edited `serverless.yml`). Use `osls deploy function -f myFunction` when you have made code changes and you want to quickly upload your updated code to AWS Lambda or just change function configuration. +With [`provider.deploymentMode: express`](../guides/deploying.md#deployment-mode), the stack operation completes as soon as the configuration is applied and resources may still be stabilizing when the command returns. + ```bash osls deploy ``` diff --git a/docs/cli-reference/remove.md b/docs/cli-reference/remove.md index 4b0c081f4d..c52e849abe 100644 --- a/docs/cli-reference/remove.md +++ b/docs/cli-reference/remove.md @@ -4,6 +4,8 @@ The `osls remove` command will remove the deployed service, defined in your curr If the stack has deletion protection enabled (see [`provider.deletionProtection`](../guides/deploying.md#deletion-protection)), the command fails before deleting anything. +With [`provider.deploymentMode: express`](../guides/deploying.md#deployment-mode), the stack is deleted in CloudFormation express mode and the command returns while resources may still be deleting. + ```bash osls remove ``` diff --git a/docs/cli-reference/rollback.md b/docs/cli-reference/rollback.md index f3ce02204a..5a67595638 100644 --- a/docs/cli-reference/rollback.md +++ b/docs/cli-reference/rollback.md @@ -19,6 +19,8 @@ If `timestamp` is not specified, the CLI will show your existing deployments. Rollback only works while the target deployment's artifacts still exist in the deployment bucket. osls keeps the most recent deployments and prunes older ones (the last `5` by default, configurable via `provider.deploymentBucket.maxPreviousDeploymentArtifacts`). Once a deployment's artifacts have been pruned, you can no longer roll back to it. +Rollback uses the [`provider.deploymentMode`](../guides/deploying.md#deployment-mode) from the current `serverless.yml`, not the one saved with the target deployment. + ## Examples ### AWS diff --git a/docs/guides/compose.md b/docs/guides/compose.md index c3636b7402..ebc2b39acd 100644 --- a/docs/guides/compose.md +++ b/docs/guides/compose.md @@ -141,6 +141,8 @@ services: As seen in the above example, it is possible to configure more than one dependency by providing `dependsOn` as a list. +Dependencies, explicit or from variables, only order the commands: a dependent service starts deploying as soon as the services it depends on have finished. With [`provider.deploymentMode: express`](./deploying.md#deployment-mode) on an upstream service, its stack completes before its resources have stabilized, so a dependent service that uses those resources while it deploys (an event source mapping on a stream, a custom resource that calls an endpoint, or networking such as a VPC and NAT gateway, for example) can fail and need to be deployed again. Removals run in reverse order, and an express removal returns while resources such as VPC network interfaces may not have been released yet, so removing the upstream service straight afterwards can fail with a dependency error until they are gone. + ### Global commands On top of `osls deploy`, the following commands can be run globally across all services: diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index 00161855d9..e40babcf6a 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -44,6 +44,34 @@ provider: deploymentMethod: direct ``` +### Deployment mode + +[CloudFormation express mode](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cloudformation-express-mode.html) completes stack operations as soon as resource configuration is applied, without waiting for resources to stabilize. Deployments usually finish faster, but resources may still be initializing when the command returns. AWS positions it for development iteration; keep the default mode where a successful deployment must mean that resources are ready to serve traffic. + +Enable it with `provider.deploymentMode`: + +```yaml +provider: + name: aws + deploymentMode: express +``` + +The setting applies to every stack operation osls performs for the service: `osls deploy` (with either `deploymentMethod`), `osls rollback` and `osls remove`. `osls deploy function` does not use CloudFormation and is unaffected. No template changes are needed. CloudFormation still waits for custom resources to respond, and stack outputs that reference resource attributes are resolved before the operation completes. AWS documents no resource restrictions, but a resource that depends on another one being fully operational can fail; if that happens, use the default mode for that stage. + +Rollback keeps working as in the default mode: a failed deployment is rolled back unless `provider.disableRollback` is `true`. This differs from CloudFormation's own express default, used by the AWS CLI and the CDK, which disables rollback unless you opt back in; the SAM CLI makes the same choice as osls. Keep rollback enabled unless you need to inspect failed resources: the constraints below only apply while it is disabled. + +Keep in mind: + +- `osls deploy` returns as soon as the configuration is applied. Resources such as CloudFront distributions may still be propagating when osls prints the service information, so a request made straight after the deploy can still reach the previous configuration. Express mode is not always faster: an express operation can still take tens of seconds for a single resource, and a resource that keeps failing can be retried for several minutes before the deployment fails. +- CloudFormation does not accept `OnFailure` in express mode, so osls cannot ask it to delete a stack whose creation failed, which it otherwise does with `deploymentMethod: direct`. This matters when a deployment creates the stack: the first deployment of a service creates the stack with the deployment bucket, and with a custom `provider.deploymentBucket` it creates the whole stack at once. A failed creation leaves the stack in `ROLLBACK_COMPLETE`; run `osls remove` before deploying again, as is already the case for change set deployments. +- With `disableRollback: true`, a failed express deployment leaves the stack in `CREATE_FAILED` or `UPDATE_FAILED`. Until an update succeeds, CloudFormation rejects every update that does not also use express mode with rollback disabled, and `RollbackStack` (`aws cloudformation rollback-stack`) is rejected too. Keep both settings, fix the problem and deploy again, with either deployment method. If `osls deploy` reports that there are no changes, deploy with `--force`. +- While rollback is disabled, CloudFormation rejects updates that replace a resource, for example changing a function's `name` (which also replaces its log group) or a DynamoDB table's or SQS queue's name. The deployment fails and leaves the stack in `UPDATE_FAILED`, and CloudFormation records the attempted properties, so reverting the change is treated as a replacement as well: a resource that keeps its physical name is deleted before it is created again, which discards a log group and its logs, and the creation can fail once more with `AlreadyExists` until the deletion has propagated. Deploy the reverted configuration with `deploymentMethod: direct` and `disableRollback: true` still set (`osls rollback --timestamp` uses the same path, while a change set built from the reverted configuration alone reports nothing to deploy), or remove and redeploy the service. Re-enable rollback before deploying the replacement. +- `provider.rollbackConfiguration` still applies, and the operation only completes after its monitoring period. +- Switching between express and the default mode is a per-deployment choice: after a successful operation, the next one can use either mode. +- `osls deploy --package` uses the value saved by `osls package`; re-run `osls package` after changing it. `osls rollback` and `osls remove` use the current `serverless.yml`. +- `osls remove` reports completion while resources may still be deleting in the background. Deploying a service that reuses the same physical resource names straight afterwards, including redeploying the same service right after `osls remove`, can fail with a name conflict. +- Nested stacks inherit the mode from the root stack. + ### Deletion protection Set `provider.deletionProtection` to have osls manage [CloudFormation termination protection](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-protect-stacks.html) for the service stack: diff --git a/docs/guides/serverless.yml.md b/docs/guides/serverless.yml.md index 4757736f44..8382dda19d 100644 --- a/docs/guides/serverless.yml.md +++ b/docs/guides/serverless.yml.md @@ -82,6 +82,9 @@ provider: key: value # Method used for CloudFormation deployments: 'changesets' or 'direct' (default: changesets) deploymentMethod: direct + # CloudFormation deployment mode: 'standard' or 'express' (default: standard, values are case-sensitive) + # Express mode completes stack operations once resource configuration is applied, without waiting for resources to stabilize + deploymentMode: express # Manage CloudFormation termination protection for the stack after each deploy (not managed by default). # `true`/`false` applies to every stage; the `stages` form enables protection for the listed # stages and disables it for all others. diff --git a/lib/plugins/aws/deploy/index.js b/lib/plugins/aws/deploy/index.js index 38d05a13a1..3aa079664e 100644 --- a/lib/plugins/aws/deploy/index.js +++ b/lib/plugins/aws/deploy/index.js @@ -227,6 +227,13 @@ class AwsDeploy { )}s)` )}` ); + if (this.serverless.service.provider.deploymentMode === 'express') { + log.notice( + style.aside( + 'Deployed with CloudFormation express mode. Resources may still be stabilizing in the background.' + ) + ); + } writeText(); writeServiceOutputs(this.serverless.serviceOutputs); writeServiceOutputs(this.serverless.servicePluginOutputs); diff --git a/lib/plugins/aws/lib/get-create-stack-params.js b/lib/plugins/aws/lib/get-create-stack-params.js index 48f65d8dd3..d481b0e32d 100644 --- a/lib/plugins/aws/lib/get-create-stack-params.js +++ b/lib/plugins/aws/lib/get-create-stack-params.js @@ -2,14 +2,15 @@ module.exports = { getCreateStackParams(options) { - const params = { - ...this.getSharedStackActionParams(options), - OnFailure: 'DELETE', - }; + const params = this.getSharedStackActionParams(options); - if (this.serverless.service.provider.disableRollback) { - delete params.OnFailure; - params.DisableRollback = this.serverless.service.provider.disableRollback; + // CloudFormation rejects OnFailure in express mode + if (this.serverless.service.provider.deploymentMode !== 'express') { + if (this.serverless.service.provider.disableRollback) { + params.DisableRollback = this.serverless.service.provider.disableRollback; + } else { + params.OnFailure = 'DELETE'; + } } return params; diff --git a/lib/plugins/aws/lib/get-shared-stack-action-params.js b/lib/plugins/aws/lib/get-shared-stack-action-params.js index a0a2659f58..04d02dacd0 100644 --- a/lib/plugins/aws/lib/get-shared-stack-action-params.js +++ b/lib/plugins/aws/lib/get-shared-stack-action-params.js @@ -49,6 +49,13 @@ module.exports = { params.RoleARN = customDeploymentRole; } + if (this.serverless.service.provider.deploymentMode === 'express') { + params.DeploymentConfig = { + Mode: 'EXPRESS', + DisableRollback: Boolean(this.serverless.service.provider.disableRollback), + }; + } + if (this.serverless.service.provider.notificationArns) { params.NotificationARNs = this.serverless.service.provider.notificationArns; } else { diff --git a/lib/plugins/aws/lib/get-update-stack-params.js b/lib/plugins/aws/lib/get-update-stack-params.js index 4fc18be530..700f07e4c7 100644 --- a/lib/plugins/aws/lib/get-update-stack-params.js +++ b/lib/plugins/aws/lib/get-update-stack-params.js @@ -18,7 +18,10 @@ module.exports = { params.RollbackConfiguration = this.serverless.service.provider.rollbackConfiguration; } - if (this.serverless.service.provider.disableRollback) { + if ( + this.serverless.service.provider.disableRollback && + this.serverless.service.provider.deploymentMode !== 'express' + ) { params.DisableRollback = this.serverless.service.provider.disableRollback; } return params; diff --git a/lib/plugins/aws/provider.js b/lib/plugins/aws/provider.js index d67f70fc60..ff91e9a962 100644 --- a/lib/plugins/aws/provider.js +++ b/lib/plugins/aws/provider.js @@ -965,6 +965,7 @@ class AwsProvider { }, ], }, + deploymentMode: { enum: ['standard', 'express'] }, deploymentPrefix: { type: 'string' }, disableRollback: { type: 'boolean' }, endpointType: { diff --git a/lib/plugins/aws/remove/index.js b/lib/plugins/aws/remove/index.js index 286217e5af..f80e6ce524 100644 --- a/lib/plugins/aws/remove/index.js +++ b/lib/plugins/aws/remove/index.js @@ -75,6 +75,13 @@ class AwsRemove { )}s)` )}` ); + if (this.serverless.service.provider.deploymentMode === 'express') { + log.notice( + style.aside( + 'Removed with CloudFormation express mode. Resources may still be deleting in the background.' + ) + ); + } }, }; } diff --git a/lib/plugins/aws/remove/lib/stack.js b/lib/plugins/aws/remove/lib/stack.js index 471965c946..cd53667ff8 100644 --- a/lib/plugins/aws/remove/lib/stack.js +++ b/lib/plugins/aws/remove/lib/stack.js @@ -62,6 +62,10 @@ module.exports = { params.RoleARN = customDeploymentRole; } + if (this.serverless.service.provider.deploymentMode === 'express') { + params.DeploymentConfig = { Mode: 'EXPRESS' }; + } + const cfData = { StackId: stackName, }; diff --git a/lib/plugins/aws/rollback.js b/lib/plugins/aws/rollback.js index 61a5f3dd82..a1708ab9ac 100644 --- a/lib/plugins/aws/rollback.js +++ b/lib/plugins/aws/rollback.js @@ -90,6 +90,13 @@ class AwsRollback { )}s)` )}` ); + if (this.serverless.service.provider.deploymentMode === 'express') { + log.notice( + style.aside( + 'Rolled back with CloudFormation express mode. Resources may still be stabilizing in the background.' + ) + ); + } } else { log.notice.skip( `No updates to be performed. Rollback skipped. ${style.aside( diff --git a/test/unit/lib/plugins/aws/deploy/index.test.js b/test/unit/lib/plugins/aws/deploy/index.test.js index fad9de31a4..7a588a25e8 100644 --- a/test/unit/lib/plugins/aws/deploy/index.test.js +++ b/test/unit/lib/plugins/aws/deploy/index.test.js @@ -1,6 +1,7 @@ 'use strict'; const sinon = require('sinon'); +const logEmitter = require('log/lib/emitter'); const runServerless = require('../../../../../utils/run-serverless'); @@ -229,6 +230,155 @@ describe('test/unit/lib/plugins/aws/deploy/index.test.js', () => { }); }); + describe('express deployment mode', () => { + async function deployExpress(options = {}) { + const createStackStub = sinon.stub().resolves({}); + const updateStackStub = sinon.stub().resolves({}); + const createChangeSetStub = sinon.stub().resolves({}); + const executeChangeSetStub = sinon.stub().resolves({}); + const logEvents = []; + const listener = (event) => logEvents.push(event); + logEmitter.on('log', listener); + try { + await runServerless({ + fixture: 'function', + command: 'deploy', + awsSdkV3StubMap: { + ...baseAwsSdkV3StubMap, + ECR: { + describeRepositories: sinon.stub().throws({ + providerError: { code: 'RepositoryNotFoundException' }, + }), + }, + S3: { + deleteObjects: {}, + listObjectsV2: { Contents: [] }, + upload: {}, + headBucket: {}, + }, + CloudFormation: { + describeStacks: sinon + .stub() + .onFirstCall() + .throws(createCloudFormationValidationError('stack does not exist')) + .onSecondCall() + .resolves({ Stacks: [{}] }), + createStack: createStackStub, + updateStack: updateStackStub, + createChangeSet: createChangeSetStub, + executeChangeSet: executeChangeSetStub, + deleteChangeSet: {}, + describeChangeSet: { + ChangeSetName: 'new-service-dev-change-set', + ChangeSetId: 'some-change-set-id', + StackName: 'new-service-dev', + Status: 'CREATE_COMPLETE', + }, + describeStackEvents: { + StackEvents: [ + { + EventId: '1e2f3g4h', + StackName: 'new-service-dev', + LogicalResourceId: 'new-service-dev', + ResourceType: 'AWS::CloudFormation::Stack', + Timestamp: new Date(), + ResourceStatus: 'CREATE_COMPLETE', + }, + ], + }, + describeStackResource: { + StackResourceDetail: { PhysicalResourceId: 's3-bucket-resource' }, + }, + validateTemplate: {}, + listStackResources: {}, + }, + }, + configExt: { + service: 'new-service', + provider: { deploymentMode: 'express', ...options.provider }, + }, + }); + } finally { + logEmitter.off('log', listener); + } + const noticeMessages = logEvents + .filter((event) => event.logger.level === 'notice') + .map((event) => String(event.messageTokens[0])); + return { + createStackStub, + updateStackStub, + createChangeSetStub, + executeChangeSetStub, + noticeMessages, + }; + } + + it('passes DeploymentConfig with rollback enabled on direct deployments', async () => { + const { createStackStub, updateStackStub, noticeMessages } = await deployExpress({ + provider: { deploymentMethod: 'direct' }, + }); + const deploymentConfig = { Mode: 'EXPRESS', DisableRollback: false }; + expect(createStackStub.getCall(0).args[0].DeploymentConfig).to.deep.equal(deploymentConfig); + expect(createStackStub.getCall(0).args[0]).to.not.have.property('OnFailure'); + expect(updateStackStub.getCall(0).args[0].DeploymentConfig).to.deep.equal(deploymentConfig); + expect(noticeMessages.join('\n')).to.include('Deployed with CloudFormation express mode'); + }); + + it('carries `disableRollback` in DeploymentConfig on direct deployments', async () => { + const { createStackStub, updateStackStub } = await deployExpress({ + provider: { deploymentMethod: 'direct', disableRollback: true }, + }); + const deploymentConfig = { Mode: 'EXPRESS', DisableRollback: true }; + expect(createStackStub.getCall(0).args[0].DeploymentConfig).to.deep.equal(deploymentConfig); + expect(createStackStub.getCall(0).args[0]).to.not.have.property('OnFailure'); + expect(createStackStub.getCall(0).args[0]).to.not.have.property('DisableRollback'); + expect(updateStackStub.getCall(0).args[0].DeploymentConfig).to.deep.equal(deploymentConfig); + expect(updateStackStub.getCall(0).args[0]).to.not.have.property('DisableRollback'); + }); + + it('passes DeploymentConfig on change sets and executes them as before', async () => { + const { createChangeSetStub, executeChangeSetStub } = await deployExpress(); + const deploymentConfig = { Mode: 'EXPRESS', DisableRollback: false }; + expect(createChangeSetStub.getCall(0).args[0].DeploymentConfig).to.deep.equal( + deploymentConfig + ); + expect(createChangeSetStub.getCall(1).args[0].DeploymentConfig).to.deep.equal( + deploymentConfig + ); + expect(executeChangeSetStub.getCall(0).args[0]).to.deep.equal({ + StackName: 'new-service-dev', + ChangeSetName: 'new-service-dev-change-set', + }); + expect(executeChangeSetStub.getCall(1).args[0]).to.deep.equal({ + StackName: 'new-service-dev', + ChangeSetName: 'new-service-dev-change-set', + }); + }); + + it('keeps `disableRollback` on change set execution', async () => { + const { createChangeSetStub, executeChangeSetStub } = await deployExpress({ + provider: { disableRollback: true }, + }); + const deploymentConfig = { Mode: 'EXPRESS', DisableRollback: true }; + expect(createChangeSetStub.getCall(0).args[0].DeploymentConfig).to.deep.equal( + deploymentConfig + ); + expect(createChangeSetStub.getCall(1).args[0].DeploymentConfig).to.deep.equal( + deploymentConfig + ); + expect(executeChangeSetStub.getCall(0).args[0]).to.deep.equal({ + StackName: 'new-service-dev', + ChangeSetName: 'new-service-dev-change-set', + DisableRollback: true, + }); + expect(executeChangeSetStub.getCall(1).args[0]).to.deep.equal({ + StackName: 'new-service-dev', + ChangeSetName: 'new-service-dev-change-set', + DisableRollback: true, + }); + }); + }); + describe('with direct create/update calls', () => { it('with nonexistent stack - first deploy', async () => { const describeStacksStub = sinon @@ -291,6 +441,9 @@ describe('test/unit/lib/plugins/aws/deploy/index.test.js', () => { expect(createStackStub).to.be.calledOnce; expect(updateStackStub).to.be.calledOnce; + expect(createStackStub.getCall(0).args[0].OnFailure).to.equal('DELETE'); + expect(createStackStub.getCall(0).args[0]).to.not.have.property('DeploymentConfig'); + expect(updateStackStub.getCall(0).args[0]).to.not.have.property('DeploymentConfig'); const createStackSends = getCloudFormationSends(awsSdkV3Stub, 'createStack'); const updateStackSends = getCloudFormationSends(awsSdkV3Stub, 'updateStack'); const validateTemplateSends = getCloudFormationSends(awsSdkV3Stub, 'validateTemplate'); diff --git a/test/unit/lib/plugins/aws/provider.test.js b/test/unit/lib/plugins/aws/provider.test.js index 9f931ddfda..2d00dec9e3 100644 --- a/test/unit/lib/plugins/aws/provider.test.js +++ b/test/unit/lib/plugins/aws/provider.test.js @@ -357,6 +357,21 @@ describe('AwsProvider', () => { } }); + describe('deploymentMode validation', () => { + it('rejects "EXPRESS"', async () => { + await expect( + runServerless({ + fixture: 'function', + command: 'print', + configExt: { provider: { deploymentMode: 'EXPRESS' } }, + }) + ).to.eventually.be.rejected.and.have.property( + 'code', + 'INVALID_NON_SCHEMA_COMPLIANT_CONFIGURATION' + ); + }); + }); + describe('deploymentBucket configuration', () => { it('should do nothing if not defined', () => { serverless.service.provider.deploymentBucket = undefined; diff --git a/test/unit/lib/plugins/aws/remove/index.test.js b/test/unit/lib/plugins/aws/remove/index.test.js index 5406bbc6b8..a01c43d56b 100644 --- a/test/unit/lib/plugins/aws/remove/index.test.js +++ b/test/unit/lib/plugins/aws/remove/index.test.js @@ -2,6 +2,7 @@ const sinon = require('sinon'); const proxyquire = require('proxyquire'); +const logEmitter = require('log/lib/emitter'); const { S3Client, DeleteObjectsCommand } = require('@aws-sdk/client-s3'); const emptyS3Bucket = require('../../../../../../lib/plugins/aws/remove/lib/bucket'); const runServerless = require('../../../../../utils/run-serverless'); @@ -259,6 +260,33 @@ describe('test/unit/lib/plugins/aws/remove/index.test.js', () => { ).to.equal(cloudFormationSends.find(({ method }) => method === 'deleteStack').client); }); + it('removes the stack in express mode and reports it', async () => { + describeRepositoriesStub.throws({ providerError: { code: 'RepositoryNotFoundException' } }); + const logEvents = []; + const listener = (event) => logEvents.push(event); + logEmitter.on('log', listener); + let awsNaming; + try { + ({ awsNaming } = await runServerless({ + fixture: 'function', + command: 'remove', + awsSdkV3StubMap, + configExt: { provider: { deploymentMode: 'express' } }, + })); + } finally { + logEmitter.off('log', listener); + } + + expectAwsSdkV3StubInput(deleteStackStub, { + StackName: awsNaming.getStackName(), + DeploymentConfig: { Mode: 'EXPRESS' }, + }); + const noticeMessages = logEvents + .filter((event) => event.logger.level === 'notice') + .map((event) => String(event.messageTokens[0])); + expect(noticeMessages.join('\n')).to.include('Removed with CloudFormation express mode'); + }); + it('fails before cleanup when the stack has deletion protection enabled', async () => { await expect( runServerless({ diff --git a/test/unit/lib/plugins/aws/remove/lib/stack.test.js b/test/unit/lib/plugins/aws/remove/lib/stack.test.js index 114e83b3c6..4c23899b5b 100644 --- a/test/unit/lib/plugins/aws/remove/lib/stack.test.js +++ b/test/unit/lib/plugins/aws/remove/lib/stack.test.js @@ -25,6 +25,7 @@ describe('removeStack', () => { }; return { ...removeStack, + serverless: { service: { provider: {} } }, ...overrides, provider, }; @@ -168,6 +169,20 @@ describe('removeStack', () => { expect(removeStackStub.firstCall.args[0]).to.be.instanceOf(DeleteStackCommand); expect(removeStackStub.firstCall.args[0].input.RoleARN).to.equal(customDeploymentRole); }); + + it('should delete the stack in express mode when configured', async () => { + const context = createRemoveStackContext({ + serverless: { service: { provider: { deploymentMode: 'express' } } }, + }); + + await context.remove(); + + expect(removeStackStub.firstCall.args[0]).to.be.instanceOf(DeleteStackCommand); + expect(removeStackStub.firstCall.args[0].input).to.deep.equal({ + StackName: stackName, + DeploymentConfig: { Mode: 'EXPRESS' }, + }); + }); }); describe('#removeStack()', () => { diff --git a/test/unit/lib/plugins/aws/rollback.test.js b/test/unit/lib/plugins/aws/rollback.test.js index 919d3b91b4..d9a27e22e8 100644 --- a/test/unit/lib/plugins/aws/rollback.test.js +++ b/test/unit/lib/plugins/aws/rollback.test.js @@ -7,6 +7,7 @@ const Serverless = require('../../../../../lib/serverless'); const chai = require('chai'); const assert = require('chai').assert; const sinon = require('sinon'); +const logEmitter = require('log/lib/emitter'); const { S3Client, ListObjectsV2Command, GetObjectCommand } = require('@aws-sdk/client-s3'); const { Readable } = require('stream'); @@ -579,4 +580,72 @@ describe('test/unit/lib/plugins/aws/rollback.test.js', () => { expect(sentMethods).to.not.include('updateTerminationProtection'); expect(updateTerminationProtectionStub).not.to.have.been.called; }); + + it('rolls back in express mode and reports it', async () => { + const updateStackStub = sinon.stub().resolves({}); + const deploymentDirectory = '1476779096930-2016-10-18T08:24:56.930Z'; + const logEvents = []; + const listener = (event) => logEvents.push(event); + logEmitter.on('log', listener); + try { + await runServerless({ + fixture: 'function', + command: 'rollback', + options: { timestamp: '1476779096930' }, + configExt: { provider: { deploymentMethod: 'direct', deploymentMode: 'express' } }, + awsSdkV3StubMap: { + CloudFormation: { + describeStackResource: { + StackResourceDetail: { PhysicalResourceId: 'deployment-bucket' }, + }, + updateStack: updateStackStub, + describeStackEvents: { + StackEvents: [ + { + EventId: '1e2f3g4h', + StackName: 'service-dev', + LogicalResourceId: 'service-dev', + ResourceType: 'AWS::CloudFormation::Stack', + Timestamp: new Date(), + ResourceStatus: 'UPDATE_COMPLETE', + }, + ], + }, + }, + STS: { + getCallerIdentity: { + ResponseMetadata: { RequestId: 'ffffffff-ffff-ffff-ffff-ffffffffffff' }, + UserId: 'XXXXXXXXXXXXXXXXXXXXX', + Account: '999999999999', + Arn: 'arn:aws:iam::999999999999:user/test', + }, + }, + S3: { + headObject: () => {}, + headBucket: () => {}, + listObjectsV2: ({ Prefix }) => ({ + Contents: [ + { Key: `${Prefix}${deploymentDirectory}/compiled-cloudformation-template.json` }, + { Key: `${Prefix}${deploymentDirectory}/service.zip` }, + ], + }), + getObject: { Body: '{}' }, + }, + }, + }); + } finally { + logEmitter.off('log', listener); + } + + expect(updateStackStub).to.have.been.calledOnce; + expect(updateStackStub.firstCall.args[0].DeploymentConfig).to.deep.equal({ + Mode: 'EXPRESS', + DisableRollback: false, + }); + expect(updateStackStub.firstCall.args[0]).to.not.have.property('DisableRollback'); + const noticeMessages = logEvents + .filter((event) => event.logger.level === 'notice') + .map((event) => String(event.messageTokens[0])); + expect(noticeMessages.join('\n')).to.include('Rolled back with CloudFormation express mode'); + }); }); diff --git a/types/index.d.ts b/types/index.d.ts index de681791b1..06b2482efd 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -933,6 +933,7 @@ export interface AWS { sseKMSKeyId?: string; tags?: AwsResourceTags; }; + deploymentMode?: 'standard' | 'express'; deploymentPrefix?: string; disableRollback?: boolean; endpointType?: string;