@patronage/factory-ci 1.0.0-alpha.32 → 1.0.0-alpha.34
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 +67 -14
- package/dist/.build-fingerprint.json +1 -1
- package/dist/alchemy/index.d.ts +211 -33
- package/dist/alchemy/index.js +725 -254
- package/dist/{execute-alchemy-entry-Dbna-8xq.js → execute-alchemy-entry-BZpk0eOQ.js} +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.js +25 -9
- package/package.json +4 -3
- package/src/alchemy/first-deploy-authorization.ts +49 -7
- package/src/alchemy/index.ts +9 -0
- package/src/alchemy/lifecycle.ts +50 -37
- package/src/alchemy/local-emulation.ts +89 -0
- package/src/alchemy/plan-effects.ts +388 -66
- package/src/alchemy/retained-identity.ts +9 -10
- package/src/alchemy/stage-policy.ts +24 -1
- package/src/alchemy/state-tree.ts +168 -0
- package/src/alchemy-baseline.ts +1 -1
- package/src/merge-freeze-job.ts +24 -8
package/dist/alchemy/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { i as isLocalPreviewStage, n as executeAlchemyEntry, r as resolveAlchemyChildCwd, s as ALCHEMY_BASELINE } from "../execute-alchemy-entry-
|
|
1
|
+
import { i as isLocalPreviewStage, n as executeAlchemyEntry, r as resolveAlchemyChildCwd, s as ALCHEMY_BASELINE } from "../execute-alchemy-entry-BZpk0eOQ.js";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
@@ -9,6 +9,8 @@ import { tryFindProviderByType } from "alchemy/Provider";
|
|
|
9
9
|
import * as Effect from "effect/Effect";
|
|
10
10
|
import * as Layer from "effect/Layer";
|
|
11
11
|
import * as Option from "effect/Option";
|
|
12
|
+
import { State } from "alchemy/State";
|
|
13
|
+
import { allStages, deleteState } from "alchemy/State/Tree";
|
|
12
14
|
import { isDeepStrictEqual } from "node:util";
|
|
13
15
|
//#region src/alchemy/evaluate-stack.ts
|
|
14
16
|
var EvaluationProviders = class extends Alchemy.ProviderCollection()("factory-ci/evaluate-stack") {};
|
|
@@ -90,15 +92,14 @@ var RetainedIdentityError = class extends Error {
|
|
|
90
92
|
}
|
|
91
93
|
};
|
|
92
94
|
/**
|
|
93
|
-
* Read
|
|
95
|
+
* Read a persisted-state document into a snapshot of one stage.
|
|
94
96
|
*
|
|
95
|
-
*
|
|
96
|
-
* (`{ resources: [{ stack, stage, fqn, state }] }`);
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* the stack and stage the markers are about.
|
|
97
|
+
* The persisted-state document holds every record in scope
|
|
98
|
+
* (`{ resources: [{ stack, stage, fqn, state }] }`); a single-record document
|
|
99
|
+
* is not this shape. A document covering the whole estate holds one fqn once
|
|
100
|
+
* per stage, so a marker naming it would match whichever record sorted
|
|
101
|
+
* first — a `prod` identity asserted against a `dev` record. `scope` narrows
|
|
102
|
+
* the document to the stack and stage the markers are about.
|
|
102
103
|
*
|
|
103
104
|
* Throws on anything that is not a resource inventory, and on a scoped
|
|
104
105
|
* document that still records one fqn twice. A state this module cannot read
|
|
@@ -162,113 +163,6 @@ const assertRetainedIdentities = ({ after, before, markers }) => {
|
|
|
162
163
|
if (violations.length > 0) throw new RetainedIdentityError(violations);
|
|
163
164
|
};
|
|
164
165
|
//#endregion
|
|
165
|
-
//#region src/alchemy/stage-policy.ts
|
|
166
|
-
/**
|
|
167
|
-
* Stage and stack pairing, and protected-stage policy, from a project-supplied
|
|
168
|
-
* table (#991).
|
|
169
|
-
*
|
|
170
|
-
* Two projects wrote the same three questions twice — is this stage protected,
|
|
171
|
-
* is it disposable, and may this stack run it — and each answered them with its
|
|
172
|
-
* own stage names hard-coded into the answer. The names differ; the questions
|
|
173
|
-
* do not. This module owns the questions. A project supplies the table and gets
|
|
174
|
-
* decisions back, so no project, stack, or stage name appears here.
|
|
175
|
-
*
|
|
176
|
-
* The disposable grammar this package already owns (`disposable-stage.ts`) is
|
|
177
|
-
* available as a matcher rather than assumed, because a project's disposable
|
|
178
|
-
* set is wider than that one grammar.
|
|
179
|
-
*/
|
|
180
|
-
/** Every stage the shared disposable grammar recognises. */
|
|
181
|
-
const LOCAL_PREVIEW_STAGES = (stage) => isLocalPreviewStage(stage);
|
|
182
|
-
/**
|
|
183
|
-
* Test a pattern without carrying state between calls.
|
|
184
|
-
*
|
|
185
|
-
* A `RegExp` with `g` or `y` advances `lastIndex` on every match, so the same
|
|
186
|
-
* pattern answers "yes" and then "no" for the same stage. A policy that
|
|
187
|
-
* alternates is worse than one that always refuses, so a flagged pattern is
|
|
188
|
-
* tested through a fresh copy whose `lastIndex` starts at zero. The copy
|
|
189
|
-
* carries the complete flags: `y` anchors the match at `lastIndex`, so
|
|
190
|
-
* dropping it would let `/prod/y` accept a stage named `pre-prod` that the
|
|
191
|
-
* project's own pattern rejects. The caller's own `RegExp` is never written
|
|
192
|
-
* to, so a frozen one still works.
|
|
193
|
-
*/
|
|
194
|
-
const statelessTest$1 = (pattern, stage) => pattern.global || pattern.sticky ? new RegExp(pattern.source, pattern.flags).test(stage) : pattern.test(stage);
|
|
195
|
-
const stageMatches = (matcher, stage) => {
|
|
196
|
-
if (typeof matcher === "function") return matcher(stage);
|
|
197
|
-
if (Array.isArray(matcher)) return matcher.includes(stage);
|
|
198
|
-
return statelessTest$1(matcher, stage);
|
|
199
|
-
};
|
|
200
|
-
/** A matcher that accepts a stage any of `matchers` accepts. */
|
|
201
|
-
const anyStage = (...matchers) => (stage) => matchers.some((matcher) => stageMatches(matcher, stage));
|
|
202
|
-
/**
|
|
203
|
-
* A policy refusal. The `code` is the stable part: a project maps it onto its
|
|
204
|
-
* own lifecycle failure envelope, and the message is for a human.
|
|
205
|
-
*/
|
|
206
|
-
var StagePolicyError = class extends Error {
|
|
207
|
-
code;
|
|
208
|
-
constructor(code, message) {
|
|
209
|
-
super(message);
|
|
210
|
-
this.name = "StagePolicyError";
|
|
211
|
-
this.code = code;
|
|
212
|
-
}
|
|
213
|
-
};
|
|
214
|
-
const assertTable = (table) => {
|
|
215
|
-
const seen = /* @__PURE__ */ new Set();
|
|
216
|
-
for (const entry of table.stacks ?? []) {
|
|
217
|
-
if (!entry.stack) throw new TypeError("Every stack ownership entry needs a stack name.");
|
|
218
|
-
if (seen.has(entry.stack)) throw new TypeError(`Stack "${entry.stack}" appears twice in the stack registry.`);
|
|
219
|
-
seen.add(entry.stack);
|
|
220
|
-
}
|
|
221
|
-
};
|
|
222
|
-
/**
|
|
223
|
-
* Read a project's stage table and answer the stage questions from it.
|
|
224
|
-
*
|
|
225
|
-
* A stage the table classifies as both protected and disposable is a table
|
|
226
|
-
* defect, not a stage a caller may act on, so every query on it throws.
|
|
227
|
-
*/
|
|
228
|
-
const stagePolicy = (table) => {
|
|
229
|
-
assertTable(table);
|
|
230
|
-
const classify = (stage) => {
|
|
231
|
-
const isProtected = stageMatches(table.stages.protected, stage);
|
|
232
|
-
const isDisposable = stageMatches(table.stages.disposable, stage);
|
|
233
|
-
if (isProtected && isDisposable) throw new StagePolicyError("AMBIGUOUS_STAGE", `Stage "${stage}" is declared both protected and disposable.`);
|
|
234
|
-
return {
|
|
235
|
-
isDisposable,
|
|
236
|
-
isProtected
|
|
237
|
-
};
|
|
238
|
-
};
|
|
239
|
-
const isProtectedStage = (stage) => classify(stage).isProtected;
|
|
240
|
-
const isDisposableStage = (stage) => classify(stage).isDisposable;
|
|
241
|
-
const isKnownStage = (stage) => {
|
|
242
|
-
const { isDisposable, isProtected } = classify(stage);
|
|
243
|
-
return isProtected || isDisposable;
|
|
244
|
-
};
|
|
245
|
-
const assertKnownStage = (stage) => {
|
|
246
|
-
if (!isKnownStage(stage)) throw new StagePolicyError("UNKNOWN_STAGE", `Unsupported Alchemy stage: ${stage}`);
|
|
247
|
-
};
|
|
248
|
-
const assertDestructiveStage = (stage) => {
|
|
249
|
-
assertKnownStage(stage);
|
|
250
|
-
if (isProtectedStage(stage)) throw new StagePolicyError("PROTECTED_STAGE", `Refusing destructive operation for protected stage: ${stage}`);
|
|
251
|
-
};
|
|
252
|
-
const assertStackOwnsStage = (stack, stage) => {
|
|
253
|
-
assertKnownStage(stage);
|
|
254
|
-
const registry = table.stacks;
|
|
255
|
-
if (registry === void 0) return;
|
|
256
|
-
const entry = registry.find((candidate) => candidate.stack === stack);
|
|
257
|
-
if (entry === void 0) throw new StagePolicyError("UNKNOWN_STACK", `Unsupported Alchemy stack: ${stack}`);
|
|
258
|
-
const owner = registry.find((candidate) => candidate.stack !== stack && candidate.claims !== void 0 && stageMatches(candidate.claims, stage));
|
|
259
|
-
if (owner !== void 0) throw new StagePolicyError("STAGE_NOT_OWNED", `Stage ${stage} belongs only to stack ${owner.stack}, not ${stack}.`);
|
|
260
|
-
if (entry.stages !== void 0 && !stageMatches(entry.stages, stage)) throw new StagePolicyError("STAGE_NOT_OWNED", `Stack ${stack} does not run stage ${stage}.`);
|
|
261
|
-
};
|
|
262
|
-
return {
|
|
263
|
-
assertDestructiveStage,
|
|
264
|
-
assertKnownStage,
|
|
265
|
-
assertStackOwnsStage,
|
|
266
|
-
isDisposableStage,
|
|
267
|
-
isKnownStage,
|
|
268
|
-
isProtectedStage
|
|
269
|
-
};
|
|
270
|
-
};
|
|
271
|
-
//#endregion
|
|
272
166
|
//#region src/alchemy/credential-preflight.ts
|
|
273
167
|
/**
|
|
274
168
|
* Cloudflare credential preflight for an Alchemy deploy.
|
|
@@ -437,6 +331,201 @@ const credentialPreflight = async ({ accountId, environment, fetchImplementation
|
|
|
437
331
|
};
|
|
438
332
|
};
|
|
439
333
|
//#endregion
|
|
334
|
+
//#region src/alchemy/stage-policy.ts
|
|
335
|
+
/**
|
|
336
|
+
* Stage and stack pairing, and protected-stage policy, from a project-supplied
|
|
337
|
+
* table (#991).
|
|
338
|
+
*
|
|
339
|
+
* Two projects wrote the same three questions twice — is this stage protected,
|
|
340
|
+
* is it disposable, and may this stack run it — and each answered them with its
|
|
341
|
+
* own stage names hard-coded into the answer. The names differ; the questions
|
|
342
|
+
* do not. This module owns the questions. A project supplies the table and gets
|
|
343
|
+
* decisions back, so no project or stack name appears here.
|
|
344
|
+
*
|
|
345
|
+
* One stage name does appear: `local`. It names the local emulator rather than
|
|
346
|
+
* any project's estate, every project spells it the same way, and the question
|
|
347
|
+
* it answers — is this the emulated stage — has one answer for the whole fleet.
|
|
348
|
+
* See {@link isLocalEmulationStage}.
|
|
349
|
+
*
|
|
350
|
+
* The disposable grammar this package already owns (`disposable-stage.ts`) is
|
|
351
|
+
* available as a matcher rather than assumed, because a project's disposable
|
|
352
|
+
* set is wider than that one grammar.
|
|
353
|
+
*/
|
|
354
|
+
/** Every stage the shared disposable grammar recognises. */
|
|
355
|
+
const LOCAL_PREVIEW_STAGES = (stage) => isLocalPreviewStage(stage);
|
|
356
|
+
/**
|
|
357
|
+
* Test a pattern without carrying state between calls.
|
|
358
|
+
*
|
|
359
|
+
* A `RegExp` with `g` or `y` advances `lastIndex` on every match, so the same
|
|
360
|
+
* pattern answers "yes" and then "no" for the same stage. A policy that
|
|
361
|
+
* alternates is worse than one that always refuses, so a flagged pattern is
|
|
362
|
+
* tested through a fresh copy whose `lastIndex` starts at zero. The copy
|
|
363
|
+
* carries the complete flags: `y` anchors the match at `lastIndex`, so
|
|
364
|
+
* dropping it would let `/prod/y` accept a stage named `pre-prod` that the
|
|
365
|
+
* project's own pattern rejects. The caller's own `RegExp` is never written
|
|
366
|
+
* to, so a frozen one still works.
|
|
367
|
+
*/
|
|
368
|
+
const statelessTest$1 = (pattern, stage) => pattern.global || pattern.sticky ? new RegExp(pattern.source, pattern.flags).test(stage) : pattern.test(stage);
|
|
369
|
+
const stageMatches = (matcher, stage) => {
|
|
370
|
+
if (typeof matcher === "function") return matcher(stage);
|
|
371
|
+
if (Array.isArray(matcher)) return matcher.includes(stage);
|
|
372
|
+
return statelessTest$1(matcher, stage);
|
|
373
|
+
};
|
|
374
|
+
/** A matcher that accepts a stage any of `matchers` accepts. */
|
|
375
|
+
const anyStage = (...matchers) => (stage) => matchers.some((matcher) => stageMatches(matcher, stage));
|
|
376
|
+
/**
|
|
377
|
+
* A policy refusal. The `code` is the stable part: a project maps it onto its
|
|
378
|
+
* own lifecycle failure envelope, and the message is for a human.
|
|
379
|
+
*/
|
|
380
|
+
var StagePolicyError = class extends Error {
|
|
381
|
+
code;
|
|
382
|
+
constructor(code, message) {
|
|
383
|
+
super(message);
|
|
384
|
+
this.name = "StagePolicyError";
|
|
385
|
+
this.code = code;
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
const assertTable = (table) => {
|
|
389
|
+
const seen = /* @__PURE__ */ new Set();
|
|
390
|
+
for (const entry of table.stacks ?? []) {
|
|
391
|
+
if (!entry.stack) throw new TypeError("Every stack ownership entry needs a stack name.");
|
|
392
|
+
if (seen.has(entry.stack)) throw new TypeError(`Stack "${entry.stack}" appears twice in the stack registry.`);
|
|
393
|
+
seen.add(entry.stack);
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
/**
|
|
397
|
+
* Is this the stage that runs against the local emulator?
|
|
398
|
+
*
|
|
399
|
+
* True for the literal `local` and nothing else (#991). Two projects and HQ
|
|
400
|
+
* each answered this with their own rule — a refusal list, a constant, and
|
|
401
|
+
* `$(whoami)` — and all three meant the same single name, so the answer is one
|
|
402
|
+
* literal rather than a pattern or a table. A disposable preview stage such as
|
|
403
|
+
* `local-pr-1-abcdef0` shares the prefix but runs against a real account, so it
|
|
404
|
+
* is false here. The comparison is exact: no trim and no case folding, because
|
|
405
|
+
* a stage name reaches Alchemy exactly as it is spelled.
|
|
406
|
+
*
|
|
407
|
+
* This question is deliberately outside {@link stagePolicy}: a project's table
|
|
408
|
+
* classifies the stages of its own estate, and the emulated stage belongs to
|
|
409
|
+
* no estate.
|
|
410
|
+
*/
|
|
411
|
+
const isLocalEmulationStage = (stage) => stage === "local";
|
|
412
|
+
/**
|
|
413
|
+
* Read a project's stage table and answer the stage questions from it.
|
|
414
|
+
*
|
|
415
|
+
* A stage the table classifies as both protected and disposable is a table
|
|
416
|
+
* defect, not a stage a caller may act on, so every query on it throws.
|
|
417
|
+
*/
|
|
418
|
+
const stagePolicy = (table) => {
|
|
419
|
+
assertTable(table);
|
|
420
|
+
const classify = (stage) => {
|
|
421
|
+
const isProtected = stageMatches(table.stages.protected, stage);
|
|
422
|
+
const isDisposable = stageMatches(table.stages.disposable, stage);
|
|
423
|
+
if (isProtected && isDisposable) throw new StagePolicyError("AMBIGUOUS_STAGE", `Stage "${stage}" is declared both protected and disposable.`);
|
|
424
|
+
return {
|
|
425
|
+
isDisposable,
|
|
426
|
+
isProtected
|
|
427
|
+
};
|
|
428
|
+
};
|
|
429
|
+
const isProtectedStage = (stage) => classify(stage).isProtected;
|
|
430
|
+
const isDisposableStage = (stage) => classify(stage).isDisposable;
|
|
431
|
+
const isKnownStage = (stage) => {
|
|
432
|
+
const { isDisposable, isProtected } = classify(stage);
|
|
433
|
+
return isProtected || isDisposable;
|
|
434
|
+
};
|
|
435
|
+
const assertKnownStage = (stage) => {
|
|
436
|
+
if (!isKnownStage(stage)) throw new StagePolicyError("UNKNOWN_STAGE", `Unsupported Alchemy stage: ${stage}`);
|
|
437
|
+
};
|
|
438
|
+
const assertDestructiveStage = (stage) => {
|
|
439
|
+
assertKnownStage(stage);
|
|
440
|
+
if (isProtectedStage(stage)) throw new StagePolicyError("PROTECTED_STAGE", `Refusing destructive operation for protected stage: ${stage}`);
|
|
441
|
+
};
|
|
442
|
+
const assertStackOwnsStage = (stack, stage) => {
|
|
443
|
+
assertKnownStage(stage);
|
|
444
|
+
const registry = table.stacks;
|
|
445
|
+
if (registry === void 0) return;
|
|
446
|
+
const entry = registry.find((candidate) => candidate.stack === stack);
|
|
447
|
+
if (entry === void 0) throw new StagePolicyError("UNKNOWN_STACK", `Unsupported Alchemy stack: ${stack}`);
|
|
448
|
+
const owner = registry.find((candidate) => candidate.stack !== stack && candidate.claims !== void 0 && stageMatches(candidate.claims, stage));
|
|
449
|
+
if (owner !== void 0) throw new StagePolicyError("STAGE_NOT_OWNED", `Stage ${stage} belongs only to stack ${owner.stack}, not ${stack}.`);
|
|
450
|
+
if (entry.stages !== void 0 && !stageMatches(entry.stages, stage)) throw new StagePolicyError("STAGE_NOT_OWNED", `Stack ${stack} does not run stage ${stage}.`);
|
|
451
|
+
};
|
|
452
|
+
return {
|
|
453
|
+
assertDestructiveStage,
|
|
454
|
+
assertKnownStage,
|
|
455
|
+
assertStackOwnsStage,
|
|
456
|
+
isDisposableStage,
|
|
457
|
+
isKnownStage,
|
|
458
|
+
isProtectedStage
|
|
459
|
+
};
|
|
460
|
+
};
|
|
461
|
+
//#endregion
|
|
462
|
+
//#region src/alchemy/local-emulation.ts
|
|
463
|
+
/**
|
|
464
|
+
* The child environment a local-emulation Alchemy run needs (#1168).
|
|
465
|
+
*
|
|
466
|
+
* Alchemy refuses to start when `CLOUDFLARE_ACCOUNT_ID` is missing or
|
|
467
|
+
* malformed, even for a run that only drives the local emulator and calls no
|
|
468
|
+
* Cloudflare API. Every project worked around that by handing the child a real
|
|
469
|
+
* account id and a real token, so a purely local run carried live credentials.
|
|
470
|
+
* This module hands it a sentinel pair instead: a well-formed account id that
|
|
471
|
+
* addresses no account, and a token that is not a token.
|
|
472
|
+
*
|
|
473
|
+
* The helper never reads a real credential. It reads one variable — the
|
|
474
|
+
* live-binding opt-in — and nothing else. The presence of a real credential in
|
|
475
|
+
* the parent environment is never the signal: a developer who is logged in to
|
|
476
|
+
* Cloudflare still gets the sentinel pair unless the opt-in says otherwise.
|
|
477
|
+
*
|
|
478
|
+
* Scrubbing an ambient token out of the child is the launcher's job, not this
|
|
479
|
+
* module's. This module owns the environment shape only.
|
|
480
|
+
*/
|
|
481
|
+
const [ACCOUNT_ID_NAME, API_TOKEN_NAME] = HOSTED_CLOUDFLARE_CREDENTIAL_NAMES;
|
|
482
|
+
/**
|
|
483
|
+
* A well-formed Cloudflare account id that addresses no account.
|
|
484
|
+
*
|
|
485
|
+
* Alchemy validates the value against `/^[0-9a-f]{32}$/i` and rejects
|
|
486
|
+
* placeholders such as `""` or `dummy` with an authentication error before any
|
|
487
|
+
* run starts. The check is `validateAccountId`, in
|
|
488
|
+
* `alchemy/lib/Cloudflare/Auth/AuthProvider.js` at 2.0.0-beta.76 and in
|
|
489
|
+
* `alchemy/lib/Cloudflare/Auth/AuthConfig.js` at 2.0.0-beta.77. The file name
|
|
490
|
+
* moves; the pattern is the same in both. Thirty-two zeros pass that shape
|
|
491
|
+
* check and route to nothing.
|
|
492
|
+
*/
|
|
493
|
+
const SENTINEL_ACCOUNT_ID = "0".repeat(32);
|
|
494
|
+
/**
|
|
495
|
+
* A token that is not a token. It is spelled as words, so no secret scanner
|
|
496
|
+
* reads it as a credential and no reader mistakes it for one. Any API call
|
|
497
|
+
* made with it fails, which is the intended outcome: a local-emulation run
|
|
498
|
+
* must reach no Cloudflare account.
|
|
499
|
+
*/
|
|
500
|
+
const SENTINEL_API_TOKEN = "factory-ci-local-emulation-no-cloudflare-token";
|
|
501
|
+
/**
|
|
502
|
+
* The one way a real credential reaches a local-emulation child. Set it to a
|
|
503
|
+
* non-empty value and this helper returns no sentinel, so whatever
|
|
504
|
+
* `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` the parent carries pass
|
|
505
|
+
* through untouched and the run may bind a live Cloudflare resource.
|
|
506
|
+
*/
|
|
507
|
+
const LIVE_BINDING_OPT_IN = "FACTORY_CI_LIVE_CLOUDFLARE_BINDING";
|
|
508
|
+
/**
|
|
509
|
+
* Build the Cloudflare variables a local-emulation child is launched with.
|
|
510
|
+
*
|
|
511
|
+
* Returns exactly two variables for the local-emulation stage, or no variables
|
|
512
|
+
* when the live-binding opt-in is set. The result is an overlay: merge it over
|
|
513
|
+
* the child environment the launcher has otherwise assembled.
|
|
514
|
+
*
|
|
515
|
+
* Throws for any other stage. A non-local stage deploys to a real account, so
|
|
516
|
+
* a sentinel there would break that deploy, and returning nothing instead
|
|
517
|
+
* would let a caller believe a local run was protected when it was not.
|
|
518
|
+
*/
|
|
519
|
+
const localEmulationEnvironment = (options) => {
|
|
520
|
+
const { environment = process.env, stage } = options;
|
|
521
|
+
if (!isLocalEmulationStage(stage)) throw new Error(`localEmulationEnvironment is only for the local-emulation stage, not "${stage}".`);
|
|
522
|
+
if ((environment[LIVE_BINDING_OPT_IN] ?? "").trim().length > 0) return {};
|
|
523
|
+
return {
|
|
524
|
+
[ACCOUNT_ID_NAME]: SENTINEL_ACCOUNT_ID,
|
|
525
|
+
[API_TOKEN_NAME]: SENTINEL_API_TOKEN
|
|
526
|
+
};
|
|
527
|
+
};
|
|
528
|
+
//#endregion
|
|
440
529
|
//#region src/alchemy/destroy-postcondition.ts
|
|
441
530
|
/**
|
|
442
531
|
* Read the state store through the project's reader.
|
|
@@ -475,6 +564,370 @@ const assertDestroyLeftNothing = async ({ stack, stage, stateReader }) => {
|
|
|
475
564
|
if (remaining.length > 0) throw new Error(`Alchemy destroy left ${remaining.length} resource registered for ${stack} in ${stage}: ${remaining.join(", ")}`);
|
|
476
565
|
};
|
|
477
566
|
//#endregion
|
|
567
|
+
//#region src/alchemy/plan-effects.ts
|
|
568
|
+
/**
|
|
569
|
+
* The plan-output adapter (#1077): the one reader of what the installed
|
|
570
|
+
* Alchemy CLI prints for a plan.
|
|
571
|
+
*
|
|
572
|
+
* Alchemy has no structured plan output at the fleet baseline, so the plan
|
|
573
|
+
* is read from the lines `formatPlanLines` in `alchemy/src/Cli/LoggingCli.ts`
|
|
574
|
+
* prints: one `Plan:` summary, one `[fqn] action` line per resource, one
|
|
575
|
+
* `[fqn/name] action` line per binding of that resource, and one
|
|
576
|
+
* `[fqn] run|drop [action]` line per task. Paitronage and HQ each wrote this
|
|
577
|
+
* reader; this is the one copy, and it is bound to the Alchemy version whose
|
|
578
|
+
* renderer it reads. A consumer running a different Alchemy is refused by
|
|
579
|
+
* `runAlchemyLifecycle` before any child runs, so a renderer change cannot
|
|
580
|
+
* be read as a plan with different effects.
|
|
581
|
+
*
|
|
582
|
+
* The renderer logs every line through Effect's pretty console logger, so a
|
|
583
|
+
* line arrives as `[HH:MM:SS.mmm] INFO (#1): <line>`, and coloured when the
|
|
584
|
+
* child inherits `FORCE_COLOR`. Both wrappers are stripped before a line is
|
|
585
|
+
* read. The captured output under `tests/fixtures/plan-output/beta-77/` is
|
|
586
|
+
* the evidence for every shape this file reads.
|
|
587
|
+
*
|
|
588
|
+
* The adapter fails on anything it does not recognise: no summary, two
|
|
589
|
+
* summaries, an action outside the known set, a summary it cannot parse, a
|
|
590
|
+
* summary whose counts do not equal the lines under it, or rows the summary
|
|
591
|
+
* can place in more than one way. An effect it could not account for is
|
|
592
|
+
* never reported as zero.
|
|
593
|
+
*
|
|
594
|
+
* Two readings share that work. `parsePlanEffects` answers what the plan
|
|
595
|
+
* does, which is what the lifecycle's admission rules read. `canonicalPlan`
|
|
596
|
+
* answers what the plan *is*, as a stable string: the rows the renderer
|
|
597
|
+
* printed and the reading of them, with the per-run logger metadata it also
|
|
598
|
+
* prints left out. `firstDeployPlanHash` hashes that string, so a plan
|
|
599
|
+
* reviewed at one moment and deployed at another is the same plan (#1232).
|
|
600
|
+
*/
|
|
601
|
+
/** The Alchemy version whose plan renderer this adapter reads. */
|
|
602
|
+
const PLAN_OUTPUT_ALCHEMY_VERSION = ALCHEMY_BASELINE.alchemy;
|
|
603
|
+
/**
|
|
604
|
+
* Every action a resource row can carry. The first six are the actions the
|
|
605
|
+
* summary counts, in the order the renderer prints them; `noop` rows print
|
|
606
|
+
* under the summary but are never counted in it.
|
|
607
|
+
*/
|
|
608
|
+
const PLAN_ACTIONS = [
|
|
609
|
+
"create",
|
|
610
|
+
"update",
|
|
611
|
+
"adopted",
|
|
612
|
+
"replace",
|
|
613
|
+
"delete",
|
|
614
|
+
"orphaned",
|
|
615
|
+
"noop"
|
|
616
|
+
];
|
|
617
|
+
/** The actions the summary counts. */
|
|
618
|
+
const SUMMARY_ACTIONS = PLAN_ACTIONS.filter((action) => action !== "noop");
|
|
619
|
+
/** The adapter could not read the output. `reason` is the whole detail. */
|
|
620
|
+
var PlanEffectsError = class extends Error {
|
|
621
|
+
reason;
|
|
622
|
+
constructor(reason) {
|
|
623
|
+
super(`Alchemy plan output was not readable: ${reason}.`);
|
|
624
|
+
this.name = "PlanEffectsError";
|
|
625
|
+
this.reason = reason;
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
const ACTION_SET = new Set(PLAN_ACTIONS);
|
|
629
|
+
const BINDING_ACTIONS = new Set([
|
|
630
|
+
"create",
|
|
631
|
+
"noop",
|
|
632
|
+
"unbind",
|
|
633
|
+
"update"
|
|
634
|
+
]);
|
|
635
|
+
const TASK_ACTIONS = new Set(["drop", "run"]);
|
|
636
|
+
const ansiPattern = new RegExp(`${String.fromCodePoint(27)}\\[[0-?]*[ -/]*[@-~]`, "gu");
|
|
637
|
+
/**
|
|
638
|
+
* The prefix Effect's pretty console logger puts before every line the
|
|
639
|
+
* renderer logs. Optional, so output that was not logged through it (an
|
|
640
|
+
* apply session's stack output, a scripted CLI in a test) reads the same.
|
|
641
|
+
*/
|
|
642
|
+
const EFFECT_LOG_PREFIX = /^\[\d{2}:\d{2}:\d{2}\.\d{3}\] (?:ALL|TRACE|DEBUG|INFO|WARN|ERROR|FATAL|NONE) \(#\d+\)(?: [^:]+)?:(?: |$)/u;
|
|
643
|
+
const ROW_PATTERN = /^\[(?<tag>[^\]]+)\]\s+(?<action>\S+)/u;
|
|
644
|
+
const TASK_ROW_PATTERN = /^\[(?<tag>[^\]]+)\]\s+(?<action>\S+)\s+\[action\]$/u;
|
|
645
|
+
const SUMMARY_PREFIX = "Plan:";
|
|
646
|
+
/** Every resource row is `noop`; there may be rows. */
|
|
647
|
+
const NO_CHANGES = "Plan: no changes";
|
|
648
|
+
/** The stack declares nothing and persists nothing; there are no rows. */
|
|
649
|
+
const NO_RESOURCES = "Plan: no resources";
|
|
650
|
+
const SUMMARY_ACTION_ITEM = /^(?<count>\d+) to (?<action>create|update|adopted|replace|delete|orphaned)$/u;
|
|
651
|
+
const SUMMARY_BINDINGS_ITEM = /^(?<count>\d+) binding changes$/u;
|
|
652
|
+
const SUMMARY_TASKS_ITEM = /^(?<count>\d+) tasks$/u;
|
|
653
|
+
const zeroCounts = () => ({
|
|
654
|
+
adopted: 0,
|
|
655
|
+
create: 0,
|
|
656
|
+
delete: 0,
|
|
657
|
+
noop: 0,
|
|
658
|
+
orphaned: 0,
|
|
659
|
+
replace: 0,
|
|
660
|
+
update: 0
|
|
661
|
+
});
|
|
662
|
+
const isPlanAction = (action) => ACTION_SET.has(action);
|
|
663
|
+
const isTaskAction = (action) => TASK_ACTIONS.has(action);
|
|
664
|
+
/**
|
|
665
|
+
* The rows under the summary, up to the first line that is not a row. An
|
|
666
|
+
* apply session prints its progress after a blank line, so that line ends
|
|
667
|
+
* the plan. A task row is told apart by its `[action]` suffix; a resource
|
|
668
|
+
* row and a binding row are told apart by `readRows` below.
|
|
669
|
+
*/
|
|
670
|
+
const rowsUnder = (lines, summaryIndex) => {
|
|
671
|
+
const rows = [];
|
|
672
|
+
const tasks = [];
|
|
673
|
+
for (const line of lines.slice(summaryIndex + 1)) {
|
|
674
|
+
const task = TASK_ROW_PATTERN.exec(line)?.groups;
|
|
675
|
+
if (task?.tag !== void 0 && task.action !== void 0) {
|
|
676
|
+
if (!isTaskAction(task.action)) throw new PlanEffectsError("unknown-action");
|
|
677
|
+
tasks.push({
|
|
678
|
+
action: task.action,
|
|
679
|
+
id: task.tag
|
|
680
|
+
});
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
const row = ROW_PATTERN.exec(line)?.groups;
|
|
684
|
+
if (row?.tag === void 0 || row.action === void 0) break;
|
|
685
|
+
if (!(isPlanAction(row.action) || BINDING_ACTIONS.has(row.action))) throw new PlanEffectsError("unknown-action");
|
|
686
|
+
rows.push({
|
|
687
|
+
action: row.action,
|
|
688
|
+
tag: row.tag
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
return {
|
|
692
|
+
rows,
|
|
693
|
+
tasks
|
|
694
|
+
};
|
|
695
|
+
};
|
|
696
|
+
/**
|
|
697
|
+
* The one reading of the rows as resources and bindings whose counts equal
|
|
698
|
+
* the summary. No such reading is a count mismatch; two are refused as
|
|
699
|
+
* ambiguous rather than guessed.
|
|
700
|
+
*
|
|
701
|
+
* A binding row is tagged `<resource fqn>/<name>` and printed directly under
|
|
702
|
+
* its resource, so it always extends the FQN of the last resource row. A
|
|
703
|
+
* resource inside a namespace is tagged the same way, and when it follows a
|
|
704
|
+
* resource whose FQN is its namespace path the two rows look alike:
|
|
705
|
+
* `prefix/plan.txt` under the fixtures prints `[Parent/bound] create` and
|
|
706
|
+
* `[Parent/Zed] create` under `[Parent] create`. The renderer gives no other
|
|
707
|
+
* evidence, so a row that extends the last resource's FQN is read both ways
|
|
708
|
+
* and the summary decides: it counts resources per action and the binding
|
|
709
|
+
* rows with a change, and only a reading that adds up to it survives.
|
|
710
|
+
*
|
|
711
|
+
* A `noop` row is counted by neither, so the summary cannot place it. Such a
|
|
712
|
+
* row extending the last resource's FQN is read as a binding: a `noop` row
|
|
713
|
+
* drives no decision (`runAlchemyLifecycle` reads only rows with work), and
|
|
714
|
+
* a plan's bindings are far more often `noop` than its namespaced resources
|
|
715
|
+
* are named after a sibling. `prefix/noop.txt` is that residual.
|
|
716
|
+
*
|
|
717
|
+
* The search counts readings, stopping at two, and is memoised on what the
|
|
718
|
+
* rest of the rows can depend on: the position, the last resource's FQN,
|
|
719
|
+
* and the `create` and `update` resources read so far (every other counted
|
|
720
|
+
* row is a resource, `unbind` is a binding, and `noop` is placed by rule, so
|
|
721
|
+
* those tallies follow from the position). A plan with many resources each
|
|
722
|
+
* followed by rows that extend its FQN then costs the product of the row
|
|
723
|
+
* count and the two tallies, not the product of the readings of each group.
|
|
724
|
+
* The first reading found is kept; every branch is on a row with work, so a
|
|
725
|
+
* second reading always places a row with work differently.
|
|
726
|
+
*/
|
|
727
|
+
const readRows = (rows, stated) => {
|
|
728
|
+
const seen = zeroCounts();
|
|
729
|
+
const resources = [];
|
|
730
|
+
let bindingChanges = 0;
|
|
731
|
+
let first;
|
|
732
|
+
const memo = /* @__PURE__ */ new Map();
|
|
733
|
+
const visit = (index, owner) => {
|
|
734
|
+
const row = rows[index];
|
|
735
|
+
if (row === void 0) {
|
|
736
|
+
if (bindingChanges !== stated.bindingChanges || SUMMARY_ACTIONS.some((action) => seen[action] !== stated.counts[action])) return 0;
|
|
737
|
+
first ??= [...resources];
|
|
738
|
+
return 1;
|
|
739
|
+
}
|
|
740
|
+
const key = JSON.stringify([
|
|
741
|
+
index,
|
|
742
|
+
owner,
|
|
743
|
+
seen.create,
|
|
744
|
+
seen.update
|
|
745
|
+
]);
|
|
746
|
+
const known = memo.get(key);
|
|
747
|
+
if (known !== void 0) return known;
|
|
748
|
+
let readings = 0;
|
|
749
|
+
const { action, tag } = row;
|
|
750
|
+
if (owner !== void 0 && tag.startsWith(`${owner}/`) && BINDING_ACTIONS.has(action)) {
|
|
751
|
+
if (action === "noop") return visit(index + 1, owner);
|
|
752
|
+
bindingChanges += 1;
|
|
753
|
+
if (bindingChanges <= stated.bindingChanges) readings += visit(index + 1, owner);
|
|
754
|
+
bindingChanges -= 1;
|
|
755
|
+
}
|
|
756
|
+
if (readings < 2 && isPlanAction(action)) {
|
|
757
|
+
seen[action] += 1;
|
|
758
|
+
resources.push({
|
|
759
|
+
action,
|
|
760
|
+
id: tag
|
|
761
|
+
});
|
|
762
|
+
if (action === "noop" || seen[action] <= stated.counts[action]) readings += visit(index + 1, tag);
|
|
763
|
+
resources.pop();
|
|
764
|
+
seen[action] -= 1;
|
|
765
|
+
}
|
|
766
|
+
const capped = Math.min(readings, 2);
|
|
767
|
+
memo.set(key, capped);
|
|
768
|
+
return capped;
|
|
769
|
+
};
|
|
770
|
+
const readings = visit(0);
|
|
771
|
+
if (first === void 0 || readings === 0) throw new PlanEffectsError("count-mismatch");
|
|
772
|
+
if (readings > 1) throw new PlanEffectsError("ambiguous-rows");
|
|
773
|
+
return {
|
|
774
|
+
bindingChanges: stated.bindingChanges,
|
|
775
|
+
resources: first
|
|
776
|
+
};
|
|
777
|
+
};
|
|
778
|
+
/**
|
|
779
|
+
* The counts the summary states, in the order Alchemy prints them: the six
|
|
780
|
+
* actions, then `N binding changes`, then `N tasks`, each only when non-zero.
|
|
781
|
+
* The summary is rebuilt from what was read and must equal the line, so a
|
|
782
|
+
* summary with an extra clause, a repeated part, or a different order is
|
|
783
|
+
* refused instead of partially read.
|
|
784
|
+
*/
|
|
785
|
+
const countsStated = (summary) => {
|
|
786
|
+
const counts = zeroCounts();
|
|
787
|
+
let bindingChanges = 0;
|
|
788
|
+
let tasks = 0;
|
|
789
|
+
for (const item of summary.slice(5).trim().split(", ")) {
|
|
790
|
+
const action = SUMMARY_ACTION_ITEM.exec(item)?.groups;
|
|
791
|
+
const bindings = SUMMARY_BINDINGS_ITEM.exec(item)?.groups;
|
|
792
|
+
const taskCount = SUMMARY_TASKS_ITEM.exec(item)?.groups;
|
|
793
|
+
if (action?.action && isPlanAction(action.action)) counts[action.action] = Number(action.count);
|
|
794
|
+
else if (bindings) bindingChanges = Number(bindings.count);
|
|
795
|
+
else if (taskCount) tasks = Number(taskCount.count);
|
|
796
|
+
else throw new PlanEffectsError("summary-unrecognized");
|
|
797
|
+
}
|
|
798
|
+
const canonical = [
|
|
799
|
+
...SUMMARY_ACTIONS.filter((action) => counts[action] > 0).map((action) => `${counts[action]} to ${action}`),
|
|
800
|
+
...bindingChanges > 0 ? [`${bindingChanges} binding changes`] : [],
|
|
801
|
+
...tasks > 0 ? [`${tasks} tasks`] : []
|
|
802
|
+
].join(", ");
|
|
803
|
+
if (canonical === "" || `${SUMMARY_PREFIX} ${canonical}` !== summary) throw new PlanEffectsError("summary-unrecognized");
|
|
804
|
+
return {
|
|
805
|
+
bindingChanges,
|
|
806
|
+
counts,
|
|
807
|
+
tasks
|
|
808
|
+
};
|
|
809
|
+
};
|
|
810
|
+
/**
|
|
811
|
+
* Split the output into the one summary line and the rows under it. This is
|
|
812
|
+
* the whole of what the renderer's output is read as; `planEffects` below
|
|
813
|
+
* interprets it, and `canonicalPlan` serialises it.
|
|
814
|
+
*/
|
|
815
|
+
const planLines = (output) => {
|
|
816
|
+
const lines = output.replaceAll(ansiPattern, "").split("\n").map((line) => line.replace(EFFECT_LOG_PREFIX, "").trim());
|
|
817
|
+
const summaryIndexes = lines.flatMap((line, index) => line.startsWith(SUMMARY_PREFIX) ? [index] : []);
|
|
818
|
+
if (summaryIndexes.length === 0) throw new PlanEffectsError("no-summary");
|
|
819
|
+
if (summaryIndexes.length > 1) throw new PlanEffectsError("multiple-summaries");
|
|
820
|
+
const summaryIndex = summaryIndexes[0] ?? 0;
|
|
821
|
+
return {
|
|
822
|
+
summary: lines[summaryIndex] ?? "",
|
|
823
|
+
...rowsUnder(lines, summaryIndex)
|
|
824
|
+
};
|
|
825
|
+
};
|
|
826
|
+
/** What the summary and the rows under it say the plan does. */
|
|
827
|
+
const planEffects = ({ rows, summary, tasks }) => {
|
|
828
|
+
if (summary === NO_RESOURCES) {
|
|
829
|
+
if (rows.length > 0 || tasks.length > 0) throw new PlanEffectsError("effects-under-no-changes");
|
|
830
|
+
return {
|
|
831
|
+
bindingChanges: 0,
|
|
832
|
+
counts: zeroCounts(),
|
|
833
|
+
resources: [],
|
|
834
|
+
tasks: []
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
const stated = summary === NO_CHANGES ? {
|
|
838
|
+
bindingChanges: 0,
|
|
839
|
+
counts: zeroCounts(),
|
|
840
|
+
tasks: 0
|
|
841
|
+
} : countsStated(summary);
|
|
842
|
+
if (tasks.length !== stated.tasks) throw new PlanEffectsError("count-mismatch");
|
|
843
|
+
const { bindingChanges, resources } = readRows(rows, stated);
|
|
844
|
+
const counts = zeroCounts();
|
|
845
|
+
for (const { action } of resources) counts[action] += 1;
|
|
846
|
+
return {
|
|
847
|
+
bindingChanges,
|
|
848
|
+
counts,
|
|
849
|
+
resources,
|
|
850
|
+
tasks
|
|
851
|
+
};
|
|
852
|
+
};
|
|
853
|
+
/**
|
|
854
|
+
* Read one plan from Alchemy CLI output.
|
|
855
|
+
*
|
|
856
|
+
* Throws `PlanEffectsError` on output the adapter cannot account for. The
|
|
857
|
+
* output itself never appears in the error.
|
|
858
|
+
*/
|
|
859
|
+
const parsePlanEffects = (output) => planEffects(planLines(output));
|
|
860
|
+
const compareText = (left, right) => {
|
|
861
|
+
if (left < right) return -1;
|
|
862
|
+
return left > right ? 1 : 0;
|
|
863
|
+
};
|
|
864
|
+
const compareCanonicalRows = (left, right) => compareText(left.kind, right.kind) || compareText(left.tag, right.tag) || compareText(left.action, right.action);
|
|
865
|
+
const compareResources = (left, right) => compareText(left.id, right.id) || compareText(left.action, right.action);
|
|
866
|
+
/**
|
|
867
|
+
* The plan as a stable string: what the renderer said about the plan, with
|
|
868
|
+
* what it said about the run that printed it left out.
|
|
869
|
+
*
|
|
870
|
+
* A plan carries no resource properties — the renderer prints a summary and
|
|
871
|
+
* one `[tag] action` row per resource, binding and task, and nothing else.
|
|
872
|
+
* So the canonical form is the summary line, every row under it as
|
|
873
|
+
* `{ action, kind, tag }`, and the reading of those rows `parsePlanEffects`
|
|
874
|
+
* arrives at (`resources` and `bindingChanges`). What is left out is the
|
|
875
|
+
* logger's timestamps and fiber ids, the `Plan ready (2.9s)` duration, ANSI
|
|
876
|
+
* colour, and everything after the rows: per-run facts about the process,
|
|
877
|
+
* not about the plan, and hashing them is why a reviewed plan could never be
|
|
878
|
+
* deployed (#1232).
|
|
879
|
+
*
|
|
880
|
+
* The rows and the reading each say something the other does not, so both
|
|
881
|
+
* are kept:
|
|
882
|
+
*
|
|
883
|
+
* - Every row is kept, including binding rows and `noop` rows, because
|
|
884
|
+
* `PlanEffects` reduces bindings to a count. A binding renamed with the
|
|
885
|
+
* count unchanged is one `PlanEffects` value and two row multisets.
|
|
886
|
+
* - The reading is kept because the rows alone do not say which of them are
|
|
887
|
+
* resources. A binding row and a namespaced resource row print alike —
|
|
888
|
+
* `[Parent/bound] create` and `[Parent/Zed] create` under
|
|
889
|
+
* `[Parent] create` — and only print order and the summary's counts tell
|
|
890
|
+
* them apart. Sorting the rows drops the order, so swapping those two rows
|
|
891
|
+
* is two plans creating different resources over one row multiset. The
|
|
892
|
+
* resource list the summary forces is what separates them.
|
|
893
|
+
*
|
|
894
|
+
* Together they pin the bindings too: the rows are the whole multiset and
|
|
895
|
+
* the resources are a sub-multiset of it, so the bindings are what remains.
|
|
896
|
+
*
|
|
897
|
+
* Rows and resources are both sorted, keeping duplicates, because print
|
|
898
|
+
* order is the renderer's traversal and authorizes nothing on its own: a
|
|
899
|
+
* plan that printed the same resources and bindings in another order must
|
|
900
|
+
* not read as a different plan.
|
|
901
|
+
*
|
|
902
|
+
* The full `parsePlanEffects` validation runs first, so anything the adapter
|
|
903
|
+
* refuses is refused here too (`PlanEffectsError`). Output it cannot account
|
|
904
|
+
* for is never canonicalised, and so is never authorized. `rowsUnder` stops
|
|
905
|
+
* at the first line that is not a row, so a log line interleaved into the
|
|
906
|
+
* rows on a chattier run drops the rows after it: an uncounted (`noop`) row
|
|
907
|
+
* lost that way changes the hash, and a row with work lost that way fails
|
|
908
|
+
* the summary's counts (`count-mismatch`). Either way the run is refused
|
|
909
|
+
* rather than admitted on less than the plan.
|
|
910
|
+
*/
|
|
911
|
+
const canonicalPlan = (output) => {
|
|
912
|
+
const lines = planLines(output);
|
|
913
|
+
const { bindingChanges, resources } = planEffects(lines);
|
|
914
|
+
const rows = [...lines.rows.map(({ action, tag }) => ({
|
|
915
|
+
action,
|
|
916
|
+
kind: "row",
|
|
917
|
+
tag
|
|
918
|
+
})), ...lines.tasks.map(({ action, id }) => ({
|
|
919
|
+
action,
|
|
920
|
+
kind: "task",
|
|
921
|
+
tag: id
|
|
922
|
+
}))].toSorted(compareCanonicalRows);
|
|
923
|
+
return JSON.stringify({
|
|
924
|
+
bindingChanges,
|
|
925
|
+
resources: resources.toSorted(compareResources),
|
|
926
|
+
rows,
|
|
927
|
+
summary: lines.summary
|
|
928
|
+
});
|
|
929
|
+
};
|
|
930
|
+
//#endregion
|
|
478
931
|
//#region src/alchemy/first-deploy-authorization.ts
|
|
479
932
|
/**
|
|
480
933
|
* First-deploy authorization by plan hash.
|
|
@@ -491,16 +944,51 @@ const assertDestroyLeftNothing = async ({ stack, stage, stateReader }) => {
|
|
|
491
944
|
* with the project. This function answers one question: does this exact plan
|
|
492
945
|
* appear on the list the project authorized?
|
|
493
946
|
*
|
|
494
|
-
* It fails closed. An empty list, a malformed list entry, an empty plan,
|
|
495
|
-
* an unlisted hash are all refusals.
|
|
947
|
+
* It fails closed. An empty list, a malformed list entry, an empty plan, plan
|
|
948
|
+
* output the plan adapter cannot read, and an unlisted hash are all refusals.
|
|
949
|
+
*
|
|
950
|
+
* What the hash binds is the plan, not the run that printed it: the summary
|
|
951
|
+
* line and every row's tag and action, with the target and the reviewed
|
|
952
|
+
* source. Alchemy's renderer logs each line through Effect's pretty console
|
|
953
|
+
* logger and prints its own elapsed duration, so the verbatim output carries
|
|
954
|
+
* a timestamp per line, a fiber id, `Plan ready (2.9s)`, and colour when the
|
|
955
|
+
* child inherits `FORCE_COLOR` — all facts about one process, none about the
|
|
956
|
+
* plan. Hashing them meant a reviewed plan could never be redeployed at its
|
|
957
|
+
* reviewed hash (#1232), so `canonicalPlan` is hashed instead.
|
|
496
958
|
*/
|
|
497
959
|
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
|
960
|
+
/**
|
|
961
|
+
* Which plan representation the payload hashes. It is part of the payload so
|
|
962
|
+
* a hash can never be read as one of a different shape.
|
|
963
|
+
*/
|
|
964
|
+
const PLAN_HASH_FORMAT = "canonical-plan-v1";
|
|
498
965
|
const HASH_PATTERN = /^[0-9a-f]{64}$/u;
|
|
499
966
|
const SOURCE_SHA_PATTERN = /^[0-9a-f]{40}$/u;
|
|
500
967
|
/**
|
|
501
|
-
* The hash a first deploy is authorized by: the plan
|
|
502
|
-
*
|
|
503
|
-
*
|
|
968
|
+
* The hash a first deploy is authorized by: the plan, the target, and the
|
|
969
|
+
* reviewed source, in one payload.
|
|
970
|
+
*
|
|
971
|
+
* The plan enters the payload as the sha256 of `canonicalPlan`, so the value
|
|
972
|
+
* that is compared never embeds plan output, and two runs of one plan agree.
|
|
973
|
+
* Bound: the summary line, every row's tag and action (resource, binding and
|
|
974
|
+
* task rows alike, `noop` included), which of those rows are resources and
|
|
975
|
+
* which are bindings, the stack, the stage, and the source SHA. Not bound:
|
|
976
|
+
* logger timestamps and fiber ids, the run's elapsed durations, ANSI colour,
|
|
977
|
+
* progress lines, and the order the rows were printed in.
|
|
978
|
+
*
|
|
979
|
+
* Row order is not bound but the reading of the rows is, because they are
|
|
980
|
+
* not the same fact. Order alone used to carry which rows are a resource's
|
|
981
|
+
* bindings and which are resources inside its namespace; the canonical form
|
|
982
|
+
* states that outright, so two plans creating different resources can never
|
|
983
|
+
* agree merely by printing one multiset of rows two ways.
|
|
984
|
+
*
|
|
985
|
+
* `planFormat` names that shape. A hash issued by an earlier version of this
|
|
986
|
+
* function, which hashed the verbatim output, can never equal one issued
|
|
987
|
+
* now — those hashes were not reproducible in the first place, so they
|
|
988
|
+
* authorized nothing that could be deployed.
|
|
989
|
+
*
|
|
990
|
+
* Throws a `PlanEffectsError` when the plan adapter cannot account for the
|
|
991
|
+
* output: an unreadable plan is refused, never hashed.
|
|
504
992
|
*/
|
|
505
993
|
const firstDeployPlanHash = ({ planText, sourceSha, stack, stage }) => {
|
|
506
994
|
if (!(stack.trim() && stage.trim())) throw new Error("First-deploy authorization requires a stack and a stage.");
|
|
@@ -510,7 +998,8 @@ const firstDeployPlanHash = ({ planText, sourceSha, stack, stage }) => {
|
|
|
510
998
|
if (!planText.isWellFormed()) throw new Error("First-deploy authorization requires well-formed plan output.");
|
|
511
999
|
return sha256(JSON.stringify({
|
|
512
1000
|
operation: "deploy",
|
|
513
|
-
|
|
1001
|
+
planFormat: PLAN_HASH_FORMAT,
|
|
1002
|
+
planSha256: sha256(canonicalPlan(planText)),
|
|
514
1003
|
sourceSha,
|
|
515
1004
|
stack,
|
|
516
1005
|
stage
|
|
@@ -775,121 +1264,108 @@ var AlchemyLifecycleError = class extends Error {
|
|
|
775
1264
|
};
|
|
776
1265
|
const isAlchemyLifecycleError = (error) => error instanceof AlchemyLifecycleError;
|
|
777
1266
|
//#endregion
|
|
778
|
-
//#region src/alchemy/
|
|
1267
|
+
//#region src/alchemy/state-tree.ts
|
|
779
1268
|
/**
|
|
780
|
-
*
|
|
781
|
-
* Alchemy CLI prints for a plan.
|
|
1269
|
+
* One read and delete surface over a state layer.
|
|
782
1270
|
*
|
|
783
|
-
* Alchemy
|
|
784
|
-
*
|
|
785
|
-
*
|
|
786
|
-
*
|
|
787
|
-
*
|
|
788
|
-
*
|
|
789
|
-
* runs, so a renderer change cannot be read as a plan with different effects.
|
|
1271
|
+
* Alchemy's `State/Tree` module answers "which `(stack, stage)` pairs does
|
|
1272
|
+
* this store hold" and "remove one stage" against the `State` service
|
|
1273
|
+
* interface, so it works over any state layer a consumer configured,
|
|
1274
|
+
* including `@patronage/alchemy-d1-state`. This module is the typed entry to
|
|
1275
|
+
* those two operations plus the record read, and it launches no child
|
|
1276
|
+
* process: every answer comes from the state layer the caller passes in.
|
|
790
1277
|
*
|
|
791
|
-
* The
|
|
792
|
-
*
|
|
793
|
-
*
|
|
794
|
-
*
|
|
1278
|
+
* The caller owns the state layer and the policy. This module owns the
|
|
1279
|
+
* address: a `(stack, stage)` pair is one stack and one stage, and nothing
|
|
1280
|
+
* else. `Tree` addresses a stage by the path `stack/stage`, so an empty or
|
|
1281
|
+
* padded stage collapses that path to the stack and a delete meant for one
|
|
1282
|
+
* stage removes every stage of the stack. `assertStageAddress` refuses those
|
|
1283
|
+
* inputs before any path is built.
|
|
1284
|
+
*
|
|
1285
|
+
* Which stages a consumer may delete stays with the consumer: this module
|
|
1286
|
+
* deletes the stage it is given.
|
|
795
1287
|
*/
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
constructor(reason) {
|
|
809
|
-
super(`Alchemy plan output was not readable: ${reason}.`);
|
|
810
|
-
this.name = "PlanEffectsError";
|
|
811
|
-
this.reason = reason;
|
|
812
|
-
}
|
|
813
|
-
};
|
|
814
|
-
const ACTION_SET = new Set(PLAN_ACTIONS);
|
|
815
|
-
const ansiPattern = new RegExp(`${String.fromCodePoint(27)}\\[[0-?]*[ -/]*[@-~]`, "gu");
|
|
816
|
-
const ROW_PATTERN = /^\[(?<tag>[^\]]+)\]\s+(?<action>\S+)/u;
|
|
817
|
-
const SUMMARY_PREFIX = "Plan:";
|
|
818
|
-
const NO_CHANGES = "Plan: no changes";
|
|
819
|
-
const SUMMARY_ITEM = /^(?<count>\d+) to (?<action>create|update|replace|delete|noop)$/u;
|
|
820
|
-
const zeroCounts = () => ({
|
|
821
|
-
create: 0,
|
|
822
|
-
delete: 0,
|
|
823
|
-
noop: 0,
|
|
824
|
-
replace: 0,
|
|
825
|
-
update: 0
|
|
826
|
-
});
|
|
827
|
-
const isPlanAction = (action) => ACTION_SET.has(action);
|
|
828
|
-
/** The rows under the summary, up to the first line that is not a row. */
|
|
829
|
-
const rowsUnder = (lines, summaryIndex) => {
|
|
830
|
-
const rows = [];
|
|
831
|
-
for (const line of lines.slice(summaryIndex + 1)) {
|
|
832
|
-
const match = ROW_PATTERN.exec(line);
|
|
833
|
-
if (match?.groups === void 0) break;
|
|
834
|
-
const { action, tag } = match.groups;
|
|
835
|
-
if (action === void 0 || tag === void 0 || !isPlanAction(action)) throw new PlanEffectsError("unknown-action");
|
|
836
|
-
if (!tag.includes("/")) rows.push({
|
|
837
|
-
action,
|
|
838
|
-
id: tag
|
|
839
|
-
});
|
|
840
|
-
}
|
|
841
|
-
return rows;
|
|
1288
|
+
const isRecord$1 = (value) => typeof value === "object" && value !== null;
|
|
1289
|
+
/**
|
|
1290
|
+
* Refuse a stack or stage name that does not address exactly one scope.
|
|
1291
|
+
*
|
|
1292
|
+
* `Tree` splits a path on `/` and drops empty and `.` segments, so `""`,
|
|
1293
|
+
* `"."`, a padded name and a name holding a separator all address something
|
|
1294
|
+
* other than the one stage the caller named. A stage read from an
|
|
1295
|
+
* environment variable carries a trailing newline often enough to matter.
|
|
1296
|
+
*/
|
|
1297
|
+
const assertStageAddress = (field, value) => {
|
|
1298
|
+
if (value !== value.trim() || value === "") throw new Error(`A state ${field} must be a non-empty name with no surrounding whitespace.`);
|
|
1299
|
+
if (value.includes("/") || value === "." || value === "..") throw new Error(`A state ${field} must name one scope: '/', '.' and '..' are not allowed.`);
|
|
842
1300
|
};
|
|
1301
|
+
/** Run one `Tree` effect over the caller's state layer. */
|
|
1302
|
+
const overState = (state, effect) => Effect.runPromise(Effect.provideService(effect, State, Effect.succeed(state)));
|
|
843
1303
|
/**
|
|
844
|
-
*
|
|
845
|
-
*
|
|
846
|
-
*
|
|
847
|
-
*
|
|
1304
|
+
* Every `(stack, stage)` pair the state layer holds, or the subset a filter
|
|
1305
|
+
* pins. Ordered by stack then stage.
|
|
1306
|
+
*
|
|
1307
|
+
* The pairs come from the store's own `listStacks` and `listStages`, so an
|
|
1308
|
+
* empty result is the store reporting nothing, never a read this module
|
|
1309
|
+
* could not make: a state layer that cannot be read fails instead.
|
|
848
1310
|
*/
|
|
849
|
-
const
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
if (action === void 0 || !isPlanAction(action) || !Number.isSafeInteger(count) || stated.includes(action)) throw new PlanEffectsError("summary-unrecognized");
|
|
857
|
-
counts[action] = count;
|
|
858
|
-
stated.push(action);
|
|
859
|
-
}
|
|
860
|
-
const canonical = PLAN_ACTIONS.filter((action) => counts[action] > 0).map((action) => `${counts[action]} to ${action}`).join(", ");
|
|
861
|
-
if (stated.length === 0 || `${SUMMARY_PREFIX} ${canonical}` !== summary) throw new PlanEffectsError("summary-unrecognized");
|
|
862
|
-
return counts;
|
|
1311
|
+
const inventoryStages = async (state, filter = {}) => {
|
|
1312
|
+
if (filter.stack !== void 0) assertStageAddress("stack", filter.stack);
|
|
1313
|
+
if (filter.stage !== void 0) assertStageAddress("stage", filter.stage);
|
|
1314
|
+
return (await overState(state, allStages(filter))).map(({ stack, stage }) => ({
|
|
1315
|
+
stack,
|
|
1316
|
+
stage
|
|
1317
|
+
}));
|
|
863
1318
|
};
|
|
864
1319
|
/**
|
|
865
|
-
*
|
|
1320
|
+
* The persisted-state document for one stage, as `parseStateSnapshot` reads
|
|
1321
|
+
* it: `{ resources: [{ stack, stage, fqn, state }] }`, ordered by fqn.
|
|
866
1322
|
*
|
|
867
|
-
*
|
|
868
|
-
*
|
|
1323
|
+
* The read is the one `runAlchemyLifecycle` already makes — the atomic stage
|
|
1324
|
+
* view, then each listed resource record — so a caller holding the document
|
|
1325
|
+
* and a caller holding the lifecycle see the same records. A listed resource
|
|
1326
|
+
* with no readable record fails the read; it is never left out, because a
|
|
1327
|
+
* short document reads as a stage with less in it than it has.
|
|
1328
|
+
*
|
|
1329
|
+
* A stage the store does not hold reads as a document with no resource in
|
|
1330
|
+
* it, the same reading the lifecycle takes for a first deploy. Only a stage
|
|
1331
|
+
* the store cannot read fails. `deleteStageRows` answers an absent stage the
|
|
1332
|
+
* other way, because it addresses a path rather than a stage view.
|
|
869
1333
|
*/
|
|
870
|
-
const
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
const summary = lines[summaryIndex] ?? "";
|
|
877
|
-
const resources = rowsUnder(lines, summaryIndex);
|
|
878
|
-
if (summary === NO_CHANGES) {
|
|
879
|
-
if (resources.length > 0) throw new PlanEffectsError("effects-under-no-changes");
|
|
880
|
-
return {
|
|
881
|
-
counts: zeroCounts(),
|
|
882
|
-
resources
|
|
883
|
-
};
|
|
884
|
-
}
|
|
885
|
-
const counts = countsStated(summary);
|
|
886
|
-
const seen = zeroCounts();
|
|
887
|
-
for (const { action } of resources) seen[action] += 1;
|
|
888
|
-
if (PLAN_ACTIONS.some((action) => seen[action] !== counts[action])) throw new PlanEffectsError("count-mismatch");
|
|
889
|
-
return {
|
|
890
|
-
counts,
|
|
891
|
-
resources
|
|
1334
|
+
const readStageSnapshot = async (state, target) => {
|
|
1335
|
+
assertStageAddress("stack", target.stack);
|
|
1336
|
+
assertStageAddress("stage", target.stage);
|
|
1337
|
+
const scope = {
|
|
1338
|
+
stack: target.stack,
|
|
1339
|
+
stage: target.stage
|
|
892
1340
|
};
|
|
1341
|
+
const view = await Effect.runPromise(state.snapshotStage(scope));
|
|
1342
|
+
if (!Array.isArray(view.resources) || view.resources.some((fqn) => typeof fqn !== "string" || fqn === "")) throw new Error(`The state layer returned no resource list for ${target.stack} in ${target.stage}.`);
|
|
1343
|
+
const resources = await Promise.all([...view.resources].toSorted().map(async (fqn) => ({
|
|
1344
|
+
...scope,
|
|
1345
|
+
fqn,
|
|
1346
|
+
state: await Effect.runPromise(state.get({
|
|
1347
|
+
...scope,
|
|
1348
|
+
fqn
|
|
1349
|
+
}))
|
|
1350
|
+
})));
|
|
1351
|
+
if (resources.some((resource) => !isRecord$1(resource.state))) throw new Error(`The state layer listed a resource it could not read for ${target.stack} in ${target.stage}.`);
|
|
1352
|
+
return JSON.stringify({ resources });
|
|
1353
|
+
};
|
|
1354
|
+
/**
|
|
1355
|
+
* Remove every row the state layer holds for one stage. Other stages of the
|
|
1356
|
+
* same stack keep every row.
|
|
1357
|
+
*
|
|
1358
|
+
* A stage the store does not hold is refused by the state layer rather than
|
|
1359
|
+
* reported as removed, so a caller never reads a mistyped stage as a stage
|
|
1360
|
+
* that was already empty.
|
|
1361
|
+
*/
|
|
1362
|
+
const deleteStageRows = async (state, target) => {
|
|
1363
|
+
assertStageAddress("stack", target.stack);
|
|
1364
|
+
assertStageAddress("stage", target.stage);
|
|
1365
|
+
await overState(state, deleteState({
|
|
1366
|
+
path: `${target.stack}/${target.stage}`,
|
|
1367
|
+
recursive: true
|
|
1368
|
+
}));
|
|
893
1369
|
};
|
|
894
1370
|
//#endregion
|
|
895
1371
|
//#region src/alchemy/lifecycle.ts
|
|
@@ -962,25 +1438,26 @@ const persistedToSnapshot = (target, persisted) => ({ resources: persisted.resou
|
|
|
962
1438
|
*/
|
|
963
1439
|
const IN_FLIGHT_STATUSES = new Set(["creating", "deleting"]);
|
|
964
1440
|
const statusOf = (record) => isRecord(record.state) ? record.state.status : void 0;
|
|
965
|
-
/** The leaf of a persisted FQN: what Alchemy prints as a plan row's tag. */
|
|
966
|
-
const leafOf = (fqn) => fqn.slice(fqn.lastIndexOf("/") + 1);
|
|
967
1441
|
/**
|
|
968
|
-
* Resolve plan rows
|
|
969
|
-
*
|
|
970
|
-
*
|
|
971
|
-
*
|
|
972
|
-
*
|
|
1442
|
+
* Resolve plan rows onto persisted FQNs. A row's `id` is the FQN Alchemy
|
|
1443
|
+
* printed in its tag, which is the identity the resource is persisted under
|
|
1444
|
+
* (epic #1165, D1), so a row resolves only to the candidate that is that
|
|
1445
|
+
* FQN exactly. A row with no candidate is `undefined`, and the caller
|
|
1446
|
+
* refuses rather than guessing.
|
|
973
1447
|
*/
|
|
974
1448
|
const resolveRows = (rows, candidates) => {
|
|
975
1449
|
const resolved = [];
|
|
976
1450
|
for (const { id } of rows) {
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
resolved.push(matches[0]);
|
|
1451
|
+
if (!candidates.includes(id)) return;
|
|
1452
|
+
resolved.push(id);
|
|
980
1453
|
}
|
|
981
1454
|
return resolved;
|
|
982
1455
|
};
|
|
983
|
-
/**
|
|
1456
|
+
/**
|
|
1457
|
+
* Two rows with one FQN name one persisted identity twice. Alchemy keys a
|
|
1458
|
+
* plan by FQN, so its renderer cannot print this; output that does is not a
|
|
1459
|
+
* plan this operation can bind to, and is refused rather than read.
|
|
1460
|
+
*/
|
|
984
1461
|
const hasRepeatedIds = (effects) => new Set(effects.resources.map(({ id }) => id)).size !== effects.resources.length;
|
|
985
1462
|
/** Whether a lease still fences the stage, by the store's own fenced renewal. */
|
|
986
1463
|
const fenceState = async (lease) => {
|
|
@@ -1180,9 +1657,6 @@ var LifecycleRun = class {
|
|
|
1180
1657
|
resources: records
|
|
1181
1658
|
};
|
|
1182
1659
|
}
|
|
1183
|
-
profileArgs() {
|
|
1184
|
-
return this.policy.profile === void 0 ? [] : ["--profile", this.policy.profile];
|
|
1185
|
-
}
|
|
1186
1660
|
stageArgs() {
|
|
1187
1661
|
return ["--stage", this.request.stage];
|
|
1188
1662
|
}
|
|
@@ -1232,7 +1706,6 @@ var LifecycleRun = class {
|
|
|
1232
1706
|
"deploy",
|
|
1233
1707
|
"--dry-run",
|
|
1234
1708
|
...adopt ? ["--adopt"] : [],
|
|
1235
|
-
...this.profileArgs(),
|
|
1236
1709
|
...this.stageArgs()
|
|
1237
1710
|
];
|
|
1238
1711
|
}
|
|
@@ -1296,7 +1769,7 @@ var LifecycleRun = class {
|
|
|
1296
1769
|
};
|
|
1297
1770
|
}
|
|
1298
1771
|
admitFirstDeploy(effects, text) {
|
|
1299
|
-
if (effects.counts.update + effects.counts.replace + effects.counts.delete > 0) throw this.fail("EFFECT_NOT_PERMITTED", "admission");
|
|
1772
|
+
if (effects.counts.update + effects.counts.replace + effects.counts.delete + effects.counts.orphaned > 0) throw this.fail("EFFECT_NOT_PERMITTED", "admission");
|
|
1300
1773
|
const planHash = this.planHashOf(text);
|
|
1301
1774
|
const { firstDeploy } = this.policy;
|
|
1302
1775
|
if (firstDeploy === void 0 || planHash === void 0) throw this.fail("FIRST_DEPLOY_UNAUTHORIZED", "admission");
|
|
@@ -1336,7 +1809,7 @@ var LifecycleRun = class {
|
|
|
1336
1809
|
return;
|
|
1337
1810
|
}
|
|
1338
1811
|
if (!isProtected) return;
|
|
1339
|
-
if (effects.counts.replace + effects.counts.delete > 0) throw this.fail("EFFECT_NOT_PERMITTED", "admission");
|
|
1812
|
+
if (effects.counts.replace + effects.counts.delete + effects.counts.orphaned > 0) throw this.fail("EFFECT_NOT_PERMITTED", "admission");
|
|
1340
1813
|
this.assertRetained(persistedToSnapshot(this.target, before), void 0, "admission");
|
|
1341
1814
|
}
|
|
1342
1815
|
/**
|
|
@@ -1348,14 +1821,11 @@ var LifecycleRun = class {
|
|
|
1348
1821
|
* FQN, and that recovery is the ordinary accident this operation exists
|
|
1349
1822
|
* for.
|
|
1350
1823
|
*
|
|
1351
|
-
* A plan row carries
|
|
1352
|
-
*
|
|
1353
|
-
* `
|
|
1354
|
-
*
|
|
1355
|
-
*
|
|
1356
|
-
* leaf matches; a nested recovery is therefore refused rather than
|
|
1357
|
-
* guessed. Zero candidates is a create that did not happen; several is an
|
|
1358
|
-
* identity the plan cannot name.
|
|
1824
|
+
* A plan row carries the resource's FQN, so a row names one persisted
|
|
1825
|
+
* identity exactly, under any namespace. An in-flight record at another
|
|
1826
|
+
* FQN (`other/Worker` for a `[Worker]` row) is not the one the row names,
|
|
1827
|
+
* so it never qualifies as a recovery. No candidate is a create that did
|
|
1828
|
+
* not happen.
|
|
1359
1829
|
*/
|
|
1360
1830
|
createdFqns(before, after, effects) {
|
|
1361
1831
|
const planned = effects.resources.filter(({ action }) => action === "create");
|
|
@@ -1368,6 +1838,8 @@ var LifecycleRun = class {
|
|
|
1368
1838
|
}
|
|
1369
1839
|
assertConverged(effects, after) {
|
|
1370
1840
|
const tolerated = new Set(this.policy.nonConvergentResources);
|
|
1841
|
+
if (effects.bindingChanges > 0) throw this.fail("NOT_CONVERGED", "postcondition");
|
|
1842
|
+
if (effects.tasks.length > 0) throw this.fail("NOT_CONVERGED", "postcondition");
|
|
1371
1843
|
const pending = effects.resources.filter(({ action }) => action !== "noop");
|
|
1372
1844
|
const fqns = resolveRows(pending, after.resources.map(({ fqn }) => fqn));
|
|
1373
1845
|
if (fqns === void 0) throw this.fail("NOT_CONVERGED", "postcondition");
|
|
@@ -1382,7 +1854,6 @@ var LifecycleRun = class {
|
|
|
1382
1854
|
await this.runChild("deploy", [
|
|
1383
1855
|
"deploy",
|
|
1384
1856
|
...adopt ? ["--adopt"] : [],
|
|
1385
|
-
...this.profileArgs(),
|
|
1386
1857
|
...this.stageArgs(),
|
|
1387
1858
|
"--yes"
|
|
1388
1859
|
], lease);
|
|
@@ -1415,7 +1886,6 @@ var LifecycleRun = class {
|
|
|
1415
1886
|
const { cleanup, effects } = await this.withLease("state", "postcondition", async (lease, accept) => {
|
|
1416
1887
|
const text = await this.runChild("destroy", [
|
|
1417
1888
|
"destroy",
|
|
1418
|
-
...this.profileArgs(),
|
|
1419
1889
|
...this.stageArgs(),
|
|
1420
1890
|
"--yes"
|
|
1421
1891
|
], lease);
|
|
@@ -1457,7 +1927,8 @@ var LifecycleRun = class {
|
|
|
1457
1927
|
* apply child, then the persisted state again: it must record what the plan
|
|
1458
1928
|
* created and, on a protected stage, the same identities as before. Then a
|
|
1459
1929
|
* second dry-run plan, which must be a no-op except an `update` on a resource
|
|
1460
|
-
* the policy names non-convergent.
|
|
1930
|
+
* the policy names non-convergent. A binding change or a task row in that
|
|
1931
|
+
* plan is work left behind, and no policy tolerates it.
|
|
1461
1932
|
*
|
|
1462
1933
|
* `destroy`: refused on a protected stage. Then, under the stage lease, the
|
|
1463
1934
|
* destroy child, then the atomic stage view must be empty and the empty
|
|
@@ -1505,4 +1976,4 @@ const runAlchemyLifecycle = async (options) => {
|
|
|
1505
1976
|
/** The specifier a consumer imports this surface by. */
|
|
1506
1977
|
const ALCHEMY_SUBPATH = "@patronage/factory-ci/alchemy";
|
|
1507
1978
|
//#endregion
|
|
1508
|
-
export { ALCHEMY_SUBPATH, AlchemyLifecycleError, CloudflareCredentialUnavailableError, HOSTED_CLOUDFLARE_CREDENTIAL_NAMES, LIFECYCLE_FAILURE_MESSAGES, LOCAL_PREVIEW_STAGES, MINIMUM_REDACTABLE_SECRET_LENGTH, PLAN_ACTIONS, PLAN_OUTPUT_ALCHEMY_VERSION, PlanEffectsError, RetainedIdentityError, SECRET_REDACTION_PLACEHOLDER, StagePolicyError, UrlImpliesAuthError, anyStage, assertDestroyLeftNothing, assertRetainedIdentities, assertUrlImpliesAuth, credentialPreflight, evaluateStack, firstDeployAuthorization, firstDeployPlanHash, isAlchemyLifecycleError, parsePlanEffects, parseStateSnapshot, runAlchemyLifecycle, secretNamePolicy, stagePolicy };
|
|
1979
|
+
export { ALCHEMY_SUBPATH, AlchemyLifecycleError, CloudflareCredentialUnavailableError, HOSTED_CLOUDFLARE_CREDENTIAL_NAMES, LIFECYCLE_FAILURE_MESSAGES, LOCAL_PREVIEW_STAGES, MINIMUM_REDACTABLE_SECRET_LENGTH, PLAN_ACTIONS, PLAN_OUTPUT_ALCHEMY_VERSION, PlanEffectsError, RetainedIdentityError, SECRET_REDACTION_PLACEHOLDER, StagePolicyError, UrlImpliesAuthError, anyStage, assertDestroyLeftNothing, assertRetainedIdentities, assertUrlImpliesAuth, credentialPreflight, deleteStageRows, evaluateStack, firstDeployAuthorization, firstDeployPlanHash, inventoryStages, isAlchemyLifecycleError, isLocalEmulationStage, localEmulationEnvironment, parsePlanEffects, parseStateSnapshot, readStageSnapshot, runAlchemyLifecycle, secretNamePolicy, stagePolicy };
|