blogwright 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +12 -0
  3. package/agent/Dockerfile +20 -0
  4. package/agent/agent-manifest.json +3 -0
  5. package/agent/server.js +7935 -0
  6. package/dist/adapters/fetch-ping.d.ts +9 -0
  7. package/dist/adapters/fetch-ping.js +26 -0
  8. package/dist/adapters/fetch-ping.js.map +1 -0
  9. package/dist/adapters/process-vcs.d.ts +12 -0
  10. package/dist/adapters/process-vcs.js +49 -0
  11. package/dist/adapters/process-vcs.js.map +1 -0
  12. package/dist/agent-package.d.ts +19 -0
  13. package/dist/agent-package.js +49 -0
  14. package/dist/agent-package.js.map +1 -0
  15. package/dist/bin.d.ts +2 -0
  16. package/dist/bin.js +14 -0
  17. package/dist/bin.js.map +1 -0
  18. package/dist/cli.d.ts +6 -0
  19. package/dist/cli.js +249 -0
  20. package/dist/cli.js.map +1 -0
  21. package/dist/commands.d.ts +31 -0
  22. package/dist/commands.js +269 -0
  23. package/dist/commands.js.map +1 -0
  24. package/dist/context.d.ts +50 -0
  25. package/dist/context.js +77 -0
  26. package/dist/context.js.map +1 -0
  27. package/dist/deploy.d.ts +57 -0
  28. package/dist/deploy.js +247 -0
  29. package/dist/deploy.js.map +1 -0
  30. package/dist/graph.d.ts +20 -0
  31. package/dist/graph.js +73 -0
  32. package/dist/graph.js.map +1 -0
  33. package/dist/init.d.ts +4 -0
  34. package/dist/init.js +92 -0
  35. package/dist/init.js.map +1 -0
  36. package/dist/logger.d.ts +18 -0
  37. package/dist/logger.js +34 -0
  38. package/dist/logger.js.map +1 -0
  39. package/dist/microvms.d.ts +15 -0
  40. package/dist/microvms.js +56 -0
  41. package/dist/microvms.js.map +1 -0
  42. package/dist/nodes.d.ts +34 -0
  43. package/dist/nodes.js +858 -0
  44. package/dist/nodes.js.map +1 -0
  45. package/dist/ports.d.ts +25 -0
  46. package/dist/ports.js +7 -0
  47. package/dist/ports.js.map +1 -0
  48. package/dist/render.d.ts +33 -0
  49. package/dist/render.js +81 -0
  50. package/dist/render.js.map +1 -0
  51. package/dist/repo.d.ts +25 -0
  52. package/dist/repo.js +75 -0
  53. package/dist/repo.js.map +1 -0
  54. package/dist/rkey.d.ts +6 -0
  55. package/dist/rkey.js +7 -0
  56. package/dist/rkey.js.map +1 -0
  57. package/dist/seo.d.ts +15 -0
  58. package/dist/seo.js +30 -0
  59. package/dist/seo.js.map +1 -0
  60. package/dist/test-support.d.ts +44 -0
  61. package/dist/test-support.js +120 -0
  62. package/dist/test-support.js.map +1 -0
  63. package/package.json +57 -0
package/dist/nodes.js ADDED
@@ -0,0 +1,858 @@
1
+ import { AwsError, CACHING_DISABLED, pollUntil } from 'blogwright-core';
2
+ import { packageAndUploadAgent } from './agent-package.js';
3
+ /** Lambda-managed MicroVM base image (Amazon Linux 2023) for the primary region. */
4
+ function microvmBaseImageArn(region) {
5
+ return `arn:aws:lambda:${region}:aws:microvm-image:al2023-1`;
6
+ }
7
+ function output(ctx, id) {
8
+ return (ctx.state.resources[id] ??= {});
9
+ }
10
+ function logGroupArn(ctx, name) {
11
+ return `arn:aws:logs:${ctx.config.region}:${ctx.accountId}:log-group:${name}:*`;
12
+ }
13
+ /** S3 resource ARN the build writes the site into (per-PR prefix for preview stacks). */
14
+ function siteWriteResource(ctx) {
15
+ return ctx.preview
16
+ ? `arn:aws:s3:::${ctx.names.bucket}/previews/*/site/*`
17
+ : `arn:aws:s3:::${ctx.names.bucket}/site/*`;
18
+ }
19
+ /** The S3 bucket holding build artifacts, the live site, and topology state. */
20
+ function bucketNode() {
21
+ return {
22
+ id: 'bucket',
23
+ dependsOn: [],
24
+ title: 'S3 bucket',
25
+ async read(ctx) {
26
+ const exists = await ctx.clients.s3.bucketExists(ctx.names.bucket);
27
+ if (exists)
28
+ output(ctx, 'bucket').name = ctx.names.bucket;
29
+ return exists;
30
+ },
31
+ async create(ctx) {
32
+ await ctx.clients.s3.createBucket(ctx.names.bucket);
33
+ await ctx.clients.s3.putPublicAccessBlock(ctx.names.bucket);
34
+ output(ctx, 'bucket').name = ctx.names.bucket;
35
+ },
36
+ async delete(ctx) {
37
+ // Empty every prefix (site/build/state) before removing the bucket.
38
+ await ctx.clients.s3.deletePrefix(ctx.names.bucket, '');
39
+ await ctx.clients.s3.deleteBucket(ctx.names.bucket);
40
+ },
41
+ };
42
+ }
43
+ function logGroupNode(id, title, name, days) {
44
+ return {
45
+ id,
46
+ dependsOn: [],
47
+ title,
48
+ async read(ctx) {
49
+ const exists = await ctx.clients.logs.logGroupExists(name(ctx));
50
+ if (exists)
51
+ output(ctx, id).arn = logGroupArn(ctx, name(ctx));
52
+ return exists;
53
+ },
54
+ async create(ctx) {
55
+ await ctx.clients.logs.ensureLogGroup(name(ctx));
56
+ await ctx.clients.logs.putRetentionPolicy(name(ctx), days(ctx));
57
+ output(ctx, id).arn = logGroupArn(ctx, name(ctx));
58
+ },
59
+ async update(ctx) {
60
+ await ctx.clients.logs.putRetentionPolicy(name(ctx), days(ctx));
61
+ },
62
+ async delete(ctx) {
63
+ await ctx.clients.logs.deleteLogGroup(name(ctx));
64
+ },
65
+ };
66
+ }
67
+ const LAMBDA_TRUST = {
68
+ Version: '2012-10-17',
69
+ Statement: [
70
+ {
71
+ Effect: 'Allow',
72
+ Principal: { Service: 'lambda.amazonaws.com' },
73
+ Action: ['sts:AssumeRole', 'sts:TagSession'],
74
+ },
75
+ ],
76
+ };
77
+ /** IAM role Lambda assumes while building the MicroVM image. */
78
+ /** Apply the build role's inline policy (idempotent — used by create + update). */
79
+ async function applyBuildRolePolicy(ctx) {
80
+ // The build role is BOTH the image-build role AND the MicroVM's ambient runtime
81
+ // identity (via IMDS), so it needs the build's runtime S3 permissions: read the
82
+ // source, list the bucket (to clear site/), and write the built site.
83
+ await ctx.clients.iam.putRolePolicy(ctx.names.buildRole, 'build', {
84
+ Version: '2012-10-17',
85
+ Statement: [
86
+ { Effect: 'Allow', Action: ['s3:GetObject'], Resource: `arn:aws:s3:::${ctx.names.bucket}/*` },
87
+ { Effect: 'Allow', Action: ['s3:ListBucket'], Resource: `arn:aws:s3:::${ctx.names.bucket}` },
88
+ {
89
+ Effect: 'Allow',
90
+ Action: ['s3:PutObject', 's3:DeleteObject'],
91
+ Resource: siteWriteResource(ctx),
92
+ },
93
+ {
94
+ // The agent writes the changed-paths manifest the CLI reads for targeted invalidation.
95
+ Effect: 'Allow',
96
+ Action: ['s3:PutObject'],
97
+ Resource: `arn:aws:s3:::${ctx.names.bucket}/build/changed/*`,
98
+ },
99
+ {
100
+ Effect: 'Allow',
101
+ Action: ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'],
102
+ Resource: logGroupArn(ctx, ctx.names.microvmLogGroup),
103
+ },
104
+ ],
105
+ });
106
+ }
107
+ function buildRoleNode() {
108
+ return {
109
+ id: 'iam-build-role',
110
+ dependsOn: ['bucket', 'microvm-log-group'],
111
+ title: 'IAM build role',
112
+ async read(ctx) {
113
+ const arn = await ctx.clients.iam.getRoleArn(ctx.names.buildRole);
114
+ if (arn)
115
+ output(ctx, 'iam-build-role').arn = arn;
116
+ return Boolean(arn);
117
+ },
118
+ async create(ctx) {
119
+ const arn = await ctx.clients.iam.ensureRole(ctx.names.buildRole, LAMBDA_TRUST, `Builds the ${ctx.config.siteName} MicroVM image`);
120
+ await applyBuildRolePolicy(ctx);
121
+ output(ctx, 'iam-build-role').arn = arn;
122
+ },
123
+ async update(ctx) {
124
+ await applyBuildRolePolicy(ctx);
125
+ },
126
+ async delete(ctx) {
127
+ await ctx.clients.iam.deleteRole(ctx.names.buildRole);
128
+ },
129
+ };
130
+ }
131
+ async function applyExecRolePolicy(ctx) {
132
+ await ctx.clients.iam.putRolePolicy(ctx.names.execRole, 'exec', {
133
+ Version: '2012-10-17',
134
+ Statement: [
135
+ {
136
+ Effect: 'Allow',
137
+ Action: ['s3:GetObject'],
138
+ Resource: `arn:aws:s3:::${ctx.names.bucket}/*`,
139
+ },
140
+ {
141
+ Effect: 'Allow',
142
+ Action: ['s3:PutObject', 's3:DeleteObject'],
143
+ Resource: siteWriteResource(ctx),
144
+ },
145
+ {
146
+ // Manifests + the changed-paths manifest the agent writes for invalidation.
147
+ Effect: 'Allow',
148
+ Action: ['s3:PutObject'],
149
+ Resource: [
150
+ `arn:aws:s3:::${ctx.names.bucket}/build/manifests/*`,
151
+ `arn:aws:s3:::${ctx.names.bucket}/build/changed/*`,
152
+ ],
153
+ },
154
+ {
155
+ Effect: 'Allow',
156
+ Action: ['s3:ListBucket'],
157
+ Resource: `arn:aws:s3:::${ctx.names.bucket}`,
158
+ },
159
+ {
160
+ Effect: 'Allow',
161
+ Action: ['logs:CreateLogStream', 'logs:PutLogEvents'],
162
+ Resource: logGroupArn(ctx, ctx.names.microvmLogGroup),
163
+ },
164
+ ],
165
+ });
166
+ }
167
+ /** IAM role the running builder MicroVM assumes to read source + write the site. */
168
+ function execRoleNode() {
169
+ return {
170
+ id: 'iam-exec-role',
171
+ dependsOn: ['bucket', 'microvm-log-group'],
172
+ title: 'IAM exec role',
173
+ async read(ctx) {
174
+ const arn = await ctx.clients.iam.getRoleArn(ctx.names.execRole);
175
+ if (arn)
176
+ output(ctx, 'iam-exec-role').arn = arn;
177
+ return Boolean(arn);
178
+ },
179
+ async create(ctx) {
180
+ const arn = await ctx.clients.iam.ensureRole(ctx.names.execRole, LAMBDA_TRUST, `Runtime role for the ${ctx.config.siteName} builder MicroVM`);
181
+ await applyExecRolePolicy(ctx);
182
+ output(ctx, 'iam-exec-role').arn = arn;
183
+ },
184
+ async update(ctx) {
185
+ await applyExecRolePolicy(ctx);
186
+ },
187
+ async delete(ctx) {
188
+ await ctx.clients.iam.deleteRole(ctx.names.execRole);
189
+ },
190
+ };
191
+ }
192
+ /** The builder MicroVM image (snapshot of the build-agent server). */
193
+ async function imageInput(ctx) {
194
+ const artifact = await packageAndUploadAgent(ctx);
195
+ return {
196
+ hash: artifact.hash,
197
+ input: {
198
+ name: ctx.names.microvmImage,
199
+ codeArtifactUri: `s3://${ctx.names.bucket}/${artifact.key}`,
200
+ baseImageArn: microvmBaseImageArn(ctx.config.region),
201
+ buildRoleArn: String(output(ctx, 'iam-build-role').arn),
202
+ memoryGb: ctx.config.microvm.memory,
203
+ logGroupName: ctx.names.microvmLogGroup,
204
+ // Bucket + region for the agent, which polls s3://<bucket>/build/pending.json and
205
+ // resolves credentials from IMDS (→ the build role identity). Hookless: hooks are
206
+ // delivered over TLS which the agent can't satisfy, and are unnecessary here.
207
+ environmentVariables: { BUILD_BUCKET: ctx.names.bucket, BUILD_REGION: ctx.config.region },
208
+ clientToken: `img-${artifact.hash}`,
209
+ description: `${ctx.config.siteName} ${ctx.env} builder`,
210
+ },
211
+ };
212
+ }
213
+ /**
214
+ * Poll until the image reaches its expected terminal state, treating a timeout (still
215
+ * CREATING/UPDATING) as failure — never success. For updates, require the version to
216
+ * advance past `priorVersion` so a stale pre-update state can't be read as done.
217
+ */
218
+ async function awaitImageSettled(ctx, arn, expected, priorVersion) {
219
+ const ok = new RegExp(`^${expected}$`, 'i');
220
+ const settled = await pollUntil(() => ctx.clients.microvms.getImage(arn), (img) => {
221
+ if (!img)
222
+ return false;
223
+ if (/CREATE_FAILED|UPDATE_FAILED/i.test(img.state))
224
+ return true;
225
+ return (ok.test(img.state) && (priorVersion === undefined || img.imageVersion !== priorVersion));
226
+ }, { intervalMs: 5000, timeoutMs: 600_000 });
227
+ if (!settled || !ok.test(settled.state)) {
228
+ throw new Error(`MicroVM image build did not succeed (state=${settled?.state ?? 'unknown'})`);
229
+ }
230
+ const out = output(ctx, 'microvm-image');
231
+ out.arn = settled.imageArn;
232
+ if (settled.imageVersion)
233
+ out.version = settled.imageVersion;
234
+ }
235
+ /**
236
+ * Decide what a builder-image reconcile should do: create when the image is missing or
237
+ * being deleted, skip when a healthy image already matches the current agent bundle and
238
+ * log group, otherwise update (agent bundle changed, log group changed, or last build
239
+ * unhealthy). Pure so the decision is unit-testable independent of the AWS calls.
240
+ */
241
+ export function builderImageAction(image, recorded, hash, logGroup) {
242
+ if (!image || /DELET/i.test(image.state))
243
+ return 'create';
244
+ const healthy = /CREATED|UPDATED/i.test(image.state);
245
+ const unchanged = recorded.agentHash === hash && recorded.logGroup === logGroup;
246
+ return healthy && unchanged ? 'skip' : 'update';
247
+ }
248
+ /**
249
+ * Create, rebuild, or leave the MicroVM builder image, depending on what's deployed:
250
+ * create it if missing, rebuild it if the agent bundle (or its log group) changed or the
251
+ * last build is unhealthy, otherwise no-op. Idempotent and cheap in the common case (a
252
+ * single GetMicrovmImage + hash compare), so it's safe to run before every deploy — which
253
+ * is how build-agent changes propagate through CI without a separate `bootstrap`.
254
+ */
255
+ export async function reconcileBuilderImage(ctx) {
256
+ // GetMicrovmImage requires an ARN/ID (not the friendly name), looked up via the ARN
257
+ // recorded in state on a prior create.
258
+ const recordedArn = output(ctx, 'microvm-image').arn;
259
+ const existing = typeof recordedArn === 'string' ? await ctx.clients.microvms.getImage(recordedArn) : undefined;
260
+ const out = output(ctx, 'microvm-image');
261
+ const { input, hash } = await imageInput(ctx);
262
+ const action = builderImageAction(existing, {
263
+ agentHash: out.agentHash,
264
+ logGroup: out.logGroup,
265
+ }, hash, ctx.names.microvmLogGroup);
266
+ if (action === 'skip')
267
+ return;
268
+ if (action === 'create') {
269
+ ctx.logger.step(`create MicroVM image (agent ${hash})`);
270
+ const image = await ctx.clients.microvms.createImage(input);
271
+ // Persist the ARN immediately so a later failure/retry finds the image (and updates it)
272
+ // instead of re-issuing create() and hitting a 409 on the existing name.
273
+ out.arn = image.imageArn;
274
+ await ctx.save();
275
+ await awaitImageSettled(ctx, image.imageArn, 'CREATED');
276
+ }
277
+ else {
278
+ const arn = String(recordedArn);
279
+ ctx.logger.step(`update MicroVM image (agent ${hash})`);
280
+ await ctx.clients.microvms.updateImage(arn, input);
281
+ await awaitImageSettled(ctx, arn, 'UPDATED', existing?.imageVersion);
282
+ }
283
+ out.agentHash = hash;
284
+ out.logGroup = ctx.names.microvmLogGroup;
285
+ await ctx.save();
286
+ }
287
+ function microvmImageNode() {
288
+ return {
289
+ id: 'microvm-image',
290
+ dependsOn: ['bucket', 'iam-build-role'],
291
+ title: 'MicroVM builder image',
292
+ async read(ctx) {
293
+ const arn = output(ctx, 'microvm-image').arn;
294
+ if (typeof arn !== 'string')
295
+ return false;
296
+ const image = await ctx.clients.microvms.getImage(arn);
297
+ if (!image || /DELET/i.test(image.state))
298
+ return false;
299
+ if (image.imageVersion)
300
+ output(ctx, 'microvm-image').version = image.imageVersion;
301
+ return true;
302
+ },
303
+ // Both paths reconcile: create-if-missing / rebuild-if-changed / else no-op.
304
+ create: reconcileBuilderImage,
305
+ update: reconcileBuilderImage,
306
+ async delete(ctx) {
307
+ const arn = output(ctx, 'microvm-image').arn;
308
+ if (typeof arn === 'string')
309
+ await ctx.clients.microvms.deleteImage(arn);
310
+ },
311
+ };
312
+ }
313
+ /** ACM certificate (us-east-1) — only present when a custom domain is configured. */
314
+ function certificateNode() {
315
+ return {
316
+ id: 'acm-certificate',
317
+ // Depends on the bucket so state (with the cert ARN) can be saved before the long
318
+ // ISSUED wait — the id sorts before 'bucket', so without this it would run first.
319
+ dependsOn: ['bucket'],
320
+ title: 'ACM certificate',
321
+ async read(ctx) {
322
+ const arn = output(ctx, 'acm-certificate').arn;
323
+ if (typeof arn !== 'string')
324
+ return false;
325
+ try {
326
+ const status = await ctx.clients.acm.describeCertificate(arn);
327
+ return status.status === 'ISSUED';
328
+ }
329
+ catch (err) {
330
+ // Cert deleted out-of-band with a stale ARN in state → recreate, don't throw.
331
+ if (err instanceof AwsError && err.isNotFound)
332
+ return false;
333
+ throw err;
334
+ }
335
+ },
336
+ async create(ctx) {
337
+ const domain = ctx.domain;
338
+ if (!domain)
339
+ throw new Error('certificate node requires a domain');
340
+ // Previews are served at <pr-id>.<domain>, so the cert must be wildcard.
341
+ const certDomain = ctx.preview ? `*.${domain}` : domain;
342
+ let arn = output(ctx, 'acm-certificate').arn;
343
+ if (!arn) {
344
+ // ACM idempotency token must match \w+ (no dashes).
345
+ const token = `${ctx.config.siteName}${ctx.env}`.replace(/\W/g, '');
346
+ arn = await ctx.clients.acm.requestCertificate(certDomain, token);
347
+ output(ctx, 'acm-certificate').arn = arn;
348
+ await ctx.save();
349
+ }
350
+ const initial = await ctx.clients.acm.describeCertificate(arn);
351
+ if (initial.status !== 'ISSUED' && initial.validation.length > 0) {
352
+ if (ctx.preview) {
353
+ // Preview domain is a Route53 hosted zone — create the validation records for you.
354
+ const zoneId = await ctx.clients.route53.hostedZoneId(domain);
355
+ if (!zoneId)
356
+ throw new Error(`no Route53 hosted zone found for ${domain}`);
357
+ for (const r of initial.validation) {
358
+ await ctx.clients.route53.upsertRecord(zoneId, {
359
+ name: r.name,
360
+ type: r.type,
361
+ value: r.value,
362
+ });
363
+ }
364
+ ctx.logger.step('created ACM validation records in Route53; waiting for ISSUED…');
365
+ }
366
+ else {
367
+ ctx.logger.warn('Add these DNS records at your registrar to validate the certificate:');
368
+ for (const r of initial.validation) {
369
+ ctx.logger.info(` ${r.type} ${r.name} -> ${r.value}`);
370
+ }
371
+ ctx.logger.step('waiting for certificate to be ISSUED (Ctrl-C to background)…');
372
+ }
373
+ }
374
+ const settled = await pollUntil(() => ctx.clients.acm.describeCertificate(arn), (s) => s.status === 'ISSUED' || s.status === 'FAILED', { intervalMs: 15_000, timeoutMs: 30 * 60_000 });
375
+ if (settled.status !== 'ISSUED') {
376
+ throw new Error(`certificate not ISSUED (status=${settled.status}); re-run bootstrap once DNS propagates`);
377
+ }
378
+ },
379
+ async delete(ctx) {
380
+ const arn = output(ctx, 'acm-certificate').arn;
381
+ if (typeof arn === 'string')
382
+ await ctx.clients.acm.deleteCertificate(arn);
383
+ },
384
+ };
385
+ }
386
+ /** CloudFront Origin Access Control granting the distribution private read on S3. */
387
+ function oacNode() {
388
+ return {
389
+ id: 'oac',
390
+ dependsOn: [],
391
+ title: 'CloudFront OAC',
392
+ async read(ctx) {
393
+ return typeof output(ctx, 'oac').id === 'string';
394
+ },
395
+ async create(ctx) {
396
+ const id = await ctx.clients.cloudfront.createOriginAccessControl(ctx.names.oac);
397
+ output(ctx, 'oac').id = id;
398
+ },
399
+ async delete(ctx) {
400
+ const id = output(ctx, 'oac').id;
401
+ if (typeof id === 'string')
402
+ await ctx.clients.cloudfront.deleteOriginAccessControl(id);
403
+ },
404
+ };
405
+ }
406
+ /**
407
+ * CloudFront Function (viewer-request) that routes a preview subdomain to its S3 prefix:
408
+ * `pr-42.preview.example.com/foo` → origin `previews/pr-42/site/foo`, with directory →
409
+ * index.html resolution. Runs on every request (previews are not CDN-cached).
410
+ */
411
+ const PREVIEW_FUNCTION_CODE = `function handler(event) {
412
+ var request = event.request;
413
+ var host = request.headers.host.value;
414
+ var id = host.split('.')[0];
415
+ var uri = request.uri;
416
+ if (uri.endsWith('/')) { uri += 'index.html'; }
417
+ else if (uri.lastIndexOf('.') < uri.lastIndexOf('/')) { uri += '/index.html'; }
418
+ request.uri = '/previews/' + id + '/site' + uri;
419
+ return request;
420
+ }`;
421
+ /**
422
+ * CloudFront Function (viewer-request) for staging/production: resolve a directory URL
423
+ * to its index document (`/projects/` → `/projects/index.html`). Required because the S3
424
+ * origin is the private REST endpoint (via OAC), which — unlike an S3 website endpoint —
425
+ * does no index-document resolution, so `DefaultRootObject` only covers the apex. The
426
+ * `/site` origin path is applied by the distribution, so this function must not add it.
427
+ */
428
+ const STATIC_ROUTER_CODE = `function handler(event) {
429
+ var request = event.request;
430
+ var uri = request.uri;
431
+ if (uri.endsWith('/')) { uri += 'index.html'; }
432
+ else if (uri.lastIndexOf('.') < uri.lastIndexOf('/')) { uri += '/index.html'; }
433
+ request.uri = uri;
434
+ return request;
435
+ }`;
436
+ /**
437
+ * CloudFront viewer-request function. Preview stacks route a subdomain to its S3 prefix
438
+ * (and resolve index documents); staging/production just resolve directory URLs to their
439
+ * index document. Both are needed because the OAC/REST S3 origin does no index resolution.
440
+ */
441
+ function routerFunctionNode(preview) {
442
+ return {
443
+ id: 'cloudfront-function',
444
+ dependsOn: [],
445
+ title: 'CloudFront routing function',
446
+ async read(ctx) {
447
+ return typeof output(ctx, 'cloudfront-function').arn === 'string';
448
+ },
449
+ async create(ctx) {
450
+ const arn = await ctx.clients.cloudfront.ensureFunction(`${ctx.names.prefix}-router`, preview ? PREVIEW_FUNCTION_CODE : STATIC_ROUTER_CODE, `${ctx.config.siteName} ${ctx.env} ${preview ? 'preview host router' : 'directory-index router'}`);
451
+ output(ctx, 'cloudfront-function').arn = arn;
452
+ },
453
+ async update() {
454
+ // The router code is static; leave the already-published function as-is (updating +
455
+ // re-publishing hits CloudFront's ETag/stage preconditions). To change the router,
456
+ // delete the function (teardown) and re-bootstrap.
457
+ },
458
+ async delete(ctx) {
459
+ await ctx.clients.cloudfront.deleteFunction(`${ctx.names.prefix}-router`);
460
+ },
461
+ };
462
+ }
463
+ /** The CloudFront distribution. Preview stacks use a host-routing function + no caching. */
464
+ function distributionNode(hasDomain, preview) {
465
+ const dependsOn = [
466
+ 'bucket',
467
+ 'oac',
468
+ 'cloudfront-function',
469
+ ...(hasDomain ? ['acm-certificate'] : []),
470
+ ];
471
+ return {
472
+ id: 'cloudfront-distribution',
473
+ dependsOn,
474
+ title: 'CloudFront distribution',
475
+ async read(ctx) {
476
+ const id = output(ctx, 'cloudfront-distribution').id;
477
+ if (typeof id !== 'string')
478
+ return false;
479
+ const dist = await ctx.clients.cloudfront.getDistribution(id);
480
+ return Boolean(dist);
481
+ },
482
+ async create(ctx) {
483
+ const dist = await ctx.clients.cloudfront.createDistribution({
484
+ callerReference: `${ctx.names.prefix}-${ctx.accountId}`,
485
+ comment: `${ctx.config.siteName} ${ctx.env}`,
486
+ bucketDomainName: `${ctx.names.bucket}.s3.${ctx.config.region}.amazonaws.com`,
487
+ // Preview: function rewrites the full path, so the origin path is the bucket root.
488
+ originPath: preview ? '' : '/site',
489
+ originAccessControlId: String(output(ctx, 'oac').id),
490
+ defaultRootObject: ctx.config.defaultRootObject,
491
+ aliases: ctx.domain ? [preview ? `*.${ctx.domain}` : ctx.domain] : [],
492
+ acmCertificateArn: hasDomain ? String(output(ctx, 'acm-certificate').arn) : undefined,
493
+ functionArn: String(output(ctx, 'cloudfront-function').arn),
494
+ // Previews are served uncached (per-PR content, host-routed); staging/production
495
+ // keep the default cache policy. Non-preview stacks map the S3 REST origin's
496
+ // 403/404 (a missing key) to the site's 404 page — or, in SPA mode, to
497
+ // /index.html with a 200 so client-side routes deep-link correctly.
498
+ ...(preview
499
+ ? { cachePolicyId: CACHING_DISABLED }
500
+ : {
501
+ customErrorResponses: [403, 404].map((errorCode) => ({
502
+ errorCode,
503
+ responsePagePath: ctx.config.spa ? '/index.html' : '/404.html',
504
+ responseCode: ctx.config.spa ? 200 : 404,
505
+ })),
506
+ }),
507
+ });
508
+ const out = output(ctx, 'cloudfront-distribution');
509
+ out.id = dist.id;
510
+ out.arn = dist.arn;
511
+ out.domainName = dist.domainName;
512
+ ctx.logger.info(` CloudFront domain: ${dist.domainName}`);
513
+ if (ctx.domain) {
514
+ const record = preview ? `*.${ctx.domain}` : ctx.domain;
515
+ ctx.logger.info(` point ${record} (CNAME/ALIAS) at ${dist.domainName}`);
516
+ }
517
+ },
518
+ async update(ctx) {
519
+ // A domain added (or changed) after the first bootstrap must reach the
520
+ // existing distribution — the certificate node validates the cert, but
521
+ // only this reconcile attaches the alias + viewer certificate.
522
+ const id = output(ctx, 'cloudfront-distribution').id;
523
+ if (typeof id !== 'string')
524
+ return;
525
+ if (!ctx.domain) {
526
+ // Deliberately no automatic alias removal: dropping --domain from a
527
+ // later run must not detach a live site's hostname.
528
+ ctx.logger.ok('no domain configured — existing aliases left as-is');
529
+ return;
530
+ }
531
+ const alias = preview ? `*.${ctx.domain}` : ctx.domain;
532
+ const certArn = String(output(ctx, 'acm-certificate').arn);
533
+ const changed = await ctx.clients.cloudfront.setDistributionAliases(id, [alias], certArn);
534
+ if (changed) {
535
+ ctx.logger.ok(`attached ${alias} to the distribution`);
536
+ const domainName = output(ctx, 'cloudfront-distribution').domainName;
537
+ if (typeof domainName === 'string') {
538
+ ctx.logger.info(` point ${alias} (CNAME/ALIAS) at ${domainName}`);
539
+ }
540
+ }
541
+ else {
542
+ ctx.logger.ok('aliases up to date');
543
+ }
544
+ },
545
+ async delete(ctx) {
546
+ const id = output(ctx, 'cloudfront-distribution').id;
547
+ if (typeof id !== 'string')
548
+ return;
549
+ ctx.logger.step('disabling distribution (this can take several minutes)…');
550
+ await ctx.clients.cloudfront.disableDistribution(id);
551
+ await pollUntil(() => ctx.clients.cloudfront.getDistribution(id), (d) => !d || d.status === 'Deployed', { intervalMs: 30_000, timeoutMs: 30 * 60_000 });
552
+ await ctx.clients.cloudfront.deleteDistribution(id);
553
+ },
554
+ };
555
+ }
556
+ /** Wire CloudFront access logs to the CloudWatch log group via vended log delivery. */
557
+ function logDeliveryNode() {
558
+ async function wire(ctx) {
559
+ const distArn = String(output(ctx, 'cloudfront-distribution').arn);
560
+ const groupArn = String(output(ctx, 'cloudfront-log-group').arn).replace(/:\*$/, '');
561
+ const sourceArn = await ctx.clients.logs.putDeliverySource(ctx.names.deliverySource, distArn, 'ACCESS_LOGS');
562
+ const destArn = await ctx.clients.logs.putDeliveryDestination(ctx.names.deliveryDestination, groupArn);
563
+ await ctx.clients.logs.createDelivery(ctx.names.deliverySource, destArn);
564
+ const out = output(ctx, 'cloudfront-log-delivery');
565
+ out.delivery = 'configured';
566
+ out.source = sourceArn;
567
+ out.destination = destArn;
568
+ }
569
+ return {
570
+ id: 'cloudfront-log-delivery',
571
+ dependsOn: ['cloudfront-distribution', 'cloudfront-log-group'],
572
+ title: 'CloudFront log delivery',
573
+ async read(ctx) {
574
+ return typeof output(ctx, 'cloudfront-log-delivery').delivery === 'string';
575
+ },
576
+ async create(ctx) {
577
+ try {
578
+ await wire(ctx);
579
+ }
580
+ catch (err) {
581
+ // delete() below leaves the delivery plumbing behind, and PutDeliverySource
582
+ // refuses to repoint an existing source at a new distribution ARN — so a
583
+ // destroy → bootstrap cycle hits ConflictException here. Remove the stale
584
+ // delivery/source/destination trio and retry once.
585
+ if (!(err instanceof AwsError && /Conflict/i.test(err.code)))
586
+ throw err;
587
+ ctx.logger.step('stale log delivery from a previous stack — removing and retrying');
588
+ for (const id of await ctx.clients.logs.deliveriesForSource(ctx.names.deliverySource)) {
589
+ await ctx.clients.logs.deleteDelivery(id);
590
+ }
591
+ await ctx.clients.logs.deleteDeliverySource(ctx.names.deliverySource);
592
+ await ctx.clients.logs.deleteDeliveryDestination(ctx.names.deliveryDestination);
593
+ await wire(ctx);
594
+ }
595
+ },
596
+ async delete(ctx) {
597
+ // Removing the distribution/log group does NOT clean up vended log delivery: the
598
+ // delivery source/destination persist, and a later bootstrap against a new
599
+ // distribution ARN fails with ConflictException ("Update to existing Delivery Source
600
+ // with new ResourceId is not allowed"). Delete the delivery first (it references both),
601
+ // then the source and destination. The id isn't in state, so look it up by source name.
602
+ const deliveryId = await ctx.clients.logs.findDeliveryIdBySource(ctx.names.deliverySource);
603
+ if (deliveryId)
604
+ await ctx.clients.logs.deleteDelivery(deliveryId);
605
+ await ctx.clients.logs.deleteDeliverySource(ctx.names.deliverySource);
606
+ await ctx.clients.logs.deleteDeliveryDestination(ctx.names.deliveryDestination);
607
+ },
608
+ };
609
+ }
610
+ /** Bucket policy granting the distribution read on site/* (applied after the dist exists). */
611
+ function bucketPolicyNode() {
612
+ return {
613
+ id: 'bucket-policy',
614
+ dependsOn: ['bucket', 'cloudfront-distribution'],
615
+ title: 'S3 bucket policy',
616
+ async read() {
617
+ // Always reconcile so the policy tracks the distribution ARN.
618
+ return false;
619
+ },
620
+ async create(ctx) {
621
+ const distArn = String(output(ctx, 'cloudfront-distribution').arn);
622
+ const policy = {
623
+ Version: '2012-10-17',
624
+ Statement: [
625
+ {
626
+ Sid: 'AllowCloudFrontRead',
627
+ Effect: 'Allow',
628
+ Principal: { Service: 'cloudfront.amazonaws.com' },
629
+ Action: ['s3:GetObject'],
630
+ Resource: ctx.preview
631
+ ? `arn:aws:s3:::${ctx.names.bucket}/previews/*`
632
+ : `arn:aws:s3:::${ctx.names.bucket}/site/*`,
633
+ Condition: { StringEquals: { 'AWS:SourceArn': distArn } },
634
+ },
635
+ ],
636
+ };
637
+ await ctx.clients.s3.putBucketPolicy(ctx.names.bucket, JSON.stringify(policy));
638
+ output(ctx, 'bucket-policy').applied = true;
639
+ },
640
+ async delete() {
641
+ // Removed together with the bucket during destroy.
642
+ },
643
+ };
644
+ }
645
+ const GITHUB_OIDC_URL = 'token.actions.githubusercontent.com';
646
+ // GitHub's OIDC thumbprint. AWS validates GitHub via its trust store and does not rely on
647
+ // this value, but the API requires one.
648
+ const GITHUB_OIDC_THUMBPRINT = '6938fd4d98bab03faadb97b34396831e3780aea1';
649
+ /**
650
+ * IAM role a GitHub Actions workflow assumes via OIDC — to deploy/destroy previews
651
+ * (preview stack, any ref) or to deploy production (main branch only, plus CloudFront
652
+ * invalidation and read access to the PDS credentials secret).
653
+ */
654
+ function githubOidcRoleNode(preview) {
655
+ const roleName = (ctx) => `${ctx.names.prefix}-gh`;
656
+ return {
657
+ id: 'gh-oidc-role',
658
+ // Production deploys invalidate the distribution, so its ARN must be in state.
659
+ dependsOn: preview ? ['iam-exec-role'] : ['iam-exec-role', 'cloudfront-distribution'],
660
+ title: 'GitHub OIDC deploy role',
661
+ async read(ctx) {
662
+ const arn = await ctx.clients.iam.getRoleArn(roleName(ctx));
663
+ if (arn)
664
+ output(ctx, 'gh-oidc-role').arn = arn;
665
+ return Boolean(arn);
666
+ },
667
+ async create(ctx) {
668
+ await applyOidcRole(ctx, roleName(ctx));
669
+ },
670
+ async update(ctx) {
671
+ await applyOidcRole(ctx, roleName(ctx));
672
+ },
673
+ async delete(ctx) {
674
+ // Leave the account-global OIDC provider; only remove the repo-scoped role.
675
+ await ctx.clients.iam.deleteRole(roleName(ctx));
676
+ },
677
+ };
678
+ }
679
+ /**
680
+ * The workflow's OIDC subject claim, scoped per environment to match how each one
681
+ * deploys: previews from any PR ref; staging from pushes to main; production from the
682
+ * `production` GitHub Environment (release-gated — see production.yml), which lets
683
+ * deploys be gated behind environment protection rules.
684
+ */
685
+ export function oidcSubClaim(repo, env, preview) {
686
+ if (preview)
687
+ return `repo:${repo}:*`;
688
+ if (env === 'production')
689
+ return `repo:${repo}:environment:production`;
690
+ return `repo:${repo}:ref:refs/heads/main`;
691
+ }
692
+ /** The deploy role's inline policy statements (exported for tests). */
693
+ export function oidcRolePolicyStatements(ctx) {
694
+ const statements = [
695
+ { Effect: 'Allow', Action: ['sts:GetCallerIdentity'], Resource: '*' },
696
+ {
697
+ Effect: 'Allow',
698
+ Action: ['s3:GetObject', 's3:PutObject', 's3:DeleteObject'],
699
+ Resource: `arn:aws:s3:::${ctx.names.bucket}/*`,
700
+ },
701
+ { Effect: 'Allow', Action: ['s3:ListBucket'], Resource: `arn:aws:s3:::${ctx.names.bucket}` },
702
+ {
703
+ Effect: 'Allow',
704
+ Action: [
705
+ 'lambda:RunMicrovm',
706
+ 'lambda:GetMicrovm',
707
+ 'lambda:ListMicrovms',
708
+ 'lambda:TerminateMicrovm',
709
+ 'lambda:CreateMicrovmAuthToken',
710
+ 'lambda:GetMicrovmImage',
711
+ // Rebuild the builder image in-deploy when the agent bundle changed, so
712
+ // build-agent fixes propagate through CI without a separate `bootstrap`.
713
+ 'lambda:CreateMicrovmImage',
714
+ 'lambda:UpdateMicrovmImage',
715
+ // RunMicrovm attaches the managed ingress/egress network connectors.
716
+ 'lambda:PassNetworkConnector',
717
+ ],
718
+ Resource: '*',
719
+ },
720
+ {
721
+ Effect: 'Allow',
722
+ Action: ['logs:FilterLogEvents', 'logs:GetLogEvents'],
723
+ Resource: logGroupArn(ctx, ctx.names.microvmLogGroup),
724
+ },
725
+ {
726
+ // RunMicrovm passes the exec role to the MicroVM; rebuilding the builder image
727
+ // passes the build role.
728
+ Effect: 'Allow',
729
+ Action: ['iam:PassRole'],
730
+ Resource: [
731
+ String(output(ctx, 'iam-exec-role').arn),
732
+ String(output(ctx, 'iam-build-role').arn),
733
+ ],
734
+ },
735
+ ];
736
+ if (!ctx.preview) {
737
+ // Production deploys invalidate changed paths; previews are never cached.
738
+ statements.push({
739
+ Effect: 'Allow',
740
+ Action: ['cloudfront:CreateInvalidation'],
741
+ Resource: String(output(ctx, 'cloudfront-distribution').arn),
742
+ });
743
+ if (ctx.config.pds) {
744
+ // The post-deploy PDS sync reads the OAuth secret and writes it back:
745
+ // refresh tokens are single-use, so every sync persists the rotated
746
+ // session (PutSecretValue via the upsert helper, which tries CreateSecret
747
+ // first when the secret is missing).
748
+ statements.push({
749
+ Effect: 'Allow',
750
+ Action: [
751
+ 'secretsmanager:GetSecretValue',
752
+ 'secretsmanager:PutSecretValue',
753
+ 'secretsmanager:CreateSecret',
754
+ ],
755
+ Resource: `arn:aws:secretsmanager:${ctx.config.region}:${ctx.accountId}:secret:${ctx.config.pds.secretName}-*`,
756
+ });
757
+ }
758
+ }
759
+ return statements;
760
+ }
761
+ async function applyOidcRole(ctx, roleName) {
762
+ const repo = ctx.config.githubRepo;
763
+ if (!repo)
764
+ throw new Error('config.githubRepo is required for the GitHub OIDC role');
765
+ // CreateOpenIDConnectProvider needs the https:// scheme; the ARN + condition keys use
766
+ // the bare host.
767
+ await ctx.clients.iam.ensureOidcProvider(`https://${GITHUB_OIDC_URL}`, 'sts.amazonaws.com', GITHUB_OIDC_THUMBPRINT);
768
+ const providerArn = `arn:aws:iam::${ctx.accountId}:oidc-provider/${GITHUB_OIDC_URL}`;
769
+ const arn = await ctx.clients.iam.ensureRole(roleName, {
770
+ Version: '2012-10-17',
771
+ Statement: [
772
+ {
773
+ Effect: 'Allow',
774
+ Principal: { Federated: providerArn },
775
+ Action: 'sts:AssumeRoleWithWebIdentity',
776
+ Condition: {
777
+ StringEquals: { [`${GITHUB_OIDC_URL}:aud`]: 'sts.amazonaws.com' },
778
+ StringLike: { [`${GITHUB_OIDC_URL}:sub`]: oidcSubClaim(repo, ctx.env, ctx.preview) },
779
+ },
780
+ },
781
+ ],
782
+ }, `GitHub Actions ${ctx.env} deploy role`);
783
+ await ctx.clients.iam.putRolePolicy(roleName, `${ctx.env}-deploy`, {
784
+ Version: '2012-10-17',
785
+ Statement: oidcRolePolicyStatements(ctx),
786
+ });
787
+ output(ctx, 'gh-oidc-role').arn = arn;
788
+ }
789
+ /** Route53 wildcard record pointing *.<domain> at the preview CloudFront distribution. */
790
+ function previewDnsNode() {
791
+ return {
792
+ id: 'preview-dns',
793
+ dependsOn: ['cloudfront-distribution'],
794
+ title: 'Route53 wildcard record',
795
+ async read(ctx) {
796
+ return typeof output(ctx, 'preview-dns').record === 'string';
797
+ },
798
+ async create(ctx) {
799
+ const domain = ctx.domain;
800
+ if (!domain)
801
+ throw new Error('preview DNS requires a domain');
802
+ const zoneId = await ctx.clients.route53.hostedZoneId(domain);
803
+ if (!zoneId)
804
+ throw new Error(`no Route53 hosted zone found for ${domain}`);
805
+ const cf = String(output(ctx, 'cloudfront-distribution').domainName);
806
+ await ctx.clients.route53.upsertRecord(zoneId, {
807
+ name: `*.${domain}`,
808
+ type: 'CNAME',
809
+ value: cf,
810
+ });
811
+ const out = output(ctx, 'preview-dns');
812
+ out.record = `*.${domain}`;
813
+ out.zoneId = zoneId;
814
+ out.value = cf;
815
+ ctx.logger.info(` *.${domain} -> ${cf}`);
816
+ },
817
+ async delete(ctx) {
818
+ const out = output(ctx, 'preview-dns');
819
+ if (typeof out.zoneId === 'string' &&
820
+ typeof out.record === 'string' &&
821
+ typeof out.value === 'string') {
822
+ await ctx.clients.route53.deleteRecord(out.zoneId, {
823
+ name: out.record,
824
+ type: 'CNAME',
825
+ value: out.value,
826
+ });
827
+ }
828
+ },
829
+ };
830
+ }
831
+ /** Build the full node set for the current context (production or preview stack). */
832
+ export function buildNodes(ctx) {
833
+ const hasDomain = Boolean(ctx.domain);
834
+ const nodes = [
835
+ bucketNode(),
836
+ logGroupNode('microvm-log-group', 'MicroVM log group', (c) => c.names.microvmLogGroup, (c) => c.config.retention.microvmDays),
837
+ logGroupNode('cloudfront-log-group', 'CloudFront log group', (c) => c.names.cloudfrontLogGroup, (c) => c.config.retention.cloudfrontDays),
838
+ buildRoleNode(),
839
+ execRoleNode(),
840
+ microvmImageNode(),
841
+ oacNode(),
842
+ routerFunctionNode(ctx.preview),
843
+ distributionNode(hasDomain, ctx.preview),
844
+ logDeliveryNode(),
845
+ bucketPolicyNode(),
846
+ ];
847
+ if (hasDomain)
848
+ nodes.push(certificateNode());
849
+ if (ctx.preview) {
850
+ nodes.push(previewDnsNode(), githubOidcRoleNode(true));
851
+ }
852
+ else if (ctx.config.githubRepo) {
853
+ // staging deploys on push to main; production on release (see the deploy workflows).
854
+ nodes.push(githubOidcRoleNode(false));
855
+ }
856
+ return nodes;
857
+ }
858
+ //# sourceMappingURL=nodes.js.map