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/cli.js
CHANGED
|
@@ -15,6 +15,8 @@ Usage:
|
|
|
15
15
|
Commands:
|
|
16
16
|
init First-run wizard: writes config/production.jsonc
|
|
17
17
|
bootstrap [env] Create/reconcile the infrastructure graph
|
|
18
|
+
(--switch-mode: move a bootstrapped stack
|
|
19
|
+
between static and SSR serving)
|
|
18
20
|
deploy [env] Zip the repo, build in a MicroVM, publish to site/
|
|
19
21
|
rollback <hash> [env] Re-deploy an existing build by hash
|
|
20
22
|
delete [env] Empty the live site/ prefix
|
|
@@ -48,6 +50,10 @@ Options:
|
|
|
48
50
|
--refresh Re-upload every file on deploy, even unchanged ones, so
|
|
49
51
|
metadata fixes (content types, object tags) reach live
|
|
50
52
|
objects the ETag comparison would otherwise skip.
|
|
53
|
+
--switch-mode Allow bootstrap to change a bootstrapped stack between
|
|
54
|
+
static and SSR serving. Without it that cutover is
|
|
55
|
+
refused, because the distribution moves before a deploy
|
|
56
|
+
in the new mode exists.
|
|
51
57
|
--yes Confirm destructive operations
|
|
52
58
|
--help Show this help
|
|
53
59
|
`;
|
|
@@ -219,6 +225,10 @@ export async function main(argv, makeTerminal, makeContext, makeDiscoveryPorts,
|
|
|
219
225
|
identifier: { type: 'string' },
|
|
220
226
|
plain: { type: 'boolean', default: false },
|
|
221
227
|
refresh: { type: 'boolean', default: false },
|
|
228
|
+
// Node's `parseArgs` matches option names literally - `--switch-mode`
|
|
229
|
+
// is declared kebab-case and read back the same way, because the camel
|
|
230
|
+
// form would reject the flag the usage text and docs advertise.
|
|
231
|
+
'switch-mode': { type: 'boolean', default: false },
|
|
222
232
|
yes: { type: 'boolean', default: false },
|
|
223
233
|
help: { type: 'boolean', default: false },
|
|
224
234
|
},
|
|
@@ -343,7 +353,7 @@ export async function main(argv, makeTerminal, makeContext, makeDiscoveryPorts,
|
|
|
343
353
|
});
|
|
344
354
|
switch (command) {
|
|
345
355
|
case 'bootstrap':
|
|
346
|
-
await commands.bootstrap(ctx);
|
|
356
|
+
await commands.bootstrap(ctx, undefined, { switchMode: values['switch-mode'] });
|
|
347
357
|
break;
|
|
348
358
|
case 'deploy':
|
|
349
359
|
await commands.deploy(ctx, { refresh: values.refresh });
|
package/dist/commands.d.ts
CHANGED
|
@@ -2,8 +2,17 @@ import { type ResourceNode } from 'blogwright-core';
|
|
|
2
2
|
import type { OpsContext } from './context.js';
|
|
3
3
|
import { type GraphContext } from './graph.js';
|
|
4
4
|
import { type StatusEntry } from './render.js';
|
|
5
|
+
/**
|
|
6
|
+
* Refuse a silent serving-mode switch on a bootstrapped stack unless
|
|
7
|
+
* `--switch-mode` says the window is acceptable. A mode difference is a
|
|
8
|
+
* serving cutover, not a quiet convergence - the distribution's default
|
|
9
|
+
* behavior moves before a deploy in the new mode exists.
|
|
10
|
+
*/
|
|
11
|
+
export declare function assertModeSwitchAllowed(ctx: OpsContext, switchMode: boolean): void;
|
|
5
12
|
/** Create the full infrastructure graph. */
|
|
6
|
-
export declare function bootstrap(ctx: OpsContext, nodes?: ResourceNode<OpsContext>[]
|
|
13
|
+
export declare function bootstrap(ctx: OpsContext, nodes?: ResourceNode<OpsContext>[], opts?: {
|
|
14
|
+
switchMode?: boolean;
|
|
15
|
+
}): Promise<void>;
|
|
7
16
|
/**
|
|
8
17
|
* Refuse to destroy the site while any plugin's own state object still
|
|
9
18
|
* exists in the bucket - §State → Scoped state stores' "`blogwright
|
package/dist/commands.js
CHANGED
|
@@ -24,12 +24,13 @@ import { AwsError, colors, findRepoRoot } from 'blogwright-core';
|
|
|
24
24
|
* it. Both halves are asserted in `commands.test.ts`.
|
|
25
25
|
*/
|
|
26
26
|
import { syncAfterDeploy } from 'blogwright-pds';
|
|
27
|
-
import { invalidateChanged, invalidateCloudFront, manifestKey, microvmLogGroup, runBuild, } from './deploy.js';
|
|
27
|
+
import { invalidateChanged, invalidateCloudFront, manifestKey, microvmLogGroup, readManifest, runBuild, webFunctionJob, } from './deploy.js';
|
|
28
28
|
import { applyGraph, destroyGraph } from './graph.js';
|
|
29
29
|
import { clearRunningMicrovms } from './microvms.js';
|
|
30
30
|
import { buildNodes, reconcileBuilderImage } from './nodes.js';
|
|
31
|
-
import { formatDuration, logStatusEntries, renderHistoryTable, renderSummary, } from './render.js';
|
|
31
|
+
import { formatDuration, logStatusEntries, historyHasRevisions, NO_REVISION, renderHistoryTable, renderSummary, } from './render.js';
|
|
32
32
|
import { buildRepoZip, COMMIT_FILE, listRepoFiles } from './repo.js';
|
|
33
|
+
import { assertNotSsrForPreviews, preflightWebFunction, recordManifest, publishRecordedRevision, publishWebRevision, readWebBuildResult, requireSsr, warnAboutSsrSeo, } from './web-functions.js';
|
|
33
34
|
/** One line per invalidation outcome, shared by the summary card and logs. */
|
|
34
35
|
function describeInvalidation(inv) {
|
|
35
36
|
if (inv.mode === 'none')
|
|
@@ -48,8 +49,67 @@ function siteBaseUrl(ctx) {
|
|
|
48
49
|
const cf = ctx.state.resources['cloudfront-distribution']?.domainName;
|
|
49
50
|
return typeof cf === 'string' ? `https://${cf}` : undefined;
|
|
50
51
|
}
|
|
52
|
+
/** The serving mode the config asks for. */
|
|
53
|
+
function configuredMode(ctx) {
|
|
54
|
+
return ctx.config.ssr ? 'ssr' : 'static';
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The serving mode the live distribution was left in, or undefined before the
|
|
58
|
+
* first bootstrap. A distribution recorded without a mode was created before
|
|
59
|
+
* SSR existed, so it is static - reading that as "unknown" would let the very
|
|
60
|
+
* stacks the switch guard exists for through it.
|
|
61
|
+
*/
|
|
62
|
+
function recordedMode(ctx) {
|
|
63
|
+
const distribution = ctx.state.resources['cloudfront-distribution'];
|
|
64
|
+
if (typeof distribution?.id !== 'string')
|
|
65
|
+
return undefined;
|
|
66
|
+
return distribution.mode === 'ssr' ? 'ssr' : 'static';
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Refuse to publish in a mode the distribution is not serving. The config and
|
|
70
|
+
* the stack disagree only between editing `ssr` and re-bootstrapping, and a
|
|
71
|
+
* deploy in that gap publishes where nothing serves from - or, static over
|
|
72
|
+
* SSR, deletes the `site/` assets the live revision still references.
|
|
73
|
+
*/
|
|
74
|
+
function assertServingModeMatches(ctx) {
|
|
75
|
+
const recorded = recordedMode(ctx);
|
|
76
|
+
const configured = configuredMode(ctx);
|
|
77
|
+
if (recorded === undefined || recorded === configured)
|
|
78
|
+
return;
|
|
79
|
+
throw new Error(`"${ctx.env}" is configured for ${configured} serving but its distribution serves ` +
|
|
80
|
+
`${recorded}. Run \`blogwright bootstrap ${ctx.env} --switch-mode\` first; nothing was ` +
|
|
81
|
+
'built or published.');
|
|
82
|
+
}
|
|
83
|
+
/** What a mode switch does to the live site while the next deploy has not run. */
|
|
84
|
+
function servingWindow(mode) {
|
|
85
|
+
return mode === 'ssr'
|
|
86
|
+
? 'the distribution answers 503 from the placeholder until the first SSR deploy completes'
|
|
87
|
+
: 'the distribution serves whatever site/ holds until the next static deploy';
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Refuse a silent serving-mode switch on a bootstrapped stack unless
|
|
91
|
+
* `--switch-mode` says the window is acceptable. A mode difference is a
|
|
92
|
+
* serving cutover, not a quiet convergence - the distribution's default
|
|
93
|
+
* behavior moves before a deploy in the new mode exists.
|
|
94
|
+
*/
|
|
95
|
+
export function assertModeSwitchAllowed(ctx, switchMode) {
|
|
96
|
+
const recorded = recordedMode(ctx);
|
|
97
|
+
if (recorded === undefined)
|
|
98
|
+
return;
|
|
99
|
+
const configured = configuredMode(ctx);
|
|
100
|
+
if (recorded === configured)
|
|
101
|
+
return;
|
|
102
|
+
const window = servingWindow(configured);
|
|
103
|
+
if (!switchMode) {
|
|
104
|
+
throw new Error(`refusing to switch "${ctx.env}" from ${recorded} to ${configured} serving: ${window}. ` +
|
|
105
|
+
'Nothing was applied. Re-run with --switch-mode to move the distribution now.');
|
|
106
|
+
}
|
|
107
|
+
ctx.logger.warn(`switching "${ctx.env}" from ${recorded} to ${configured} serving: ${window}`);
|
|
108
|
+
}
|
|
51
109
|
/** Create the full infrastructure graph. */
|
|
52
|
-
export async function bootstrap(ctx, nodes = buildNodes(ctx)) {
|
|
110
|
+
export async function bootstrap(ctx, nodes = buildNodes(ctx), opts = {}) {
|
|
111
|
+
// Refused before any node is applied, so a refusal has no side effects.
|
|
112
|
+
assertModeSwitchAllowed(ctx, opts.switchMode ?? false);
|
|
53
113
|
ctx.logger.info(colors.bold(`Bootstrapping "${ctx.env}" (bucket ${ctx.names.bucket})`));
|
|
54
114
|
// The state bucket must exist before anything else can persist state.
|
|
55
115
|
await applyGraph(nodes, ctx);
|
|
@@ -207,12 +267,43 @@ export async function destroy(ctx, opts) {
|
|
|
207
267
|
await ctx.store.delete();
|
|
208
268
|
ctx.logger.ok(`destroyed "${ctx.env}"`);
|
|
209
269
|
}
|
|
270
|
+
/** The manifest source a publish step records timings under. */
|
|
271
|
+
function manifestSource(ctx, hash, startedAtMs, microvmId) {
|
|
272
|
+
return {
|
|
273
|
+
hash,
|
|
274
|
+
sourceKey: `build/${hash}.zip`,
|
|
275
|
+
startedAt: new Date(startedAtMs),
|
|
276
|
+
microvmId,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Read the agent's SSR result, recording a `failed` manifest when it is not
|
|
281
|
+
* there. A build that reported success but produced no result file has no
|
|
282
|
+
* revision outcome to wait for, and an SSR manifest's status is the outcome of
|
|
283
|
+
* the whole deploy - so the deploy cannot simply leave the hash unrecorded.
|
|
284
|
+
*/
|
|
285
|
+
async function webBuildResultOrFail(ctx, source) {
|
|
286
|
+
try {
|
|
287
|
+
return await readWebBuildResult(ctx, source.hash);
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
await recordManifest(ctx, source, 'failed', undefined, err.message);
|
|
291
|
+
throw err;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
210
294
|
/** Zip the repo, upload it, run the builder MicroVM, and invalidate the cache. */
|
|
211
295
|
export async function deploy(ctx, opts = {}) {
|
|
212
296
|
const startedAt = Date.now();
|
|
213
297
|
const cwd = await findRepoRoot(ctx.ports.fs);
|
|
214
298
|
const hash = await ctx.ports.vcs.revisionHash(cwd);
|
|
215
299
|
ctx.logger.info(colors.bold(`Deploying ${hash} to "${ctx.env}"`));
|
|
300
|
+
assertServingModeMatches(ctx);
|
|
301
|
+
// In SSR mode the endpoint probe is mandatory and comes first: a caller that
|
|
302
|
+
// cannot invoke the endpoint is stopped before anything is built or published.
|
|
303
|
+
if (ctx.config.ssr) {
|
|
304
|
+
warnAboutSsrSeo(ctx);
|
|
305
|
+
await preflightWebFunction(ctx);
|
|
306
|
+
}
|
|
216
307
|
const files = await listRepoFiles(ctx.ports, cwd, ctx.config.sourceIgnore, ctx.config.sourceInclude);
|
|
217
308
|
ctx.logger.step(`zipping ${files.length} files`);
|
|
218
309
|
const zip = await buildRepoZip(ctx.ports.fs, cwd, files, { [COMMIT_FILE]: hash });
|
|
@@ -226,14 +317,30 @@ export async function deploy(ctx, opts = {}) {
|
|
|
226
317
|
hash,
|
|
227
318
|
sourceKey,
|
|
228
319
|
baseUrl: siteBaseUrl(ctx),
|
|
320
|
+
...(ctx.config.ssr ? { webFunction: webFunctionJob(ctx, hash) } : {}),
|
|
229
321
|
...(opts.refresh ? { refresh: true } : {}),
|
|
230
322
|
});
|
|
231
|
-
|
|
323
|
+
let invalidation;
|
|
324
|
+
let outcome;
|
|
325
|
+
if (ctx.config.ssr) {
|
|
326
|
+
const source = manifestSource(ctx, hash, startedAt, manifest.microvmId);
|
|
327
|
+
const result = await webBuildResultOrFail(ctx, source);
|
|
328
|
+
outcome = await publishWebRevision(ctx, source, result);
|
|
329
|
+
invalidation = outcome.invalidation;
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
invalidation = await invalidateChanged(ctx, hash);
|
|
333
|
+
}
|
|
232
334
|
// Production content changed - mirror it to the PDS (non-fatal; see syncAfterDeploy).
|
|
233
335
|
await syncAfterDeploy(ctx);
|
|
234
336
|
const url = siteBaseUrl(ctx);
|
|
235
337
|
const rows = [
|
|
236
|
-
{
|
|
338
|
+
{
|
|
339
|
+
label: 'revision',
|
|
340
|
+
value: outcome
|
|
341
|
+
? `${outcome.revisionId} (${outcome.created ? 'created' : 'unchanged'})`
|
|
342
|
+
: hash,
|
|
343
|
+
},
|
|
237
344
|
{ label: 'environment', value: ctx.env },
|
|
238
345
|
{ label: 'source', value: `${files.length} files, ${(zip.byteLength / 1024).toFixed(0)} KiB` },
|
|
239
346
|
{ label: 'build', value: formatDuration(manifest.durationMs) },
|
|
@@ -245,8 +352,74 @@ export async function deploy(ctx, opts = {}) {
|
|
|
245
352
|
}
|
|
246
353
|
ctx.logger.ok(`deployed ${hash} in ${formatDuration(Date.now() - startedAt)}`);
|
|
247
354
|
}
|
|
355
|
+
/**
|
|
356
|
+
* SSR rollback: reproduce a recorded revision from its artifact and settings
|
|
357
|
+
* without a rebuild, falling back to the build path when the manifest has no
|
|
358
|
+
* `webFunction` block, the recorded artifact version is gone, or `staticDir`
|
|
359
|
+
* means the artifact does not carry the assets its pages reference.
|
|
360
|
+
*/
|
|
361
|
+
async function rollbackSsr(ctx, hash, opts = {}) {
|
|
362
|
+
const startedAt = Date.now();
|
|
363
|
+
warnAboutSsrSeo(ctx);
|
|
364
|
+
const config = requireSsr(ctx);
|
|
365
|
+
const manifest = await readManifest(ctx, hash);
|
|
366
|
+
const recorded = manifest?.webFunction;
|
|
367
|
+
// With staticDir configured the artifact cannot reproduce the rollback at all
|
|
368
|
+
// (it carries only the server), so the version check is not worth a request.
|
|
369
|
+
const artifactGone = recorded && config.staticDir === undefined
|
|
370
|
+
? !(await ctx.clients.s3.objectVersionExists(ctx.names.bucket, recorded.artifactKey, recorded.artifactVersionId))
|
|
371
|
+
: false;
|
|
372
|
+
const rebuild = !recorded || config.staticDir !== undefined || artifactGone;
|
|
373
|
+
// Both paths probe: the endpoint is the only evidence that a rollback serves.
|
|
374
|
+
await preflightWebFunction(ctx);
|
|
375
|
+
let outcome;
|
|
376
|
+
if (rebuild) {
|
|
377
|
+
if (!recorded)
|
|
378
|
+
ctx.logger.warn(`${hash} has no webFunction manifest block - rebuilding`);
|
|
379
|
+
else if (config.staticDir !== undefined) {
|
|
380
|
+
ctx.logger.warn('ssr.staticDir is configured - rebuilding so its assets are republished');
|
|
381
|
+
}
|
|
382
|
+
else
|
|
383
|
+
ctx.logger.warn(`artifact version ${recorded.artifactVersionId} is gone - rebuilding`);
|
|
384
|
+
const sourceKey = `build/${hash}.zip`;
|
|
385
|
+
if (!(await ctx.clients.s3.objectExists(ctx.names.bucket, sourceKey))) {
|
|
386
|
+
throw new Error(`no build artifact at ${sourceKey}; cannot roll back to ${hash}`);
|
|
387
|
+
}
|
|
388
|
+
await reconcileBuilderImage(ctx);
|
|
389
|
+
const build = await runBuild(ctx, {
|
|
390
|
+
hash,
|
|
391
|
+
sourceKey,
|
|
392
|
+
baseUrl: siteBaseUrl(ctx),
|
|
393
|
+
webFunction: webFunctionJob(ctx, hash),
|
|
394
|
+
...(opts.refresh ? { refresh: true } : {}),
|
|
395
|
+
});
|
|
396
|
+
const source = manifestSource(ctx, hash, startedAt, build.microvmId);
|
|
397
|
+
const result = await webBuildResultOrFail(ctx, source);
|
|
398
|
+
outcome = await publishWebRevision(ctx, source, result);
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
outcome = await publishRecordedRevision(ctx, {
|
|
402
|
+
hash,
|
|
403
|
+
sourceKey: `build/${hash}.zip`,
|
|
404
|
+
startedAt: new Date(startedAt),
|
|
405
|
+
microvmId: 'rollback',
|
|
406
|
+
}, recorded);
|
|
407
|
+
}
|
|
408
|
+
ctx.logger.info(` revision ${outcome.revisionId} (${outcome.created ? 'created' : 'unchanged'}), ` +
|
|
409
|
+
`${describeInvalidation(outcome.invalidation)}`);
|
|
410
|
+
// A rollback changes production content too, but the PDS mirrors the *working tree*
|
|
411
|
+
// content, which a rollback does not restore - so only warn about the divergence.
|
|
412
|
+
if (ctx.env === 'production' && ctx.config.pds) {
|
|
413
|
+
ctx.logger.warn('rollback does not sync the PDS (records mirror the current repo content); ' +
|
|
414
|
+
'check out the rolled-back revision and run `blogwright pds sync` if needed');
|
|
415
|
+
}
|
|
416
|
+
ctx.logger.ok(`rolled back to ${hash} in ${formatDuration(Date.now() - startedAt)}`);
|
|
417
|
+
}
|
|
248
418
|
/** Re-run the builder against an existing source zip for the given hash. */
|
|
249
419
|
export async function rollback(ctx, hash, opts = {}) {
|
|
420
|
+
assertServingModeMatches(ctx);
|
|
421
|
+
if (ctx.config.ssr)
|
|
422
|
+
return rollbackSsr(ctx, hash, opts);
|
|
250
423
|
const sourceKey = `build/${hash}.zip`;
|
|
251
424
|
if (!(await ctx.clients.s3.objectExists(ctx.names.bucket, sourceKey))) {
|
|
252
425
|
throw new Error(`no build artifact at ${sourceKey}; cannot roll back to ${hash}`);
|
|
@@ -275,6 +448,7 @@ function assertPreviewId(id) {
|
|
|
275
448
|
}
|
|
276
449
|
/** Provision the shared preview stack (one CloudFront distribution + host router + OIDC role). */
|
|
277
450
|
export async function previewBootstrap(ctx) {
|
|
451
|
+
assertNotSsrForPreviews(ctx);
|
|
278
452
|
if (!ctx.domain)
|
|
279
453
|
throw new Error('preview bootstrap requires a domain (e.g. preview.example.com)');
|
|
280
454
|
ctx.logger.info(colors.bold(`Bootstrapping preview stack (bucket ${ctx.names.bucket})`));
|
|
@@ -283,6 +457,7 @@ export async function previewBootstrap(ctx) {
|
|
|
283
457
|
}
|
|
284
458
|
/** Build the current repo and publish it to this PR's preview prefix. Prints the URL. */
|
|
285
459
|
export async function previewDeploy(ctx, id, opts = {}) {
|
|
460
|
+
assertNotSsrForPreviews(ctx);
|
|
286
461
|
assertPreviewId(id);
|
|
287
462
|
const startedAt = Date.now();
|
|
288
463
|
const cwd = await findRepoRoot(ctx.ports.fs);
|
|
@@ -346,6 +521,15 @@ export async function previewTeardown(ctx, opts) {
|
|
|
346
521
|
}
|
|
347
522
|
/** Empty the live site/ prefix (leaves infra and build history intact). */
|
|
348
523
|
export async function deleteSite(ctx) {
|
|
524
|
+
if (ctx.config.ssr) {
|
|
525
|
+
// Emptying site/ cannot unpublish a revision: the app renders every page
|
|
526
|
+
// the revision serves, and its routes would keep answering from a server
|
|
527
|
+
// whose static assets are gone.
|
|
528
|
+
throw new Error(`refusing to delete site/ for "${ctx.env}": the site is served by a Lambda Web Function ` +
|
|
529
|
+
'revision, and emptying site/ cannot unpublish it. Deploy a new revision (or roll back), ' +
|
|
530
|
+
`or switch the stack back to static serving with \`blogwright bootstrap ${ctx.env} ` +
|
|
531
|
+
'--switch-mode` and deploy before deleting.');
|
|
532
|
+
}
|
|
349
533
|
const count = await ctx.clients.s3.deletePrefix(ctx.names.bucket, 'site/');
|
|
350
534
|
ctx.logger.ok(`deleted ${count} object(s) under site/`);
|
|
351
535
|
await invalidateCloudFront(ctx);
|
|
@@ -378,13 +562,19 @@ export async function history(ctx) {
|
|
|
378
562
|
ctx.logger.info(line);
|
|
379
563
|
return;
|
|
380
564
|
}
|
|
381
|
-
// The plain form is the stable contract for CI logs and agents.
|
|
382
|
-
|
|
565
|
+
// The plain form is the stable contract for CI logs and agents. The revision
|
|
566
|
+
// column is appended only once a hash recorded one, so static output is unchanged.
|
|
567
|
+
const revisions = historyHasRevisions(manifests);
|
|
568
|
+
ctx.logger.info(colors.bold(`hash status finished duration${revisions ? ' revision' : ''}`));
|
|
383
569
|
for (const m of manifests) {
|
|
384
570
|
const cell = m.status === 'succeeded'
|
|
385
571
|
? colors.green(m.status.padEnd(10))
|
|
386
572
|
: colors.red(m.status.padEnd(10));
|
|
387
|
-
|
|
573
|
+
const duration = `${(m.durationMs / 1000).toFixed(0)}s`;
|
|
574
|
+
const revision = revisions
|
|
575
|
+
? ` ${duration.padEnd(8)} ${m.webFunction?.revisionId ?? NO_REVISION}`
|
|
576
|
+
: ` ${duration}`;
|
|
577
|
+
ctx.logger.info(`${m.hash.padEnd(13)} ${cell} ${m.finishedAt}${revision}`);
|
|
388
578
|
}
|
|
389
579
|
}
|
|
390
580
|
/** Show CloudWatch build logs for a given hash. */
|
package/dist/deploy.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { type Microvm, type RunMicrovmInput } from 'blogwright-core';
|
|
1
|
+
import { type EntryShim, type Microvm, type RunMicrovmInput } from 'blogwright-core';
|
|
2
2
|
import type { OpsContext } from './context.js';
|
|
3
|
+
import type { WebFunctionManifest } from './web-functions.js';
|
|
3
4
|
export interface DeployManifest {
|
|
4
5
|
hash: string;
|
|
5
6
|
sourceKey: string;
|
|
@@ -9,7 +10,46 @@ export interface DeployManifest {
|
|
|
9
10
|
durationMs: number;
|
|
10
11
|
microvmId: string;
|
|
11
12
|
message?: string;
|
|
13
|
+
/**
|
|
14
|
+
* The last revision verified for this hash (or found already serving).
|
|
15
|
+
* Rollback reads it to reproduce the revision a hash is pinned to. A
|
|
16
|
+
* `failed` manifest carries it only when an earlier outcome for the same
|
|
17
|
+
* hash was verified - see {@link writeManifest}.
|
|
18
|
+
*/
|
|
19
|
+
webFunction?: WebFunctionManifest;
|
|
12
20
|
}
|
|
21
|
+
/** Read one hash's deployment manifest, or undefined when there is none. */
|
|
22
|
+
export declare function readManifest(ctx: OpsContext, hash: string): Promise<DeployManifest | undefined>;
|
|
23
|
+
/**
|
|
24
|
+
* Write a hash's manifest and return what was written. In SSR mode a `failed`
|
|
25
|
+
* outcome keeps the `webFunction` block the hash's manifest already carried:
|
|
26
|
+
* that block is the only record of the artifact version and settings a
|
|
27
|
+
* verified revision was created with, and a failed redeploy or rollback of the
|
|
28
|
+
* same hash must not erase what `rollback` reproduces it from.
|
|
29
|
+
*/
|
|
30
|
+
export declare function writeManifest(ctx: OpsContext, manifest: DeployManifest, opts?: {
|
|
31
|
+
ssr?: boolean | undefined;
|
|
32
|
+
}): Promise<DeployManifest>;
|
|
33
|
+
/** Artifact and result keys for one deploy hash. */
|
|
34
|
+
export declare function artifactKeys(hash: string): {
|
|
35
|
+
artifactKey: string;
|
|
36
|
+
resultKey: string;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* The pending-job half of an SSR build: what to package, what to call the
|
|
40
|
+
* artifact, and where the agent writes the result the CLI's publish step reads.
|
|
41
|
+
*/
|
|
42
|
+
export interface WebFunctionJob {
|
|
43
|
+
packageDir: string;
|
|
44
|
+
entryPoint: string;
|
|
45
|
+
/** The generated entry shim the agent adds to the package, when `ssr.shim` is set. */
|
|
46
|
+
shim?: EntryShim | undefined;
|
|
47
|
+
staticDir?: string | undefined;
|
|
48
|
+
artifactKey: string;
|
|
49
|
+
resultKey: string;
|
|
50
|
+
}
|
|
51
|
+
/** The SSR packaging job for a hash, from the environment's `ssr` block. */
|
|
52
|
+
export declare function webFunctionJob(ctx: OpsContext, hash: string): WebFunctionJob;
|
|
13
53
|
/** Terminal outcome the log poller extracts from the agent's build markers. */
|
|
14
54
|
export interface AgentStatus {
|
|
15
55
|
state: 'idle' | 'building' | 'done' | 'failed';
|
|
@@ -55,6 +95,12 @@ export declare function runBuild(ctx: OpsContext, opts: {
|
|
|
55
95
|
objectTags?: Record<string, string> | undefined;
|
|
56
96
|
/** Re-upload every file, so metadata-only fixes (content type, tags) land. */
|
|
57
97
|
refresh?: boolean | undefined;
|
|
98
|
+
/**
|
|
99
|
+
* Package the output as a Lambda Web Function revision artifact. When set,
|
|
100
|
+
* the manifest is left to the publish step: the build outcome alone is not
|
|
101
|
+
* the deploy outcome.
|
|
102
|
+
*/
|
|
103
|
+
webFunction?: WebFunctionJob | undefined;
|
|
58
104
|
}): Promise<DeployManifest>;
|
|
59
105
|
export declare function invalidateCloudFront(ctx: OpsContext, paths?: string[]): Promise<void>;
|
|
60
106
|
/** What a deploy's cache invalidation actually did, for the deploy summary. */
|
package/dist/deploy.js
CHANGED
|
@@ -1,7 +1,56 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { AwsError, networkConnectors, pollUntil, sleep, } from 'blogwright-core';
|
|
2
|
+
import { AwsError, entryShim, networkConnectors, pollUntil, sleep, } from 'blogwright-core';
|
|
3
3
|
import { formatDuration, spinnerFrame } from './render.js';
|
|
4
4
|
import { resolveSeo } from './seo.js';
|
|
5
|
+
/** Read one hash's deployment manifest, or undefined when there is none. */
|
|
6
|
+
export async function readManifest(ctx, hash) {
|
|
7
|
+
const text = await ctx.clients.s3.getObjectText(ctx.names.bucket, manifestKey(hash));
|
|
8
|
+
if (!text)
|
|
9
|
+
return undefined;
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(text);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Write a hash's manifest and return what was written. In SSR mode a `failed`
|
|
19
|
+
* outcome keeps the `webFunction` block the hash's manifest already carried:
|
|
20
|
+
* that block is the only record of the artifact version and settings a
|
|
21
|
+
* verified revision was created with, and a failed redeploy or rollback of the
|
|
22
|
+
* same hash must not erase what `rollback` reproduces it from.
|
|
23
|
+
*/
|
|
24
|
+
export async function writeManifest(ctx, manifest, opts = {}) {
|
|
25
|
+
let record = manifest;
|
|
26
|
+
if (opts.ssr && manifest.status === 'failed' && !manifest.webFunction) {
|
|
27
|
+
const verified = (await readManifest(ctx, manifest.hash))?.webFunction;
|
|
28
|
+
if (verified)
|
|
29
|
+
record = { ...manifest, webFunction: verified };
|
|
30
|
+
}
|
|
31
|
+
await ctx.clients.s3.putObject(ctx.names.bucket, manifestKey(manifest.hash), JSON.stringify(record, null, 2), 'application/json');
|
|
32
|
+
return record;
|
|
33
|
+
}
|
|
34
|
+
/** Artifact and result keys for one deploy hash. */
|
|
35
|
+
export function artifactKeys(hash) {
|
|
36
|
+
return { artifactKey: `web/artifacts/${hash}.zip`, resultKey: `web/results/${hash}.json` };
|
|
37
|
+
}
|
|
38
|
+
/** The SSR packaging job for a hash, from the environment's `ssr` block. */
|
|
39
|
+
export function webFunctionJob(ctx, hash) {
|
|
40
|
+
const ssr = ctx.config.ssr;
|
|
41
|
+
if (!ssr)
|
|
42
|
+
throw new Error(`environment "${ctx.env}" has no config.ssr block`);
|
|
43
|
+
const { artifactKey, resultKey } = artifactKeys(hash);
|
|
44
|
+
const shim = entryShim(ssr);
|
|
45
|
+
return {
|
|
46
|
+
packageDir: ssr.packageDir,
|
|
47
|
+
entryPoint: ssr.entryPoint,
|
|
48
|
+
...(shim !== undefined ? { shim } : {}),
|
|
49
|
+
...(ssr.staticDir !== undefined ? { staticDir: ssr.staticDir } : {}),
|
|
50
|
+
artifactKey,
|
|
51
|
+
resultKey,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
5
54
|
function manifestKey(hash) {
|
|
6
55
|
return `build/manifests/${hash}.json`;
|
|
7
56
|
}
|
|
@@ -130,7 +179,9 @@ export async function pollBuild(ctx, hash, startTime, endpoint, token) {
|
|
|
130
179
|
*/
|
|
131
180
|
export async function runBuild(ctx, opts) {
|
|
132
181
|
const sitePrefix = opts.sitePrefix ?? 'site/';
|
|
133
|
-
|
|
182
|
+
// robots.txt/sitemap.xml are the application's routes in SSR mode, so no SEO
|
|
183
|
+
// payload is sent (and the agent writes none).
|
|
184
|
+
const seo = opts.webFunction ? {} : resolveSeo(ctx, opts.baseUrl);
|
|
134
185
|
const pendingKey = `build/pending/${opts.target ?? 'site'}.json`;
|
|
135
186
|
const imageArn = ctx.state.resources['microvm-image']?.arn;
|
|
136
187
|
const execRoleArn = ctx.state.resources['iam-exec-role']?.arn;
|
|
@@ -175,6 +226,7 @@ export async function runBuild(ctx, opts) {
|
|
|
175
226
|
distDir: ctx.config.paths.dist,
|
|
176
227
|
objectTags: opts.objectTags ?? ctx.tags,
|
|
177
228
|
...(opts.refresh ? { refresh: true } : {}),
|
|
229
|
+
...(opts.webFunction ? { webFunction: opts.webFunction } : {}),
|
|
178
230
|
...seo,
|
|
179
231
|
}), 'application/json');
|
|
180
232
|
// Confirm the MicroVM actually reached RUNNING (a FAILED launch must not fall through
|
|
@@ -216,7 +268,12 @@ export async function runBuild(ctx, opts) {
|
|
|
216
268
|
microvmId: run.microvmId,
|
|
217
269
|
...(result.message ? { message: result.message } : {}),
|
|
218
270
|
};
|
|
219
|
-
|
|
271
|
+
// In SSR mode a successful build is NOT yet a successful deploy: the publish
|
|
272
|
+
// step writes the manifest once the revision outcome is known. A failed build
|
|
273
|
+
// has no revision to wait for, so it is recorded here.
|
|
274
|
+
if (manifest.status === 'failed' || !opts.webFunction) {
|
|
275
|
+
await writeManifest(ctx, manifest, { ssr: Boolean(opts.webFunction) });
|
|
276
|
+
}
|
|
220
277
|
if (manifest.status === 'failed') {
|
|
221
278
|
throw new Error(`build failed for ${opts.hash}${manifest.message ? `: ${manifest.message}` : ''}`);
|
|
222
279
|
}
|