Skip to content

Commit fbe6e5b

Browse files
authored
feat: Use AWS SDK to invalidate using pulumi beforeExit hook (#8)
Changes: + Call CloudFront invalidation using the AWS SDK triggered by Pulumi's beforeExit hook. See https://www.pulumi.com/blog/next-level-iac-pulumi-runtime-logic/ + Drops the requirement for the AWS CLI to be installed + README explicitly states that the Pulumi CLI should be installed
1 parent e61b868 commit fbe6e5b

File tree

8 files changed

+91
-103
lines changed

8 files changed

+91
-103
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Pulumi.
1515

1616
### Setup
1717

18+
1. Ensure that the [Pulumi CLI](https://www.pulumi.com/docs/install/) is installed.
1819
1. Create a SvelteKit project "my-app" - `npm create svelte@latest my-app`
1920
1. `cd my-app`
2021
1. `npm install`

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"vitest": "^1.6.0"
5555
},
5656
"dependencies": {
57+
"@aws-sdk/client-cloudfront": "^3.577.0",
5758
"@pulumi/aws": "^6.36.0",
5859
"@pulumi/command": "^0.10.0",
5960
"@pulumi/pulumi": "^3.116.1",

stacks/main/index.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,36 +7,36 @@ import {
77
buildStatic,
88
buildCDN,
99
createAliasRecord,
10-
buildInvalidator,
10+
createInvalidation,
1111
} from './resources.js'
1212

1313
const pulumiConfig = new pulumi.Config()
14-
const edgePath = pulumiConfig.get('edgePath')
15-
const staticPath = pulumiConfig.get('staticPath')
16-
const prerenderedPath = pulumiConfig.get('prerenderedPath')
14+
const edgePath = pulumiConfig.require('edgePath')
15+
const staticPath = pulumiConfig.require('staticPath')
16+
const prerenderedPath = pulumiConfig.require('prerenderedPath')
17+
const serverArn = pulumiConfig.require('serverArn')
18+
const optionsArn = pulumiConfig.require('optionsArn')
1719
const FQDN = pulumiConfig.get('FQDN')
1820
const serverHeadersStr = pulumiConfig.get('serverHeaders')
19-
const serverArn = pulumiConfig.get('serverArn')
20-
const optionsArn = pulumiConfig.get('optionsArn')
2121

2222
let serverHeaders: string[] = []
2323

2424
if (serverHeadersStr) {
2525
serverHeaders = JSON.parse(serverHeadersStr)
2626
}
2727

28-
const iamForLambda = getLambdaRole([serverArn!, optionsArn!])
29-
const routerHandler = buildRouter(iamForLambda, edgePath!)
28+
const iamForLambda = getLambdaRole([serverArn, optionsArn])
29+
const routerHandler = buildRouter(iamForLambda, edgePath)
3030

3131
let certificateArn: pulumi.Input<string> | undefined
3232

3333
if (FQDN) {
3434
const [_, zoneName, ...MLDs] = FQDN.split('.')
3535
const domainName = [zoneName, ...MLDs].join('.')
36-
certificateArn = validateCertificate(FQDN!, domainName)
36+
certificateArn = validateCertificate(FQDN, domainName)
3737
}
3838

39-
const bucket = buildStatic(staticPath!, prerenderedPath!)
39+
const bucket = buildStatic(staticPath, prerenderedPath)
4040
const distribution = buildCDN(
4141
routerHandler,
4242
bucket,
@@ -54,7 +54,7 @@ var getOrigins: (string | pulumi.Output<string>)[] = [
5454
]
5555
FQDN && getOrigins.push(`https://${FQDN}`)
5656

57-
buildInvalidator(distribution, staticPath!, prerenderedPath!)
57+
distribution.id.apply((id) => createInvalidation(id))
5858

5959
export const allowedOrigins = getOrigins
6060
export const appUrl = FQDN

stacks/main/resources.ts

Lines changed: 35 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import * as fs from 'fs'
22
import * as path from 'path'
33
import * as mime from 'mime-types'
44

5+
import * as cloudfront from '@aws-sdk/client-cloudfront'
56
import * as aws from '@pulumi/aws'
67
import * as pulumi from '@pulumi/pulumi'
7-
import { local } from '@pulumi/command'
88

99
import { NameRegister } from '../utils.js'
1010

11+
const pulumiConfig = new pulumi.Config('aws')
12+
1113
const nameRegister = NameRegister.getInstance()
1214
let registerName = (name: string): string => {
1315
return nameRegister.registerName(name)
@@ -401,85 +403,40 @@ export function getDomainAndSubdomain(domain: string): {
401403
}
402404
}
403405

404-
export function buildInvalidator(
405-
distribution: aws.cloudfront.Distribution,
406-
staticPath: string,
407-
prerenderedPath: string,
408-
) {
409-
interface PathHashResourceInputs {
410-
path: pulumi.Input<string>
411-
}
412-
413-
interface PathHashInputs {
414-
path: string
415-
}
416-
417-
interface PathHashOutputs {
418-
hash: string
406+
// Source: https://www.pulumi.com/blog/next-level-iac-pulumi-runtime-logic/
407+
export function createInvalidation(id: string) {
408+
// Only invalidate after a deployment.
409+
if (pulumi.runtime.isDryRun()) {
410+
console.log('This is a Pulumi preview, so skipping cache invalidation.')
411+
return
419412
}
420413

421-
const pathHashProvider: pulumi.dynamic.ResourceProvider = {
422-
async create(inputs: PathHashInputs) {
423-
const folderHash = await import('folder-hash')
424-
const pathHash = await folderHash.hashElement(inputs.path)
425-
return { id: inputs.path, outs: { hash: pathHash.toString() } }
426-
},
427-
async diff(
428-
id: string,
429-
previousOutput: PathHashOutputs,
430-
news: PathHashInputs,
431-
): Promise<pulumi.dynamic.DiffResult> {
432-
const replaces: string[] = []
433-
let changes = true
434-
435-
const oldHash = previousOutput.hash
436-
const folderHash = await import('folder-hash')
437-
const newHash = await folderHash.hashElement(news.path)
438-
439-
if (oldHash === newHash.toString()) {
440-
changes = false
441-
}
442-
443-
return {
444-
deleteBeforeReplace: false,
445-
replaces: replaces,
446-
changes: changes,
447-
}
448-
},
449-
async update(id, olds: PathHashInputs, news: PathHashInputs) {
450-
const folderHash = await import('folder-hash')
451-
const pathHash = await folderHash.hashElement(news.path)
452-
return { outs: { hash: pathHash.toString() } }
453-
},
454-
}
455-
456-
class PathHash extends pulumi.dynamic.Resource {
457-
public readonly hash!: pulumi.Output<string>
458-
constructor(
459-
name: string,
460-
args: PathHashResourceInputs,
461-
opts?: pulumi.CustomResourceOptions,
462-
) {
463-
super(pathHashProvider, name, { hash: undefined, ...args }, opts)
464-
}
465-
}
466-
467-
let staticHash = new PathHash(registerName('StaticHash'), {
468-
path: staticPath,
469-
})
414+
const region = pulumiConfig.require('region')
415+
416+
process.on('beforeExit', () => {
417+
const client = new cloudfront.CloudFrontClient({ region })
418+
const command = new cloudfront.CreateInvalidationCommand({
419+
DistributionId: id,
420+
InvalidationBatch: {
421+
CallerReference: `invalidation-${Date.now()}`,
422+
Paths: {
423+
Quantity: 1,
424+
Items: ['/*'],
425+
},
426+
},
427+
})
470428

471-
let prerenderedHash = new PathHash(registerName('PrerenderedHash'), {
472-
path: prerenderedPath!,
429+
client
430+
.send(command)
431+
.then((result) => {
432+
console.log(
433+
`Invalidation status for ${id}: ${result.Invalidation?.Status}.`,
434+
)
435+
process.exit(0)
436+
})
437+
.catch((error) => {
438+
console.error(error)
439+
process.exit(1)
440+
})
473441
})
474-
475-
const invalidationCommand = new local.Command(
476-
registerName('Invalidate'),
477-
{
478-
create: pulumi.interpolate`aws cloudfront create-invalidation --distribution-id ${distribution.id} --paths /\*`,
479-
triggers: [staticHash.hash, prerenderedHash.hash],
480-
},
481-
{
482-
dependsOn: [distribution],
483-
},
484-
)
485442
}

stacks/server/index.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ import { getLambdaRole, buildLambda } from './resources.js'
44
import { getEnvironment } from '../utils.js'
55

66
const pulumiConfig = new pulumi.Config()
7-
const projectPath = pulumiConfig.get('projectPath')
8-
const serverPath = pulumiConfig.get('serverPath')
9-
const optionsPath = pulumiConfig.get('optionsPath')
10-
const memorySizeStr = pulumiConfig.get('memorySize')
7+
const projectPath = pulumiConfig.require('projectPath')
8+
const serverPath = pulumiConfig.require('serverPath')
9+
const optionsPath = pulumiConfig.require('optionsPath')
10+
const memorySizeStr = pulumiConfig.require('memorySize')
1111
const allowedOriginsStr = pulumiConfig.get('allowedOrigins')
1212
let serverInvokeMode = pulumiConfig.get('serverInvokeMode')
1313

14-
const memorySize = Number(memorySizeStr!)
14+
const memorySize = Number(memorySizeStr)
1515

1616
let optionsEnv: any = {}
1717

@@ -24,12 +24,12 @@ if (!serverInvokeMode) {
2424
}
2525

2626
const iamForLambda = getLambdaRole()
27-
const environment = getEnvironment(projectPath!)
27+
const environment = getEnvironment(projectPath)
2828

2929
const serverURL = buildLambda(
3030
'LambdaServer',
3131
iamForLambda,
32-
serverPath!,
32+
serverPath,
3333
environment.parsed,
3434
memorySize,
3535
serverInvokeMode,
@@ -38,7 +38,7 @@ const serverURL = buildLambda(
3838
const optionsURL = buildLambda(
3939
'LambdaOptions',
4040
iamForLambda,
41-
optionsPath!,
41+
optionsPath,
4242
optionsEnv,
4343
)
4444

tests/stacks.main.index.test.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,24 @@ describe('stacks/main/index.ts', () => {
2222
})
2323

2424
it('Without FQDN', async () => {
25+
let applyMethod: any
2526
;(resources.buildRouter as any).mockImplementation(() => {
2627
return 'mock'
2728
})
2829
;(resources.buildCDN as any).mockImplementation(() => {
2930
return {
3031
domainName: 'example.com',
32+
id: { apply: (x: any) => (applyMethod = x) },
3133
}
3234
})
3335
// @ts-ignore
3436
pulumi.Config = vi.fn(() => {
3537
return {
3638
get: vi.fn((x) => {
39+
return ''
40+
}),
41+
require: vi.fn((x) => {
42+
console.log(x)
3743
if (x === 'serverHeaders') {
3844
return '{"mock": "mock"}'
3945
}
@@ -49,7 +55,7 @@ describe('stacks/main/index.ts', () => {
4955
expect(resources.buildStatic).toHaveBeenCalledTimes(1)
5056
expect(resources.buildCDN).toHaveBeenCalledTimes(1)
5157
expect(resources.createAliasRecord).toHaveBeenCalledTimes(0)
52-
expect(resources.buildInvalidator).toHaveBeenCalledTimes(1)
58+
expect(applyMethod).toBeTypeOf('function')
5359

5460
const allowedOrigin = await promiseOf(
5561
infra.allowedOrigins[0] as pulumi.Output<string>,
@@ -67,16 +73,21 @@ describe('stacks/main/index.ts', () => {
6773
;(resources.buildCDN as any).mockImplementation(() => {
6874
return {
6975
domainName: 'example.com',
76+
id: { apply: (x: any) => null },
7077
}
7178
})
7279
// @ts-ignore
7380
pulumi.Config = vi.fn(() => {
7481
return {
7582
get: vi.fn((x) => {
83+
if (x === 'FQDN') {
84+
return fqdn
85+
}
86+
return ''
87+
}),
88+
require: vi.fn((x) => {
7689
if (x === 'serverHeaders') {
7790
return '{"mock": "mock"}'
78-
} else if (x === 'FQDN') {
79-
return fqdn
8091
}
8192
return ''
8293
}),

tests/stacks.main.resources.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,18 @@ describe('stacks/main/resources.ts', () => {
1414
vi.resetModules()
1515
mocks = new MyMocks()
1616
pulumi.runtime.setMocks(mocks)
17+
18+
// @ts-ignore
19+
pulumi.Config = vi.fn(() => {
20+
return {
21+
require: vi.fn((x) => {
22+
if (x === 'aws:region') {
23+
return '{"aws:region": "mock"}'
24+
}
25+
}),
26+
}
27+
})
28+
1729
infra = await import('../stacks/main/resources.js')
1830
})
1931

tests/stacks.server.index.test.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,15 @@ describe('stacks/server/index.ts', () => {
4646
pulumi.Config = vi.fn(() => {
4747
return {
4848
get: vi.fn((x) => {
49-
if (x === 'projectPath') {
50-
return tmpDir
51-
}
5249
if (x === 'allowedOrigins') {
5350
return '[example.com]'
5451
}
52+
return ''
53+
}),
54+
require: vi.fn((x) => {
55+
if (x === 'projectPath') {
56+
return tmpDir
57+
}
5558
if (x === 'memorySize') {
5659
return '256'
5760
}
@@ -107,6 +110,9 @@ describe('stacks/server/index.ts', () => {
107110
pulumi.Config = vi.fn(() => {
108111
return {
109112
get: vi.fn((x) => {
113+
return ''
114+
}),
115+
require: vi.fn((x) => {
110116
if (x === 'projectPath') {
111117
return tmpDir
112118
}

0 commit comments

Comments
 (0)