blogwright 0.4.0-beta.3 → 0.4.0-beta.5
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 +12 -3
- package/dist/commands.js +225 -14
- 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 +697 -86
- 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
|
|
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
|
|
@@ -22,8 +31,8 @@ export declare function bootstrap(ctx: OpsContext): Promise<void>;
|
|
|
22
31
|
*
|
|
23
32
|
* Reads the bucket, not the plugin registry, so the refusal holds even for
|
|
24
33
|
* a plugin that has since been uninstalled. Runs inside the teardown verbs
|
|
25
|
-
* themselves, not `createContext
|
|
26
|
-
*
|
|
34
|
+
* themselves, not `createContext`, sharing the listing with the post-bootstrap
|
|
35
|
+
* warning while plugin discovery stays lazy - and ahead of
|
|
27
36
|
* `clearRunningMicrovms`/`destroyGraph` in each, so a refusal has zero side
|
|
28
37
|
* effects: nothing is terminated, nothing is deleted.
|
|
29
38
|
*
|
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,15 +49,75 @@ 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) {
|
|
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
|
-
await applyGraph(
|
|
115
|
+
await applyGraph(nodes, ctx);
|
|
56
116
|
ctx.logger.ok(`bootstrap complete for "${ctx.env}"`);
|
|
57
117
|
const domain = ctx.state.resources['cloudfront-distribution']?.domainName;
|
|
58
118
|
if (typeof domain === 'string')
|
|
59
119
|
ctx.logger.info(`Site will be served at https://${domain}`);
|
|
120
|
+
await warnAboutScopedState(ctx);
|
|
60
121
|
}
|
|
61
122
|
/**
|
|
62
123
|
* The prefix every state object - scoped or not - is filed under
|
|
@@ -85,6 +146,27 @@ function scopedStateScopes(env, objects) {
|
|
|
85
146
|
.map((key) => key.slice(prefix.length, -'.json'.length));
|
|
86
147
|
return [...new Set(scopes)].sort();
|
|
87
148
|
}
|
|
149
|
+
/** Listing is strict; each caller decides whether a failure can be recovered. */
|
|
150
|
+
async function listScopedStateScopes(ctx) {
|
|
151
|
+
const objects = await ctx.clients.s3.listObjects(ctx.names.bucket, STATE_PREFIX);
|
|
152
|
+
return scopedStateScopes(ctx.env, objects);
|
|
153
|
+
}
|
|
154
|
+
/** A never-bootstrapped plugin has no scoped key, so cannot produce a warning. */
|
|
155
|
+
async function warnAboutScopedState(ctx) {
|
|
156
|
+
let scopes;
|
|
157
|
+
try {
|
|
158
|
+
scopes = await listScopedStateScopes(ctx);
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
ctx.logger.warn(`could not check plugin state for "${ctx.env}" at s3://${ctx.names.bucket}/${STATE_PREFIX} ` +
|
|
162
|
+
`(bootstrap succeeded): ${String(err)}`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
for (const scope of scopes) {
|
|
166
|
+
ctx.logger.warn(`plugin state for "${scope}" still exists in "${ctx.env}"; ` +
|
|
167
|
+
`run \`blogwright ${scope} bootstrap ${ctx.env}\` to reconcile its resources`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
88
170
|
/**
|
|
89
171
|
* Refuse to destroy the site while any plugin's own state object still
|
|
90
172
|
* exists in the bucket - §State → Scoped state stores' "`blogwright
|
|
@@ -103,8 +185,8 @@ function scopedStateScopes(env, objects) {
|
|
|
103
185
|
*
|
|
104
186
|
* Reads the bucket, not the plugin registry, so the refusal holds even for
|
|
105
187
|
* a plugin that has since been uninstalled. Runs inside the teardown verbs
|
|
106
|
-
* themselves, not `createContext
|
|
107
|
-
*
|
|
188
|
+
* themselves, not `createContext`, sharing the listing with the post-bootstrap
|
|
189
|
+
* warning while plugin discovery stays lazy - and ahead of
|
|
108
190
|
* `clearRunningMicrovms`/`destroyGraph` in each, so a refusal has zero side
|
|
109
191
|
* effects: nothing is terminated, nothing is deleted.
|
|
110
192
|
*
|
|
@@ -134,9 +216,9 @@ function scopedStateScopes(env, objects) {
|
|
|
134
216
|
* `destroy()` run over the entire production graph.
|
|
135
217
|
*/
|
|
136
218
|
export async function assertNoScopedState(ctx) {
|
|
137
|
-
let
|
|
219
|
+
let scopes;
|
|
138
220
|
try {
|
|
139
|
-
|
|
221
|
+
scopes = await listScopedStateScopes(ctx);
|
|
140
222
|
}
|
|
141
223
|
catch (err) {
|
|
142
224
|
// No bucket, no state objects - see this function's doc comment. Matched
|
|
@@ -152,7 +234,6 @@ export async function assertNoScopedState(ctx) {
|
|
|
152
234
|
return;
|
|
153
235
|
throw err;
|
|
154
236
|
}
|
|
155
|
-
const scopes = scopedStateScopes(ctx.env, objects);
|
|
156
237
|
if (scopes.length === 0)
|
|
157
238
|
return;
|
|
158
239
|
// The remedy MUST name the environment. `runPlugin` resolves a plugin
|
|
@@ -186,12 +267,43 @@ export async function destroy(ctx, opts) {
|
|
|
186
267
|
await ctx.store.delete();
|
|
187
268
|
ctx.logger.ok(`destroyed "${ctx.env}"`);
|
|
188
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
|
+
}
|
|
189
294
|
/** Zip the repo, upload it, run the builder MicroVM, and invalidate the cache. */
|
|
190
295
|
export async function deploy(ctx, opts = {}) {
|
|
191
296
|
const startedAt = Date.now();
|
|
192
297
|
const cwd = await findRepoRoot(ctx.ports.fs);
|
|
193
298
|
const hash = await ctx.ports.vcs.revisionHash(cwd);
|
|
194
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
|
+
}
|
|
195
307
|
const files = await listRepoFiles(ctx.ports, cwd, ctx.config.sourceIgnore, ctx.config.sourceInclude);
|
|
196
308
|
ctx.logger.step(`zipping ${files.length} files`);
|
|
197
309
|
const zip = await buildRepoZip(ctx.ports.fs, cwd, files, { [COMMIT_FILE]: hash });
|
|
@@ -205,14 +317,30 @@ export async function deploy(ctx, opts = {}) {
|
|
|
205
317
|
hash,
|
|
206
318
|
sourceKey,
|
|
207
319
|
baseUrl: siteBaseUrl(ctx),
|
|
320
|
+
...(ctx.config.ssr ? { webFunction: webFunctionJob(ctx, hash) } : {}),
|
|
208
321
|
...(opts.refresh ? { refresh: true } : {}),
|
|
209
322
|
});
|
|
210
|
-
|
|
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
|
+
}
|
|
211
334
|
// Production content changed - mirror it to the PDS (non-fatal; see syncAfterDeploy).
|
|
212
335
|
await syncAfterDeploy(ctx);
|
|
213
336
|
const url = siteBaseUrl(ctx);
|
|
214
337
|
const rows = [
|
|
215
|
-
{
|
|
338
|
+
{
|
|
339
|
+
label: 'revision',
|
|
340
|
+
value: outcome
|
|
341
|
+
? `${outcome.revisionId} (${outcome.created ? 'created' : 'unchanged'})`
|
|
342
|
+
: hash,
|
|
343
|
+
},
|
|
216
344
|
{ label: 'environment', value: ctx.env },
|
|
217
345
|
{ label: 'source', value: `${files.length} files, ${(zip.byteLength / 1024).toFixed(0)} KiB` },
|
|
218
346
|
{ label: 'build', value: formatDuration(manifest.durationMs) },
|
|
@@ -224,8 +352,74 @@ export async function deploy(ctx, opts = {}) {
|
|
|
224
352
|
}
|
|
225
353
|
ctx.logger.ok(`deployed ${hash} in ${formatDuration(Date.now() - startedAt)}`);
|
|
226
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
|
+
}
|
|
227
418
|
/** Re-run the builder against an existing source zip for the given hash. */
|
|
228
419
|
export async function rollback(ctx, hash, opts = {}) {
|
|
420
|
+
assertServingModeMatches(ctx);
|
|
421
|
+
if (ctx.config.ssr)
|
|
422
|
+
return rollbackSsr(ctx, hash, opts);
|
|
229
423
|
const sourceKey = `build/${hash}.zip`;
|
|
230
424
|
if (!(await ctx.clients.s3.objectExists(ctx.names.bucket, sourceKey))) {
|
|
231
425
|
throw new Error(`no build artifact at ${sourceKey}; cannot roll back to ${hash}`);
|
|
@@ -254,6 +448,7 @@ function assertPreviewId(id) {
|
|
|
254
448
|
}
|
|
255
449
|
/** Provision the shared preview stack (one CloudFront distribution + host router + OIDC role). */
|
|
256
450
|
export async function previewBootstrap(ctx) {
|
|
451
|
+
assertNotSsrForPreviews(ctx);
|
|
257
452
|
if (!ctx.domain)
|
|
258
453
|
throw new Error('preview bootstrap requires a domain (e.g. preview.example.com)');
|
|
259
454
|
ctx.logger.info(colors.bold(`Bootstrapping preview stack (bucket ${ctx.names.bucket})`));
|
|
@@ -262,6 +457,7 @@ export async function previewBootstrap(ctx) {
|
|
|
262
457
|
}
|
|
263
458
|
/** Build the current repo and publish it to this PR's preview prefix. Prints the URL. */
|
|
264
459
|
export async function previewDeploy(ctx, id, opts = {}) {
|
|
460
|
+
assertNotSsrForPreviews(ctx);
|
|
265
461
|
assertPreviewId(id);
|
|
266
462
|
const startedAt = Date.now();
|
|
267
463
|
const cwd = await findRepoRoot(ctx.ports.fs);
|
|
@@ -325,6 +521,15 @@ export async function previewTeardown(ctx, opts) {
|
|
|
325
521
|
}
|
|
326
522
|
/** Empty the live site/ prefix (leaves infra and build history intact). */
|
|
327
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
|
+
}
|
|
328
533
|
const count = await ctx.clients.s3.deletePrefix(ctx.names.bucket, 'site/');
|
|
329
534
|
ctx.logger.ok(`deleted ${count} object(s) under site/`);
|
|
330
535
|
await invalidateCloudFront(ctx);
|
|
@@ -357,13 +562,19 @@ export async function history(ctx) {
|
|
|
357
562
|
ctx.logger.info(line);
|
|
358
563
|
return;
|
|
359
564
|
}
|
|
360
|
-
// The plain form is the stable contract for CI logs and agents.
|
|
361
|
-
|
|
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' : ''}`));
|
|
362
569
|
for (const m of manifests) {
|
|
363
570
|
const cell = m.status === 'succeeded'
|
|
364
571
|
? colors.green(m.status.padEnd(10))
|
|
365
572
|
: colors.red(m.status.padEnd(10));
|
|
366
|
-
|
|
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}`);
|
|
367
578
|
}
|
|
368
579
|
}
|
|
369
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
|
}
|