blogwright 0.4.0-beta.4 → 0.4.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.
- package/README.md +13 -6
- package/agent/agent-manifest.json +1 -1
- package/agent/server.js +944 -54
- package/dist/cli.js +11 -1
- package/dist/commands.d.ts +10 -1
- package/dist/commands.js +198 -8
- package/dist/deploy.d.ts +47 -1
- package/dist/deploy.js +60 -3
- package/dist/init.js +136 -9
- package/dist/nodes.d.ts +8 -0
- package/dist/nodes.js +690 -58
- package/dist/render.d.ts +13 -1
- package/dist/render.js +16 -3
- package/dist/web-functions.d.ts +123 -0
- package/dist/web-functions.js +548 -0
- package/package.json +4 -4
package/dist/nodes.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { AwsError, CACHING_DISABLED, CLOUDFRONT_ALIAS_ZONE_ID, pollUntil, textTag, } from 'blogwright-core';
|
|
1
|
+
import { ALL_VIEWER_EXCEPT_HOST_HEADER, AwsError, CACHING_DISABLED, CACHING_OPTIMIZED, CLOUDFRONT_ALIAS_ZONE_ID, MICROVM_CPU_ARCHITECTURE, pollUntil, REPRODUCIBLE_ZIP_MTIME, textTag, WEB_FUNCTION_RUNTIME, withRetry, } from 'blogwright-core';
|
|
2
|
+
import { strToU8, zipSync } from 'fflate';
|
|
2
3
|
import { packageAndUploadAgent } from './agent-package.js';
|
|
3
4
|
/** Lambda-managed MicroVM base image (Amazon Linux 2023) for the primary region. */
|
|
4
5
|
function microvmBaseImageArn(region) {
|
|
@@ -21,6 +22,23 @@ async function applyBucketConfiguration(ctx) {
|
|
|
21
22
|
await ctx.clients.s3.putBucketTagging(ctx.names.bucket, ctx.tags);
|
|
22
23
|
await ctx.clients.s3.putPublicAccessBlock(ctx.names.bucket);
|
|
23
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Enable versioning when (and only when) the environment is in SSR mode. Once
|
|
27
|
+
* enabled it is never suspended - suspending would not remove the versions
|
|
28
|
+
* already retained and would break the artifact pins a revision depends on -
|
|
29
|
+
* so a config that drops `ssr` keeps versioning and only stops relying on it.
|
|
30
|
+
*/
|
|
31
|
+
async function ensureBucketVersioning(ctx) {
|
|
32
|
+
if (!ctx.config.ssr)
|
|
33
|
+
return;
|
|
34
|
+
const out = output(ctx, 'bucket');
|
|
35
|
+
if (out.versioned === true)
|
|
36
|
+
return;
|
|
37
|
+
const status = await ctx.clients.s3.getBucketVersioning(ctx.names.bucket);
|
|
38
|
+
if (status !== 'Enabled')
|
|
39
|
+
await ctx.clients.s3.putBucketVersioning(ctx.names.bucket, 'Enabled');
|
|
40
|
+
out.versioned = true;
|
|
41
|
+
}
|
|
24
42
|
/** The S3 bucket holding build artifacts, the live site, and topology state. */
|
|
25
43
|
function bucketNode() {
|
|
26
44
|
return {
|
|
@@ -39,15 +57,22 @@ function bucketNode() {
|
|
|
39
57
|
// and tagging/PAB must still leave the bucket recorded in state.
|
|
40
58
|
output(ctx, 'bucket').name = ctx.names.bucket;
|
|
41
59
|
await applyBucketConfiguration(ctx);
|
|
60
|
+
await ensureBucketVersioning(ctx);
|
|
42
61
|
},
|
|
43
62
|
async update(ctx) {
|
|
44
63
|
// Reconcile on every apply: a bucket created by a crashed earlier run (before
|
|
45
64
|
// its tagging/PAB calls) converges to the configured shape on the next run.
|
|
46
65
|
await applyBucketConfiguration(ctx);
|
|
66
|
+
// Versioning is only ever turned ON here, so a stack that gained `ssr` after
|
|
67
|
+
// bootstrapping converges and one that lost it is left alone.
|
|
68
|
+
await ensureBucketVersioning(ctx);
|
|
47
69
|
},
|
|
48
70
|
async delete(ctx) {
|
|
49
|
-
//
|
|
50
|
-
|
|
71
|
+
// Version-aware removal is the ONLY way to empty a versioned bucket: a plain
|
|
72
|
+
// delete leaves delete markers and noncurrent versions behind, and the
|
|
73
|
+
// bucket then cannot be deleted. `deletePrefix` keeps its plain-delete
|
|
74
|
+
// behaviour for every other caller (`blogwright delete`, preview destroy).
|
|
75
|
+
await ctx.clients.s3.deleteAllVersions(ctx.names.bucket, '');
|
|
51
76
|
await ctx.clients.s3.deleteBucket(ctx.names.bucket);
|
|
52
77
|
},
|
|
53
78
|
};
|
|
@@ -92,6 +117,26 @@ const LAMBDA_TRUST = {
|
|
|
92
117
|
],
|
|
93
118
|
};
|
|
94
119
|
/** IAM role Lambda assumes while building the MicroVM image. */
|
|
120
|
+
/**
|
|
121
|
+
* S3 write grants the agent needs in SSR mode: the artifact zip and its
|
|
122
|
+
* `web/results/<hash>.json` sidecar. Static mode's roles can write only
|
|
123
|
+
* `site/*` and their `build/` prefixes, so without these the agent's upload
|
|
124
|
+
* would be denied.
|
|
125
|
+
*/
|
|
126
|
+
function webArtifactWriteStatements(ctx) {
|
|
127
|
+
if (!ctx.config.ssr)
|
|
128
|
+
return [];
|
|
129
|
+
return [
|
|
130
|
+
{
|
|
131
|
+
Effect: 'Allow',
|
|
132
|
+
Action: ['s3:PutObject'],
|
|
133
|
+
Resource: [
|
|
134
|
+
`arn:aws:s3:::${ctx.names.bucket}/web/artifacts/*`,
|
|
135
|
+
`arn:aws:s3:::${ctx.names.bucket}/web/results/*`,
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
];
|
|
139
|
+
}
|
|
95
140
|
/** Apply the build role's inline policy (idempotent - used by create + update). */
|
|
96
141
|
async function applyBuildRolePolicy(ctx) {
|
|
97
142
|
// The build role is BOTH the image-build role AND the MicroVM's ambient runtime
|
|
@@ -116,6 +161,7 @@ async function applyBuildRolePolicy(ctx) {
|
|
|
116
161
|
Action: ['s3:PutObject'],
|
|
117
162
|
Resource: `arn:aws:s3:::${ctx.names.bucket}/build/changed/*`,
|
|
118
163
|
},
|
|
164
|
+
...webArtifactWriteStatements(ctx),
|
|
119
165
|
{
|
|
120
166
|
Effect: 'Allow',
|
|
121
167
|
Action: ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'],
|
|
@@ -172,6 +218,7 @@ async function applyExecRolePolicy(ctx) {
|
|
|
172
218
|
`arn:aws:s3:::${ctx.names.bucket}/build/changed/*`,
|
|
173
219
|
],
|
|
174
220
|
},
|
|
221
|
+
...webArtifactWriteStatements(ctx),
|
|
175
222
|
{
|
|
176
223
|
Effect: 'Allow',
|
|
177
224
|
Action: ['s3:ListBucket'],
|
|
@@ -226,9 +273,15 @@ async function imageInput(ctx) {
|
|
|
226
273
|
// resolves credentials from IMDS (→ the build role identity). Hookless: hooks are
|
|
227
274
|
// delivered over TLS which the agent can't satisfy, and are unnecessary here.
|
|
228
275
|
environmentVariables: { BUILD_BUCKET: ctx.names.bucket, BUILD_REGION: ctx.config.region },
|
|
276
|
+
// Arm64 in every environment, so what the build compiles or installs matches
|
|
277
|
+
// the Web Function runtime (and `ARM_64` is the API's only value). Sent on
|
|
278
|
+
// create AND update, because `imageBody` emits it either way.
|
|
279
|
+
cpuConfigurations: [{ architecture: MICROVM_CPU_ARCHITECTURE }],
|
|
229
280
|
// Scoped by image name, not just agent hash: two environments in one account
|
|
230
281
|
// share the hash, and reusing a clientToken with different parameters is a 400.
|
|
231
|
-
|
|
282
|
+
// The architecture is part of the token because adding it to a request that
|
|
283
|
+
// reused an older token's parameters would be exactly that 400.
|
|
284
|
+
clientToken: `img-${ctx.names.microvmImage}-arm64-${artifact.hash}`,
|
|
232
285
|
description: `${ctx.config.siteName} ${ctx.env} builder`,
|
|
233
286
|
},
|
|
234
287
|
};
|
|
@@ -305,6 +358,10 @@ export async function reconcileBuilderImage(ctx) {
|
|
|
305
358
|
}
|
|
306
359
|
out.agentHash = hash;
|
|
307
360
|
out.logGroup = ctx.names.microvmLogGroup;
|
|
361
|
+
// Record the architecture the request stated. Deliberately not a rebuild
|
|
362
|
+
// reason when a legacy state record lacks it: `ARM_64` is the API's only
|
|
363
|
+
// value, so an image created before this field existed already runs it.
|
|
364
|
+
out.cpuArchitecture = MICROVM_CPU_ARCHITECTURE;
|
|
308
365
|
await ctx.save();
|
|
309
366
|
}
|
|
310
367
|
function microvmImageNode() {
|
|
@@ -426,7 +483,10 @@ function oacNode() {
|
|
|
426
483
|
return typeof output(ctx, 'oac').id === 'string';
|
|
427
484
|
},
|
|
428
485
|
async create(ctx) {
|
|
429
|
-
const id = await ctx.clients.cloudfront.createOriginAccessControl(
|
|
486
|
+
const id = await ctx.clients.cloudfront.createOriginAccessControl({
|
|
487
|
+
name: ctx.names.oac,
|
|
488
|
+
originType: 's3',
|
|
489
|
+
});
|
|
430
490
|
output(ctx, 'oac').id = id;
|
|
431
491
|
},
|
|
432
492
|
async delete(ctx) {
|
|
@@ -436,6 +496,388 @@ function oacNode() {
|
|
|
436
496
|
},
|
|
437
497
|
};
|
|
438
498
|
}
|
|
499
|
+
/**
|
|
500
|
+
* CloudFront Origin Access Control for the Web Function origin: `lambda` origin
|
|
501
|
+
* type, `always-amz-auth` signing, so every distribution request to the
|
|
502
|
+
* endpoint is SigV4-signed and the endpoint's resource policy can admit exactly
|
|
503
|
+
* this distribution.
|
|
504
|
+
*/
|
|
505
|
+
function webOacNode() {
|
|
506
|
+
return {
|
|
507
|
+
id: 'web-oac',
|
|
508
|
+
dependsOn: [],
|
|
509
|
+
title: 'CloudFront Web Function OAC',
|
|
510
|
+
async read(ctx) {
|
|
511
|
+
return typeof output(ctx, 'web-oac').id === 'string';
|
|
512
|
+
},
|
|
513
|
+
async create(ctx) {
|
|
514
|
+
const id = await ctx.clients.cloudfront.createOriginAccessControl({
|
|
515
|
+
name: ctx.names.webOac,
|
|
516
|
+
originType: 'lambda',
|
|
517
|
+
signingBehavior: 'always-amz-auth',
|
|
518
|
+
});
|
|
519
|
+
output(ctx, 'web-oac').id = id;
|
|
520
|
+
},
|
|
521
|
+
async delete(ctx) {
|
|
522
|
+
const id = output(ctx, 'web-oac').id;
|
|
523
|
+
if (typeof id === 'string')
|
|
524
|
+
await ctx.clients.cloudfront.deleteOriginAccessControl(id);
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
/** The environment's SSR config. Web nodes only exist when it is set. */
|
|
529
|
+
function ssrConfig(ctx) {
|
|
530
|
+
const config = ctx.config.ssr;
|
|
531
|
+
if (!config)
|
|
532
|
+
throw new Error('SSR node built for an environment without config.ssr');
|
|
533
|
+
return config;
|
|
534
|
+
}
|
|
535
|
+
/** The name of the Web Function's single endpoint (route to its newest revision). */
|
|
536
|
+
const WEB_FUNCTION_ENDPOINT_NAME = 'site';
|
|
537
|
+
/** Where the CLI-owned placeholder package lives, inside the Lambda-read prefix. */
|
|
538
|
+
export const PLACEHOLDER_ARTIFACT_KEY = 'web/artifacts/placeholder.zip';
|
|
539
|
+
/**
|
|
540
|
+
* The placeholder the Web Function serves until the first deploy: a tiny
|
|
541
|
+
* CLI-owned server that listens on `AWS_LAMBDA_HTTP_ENDPOINT` and answers 503.
|
|
542
|
+
* Zipped with a fixed timestamp so identical bytes never churn.
|
|
543
|
+
*/
|
|
544
|
+
export function placeholderPackageZip() {
|
|
545
|
+
const code = `import { createServer } from 'node:http';\n` +
|
|
546
|
+
`const address = process.env.AWS_LAMBDA_HTTP_ENDPOINT ?? '0.0.0.0:8080';\n` +
|
|
547
|
+
`const index = address.lastIndexOf(':');\n` +
|
|
548
|
+
`const host = address.slice(0, index);\n` +
|
|
549
|
+
`const port = Number(address.slice(index + 1));\n` +
|
|
550
|
+
`createServer((_req, res) => {\n` +
|
|
551
|
+
` res.writeHead(503, { 'content-type': 'text/plain' });\n` +
|
|
552
|
+
` res.end('blogwright: waiting for the first SSR deploy\\n');\n` +
|
|
553
|
+
`}).listen(port, host);\n`;
|
|
554
|
+
return zipSync({ 'index.js': strToU8(code) }, { level: 6, mtime: REPRODUCIBLE_ZIP_MTIME });
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* The SSR cache policy's desired shape: cache only what the application marks
|
|
558
|
+
* cacheable, key on every query string and exactly the operator-declared
|
|
559
|
+
* headers, forward cookies without keying on them, and compress.
|
|
560
|
+
*/
|
|
561
|
+
function ssrCachePolicyInput(ctx) {
|
|
562
|
+
return {
|
|
563
|
+
name: ctx.names.ssrCachePolicy,
|
|
564
|
+
comment: `${ctx.config.siteName} ${ctx.env} SSR responses`,
|
|
565
|
+
minTtl: 0,
|
|
566
|
+
defaultTtl: 0,
|
|
567
|
+
maxTtl: 31_536_000,
|
|
568
|
+
headerNames: ssrConfig(ctx).cacheKeyHeaders,
|
|
569
|
+
queryStrings: 'all',
|
|
570
|
+
cookies: 'none',
|
|
571
|
+
gzip: true,
|
|
572
|
+
brotli: true,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
/** Blogwright-managed CloudFront cache policy for the SSR default behavior. */
|
|
576
|
+
function webCachePolicyNode() {
|
|
577
|
+
return {
|
|
578
|
+
id: 'web-cache-policy',
|
|
579
|
+
dependsOn: [],
|
|
580
|
+
title: 'CloudFront SSR cache policy',
|
|
581
|
+
async read(ctx) {
|
|
582
|
+
const id = output(ctx, 'web-cache-policy').id;
|
|
583
|
+
if (typeof id !== 'string')
|
|
584
|
+
return false;
|
|
585
|
+
// Existence only: a policy that drifted from the config (a changed
|
|
586
|
+
// `cacheKeyHeaders`) is `update`'s to converge. Reporting it absent would
|
|
587
|
+
// send the graph to `create`, which CloudFront refuses for a taken name.
|
|
588
|
+
return Boolean(await ctx.clients.cloudfront.getCachePolicy(id));
|
|
589
|
+
},
|
|
590
|
+
async create(ctx) {
|
|
591
|
+
const { id } = await ctx.clients.cloudfront.createCachePolicy(ssrCachePolicyInput(ctx));
|
|
592
|
+
output(ctx, 'web-cache-policy').id = id;
|
|
593
|
+
},
|
|
594
|
+
async update(ctx) {
|
|
595
|
+
const id = output(ctx, 'web-cache-policy').id;
|
|
596
|
+
if (typeof id !== 'string')
|
|
597
|
+
return;
|
|
598
|
+
const policy = await ctx.clients.cloudfront.getCachePolicy(id);
|
|
599
|
+
if (!policy)
|
|
600
|
+
return;
|
|
601
|
+
if (ctx.clients.cloudfront.cachePolicyMatches(policy.config, ssrCachePolicyInput(ctx)))
|
|
602
|
+
return;
|
|
603
|
+
await ctx.clients.cloudfront.updateCachePolicy(id, ssrCachePolicyInput(ctx), policy.etag);
|
|
604
|
+
},
|
|
605
|
+
async delete(ctx) {
|
|
606
|
+
const id = output(ctx, 'web-cache-policy').id;
|
|
607
|
+
if (typeof id === 'string')
|
|
608
|
+
await ctx.clients.cloudfront.deleteCachePolicy(id);
|
|
609
|
+
},
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Merge one named statement into the bucket's policy, preserving every other
|
|
614
|
+
* statement - so `bucket-policy`'s `AllowCloudFrontRead` and
|
|
615
|
+
* `web-code-bucket-policy`'s `AllowLambdaWebFunctionCodeAccess` are separate
|
|
616
|
+
* Sids, each re-applied by its own node without disturbing the other.
|
|
617
|
+
*/
|
|
618
|
+
async function mergeBucketPolicyStatement(ctx, sid, statement) {
|
|
619
|
+
const current = await ctx.clients.s3.getBucketPolicy(ctx.names.bucket);
|
|
620
|
+
let statements = [];
|
|
621
|
+
if (current) {
|
|
622
|
+
try {
|
|
623
|
+
const parsed = JSON.parse(current);
|
|
624
|
+
if (Array.isArray(parsed.Statement))
|
|
625
|
+
statements = parsed.Statement;
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
// A corrupt policy is rewritten from the statements this stack owns.
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
const kept = statements.filter((s) => s.Sid !== sid);
|
|
632
|
+
await ctx.clients.s3.putBucketPolicy(ctx.names.bucket, JSON.stringify({ Version: '2012-10-17', Statement: [...kept, statement] }));
|
|
633
|
+
}
|
|
634
|
+
/** The Lambda-read statement: this function's revisions may read the artifact prefix. */
|
|
635
|
+
function webCodeBucketPolicyStatement(ctx) {
|
|
636
|
+
return {
|
|
637
|
+
Sid: 'AllowLambdaWebFunctionCodeAccess',
|
|
638
|
+
Effect: 'Allow',
|
|
639
|
+
Principal: { Service: 'lambda.amazonaws.com' },
|
|
640
|
+
Action: ['s3:GetObject', 's3:GetObjectVersion'],
|
|
641
|
+
Resource: `arn:aws:s3:::${ctx.names.bucket}/web/artifacts/*`,
|
|
642
|
+
Condition: {
|
|
643
|
+
ArnLike: {
|
|
644
|
+
// Scoped to THIS function's revisions, so no other account's function can
|
|
645
|
+
// have Lambda read this bucket on its behalf. The ARN is derivable from
|
|
646
|
+
// the function name, so the statement can be written before it exists.
|
|
647
|
+
'AWS:SourceArn': `arn:aws:lambda:${ctx.config.region}:${ctx.accountId}:web-function/${ctx.names.webFunction}/revision/*`,
|
|
648
|
+
},
|
|
649
|
+
},
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
/** Bucket statement granting Lambda read on the revision artifact prefix. */
|
|
653
|
+
function webCodeBucketPolicyNode() {
|
|
654
|
+
return {
|
|
655
|
+
id: 'web-code-bucket-policy',
|
|
656
|
+
dependsOn: ['bucket'],
|
|
657
|
+
title: 'S3 bucket policy (Web Function code)',
|
|
658
|
+
async read() {
|
|
659
|
+
// Always reconcile: tracks the function's derived revision ARN.
|
|
660
|
+
return false;
|
|
661
|
+
},
|
|
662
|
+
async create(ctx) {
|
|
663
|
+
await mergeBucketPolicyStatement(ctx, 'AllowLambdaWebFunctionCodeAccess', webCodeBucketPolicyStatement(ctx));
|
|
664
|
+
output(ctx, 'web-code-bucket-policy').applied = true;
|
|
665
|
+
},
|
|
666
|
+
async delete() {
|
|
667
|
+
// Removed together with the bucket during destroy.
|
|
668
|
+
},
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Retry bound for the two things that lag a just-created resource: IAM has not
|
|
673
|
+
* finished making the execution role assumable, and the bucket policy has not
|
|
674
|
+
* propagated so Lambda cannot read the placeholder artifact. Both surface as a
|
|
675
|
+
* 400/403 naming the role or the S3 action; anything else is thrown at once.
|
|
676
|
+
*/
|
|
677
|
+
const WEB_FUNCTION_PROPAGATION_ERROR = /cannot be assumed by|unable to assume role|not authorized to perform: s3:GetObject/i;
|
|
678
|
+
function whileWebFunctionPropagating(call) {
|
|
679
|
+
return withRetry(call, {
|
|
680
|
+
attempts: 15,
|
|
681
|
+
baseDelayMs: 1_000,
|
|
682
|
+
maxDelayMs: 5_000,
|
|
683
|
+
retryable: (err) => err instanceof AwsError &&
|
|
684
|
+
(err.statusCode === 400 || err.statusCode === 403) &&
|
|
685
|
+
WEB_FUNCTION_PROPAGATION_ERROR.test(err.message),
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
/** True for the conflict a same-named function already existing produces. */
|
|
689
|
+
function isFunctionConflict(err) {
|
|
690
|
+
return err instanceof AwsError && /Conflict|AlreadyExists|ResourceInUse/i.test(err.code);
|
|
691
|
+
}
|
|
692
|
+
/** Record an endpoint's identity in the function node's outputs. */
|
|
693
|
+
function recordEndpoint(ctx, endpoint) {
|
|
694
|
+
const out = output(ctx, 'web-function');
|
|
695
|
+
out.endpointName = endpoint.endpointName;
|
|
696
|
+
if (endpoint.endpointArn)
|
|
697
|
+
out.endpointArn = endpoint.endpointArn;
|
|
698
|
+
if (endpoint.domainName)
|
|
699
|
+
out.endpointDomainName = endpoint.domainName;
|
|
700
|
+
}
|
|
701
|
+
/** Ensure the function's `site` endpoint exists, and record its identity. */
|
|
702
|
+
async function ensureWebFunctionEndpoint(ctx) {
|
|
703
|
+
const name = ctx.names.webFunction;
|
|
704
|
+
const existing = await ctx.clients.lambdaWeb.getWebFunctionEndpoint(name, WEB_FUNCTION_ENDPOINT_NAME);
|
|
705
|
+
const endpoint = existing ??
|
|
706
|
+
(await ctx.clients.lambdaWeb.createWebFunctionEndpoint({
|
|
707
|
+
functionName: name,
|
|
708
|
+
endpointName: WEB_FUNCTION_ENDPOINT_NAME,
|
|
709
|
+
endpointType: 'HomeRegion',
|
|
710
|
+
authType: 'IamAuth',
|
|
711
|
+
autoDeploymentMode: 'LatestRevision',
|
|
712
|
+
}));
|
|
713
|
+
recordEndpoint(ctx, endpoint);
|
|
714
|
+
if (!existing && endpoint.domainName) {
|
|
715
|
+
ctx.logger.info(` endpoint: https://${endpoint.domainName}`);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* The Web Function itself, with its placeholder revision and `site` endpoint.
|
|
720
|
+
* Bootstrap never cuts a real revision: the endpoint converges to whatever the
|
|
721
|
+
* first deploy creates.
|
|
722
|
+
*/
|
|
723
|
+
function webFunctionNode() {
|
|
724
|
+
return {
|
|
725
|
+
id: 'web-function',
|
|
726
|
+
dependsOn: ['bucket', 'iam-web-role', 'web-code-bucket-policy'],
|
|
727
|
+
title: 'Lambda Web Function',
|
|
728
|
+
async read(ctx) {
|
|
729
|
+
const name = ctx.names.webFunction;
|
|
730
|
+
const fn = await ctx.clients.lambdaWeb.getWebFunction(name);
|
|
731
|
+
if (!fn)
|
|
732
|
+
return false;
|
|
733
|
+
const out = output(ctx, 'web-function');
|
|
734
|
+
out.name = name;
|
|
735
|
+
if (fn.functionArn)
|
|
736
|
+
out.arn = fn.functionArn;
|
|
737
|
+
const endpoint = await ctx.clients.lambdaWeb.getWebFunctionEndpoint(name, WEB_FUNCTION_ENDPOINT_NAME);
|
|
738
|
+
if (endpoint)
|
|
739
|
+
recordEndpoint(ctx, endpoint);
|
|
740
|
+
return true;
|
|
741
|
+
},
|
|
742
|
+
async create(ctx) {
|
|
743
|
+
const name = ctx.names.webFunction;
|
|
744
|
+
const config = ssrConfig(ctx);
|
|
745
|
+
const out = output(ctx, 'web-function');
|
|
746
|
+
// The placeholder must be in the prefix the Lambda-read bucket statement
|
|
747
|
+
// covers BEFORE the function is created: Lambda checks its own read access
|
|
748
|
+
// when it creates the initial revision and rejects the request otherwise.
|
|
749
|
+
const version = await ctx.clients.s3.putObject(ctx.names.bucket, PLACEHOLDER_ARTIFACT_KEY, placeholderPackageZip(), 'application/zip');
|
|
750
|
+
const s3Object = {
|
|
751
|
+
bucket: ctx.names.bucket,
|
|
752
|
+
key: PLACEHOLDER_ARTIFACT_KEY,
|
|
753
|
+
...(version ? { versionId: version } : {}),
|
|
754
|
+
};
|
|
755
|
+
let fn;
|
|
756
|
+
try {
|
|
757
|
+
fn = await whileWebFunctionPropagating(() => ctx.clients.lambdaWeb.createWebFunction({
|
|
758
|
+
functionName: name,
|
|
759
|
+
revisionConfig: {
|
|
760
|
+
// Never `blogwright:<fingerprint>`: a deploy must not mistake the
|
|
761
|
+
// placeholder for a revision it already published.
|
|
762
|
+
description: 'blogwright:placeholder',
|
|
763
|
+
buildConfig: {
|
|
764
|
+
codeConfig: { s3Object },
|
|
765
|
+
runtimeConfig: { runtime: WEB_FUNCTION_RUNTIME },
|
|
766
|
+
},
|
|
767
|
+
serviceConfig: {
|
|
768
|
+
executionRoleArn: String(output(ctx, 'iam-web-role').arn),
|
|
769
|
+
timeoutSeconds: config.timeoutSeconds,
|
|
770
|
+
maxConcurrencyPerEnvironment: config.maxConcurrencyPerEnvironment,
|
|
771
|
+
environmentVariables: { NODE_ENV: 'production', ...config.env },
|
|
772
|
+
telemetryConfig: { loggingConfig: { logGroup: ctx.names.webLogGroup } },
|
|
773
|
+
},
|
|
774
|
+
},
|
|
775
|
+
endpointConfig: {
|
|
776
|
+
endpointName: WEB_FUNCTION_ENDPOINT_NAME,
|
|
777
|
+
endpointType: 'HomeRegion',
|
|
778
|
+
authType: 'IamAuth',
|
|
779
|
+
autoDeploymentMode: 'LatestRevision',
|
|
780
|
+
},
|
|
781
|
+
tags: ctx.tags,
|
|
782
|
+
}));
|
|
783
|
+
}
|
|
784
|
+
catch (err) {
|
|
785
|
+
// A crashed earlier bootstrap can leave a function AWS has and state does
|
|
786
|
+
// not. When the name is taken by this site's function, adopt it instead of
|
|
787
|
+
// failing the whole bootstrap; a foreign function's conflict still surfaces.
|
|
788
|
+
if (!isFunctionConflict(err))
|
|
789
|
+
throw err;
|
|
790
|
+
fn = await ctx.clients.lambdaWeb.getWebFunction(name);
|
|
791
|
+
if (!fn)
|
|
792
|
+
throw err;
|
|
793
|
+
ctx.logger.ok(`adopted existing web function ${name} (created by an earlier run)`);
|
|
794
|
+
}
|
|
795
|
+
// Record the ARN before the endpoint: a crash in between still leaves the
|
|
796
|
+
// function destroyable.
|
|
797
|
+
out.name = name;
|
|
798
|
+
if (fn.functionArn)
|
|
799
|
+
out.arn = fn.functionArn;
|
|
800
|
+
if (fn.revision?.revisionId)
|
|
801
|
+
out.placeholderRevisionId = fn.revision.revisionId;
|
|
802
|
+
if (fn.endpoint) {
|
|
803
|
+
// Create returns the initial endpoint, so a fresh bootstrap needs no
|
|
804
|
+
// second round trip to learn the domain it just made.
|
|
805
|
+
recordEndpoint(ctx, fn.endpoint);
|
|
806
|
+
await ctx.save();
|
|
807
|
+
}
|
|
808
|
+
else {
|
|
809
|
+
await ctx.save();
|
|
810
|
+
await ensureWebFunctionEndpoint(ctx);
|
|
811
|
+
}
|
|
812
|
+
},
|
|
813
|
+
async update(ctx) {
|
|
814
|
+
await ensureWebFunctionEndpoint(ctx);
|
|
815
|
+
},
|
|
816
|
+
async delete(ctx) {
|
|
817
|
+
// Deleting the function removes its revisions and endpoints with it.
|
|
818
|
+
await ctx.clients.lambdaWeb.deleteWebFunction(ctx.names.webFunction);
|
|
819
|
+
},
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
/** The resource-policy statement admitting exactly this distribution. */
|
|
823
|
+
function webFunctionPolicyStatement(ctx) {
|
|
824
|
+
return {
|
|
825
|
+
Sid: 'AllowCloudFrontOACAccess',
|
|
826
|
+
Effect: 'Allow',
|
|
827
|
+
Principal: { Service: 'cloudfront.amazonaws.com' },
|
|
828
|
+
Action: 'lambda:InvokeWebFunctionEndpoint',
|
|
829
|
+
Resource: String(output(ctx, 'web-function').endpointArn),
|
|
830
|
+
Condition: { ArnLike: { 'AWS:SourceArn': String(output(ctx, 'cloudfront-distribution').arn) } },
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
function webFunctionPolicyNode() {
|
|
834
|
+
return {
|
|
835
|
+
id: 'web-function-policy',
|
|
836
|
+
dependsOn: ['web-function', 'cloudfront-distribution'],
|
|
837
|
+
title: 'Web Function resource policy',
|
|
838
|
+
async read(ctx) {
|
|
839
|
+
const functionArn = String(output(ctx, 'web-function').arn);
|
|
840
|
+
const current = await ctx.clients.lambdaWeb.getResourcePolicy(functionArn);
|
|
841
|
+
if (!current?.policy)
|
|
842
|
+
return false;
|
|
843
|
+
try {
|
|
844
|
+
const parsed = JSON.parse(current.policy);
|
|
845
|
+
const existing = (parsed.Statement ?? []).find((s) => s.Sid === 'AllowCloudFrontOACAccess');
|
|
846
|
+
return JSON.stringify(existing) === JSON.stringify(webFunctionPolicyStatement(ctx));
|
|
847
|
+
}
|
|
848
|
+
catch {
|
|
849
|
+
return false;
|
|
850
|
+
}
|
|
851
|
+
},
|
|
852
|
+
async create(ctx) {
|
|
853
|
+
const functionArn = String(output(ctx, 'web-function').arn);
|
|
854
|
+
const current = await ctx.clients.lambdaWeb.getResourcePolicy(functionArn);
|
|
855
|
+
let statements = [];
|
|
856
|
+
if (current?.policy) {
|
|
857
|
+
try {
|
|
858
|
+
const parsed = JSON.parse(current.policy);
|
|
859
|
+
if (Array.isArray(parsed.Statement))
|
|
860
|
+
statements = parsed.Statement;
|
|
861
|
+
}
|
|
862
|
+
catch {
|
|
863
|
+
// Rewrite from the statement this stack owns.
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
const kept = statements.filter((s) => s.Sid !== 'AllowCloudFrontOACAccess');
|
|
867
|
+
const result = await ctx.clients.lambdaWeb.putResourcePolicy(functionArn, JSON.stringify({
|
|
868
|
+
Version: '2012-10-17',
|
|
869
|
+
Statement: [...kept, webFunctionPolicyStatement(ctx)],
|
|
870
|
+
}));
|
|
871
|
+
const out = output(ctx, 'web-function-policy');
|
|
872
|
+
out.applied = true;
|
|
873
|
+
if (result.revisionId)
|
|
874
|
+
out.revisionId = result.revisionId;
|
|
875
|
+
},
|
|
876
|
+
async delete() {
|
|
877
|
+
// Deleting the function removes its resource policy with it.
|
|
878
|
+
},
|
|
879
|
+
};
|
|
880
|
+
}
|
|
439
881
|
/**
|
|
440
882
|
* CloudFront Function (viewer-request) that routes a preview subdomain to its S3 prefix:
|
|
441
883
|
* `pr-42.preview.example.com/foo` → origin `previews/pr-42/site/foo`, with directory →
|
|
@@ -510,13 +952,99 @@ async function findAdoptableDistribution(ctx, comment, callerReference) {
|
|
|
510
952
|
}
|
|
511
953
|
return undefined;
|
|
512
954
|
}
|
|
955
|
+
/** `static` unless the environment's config carries an `ssr` block. */
|
|
956
|
+
function servingMode(ctx) {
|
|
957
|
+
return ctx.config.ssr ? 'ssr' : 'static';
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* The distribution shape Blogwright owns for a mode. Static mode renders
|
|
961
|
+
* exactly the single-origin, single-behavior document the builder has always
|
|
962
|
+
* produced; SSR mode adds the Web Function origin and the per-behavior
|
|
963
|
+
* policies the serving contract specifies.
|
|
964
|
+
*/
|
|
965
|
+
function distributionShape(ctx, mode, preview) {
|
|
966
|
+
const s3Origin = {
|
|
967
|
+
id: 's3-site',
|
|
968
|
+
domainName: `${ctx.names.bucket}.s3.${ctx.config.region}.amazonaws.com`,
|
|
969
|
+
// Preview: function rewrites the full path, so the origin path is the bucket root.
|
|
970
|
+
originPath: preview ? '' : '/site',
|
|
971
|
+
kind: 's3',
|
|
972
|
+
originAccessControlId: String(output(ctx, 'oac').id),
|
|
973
|
+
};
|
|
974
|
+
const shared = {
|
|
975
|
+
callerReference: `${ctx.names.prefix}-${ctx.accountId}`,
|
|
976
|
+
comment: `${ctx.config.siteName} ${ctx.env}`,
|
|
977
|
+
aliases: ctx.domain ? [preview ? `*.${ctx.domain}` : ctx.domain] : [],
|
|
978
|
+
acmCertificateArn: ctx.domain ? String(output(ctx, 'acm-certificate').arn) : undefined,
|
|
979
|
+
};
|
|
980
|
+
if (mode === 'ssr') {
|
|
981
|
+
const config = ssrConfig(ctx);
|
|
982
|
+
return {
|
|
983
|
+
...shared,
|
|
984
|
+
// The app answers `/`; a root object would only intercept it.
|
|
985
|
+
defaultRootObject: '',
|
|
986
|
+
origins: [
|
|
987
|
+
s3Origin,
|
|
988
|
+
{
|
|
989
|
+
id: 'web-function',
|
|
990
|
+
domainName: String(output(ctx, 'web-function').endpointDomainName),
|
|
991
|
+
originPath: '',
|
|
992
|
+
kind: 'web-function',
|
|
993
|
+
originAccessControlId: String(output(ctx, 'web-oac').id),
|
|
994
|
+
// Streamed responses must not be cut at CloudFront's 30-second default.
|
|
995
|
+
originReadTimeoutSeconds: 60,
|
|
996
|
+
},
|
|
997
|
+
],
|
|
998
|
+
defaultBehavior: {
|
|
999
|
+
targetOriginId: 'web-function',
|
|
1000
|
+
cachePolicyId: String(output(ctx, 'web-cache-policy').id),
|
|
1001
|
+
originRequestPolicyId: ALL_VIEWER_EXCEPT_HOST_HEADER,
|
|
1002
|
+
allowedMethods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'POST', 'PATCH', 'DELETE'],
|
|
1003
|
+
},
|
|
1004
|
+
orderedBehaviors: config.staticPaths.map((pathPattern) => ({
|
|
1005
|
+
pathPattern,
|
|
1006
|
+
targetOriginId: 's3-site',
|
|
1007
|
+
cachePolicyId: CACHING_OPTIMIZED,
|
|
1008
|
+
})),
|
|
1009
|
+
// No custom error responses: the application owns 404s, and error
|
|
1010
|
+
// responses are distribution-wide, so the static behaviors have none either.
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
return {
|
|
1014
|
+
...shared,
|
|
1015
|
+
defaultRootObject: ctx.config.defaultRootObject,
|
|
1016
|
+
origins: [s3Origin],
|
|
1017
|
+
defaultBehavior: {
|
|
1018
|
+
targetOriginId: 's3-site',
|
|
1019
|
+
// Previews are served uncached (per-PR content, host-routed); staging/production
|
|
1020
|
+
// keep the default cache policy.
|
|
1021
|
+
cachePolicyId: preview ? CACHING_DISABLED : CACHING_OPTIMIZED,
|
|
1022
|
+
functionArn: String(output(ctx, 'cloudfront-function').arn),
|
|
1023
|
+
},
|
|
1024
|
+
// Non-preview stacks map the S3 REST origin's 403/404 (a missing key) to the
|
|
1025
|
+
// site's 404 page - or, in SPA mode, to /index.html with a 200 so
|
|
1026
|
+
// client-side routes deep-link correctly.
|
|
1027
|
+
...(preview
|
|
1028
|
+
? {}
|
|
1029
|
+
: {
|
|
1030
|
+
customErrorResponses: [403, 404].map((errorCode) => ({
|
|
1031
|
+
errorCode,
|
|
1032
|
+
responsePagePath: ctx.config.spa ? '/index.html' : '/404.html',
|
|
1033
|
+
responseCode: ctx.config.spa ? 200 : 404,
|
|
1034
|
+
})),
|
|
1035
|
+
}),
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
513
1038
|
/** The CloudFront distribution. Preview stacks use a host-routing function + no caching. */
|
|
514
|
-
function distributionNode(hasDomain, preview) {
|
|
1039
|
+
function distributionNode(hasDomain, preview, ssr) {
|
|
515
1040
|
const dependsOn = [
|
|
516
1041
|
'bucket',
|
|
517
1042
|
'oac',
|
|
518
1043
|
'cloudfront-function',
|
|
519
1044
|
...(hasDomain ? ['acm-certificate'] : []),
|
|
1045
|
+
// The SSR shapes read the function's endpoint domain, the `lambda` OAC and
|
|
1046
|
+
// the cache policy, so all three must exist first.
|
|
1047
|
+
...(ssr ? ['web-function', 'web-oac', 'web-cache-policy'] : []),
|
|
520
1048
|
];
|
|
521
1049
|
return {
|
|
522
1050
|
id: 'cloudfront-distribution',
|
|
@@ -535,31 +1063,7 @@ function distributionNode(hasDomain, preview) {
|
|
|
535
1063
|
// What create and adopt agree on: enough identity to record outputs and tag.
|
|
536
1064
|
let dist;
|
|
537
1065
|
try {
|
|
538
|
-
dist = await ctx.clients.cloudfront.createDistribution(
|
|
539
|
-
callerReference,
|
|
540
|
-
comment,
|
|
541
|
-
bucketDomainName: `${ctx.names.bucket}.s3.${ctx.config.region}.amazonaws.com`,
|
|
542
|
-
// Preview: function rewrites the full path, so the origin path is the bucket root.
|
|
543
|
-
originPath: preview ? '' : '/site',
|
|
544
|
-
originAccessControlId: String(output(ctx, 'oac').id),
|
|
545
|
-
defaultRootObject: ctx.config.defaultRootObject,
|
|
546
|
-
aliases: ctx.domain ? [preview ? `*.${ctx.domain}` : ctx.domain] : [],
|
|
547
|
-
acmCertificateArn: hasDomain ? String(output(ctx, 'acm-certificate').arn) : undefined,
|
|
548
|
-
functionArn: String(output(ctx, 'cloudfront-function').arn),
|
|
549
|
-
// Previews are served uncached (per-PR content, host-routed); staging/production
|
|
550
|
-
// keep the default cache policy. Non-preview stacks map the S3 REST origin's
|
|
551
|
-
// 403/404 (a missing key) to the site's 404 page - or, in SPA mode, to
|
|
552
|
-
// /index.html with a 200 so client-side routes deep-link correctly.
|
|
553
|
-
...(preview
|
|
554
|
-
? { cachePolicyId: CACHING_DISABLED }
|
|
555
|
-
: {
|
|
556
|
-
customErrorResponses: [403, 404].map((errorCode) => ({
|
|
557
|
-
errorCode,
|
|
558
|
-
responsePagePath: ctx.config.spa ? '/index.html' : '/404.html',
|
|
559
|
-
responseCode: ctx.config.spa ? 200 : 404,
|
|
560
|
-
})),
|
|
561
|
-
}),
|
|
562
|
-
});
|
|
1066
|
+
dist = await ctx.clients.cloudfront.createDistribution(distributionShape(ctx, servingMode(ctx), preview));
|
|
563
1067
|
}
|
|
564
1068
|
catch (err) {
|
|
565
1069
|
// A crashed earlier bootstrap can leave a distribution in AWS that state never
|
|
@@ -589,6 +1093,9 @@ function distributionNode(hasDomain, preview) {
|
|
|
589
1093
|
out.id = dist.id;
|
|
590
1094
|
out.arn = dist.arn;
|
|
591
1095
|
out.domainName = dist.domainName;
|
|
1096
|
+
// The mode the live distribution was left serving, so a later bootstrap can
|
|
1097
|
+
// tell a routine re-run from a serving cutover.
|
|
1098
|
+
out.mode = servingMode(ctx);
|
|
592
1099
|
await ctx.clients.cloudfront.tagResource(dist.arn, ctx.tags);
|
|
593
1100
|
ctx.logger.info(` CloudFront domain: ${dist.domainName}`);
|
|
594
1101
|
if (ctx.domain && !preview) {
|
|
@@ -598,12 +1105,34 @@ function distributionNode(hasDomain, preview) {
|
|
|
598
1105
|
}
|
|
599
1106
|
},
|
|
600
1107
|
async update(ctx) {
|
|
601
|
-
// A domain added (or changed) after the first bootstrap must reach the
|
|
602
|
-
// existing distribution - the certificate node validates the cert, but
|
|
603
|
-
// only this reconcile attaches the alias + viewer certificate.
|
|
604
1108
|
const id = output(ctx, 'cloudfront-distribution').id;
|
|
605
1109
|
if (typeof id !== 'string')
|
|
606
1110
|
return;
|
|
1111
|
+
const configured = servingMode(ctx);
|
|
1112
|
+
const out = output(ctx, 'cloudfront-distribution');
|
|
1113
|
+
const recorded = out.mode === 'ssr' || out.mode === 'static' ? out.mode : undefined;
|
|
1114
|
+
// Full reconcile on every SSR bootstrap (so a later config change converges)
|
|
1115
|
+
// and on either side of a mode switch (so the origins/behaviors move with the
|
|
1116
|
+
// mode). A static stack that stays static keeps the alias-only update below:
|
|
1117
|
+
// `spa` and `defaultRootObject` remain creation-time settings there.
|
|
1118
|
+
if (configured === 'ssr' || (recorded !== undefined && recorded !== configured)) {
|
|
1119
|
+
const changed = await ctx.clients.cloudfront.reconcileDistribution(id, distributionShape(ctx, configured, preview));
|
|
1120
|
+
out.mode = configured;
|
|
1121
|
+
if (changed) {
|
|
1122
|
+
ctx.logger.ok(`reconciled the distribution for ${configured} serving`);
|
|
1123
|
+
const domainName = out.domainName;
|
|
1124
|
+
if (ctx.domain && !preview && typeof domainName === 'string') {
|
|
1125
|
+
ctx.logger.info(` point ${ctx.domain} (CNAME/ALIAS) at ${domainName}`);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
else {
|
|
1129
|
+
ctx.logger.ok(`distribution already matches ${configured} serving`);
|
|
1130
|
+
}
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
// A domain added (or changed) after the first bootstrap must reach the
|
|
1134
|
+
// existing distribution - the certificate node validates the cert, but
|
|
1135
|
+
// only this reconcile attaches the alias + viewer certificate.
|
|
607
1136
|
if (!ctx.domain) {
|
|
608
1137
|
// Deliberately no automatic alias removal: dropping --domain from a
|
|
609
1138
|
// later run must not detach a live site's hostname.
|
|
@@ -681,6 +1210,51 @@ async function ownDeliveryIdsOrRefuse(ctx) {
|
|
|
681
1210
|
// refusal cannot silently start feeding foreign ids to the delete loop.
|
|
682
1211
|
return deliveries.filter((d) => isOwnDelivery(d, ctx.names.deliveryDestination)).map((d) => d.id);
|
|
683
1212
|
}
|
|
1213
|
+
/**
|
|
1214
|
+
* The Web Function's execution role: logging and nothing else. It may create
|
|
1215
|
+
* streams in, and put events to, its own two log groups - both of which are
|
|
1216
|
+
* graph nodes, so no `logs:CreateLogGroup` grant is needed.
|
|
1217
|
+
*/
|
|
1218
|
+
async function applyWebRolePolicy(ctx) {
|
|
1219
|
+
await ctx.clients.iam.putRolePolicy(ctx.names.webRole, 'web', {
|
|
1220
|
+
Version: '2012-10-17',
|
|
1221
|
+
Statement: [
|
|
1222
|
+
{
|
|
1223
|
+
Effect: 'Allow',
|
|
1224
|
+
Action: ['logs:CreateLogStream', 'logs:PutLogEvents'],
|
|
1225
|
+
Resource: [
|
|
1226
|
+
logGroupArn(ctx, ctx.names.webLogGroup),
|
|
1227
|
+
logGroupArn(ctx, ctx.names.webLocalLogGroup),
|
|
1228
|
+
],
|
|
1229
|
+
},
|
|
1230
|
+
],
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
/** IAM role the Lambda Web Function assumes; logging only, by contract. */
|
|
1234
|
+
function webRoleNode() {
|
|
1235
|
+
return {
|
|
1236
|
+
id: 'iam-web-role',
|
|
1237
|
+
dependsOn: ['web-log-group', 'web-local-log-group'],
|
|
1238
|
+
title: 'IAM Web Function role',
|
|
1239
|
+
async read(ctx) {
|
|
1240
|
+
const arn = await ctx.clients.iam.getRoleArn(ctx.names.webRole);
|
|
1241
|
+
if (arn)
|
|
1242
|
+
output(ctx, 'iam-web-role').arn = arn;
|
|
1243
|
+
return Boolean(arn);
|
|
1244
|
+
},
|
|
1245
|
+
async create(ctx) {
|
|
1246
|
+
const arn = await ctx.clients.iam.ensureRole(ctx.names.webRole, LAMBDA_TRUST, `Runtime role for the ${ctx.config.siteName} Web Function`, ctx.tags);
|
|
1247
|
+
await applyWebRolePolicy(ctx);
|
|
1248
|
+
output(ctx, 'iam-web-role').arn = arn;
|
|
1249
|
+
},
|
|
1250
|
+
async update(ctx) {
|
|
1251
|
+
await applyWebRolePolicy(ctx);
|
|
1252
|
+
},
|
|
1253
|
+
async delete(ctx) {
|
|
1254
|
+
await ctx.clients.iam.deleteRole(ctx.names.webRole);
|
|
1255
|
+
},
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
684
1258
|
/** Wire CloudFront access logs to the CloudWatch log group via vended log delivery. */
|
|
685
1259
|
function logDeliveryNode() {
|
|
686
1260
|
async function wire(ctx) {
|
|
@@ -758,22 +1332,16 @@ function bucketPolicyNode() {
|
|
|
758
1332
|
},
|
|
759
1333
|
async create(ctx) {
|
|
760
1334
|
const distArn = String(output(ctx, 'cloudfront-distribution').arn);
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
: `arn:aws:s3:::${ctx.names.bucket}/site/*`,
|
|
772
|
-
Condition: { StringEquals: { 'AWS:SourceArn': distArn } },
|
|
773
|
-
},
|
|
774
|
-
],
|
|
775
|
-
};
|
|
776
|
-
await ctx.clients.s3.putBucketPolicy(ctx.names.bucket, JSON.stringify(policy));
|
|
1335
|
+
await mergeBucketPolicyStatement(ctx, 'AllowCloudFrontRead', {
|
|
1336
|
+
Sid: 'AllowCloudFrontRead',
|
|
1337
|
+
Effect: 'Allow',
|
|
1338
|
+
Principal: { Service: 'cloudfront.amazonaws.com' },
|
|
1339
|
+
Action: ['s3:GetObject'],
|
|
1340
|
+
Resource: ctx.preview
|
|
1341
|
+
? `arn:aws:s3:::${ctx.names.bucket}/previews/*`
|
|
1342
|
+
: `arn:aws:s3:::${ctx.names.bucket}/site/*`,
|
|
1343
|
+
Condition: { StringEquals: { 'AWS:SourceArn': distArn } },
|
|
1344
|
+
});
|
|
777
1345
|
output(ctx, 'bucket-policy').applied = true;
|
|
778
1346
|
},
|
|
779
1347
|
async delete() {
|
|
@@ -796,12 +1364,20 @@ const GITHUB_OIDC_THUMBPRINT = '6938fd4d98bab03faadb97b34396831e3780aea1';
|
|
|
796
1364
|
* document never names a plugin's resource, and `applyOidcRole` rewriting it wholesale
|
|
797
1365
|
* on every `blogwright bootstrap` cannot disturb the plugin's.
|
|
798
1366
|
*/
|
|
799
|
-
function githubOidcRoleNode(preview) {
|
|
1367
|
+
function githubOidcRoleNode(preview, ssr) {
|
|
800
1368
|
const roleName = (ctx) => ctx.names.githubRole;
|
|
801
1369
|
return {
|
|
802
1370
|
id: 'gh-oidc-role',
|
|
803
1371
|
// Production deploys invalidate the distribution, so its ARN must be in state.
|
|
804
|
-
|
|
1372
|
+
// In SSR mode the policy also names the Web Function role's ARN and the
|
|
1373
|
+
// function and endpoint ARNs the service answered with.
|
|
1374
|
+
dependsOn: preview
|
|
1375
|
+
? ['iam-exec-role']
|
|
1376
|
+
: [
|
|
1377
|
+
'iam-exec-role',
|
|
1378
|
+
'cloudfront-distribution',
|
|
1379
|
+
...(ssr ? ['iam-web-role', 'web-function'] : []),
|
|
1380
|
+
],
|
|
805
1381
|
title: 'GitHub OIDC deploy role',
|
|
806
1382
|
async read(ctx) {
|
|
807
1383
|
const arn = await ctx.clients.iam.getRoleArn(roleName(ctx));
|
|
@@ -834,13 +1410,58 @@ export function oidcSubClaim(repo, env, preview) {
|
|
|
834
1410
|
return `repo:${repo}:environment:production`;
|
|
835
1411
|
return `repo:${repo}:ref:refs/heads/main`;
|
|
836
1412
|
}
|
|
1413
|
+
/**
|
|
1414
|
+
* What an SSR deploy needs from Lambda, scoped to this site's function rather
|
|
1415
|
+
* than every Web Function in the account. The function ARN is the one the
|
|
1416
|
+
* service recorded (derived from the name when a run has not recorded it yet,
|
|
1417
|
+
* the same derivation the Lambda-read bucket statement uses); revisions and
|
|
1418
|
+
* endpoints are sub-resources of it. The probes are granted on the endpoint
|
|
1419
|
+
* ARN - the resource the function's own policy admits CloudFront on for the
|
|
1420
|
+
* same action.
|
|
1421
|
+
*/
|
|
1422
|
+
function webFunctionDeployStatements(ctx) {
|
|
1423
|
+
const recorded = output(ctx, 'web-function');
|
|
1424
|
+
const functionArn = typeof recorded.arn === 'string'
|
|
1425
|
+
? recorded.arn
|
|
1426
|
+
: `arn:aws:lambda:${ctx.config.region}:${ctx.accountId}:web-function/${ctx.names.webFunction}`;
|
|
1427
|
+
const endpointArn = typeof recorded.endpointArn === 'string' ? recorded.endpointArn : `${functionArn}/*`;
|
|
1428
|
+
return [
|
|
1429
|
+
{
|
|
1430
|
+
// Revision publishing and the fingerprint lookup.
|
|
1431
|
+
Effect: 'Allow',
|
|
1432
|
+
Action: [
|
|
1433
|
+
'lambda:CreateWebFunctionRevision',
|
|
1434
|
+
'lambda:GetWebFunction*',
|
|
1435
|
+
'lambda:ListWebFunction*',
|
|
1436
|
+
],
|
|
1437
|
+
Resource: [functionArn, `${functionArn}/*`],
|
|
1438
|
+
},
|
|
1439
|
+
{
|
|
1440
|
+
// The preflight and the readiness probe.
|
|
1441
|
+
Effect: 'Allow',
|
|
1442
|
+
Action: ['lambda:InvokeWebFunctionEndpoint'],
|
|
1443
|
+
Resource: [endpointArn],
|
|
1444
|
+
},
|
|
1445
|
+
{
|
|
1446
|
+
Effect: 'Allow',
|
|
1447
|
+
Action: ['iam:PassRole'],
|
|
1448
|
+
Resource: [String(output(ctx, 'iam-web-role').arn)],
|
|
1449
|
+
},
|
|
1450
|
+
];
|
|
1451
|
+
}
|
|
837
1452
|
/** The deploy role's inline policy statements (exported for tests). */
|
|
838
1453
|
export function oidcRolePolicyStatements(ctx) {
|
|
839
1454
|
const statements = [
|
|
840
1455
|
{ Effect: 'Allow', Action: ['sts:GetCallerIdentity'], Resource: '*' },
|
|
841
1456
|
{
|
|
842
1457
|
Effect: 'Allow',
|
|
843
|
-
|
|
1458
|
+
// s3:GetObjectVersion is what reads an artifact a revision is pinned to.
|
|
1459
|
+
Action: [
|
|
1460
|
+
's3:GetObject',
|
|
1461
|
+
's3:PutObject',
|
|
1462
|
+
's3:DeleteObject',
|
|
1463
|
+
...(ctx.config.ssr ? ['s3:GetObjectVersion'] : []),
|
|
1464
|
+
],
|
|
844
1465
|
Resource: `arn:aws:s3:::${ctx.names.bucket}/*`,
|
|
845
1466
|
},
|
|
846
1467
|
{ Effect: 'Allow', Action: ['s3:ListBucket'], Resource: `arn:aws:s3:::${ctx.names.bucket}` },
|
|
@@ -862,6 +1483,7 @@ export function oidcRolePolicyStatements(ctx) {
|
|
|
862
1483
|
],
|
|
863
1484
|
Resource: '*',
|
|
864
1485
|
},
|
|
1486
|
+
...(ctx.config.ssr ? webFunctionDeployStatements(ctx) : []),
|
|
865
1487
|
{
|
|
866
1488
|
Effect: 'Allow',
|
|
867
1489
|
Action: ['logs:FilterLogEvents', 'logs:GetLogEvents'],
|
|
@@ -1001,6 +1623,9 @@ function previewDnsNode() {
|
|
|
1001
1623
|
/** Build the full node set for the current context (production or preview stack). */
|
|
1002
1624
|
export function buildNodes(ctx) {
|
|
1003
1625
|
const hasDomain = Boolean(ctx.domain);
|
|
1626
|
+
// Preview stacks never support SSR (a shared host-routed distribution cannot
|
|
1627
|
+
// front a per-PR endpoint); the mode is refused by the preview verbs too.
|
|
1628
|
+
const ssr = Boolean(ctx.config.ssr) && !ctx.preview;
|
|
1004
1629
|
const nodes = [
|
|
1005
1630
|
bucketNode(),
|
|
1006
1631
|
logGroupNode('microvm-log-group', 'MicroVM log group', (c) => c.names.microvmLogGroup, (c) => c.config.retention.microvmDays),
|
|
@@ -1010,18 +1635,25 @@ export function buildNodes(ctx) {
|
|
|
1010
1635
|
microvmImageNode(),
|
|
1011
1636
|
oacNode(),
|
|
1012
1637
|
routerFunctionNode(ctx.preview),
|
|
1013
|
-
distributionNode(hasDomain, ctx.preview),
|
|
1014
|
-
logDeliveryNode(),
|
|
1015
|
-
bucketPolicyNode(),
|
|
1016
1638
|
];
|
|
1639
|
+
if (ssr) {
|
|
1640
|
+
nodes.push(
|
|
1641
|
+
// The `.local` companion first: Lambda writes in-Region logs there before
|
|
1642
|
+
// centralising them, and would otherwise create it itself, with no
|
|
1643
|
+
// retention and outside the graph.
|
|
1644
|
+
logGroupNode('web-local-log-group', 'Web Function local log group', (c) => c.names.webLocalLogGroup, (c) => c.config.retention.webFunctionDays), logGroupNode('web-log-group', 'Web Function log group', (c) => c.names.webLogGroup, (c) => c.config.retention.webFunctionDays), webRoleNode(), webOacNode(), webCachePolicyNode(), webCodeBucketPolicyNode(), webFunctionNode());
|
|
1645
|
+
}
|
|
1646
|
+
nodes.push(distributionNode(hasDomain, ctx.preview, ssr), logDeliveryNode(), bucketPolicyNode());
|
|
1647
|
+
if (ssr)
|
|
1648
|
+
nodes.push(webFunctionPolicyNode());
|
|
1017
1649
|
if (hasDomain)
|
|
1018
1650
|
nodes.push(certificateNode());
|
|
1019
1651
|
if (ctx.preview) {
|
|
1020
|
-
nodes.push(previewDnsNode(), githubOidcRoleNode(true));
|
|
1652
|
+
nodes.push(previewDnsNode(), githubOidcRoleNode(true, false));
|
|
1021
1653
|
}
|
|
1022
1654
|
else if (ctx.config.githubRepo) {
|
|
1023
1655
|
// staging deploys on push to main; production on release (see the deploy workflows).
|
|
1024
|
-
nodes.push(githubOidcRoleNode(false));
|
|
1656
|
+
nodes.push(githubOidcRoleNode(false, ssr));
|
|
1025
1657
|
}
|
|
1026
1658
|
return nodes;
|
|
1027
1659
|
}
|