Skip to content

Commit e040a2b

Browse files
authored
feat: add s3-bucket and s3-prefix inputs for large template upload (#196)
* feat: add s3-bucket and s3-prefix inputs for large template upload (#180) When s3-bucket is provided, the action uploads the local template file to S3 and uses TemplateURL instead of TemplateBody, avoiding the 51,200 byte inline template limit. Closes #180 * chore: rebuild dist
1 parent 64bde66 commit e040a2b

8 files changed

Lines changed: 12450 additions & 630 deletions

File tree

__tests__/main.test.ts

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
ExecuteChangeSetCommand,
1414
DescribeStacksCommand
1515
} from '@aws-sdk/client-cloudformation'
16+
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
1617
import { mockClient } from 'aws-sdk-client-mock'
1718
import { FileHandle } from 'fs/promises'
1819
import 'aws-sdk-client-mock-jest'
@@ -60,6 +61,7 @@ const mockStackId =
6061
'arn:aws:cloudformation:us-east-1:123456789012:stack/myteststack/466df9e0-0dff-08e3-8e2f-5088487c4896'
6162

6263
const mockCfnClient = mockClient(CloudFormationClient)
64+
const mockS3Client = mockClient(S3Client)
6365

6466
// Helper function to create complete inputs
6567
const createInputs = (overrides: Partial<Inputs> = {}): Inputs => ({
@@ -81,6 +83,8 @@ const createInputs = (overrides: Partial<Inputs> = {}): Inputs => ({
8183
'change-set-name': '',
8284
'include-nested-stacks-change-set': '0',
8385
'deployment-mode': '',
86+
's3-bucket': '',
87+
's3-prefix': '',
8488
'execute-change-set-id': '',
8589
...overrides
8690
})
@@ -135,17 +139,15 @@ describe('Deploy CloudFormation Stack', () => {
135139
}
136140
)
137141

142+
mockS3Client.reset().on(PutObjectCommand).resolves({})
143+
138144
mockCfnClient
139145
.reset()
140146
.on(CreateChangeSetCommand)
141147
.resolves({
142148
StackId: mockStackId
143149
})
144150
.on(CreateChangeSetCommand)
145-
.resolves({
146-
StackId: mockStackId
147-
})
148-
.on(CreateChangeSetCommand)
149151
.resolves({})
150152
.on(DescribeChangeSetCommand)
151153
.resolves({
@@ -1554,4 +1556,73 @@ describe('Deploy CloudFormation Stack', () => {
15541556
expect(core.setOutput).toHaveBeenCalledWith('has-changes', 'false')
15551557
expect(core.setOutput).toHaveBeenCalledWith('changes-count', '0')
15561558
})
1559+
1560+
test('uploads template to S3 when s3-bucket is provided', async () => {
1561+
const inputs = createInputs({
1562+
's3-bucket': 'my-deploy-bucket'
1563+
})
1564+
1565+
jest.spyOn(core, 'getInput').mockImplementation((name: string) => {
1566+
return inputs[name]
1567+
})
1568+
1569+
await run()
1570+
1571+
expect(core.setFailed).toHaveBeenCalledTimes(0)
1572+
expect(mockS3Client).toHaveReceivedCommandTimes(PutObjectCommand, 1)
1573+
expect(mockS3Client).toHaveReceivedCommandWith(PutObjectCommand, {
1574+
Bucket: 'my-deploy-bucket',
1575+
Key: 'template.yaml',
1576+
Body: mockTemplate
1577+
})
1578+
expect(mockCfnClient).toHaveReceivedCommandWith(CreateChangeSetCommand, {
1579+
StackName: 'MockStack',
1580+
TemplateURL: 'https://my-deploy-bucket.s3.amazonaws.com/template.yaml',
1581+
TemplateBody: undefined
1582+
})
1583+
})
1584+
1585+
test('uploads template to S3 with prefix when s3-prefix is provided', async () => {
1586+
const inputs = createInputs({
1587+
's3-bucket': 'my-deploy-bucket',
1588+
's3-prefix': 'cfn-templates'
1589+
})
1590+
1591+
jest.spyOn(core, 'getInput').mockImplementation((name: string) => {
1592+
return inputs[name]
1593+
})
1594+
1595+
await run()
1596+
1597+
expect(core.setFailed).toHaveBeenCalledTimes(0)
1598+
expect(mockS3Client).toHaveReceivedCommandTimes(PutObjectCommand, 1)
1599+
expect(mockS3Client).toHaveReceivedCommandWith(PutObjectCommand, {
1600+
Bucket: 'my-deploy-bucket',
1601+
Key: 'cfn-templates/template.yaml',
1602+
Body: mockTemplate
1603+
})
1604+
expect(mockCfnClient).toHaveReceivedCommandWith(CreateChangeSetCommand, {
1605+
StackName: 'MockStack',
1606+
TemplateURL:
1607+
'https://my-deploy-bucket.s3.amazonaws.com/cfn-templates/template.yaml',
1608+
TemplateBody: undefined
1609+
})
1610+
})
1611+
1612+
test('does not upload to S3 when template is a URL', async () => {
1613+
const inputs = createInputs({
1614+
template:
1615+
'https://s3.amazonaws.com/templates/myTemplate.template?versionId=123ab1cdeKdOW5IH4GAcYbEngcpTJTDW',
1616+
's3-bucket': 'my-deploy-bucket'
1617+
})
1618+
1619+
jest.spyOn(core, 'getInput').mockImplementation((name: string) => {
1620+
return inputs[name]
1621+
})
1622+
1623+
await run()
1624+
1625+
expect(core.setFailed).toHaveBeenCalledTimes(0)
1626+
expect(mockS3Client).toHaveReceivedCommandTimes(PutObjectCommand, 0)
1627+
})
15571628
})

action.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ inputs:
6666
execute-change-set-id:
6767
description: "Execute an existing change set by ID or name instead of creating a new one. When provided, only 'name' (stack name) is required."
6868
required: false
69+
s3-bucket:
70+
description: "The name of the S3 bucket where the template will be uploaded before creating the change set. When provided, the template file is uploaded to S3 and TemplateURL is used instead of TemplateBody, avoiding the 51,200 byte inline limit."
71+
required: false
72+
s3-prefix:
73+
description: "A prefix to use for the S3 object key when uploading the template. The final key will be '<s3-prefix>/<template-filename>'. Defaults to no prefix."
74+
required: false
6975
deployment-mode:
7076
description: "The deployment mode for the change set. Use 'REVERT_DRIFT' to create a change set that reverts drift. Defaults to standard deployment."
7177
required: false

dist/579.index.js

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,13 @@ class EventStreamSerde {
5858
body: event.body,
5959
};
6060
}
61-
const unionMember = Object.keys(event).find((key) => {
62-
return key !== "__type";
63-
}) ?? "";
61+
let unionMember = "";
62+
for (const key in event) {
63+
if (key !== "__type") {
64+
unionMember = key;
65+
break;
66+
}
67+
}
6468
const { additionalHeaders, body, eventType, explicitPayloadContentType } = this.writeEventBody(unionMember, unionSchema, event);
6569
const headers = {
6670
":event-type": { type: "string", value: eventType },
@@ -81,9 +85,13 @@ class EventStreamSerde {
8185
const memberSchemas = unionSchema.getMemberSchemas();
8286
const initialResponseMarker = Symbol("initialResponseMarker");
8387
const asyncIterable = marshaller.deserialize(response.body, async (event) => {
84-
const unionMember = Object.keys(event).find((key) => {
85-
return key !== "__type";
86-
}) ?? "";
88+
let unionMember = "";
89+
for (const key in event) {
90+
if (key !== "__type") {
91+
unionMember = key;
92+
break;
93+
}
94+
}
8795
const body = event[unionMember].body;
8896
if (unionMember === "initial-response") {
8997
const dataObject = await this.deserializer.read(responseSchema, body);
@@ -159,8 +167,8 @@ class EventStreamSerde {
159167
if (!responseSchema) {
160168
throw new Error("@smithy::core/protocols - initial-response event encountered in event stream but no response schema given.");
161169
}
162-
for (const [key, value] of Object.entries(firstEvent.value)) {
163-
initialResponseContainer[key] = value;
170+
for (const key in firstEvent.value) {
171+
initialResponseContainer[key] = firstEvent.value[key];
164172
}
165173
}
166174
return {

0 commit comments

Comments
 (0)