@intentius/chant 0.78.0 → 0.80.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +38 -0
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/terraform-state.d.ts +14 -0
- package/dist/audit/terraform-state.d.ts.map +1 -1
- package/dist/cli/command-group.d.ts +6 -0
- package/dist/cli/command-group.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts +27 -2
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/import-agents.d.ts.map +1 -1
- package/dist/cli/commands/init.d.ts +6 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/onboard.d.ts.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/conflict-check.d.ts +6 -0
- package/dist/cli/conflict-check.d.ts.map +1 -1
- package/dist/cli/handlers/dev.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/misc.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +17 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/promote.d.ts +17 -0
- package/dist/cli/handlers/promote.d.ts.map +1 -0
- package/dist/cli/handlers/run-generate.d.ts +39 -0
- package/dist/cli/handlers/run-generate.d.ts.map +1 -0
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +9 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +18 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/capability-plugin-loader.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/discover.d.ts.map +1 -1
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/fan-out-support.d.ts.map +1 -1
- package/dist/components/pilots/alb-ecs.pilot.d.ts +7 -1
- package/dist/components/pilots/alb-ecs.pilot.d.ts.map +1 -1
- package/dist/components/presets/ecs-fargate.d.ts +10 -4
- package/dist/components/presets/ecs-fargate.d.ts.map +1 -1
- package/dist/components/promote.d.ts +211 -0
- package/dist/components/promote.d.ts.map +1 -0
- package/dist/components/unbound-gate-approval.d.ts +24 -0
- package/dist/components/unbound-gate-approval.d.ts.map +1 -0
- package/dist/config.d.ts +82 -5
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/convergence.d.ts +97 -0
- package/dist/discovery/convergence.d.ts.map +1 -0
- package/dist/discovery/files.d.ts +41 -2
- package/dist/discovery/files.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +15 -0
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +13 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/lexicon-module.d.ts +65 -0
- package/dist/lexicon-module.d.ts.map +1 -0
- package/dist/lexicon.d.ts +24 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/build-ledger-store.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +22 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/legacy-digest.d.ts +26 -0
- package/dist/lifecycle/legacy-digest.d.ts.map +1 -0
- package/dist/lifecycle/release-ledger.d.ts +25 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts +2 -3
- package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts.map +1 -1
- package/dist/lint/rules/op/index.d.ts +2 -1
- package/dist/lint/rules/op/index.d.ts.map +1 -1
- package/dist/lint/rules/op/ops015-gate-approval.d.ts +14 -0
- package/dist/lint/rules/op/ops015-gate-approval.d.ts.map +1 -0
- package/dist/op/activity-contract-registry.d.ts.map +1 -1
- package/dist/op/activity-registry.d.ts.map +1 -1
- package/dist/op/builders.d.ts +4 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts +142 -0
- package/dist/op/gate-approval.d.ts.map +1 -0
- package/dist/op/gate.d.ts +54 -0
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/generate-pipeline.d.ts.map +1 -1
- package/dist/op/index.d.ts +4 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +4 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +2 -0
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/types.d.ts +7 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/workspace/record-source.d.ts +22 -0
- package/dist/workspace/record-source.d.ts.map +1 -0
- package/dist/workspace/records-cli.d.ts +57 -0
- package/dist/workspace/records-cli.d.ts.map +1 -0
- package/dist/workspace/records.d.ts +118 -0
- package/dist/workspace/records.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/audit/core.ts +13 -2
- package/src/audit/discover.ts +90 -12
- package/src/audit/terraform-state.test.ts +86 -1
- package/src/audit/terraform-state.ts +26 -0
- package/src/cli/command-group.ts +7 -0
- package/src/cli/commands/audit-walk-warnings.test.ts +156 -0
- package/src/cli/commands/audit.ts +93 -19
- package/src/cli/commands/check-lexicon-examples.ts +2 -1
- package/src/cli/commands/doctor.ts +3 -0
- package/src/cli/commands/import-agents.ts +11 -4
- package/src/cli/commands/init.ts +57 -25
- package/src/cli/commands/lint.test.ts +58 -1
- package/src/cli/commands/lint.ts +48 -18
- package/src/cli/commands/onboard.ts +15 -0
- package/src/cli/commands/update.ts +5 -1
- package/src/cli/conflict-check.test.ts +18 -1
- package/src/cli/conflict-check.ts +13 -0
- package/src/cli/discovery-skip.test.ts +168 -0
- package/src/cli/handlers/build.test.ts +11 -0
- package/src/cli/handlers/build.ts +1 -1
- package/src/cli/handlers/dev.ts +3 -0
- package/src/cli/handlers/graph.test.ts +20 -0
- package/src/cli/handlers/graph.ts +4 -2
- package/src/cli/handlers/lifecycle.ts +2 -1
- package/src/cli/handlers/misc.ts +16 -0
- package/src/cli/handlers/operator.test.ts +85 -0
- package/src/cli/handlers/operator.ts +118 -2
- package/src/cli/handlers/promote.test.ts +261 -0
- package/src/cli/handlers/promote.ts +325 -0
- package/src/cli/handlers/run-generate.test.ts +160 -0
- package/src/cli/handlers/run-generate.ts +133 -0
- package/src/cli/handlers/run.ts +13 -1
- package/src/cli/main.test.ts +33 -1
- package/src/cli/main.ts +78 -16
- package/src/cli/mcp/op-tools.ts +2 -1
- package/src/cli/path-lexicon-messages.test.ts +152 -0
- package/src/cli/plugins.ts +44 -6
- package/src/cli/registry.ts +18 -1
- package/src/components/__fixtures__/alb-ecs-service.json +0 -8
- package/src/components/capability-plugin-loader.ts +3 -1
- package/src/components/cli-support.ts +3 -2
- package/src/components/discover.ts +26 -2
- package/src/components/driver.test.ts +12 -11
- package/src/components/driver.ts +2 -0
- package/src/components/fan-out-support.ts +2 -1
- package/src/components/pilots/README.md +1 -1
- package/src/components/pilots/alb-ecs.pilot.ts +7 -7
- package/src/components/presets/ecs-fargate.ts +10 -5
- package/src/components/promote.test.ts +371 -0
- package/src/components/promote.ts +537 -0
- package/src/components/unbound-gate-approval.test.ts +82 -0
- package/src/components/unbound-gate-approval.ts +51 -0
- package/src/config.test.ts +40 -0
- package/src/config.ts +128 -11
- package/src/discovery/convergence.test.ts +231 -0
- package/src/discovery/convergence.ts +399 -0
- package/src/discovery/files.test.ts +127 -1
- package/src/discovery/files.ts +97 -9
- package/src/discovery/fold-import.ts +83 -9
- package/src/discovery/sandbox/path-lexicon-parity.test.ts +162 -0
- package/src/graph-ir.ts +14 -0
- package/src/lexicon-module.test.ts +203 -0
- package/src/lexicon-module.ts +117 -0
- package/src/lexicon.ts +26 -0
- package/src/lifecycle/build-ledger-store.ts +11 -1
- package/src/lifecycle/gate-ledger.ts +22 -0
- package/src/lifecycle/legacy-digest.test.ts +114 -0
- package/src/lifecycle/legacy-digest.ts +62 -0
- package/src/lifecycle/release-ledger.ts +34 -0
- package/src/lint/rules/comp/comp003-mutating-no-rollback.ts +2 -3
- package/src/lint/rules/op/index.ts +4 -2
- package/src/lint/rules/op/ops015-gate-approval.test.ts +67 -0
- package/src/lint/rules/op/ops015-gate-approval.ts +57 -0
- package/src/op/activity-contract-registry.ts +18 -4
- package/src/op/activity-registry.ts +26 -0
- package/src/op/builders.ts +10 -1
- package/src/op/discover-convergence.test.ts +70 -0
- package/src/op/discover.ts +4 -0
- package/src/op/gate-approval.test.ts +258 -0
- package/src/op/gate-approval.ts +260 -0
- package/src/op/gate.ts +148 -10
- package/src/op/generate-pipeline.ts +2 -1
- package/src/op/index.ts +9 -1
- package/src/op/local-executor.test.ts +66 -0
- package/src/op/local-executor.ts +44 -1
- package/src/op/op-ir.ts +4 -0
- package/src/op/runtimes/local.ts +2 -1
- package/src/op/types.ts +7 -0
- package/src/workspace/record-source.ts +117 -0
- package/src/workspace/records-cli.ts +126 -0
- package/src/workspace/records-contract.test.ts +88 -0
- package/src/workspace/records.schema.json +120 -0
- package/src/workspace/records.test.ts +243 -0
- package/src/workspace/records.ts +383 -0
|
@@ -49,6 +49,9 @@ import { isComponent, type Component } from "./component";
|
|
|
49
49
|
import type { BuildParamProvenance } from "../provenance";
|
|
50
50
|
import { buildParamValues } from "../build-params";
|
|
51
51
|
import { setBuildParams } from "../params";
|
|
52
|
+
import { compileDiscoveryFilter, hasDiscoveryMarker } from "../discovery/files";
|
|
53
|
+
import { resolveDiscoveryGlobs } from "../config";
|
|
54
|
+
import { warnDiscoveryChanges } from "../discovery/convergence";
|
|
52
55
|
|
|
53
56
|
/** One discovered component, paired with the file it was exported from. */
|
|
54
57
|
export interface DiscoveredComponent {
|
|
@@ -118,11 +121,15 @@ export interface ImportedComponentModule {
|
|
|
118
121
|
* Recursively find every `*.component.ts` file under `path`, the same
|
|
119
122
|
* child-project boundary rule `findInfraFiles` (`../discovery/files.ts`)
|
|
120
123
|
* uses: a subdirectory with its own `chant.config.ts` is a separate scope
|
|
121
|
-
* once the outer project's own source root has been established.
|
|
124
|
+
* once the outer project's own source root has been established. The
|
|
125
|
+
* project's `exclude`/`include` globs and the skip marker apply here as they
|
|
126
|
+
* do there (#2519).
|
|
122
127
|
*/
|
|
123
128
|
async function findComponentFiles(path: string): Promise<string[]> {
|
|
124
129
|
const files: string[] = [];
|
|
125
130
|
let sourceRoot: string | null = null;
|
|
131
|
+
const skip = compileDiscoveryFilter(await resolveDiscoveryGlobs(path));
|
|
132
|
+
const skippedChildren: string[] = [];
|
|
126
133
|
|
|
127
134
|
async function scanDirectory(dir: string): Promise<void> {
|
|
128
135
|
let entries;
|
|
@@ -148,6 +155,7 @@ async function findComponentFiles(path: string): Promise<string[]> {
|
|
|
148
155
|
if (sourceRoot === null) {
|
|
149
156
|
sourceRoot = fullPath;
|
|
150
157
|
} else {
|
|
158
|
+
skippedChildren.push(fullPath);
|
|
151
159
|
continue;
|
|
152
160
|
}
|
|
153
161
|
}
|
|
@@ -156,7 +164,9 @@ async function findComponentFiles(path: string): Promise<string[]> {
|
|
|
156
164
|
entry.isFile() &&
|
|
157
165
|
entry.name.endsWith(".component.ts") &&
|
|
158
166
|
!entry.name.endsWith(".test.component.ts") &&
|
|
159
|
-
!entry.name.endsWith(".spec.component.ts")
|
|
167
|
+
!entry.name.endsWith(".spec.component.ts") &&
|
|
168
|
+
!skip?.(fullPath) &&
|
|
169
|
+
!(await hasDiscoveryMarker(fullPath))
|
|
160
170
|
) {
|
|
161
171
|
files.push(fullPath);
|
|
162
172
|
}
|
|
@@ -164,6 +174,20 @@ async function findComponentFiles(path: string): Promise<string[]> {
|
|
|
164
174
|
}
|
|
165
175
|
|
|
166
176
|
await scanDirectory(path);
|
|
177
|
+
// #2527's warning release: the list returned is today's, unchanged.
|
|
178
|
+
await warnDiscoveryChanges({
|
|
179
|
+
walker: "components",
|
|
180
|
+
root: path,
|
|
181
|
+
files,
|
|
182
|
+
sourceRoot,
|
|
183
|
+
skippedChildren,
|
|
184
|
+
fileOk: async (name, full) =>
|
|
185
|
+
name.endsWith(".component.ts") &&
|
|
186
|
+
!name.endsWith(".test.component.ts") &&
|
|
187
|
+
!name.endsWith(".spec.component.ts") &&
|
|
188
|
+
!skip?.(full) &&
|
|
189
|
+
!(await hasDiscoveryMarker(full)),
|
|
190
|
+
});
|
|
167
191
|
return files;
|
|
168
192
|
}
|
|
169
193
|
|
|
@@ -450,7 +450,7 @@ describe("runComponentDeploy — onFailure saga rollback", () => {
|
|
|
450
450
|
);
|
|
451
451
|
});
|
|
452
452
|
|
|
453
|
-
it("runs a component-level rollback (schema `rollback` field) after step rollback
|
|
453
|
+
it("runs a component-level rollback (schema `rollback` field) after step rollback", async () => {
|
|
454
454
|
const registry = new CapabilityRegistry();
|
|
455
455
|
registry.register(fakeCapability("docker-build", { run: () => ({ digest: "sha256:abc" }) }).capability);
|
|
456
456
|
registry.register(
|
|
@@ -458,26 +458,29 @@ describe("runComponentDeploy — onFailure saga rollback", () => {
|
|
|
458
458
|
.capability,
|
|
459
459
|
);
|
|
460
460
|
registry.register(fakeCapability("cfn-deploy", { run: () => ({ stackStatus: "UPDATE_COMPLETE", outputs: {} }) }).capability);
|
|
461
|
-
// ecs-update-service fails
|
|
462
|
-
//
|
|
461
|
+
// ecs-update-service fails; the component's own `rollback` phase is what
|
|
462
|
+
// should fire after the executed steps unwind.
|
|
463
463
|
registry.register(fakeCapability("ecs-update-service", { failRun: true }).capability);
|
|
464
|
-
const { capability:
|
|
464
|
+
const { capability: restoreCap, calls: restoreCalls } = fakeCapability("restore-service", {
|
|
465
465
|
run: () => ({ restored: true }),
|
|
466
466
|
});
|
|
467
|
-
registry.register(
|
|
467
|
+
registry.register(restoreCap);
|
|
468
468
|
// Verify/rollback phases never reached since Apply fails first, but register
|
|
469
469
|
// stubs anyway so a hypothetical reordering wouldn't crash the test on an
|
|
470
470
|
// unregistered kind.
|
|
471
471
|
registry.register(fakeCapability("wait-steady-state", { run: () => ({ runningCount: 1 }) }).capability);
|
|
472
472
|
registry.register(fakeCapability("health-gate", { run: () => ({ healthy: true }) }).capability);
|
|
473
473
|
|
|
474
|
-
const json =
|
|
474
|
+
const json: DriverComponent = {
|
|
475
|
+
...(projectToJson(searchService) as unknown as DriverComponent),
|
|
476
|
+
rollback: [{ phase: "Rollback", steps: [{ kind: "restore-service", service: "search" }] }],
|
|
477
|
+
};
|
|
475
478
|
const result = await runComponentDeploy(json, { env: "dev", component: "search-service" }, registry, {});
|
|
476
479
|
|
|
477
480
|
expect(result.ok).toBe(false);
|
|
478
|
-
expect(
|
|
479
|
-
expect(
|
|
480
|
-
const rollbackRecord = result.records.find((r) => r.phase === "Rollback" && r.kind === "
|
|
481
|
+
expect(restoreCalls).toHaveLength(1);
|
|
482
|
+
expect(restoreCalls[0]!.input).toMatchObject({ service: "search" });
|
|
483
|
+
const rollbackRecord = result.records.find((r) => r.phase === "Rollback" && r.kind === "restore-service");
|
|
481
484
|
expect(rollbackRecord?.status).toBe("ok");
|
|
482
485
|
});
|
|
483
486
|
});
|
|
@@ -630,7 +633,6 @@ describe("runInterpretDriver — end to end", () => {
|
|
|
630
633
|
"ecs-update-service",
|
|
631
634
|
"wait-steady-state",
|
|
632
635
|
"health-gate",
|
|
633
|
-
"rollback-previous",
|
|
634
636
|
"wait-for-stack",
|
|
635
637
|
"run-migration",
|
|
636
638
|
];
|
|
@@ -750,7 +752,6 @@ describe("runInterpretDriver — end to end", () => {
|
|
|
750
752
|
"ecs-update-service",
|
|
751
753
|
"wait-steady-state",
|
|
752
754
|
"health-gate",
|
|
753
|
-
"rollback-previous",
|
|
754
755
|
"wait-for-stack",
|
|
755
756
|
"run-migration",
|
|
756
757
|
"lambda-deploy",
|
package/src/components/driver.ts
CHANGED
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
import { topoSort } from "../codegen/topo-sort";
|
|
50
50
|
import { evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "../op/gate";
|
|
51
51
|
import { gateName } from "../op/gate-name";
|
|
52
|
+
import { warnOnUnboundComponentApproval } from "./unbound-gate-approval";
|
|
52
53
|
import type { PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
53
54
|
import type { CapabilityRegistry, DeployContext } from "./capability";
|
|
54
55
|
import type { RunProgressEvent, RunProgressStatus } from "./run-progress";
|
|
@@ -479,6 +480,7 @@ async function runPhase(
|
|
|
479
480
|
}
|
|
480
481
|
throw new GateStop(gateRecords, [], check.pending, check.pushed, check.pushWarning);
|
|
481
482
|
}
|
|
483
|
+
warnOnUnboundComponentApproval(ctx.component, gateName(gate), ctx.env, check.resolution);
|
|
482
484
|
gateRecords.push({
|
|
483
485
|
...base,
|
|
484
486
|
status: "ok" as const,
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* over.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { lexiconNames } from "../lexicon-module";
|
|
16
17
|
import { resolveComponentTargets } from "./cli-support";
|
|
17
18
|
import { applyConfigDefaults } from "./config-defaults";
|
|
18
19
|
import { buildCapabilityRegistry } from "./capability-plugin-loader";
|
|
@@ -91,5 +92,5 @@ export async function deriveFanOut(options: DeriveFanOutOptions): Promise<Derive
|
|
|
91
92
|
*/
|
|
92
93
|
export async function fanOutRegistry(path: string, config?: ChantConfig): Promise<CapabilityRegistry> {
|
|
93
94
|
const resolved = config ?? (await loadChantConfig(path)).config;
|
|
94
|
-
return buildCapabilityRegistry({ plugins: resolved.capabilities, lexicons: resolved.lexicons });
|
|
95
|
+
return buildCapabilityRegistry({ plugins: resolved.capabilities, lexicons: lexiconNames(resolved.lexicons) });
|
|
95
96
|
}
|
|
@@ -13,7 +13,7 @@ The epic ([#551](https://github.com/INTENTIUS/chant/issues/551)) picked these th
|
|
|
13
13
|
|---|---|---|---|---|---|---|
|
|
14
14
|
| **Neo4j per-instance fan-out** | [`neo4j-fanout.pilot.ts`](./neo4j-fanout.pilot.ts) / [`neo4j-fanout.json`](../__fixtures__/neo4j-fanout.json) | no-build (`infra`, applies pre-built templates) | **fan-out** — one component composes 3 per-instance mini-compositions (`cfn-deploy` + `code-deploy` + `wait-cluster-healthy`), seed-first then rolling, gated at node 1 | simple apply (no `onReplace`/`stageGsi` — no sticky CFN concerns here) | none (self-contained cluster, no shared-stack imports) | **auto** — `code-deploy` (AWS CodeDeploy) rollback is native/automatic on failure, declared once inside the capability, never scripted per node |
|
|
15
15
|
| **DynamoDB table** | [`dynamodb.pilot.ts`](./dynamodb.pilot.ts) / [`dynamodb-infra.json`](../__fixtures__/dynamodb-infra.json) | no-build (`infra`, applies an existing table template) | single (one `cfn-deploy`, no fan-out) | **sticky** — `onReplace: "block"` refuses a replacing changeset (data loss guard); `stageGsi: true` stages the GSI add→backfill→remove instead of an in-place replace | none | no rollback declared — a blocked replacement is not something to compensate, it is a stop |
|
|
16
|
-
| **ALB/ECS target** | [`alb-ecs.pilot.ts`](./alb-ecs.pilot.ts) / [`alb-ecs-service.json`](../__fixtures__/alb-ecs-service.json) | **build** — `docker-build` → `publish-image` (promote by digest at deploy time) | single (one service, one `cfn-deploy` + `ecs-update-service`) | simple apply (no replacement-sensitive resource here) | **cross-stack** — imports `shared-alb`'s `ListenerArn`/`ClusterArn`/`Subnets` via `stackOutput()`, replacing the `describe-stacks \| jq` glue in [`examples/gitlab-aws-alb-services/src/pipeline.ts`](../../../../../examples/gitlab-aws-alb-services/src/pipeline.ts) | **no**
|
|
16
|
+
| **ALB/ECS target** | [`alb-ecs.pilot.ts`](./alb-ecs.pilot.ts) / [`alb-ecs-service.json`](../__fixtures__/alb-ecs-service.json) | **build** — `docker-build` → `publish-image` (promote by digest at deploy time) | single (one service, one `cfn-deploy` + `ecs-update-service`) | simple apply (no replacement-sensitive resource here) | **cross-stack** — imports `shared-alb`'s `ListenerArn`/`ClusterArn`/`Subnets` via `stackOutput()`, replacing the `describe-stacks \| jq` glue in [`examples/gitlab-aws-alb-services/src/pipeline.ts`](../../../../../examples/gitlab-aws-alb-services/src/pipeline.ts) | **no** — the in-run unwind calls `ecs-update-service`'s best-effort re-apply and `cfn-deploy`'s native rollback. The component declares no `rollback` phase (#2576), and an earlier release is restored with `chant components rollback` |
|
|
17
17
|
|
|
18
18
|
Read together, the three cover every cell at least once: build only shows up for ALB/ECS, fan-out only for Neo4j, sticky-apply only for DynamoDB, cross-stack only for ALB/ECS, and both rollback styles (capability-native vs component-declared) appear once each.
|
|
19
19
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pilot: ALB/ECS target (#555, epic #551).
|
|
3
3
|
*
|
|
4
|
-
* Exercises the build + cross-stack
|
|
4
|
+
* Exercises the build + cross-stack axes: `docker-build` →
|
|
5
5
|
* `publish-image` (promote by digest, deploy-time) → `cfn-deploy` (importing
|
|
6
6
|
* the shared ALB's listener/cluster/subnets as cross-stack outputs, resolved
|
|
7
7
|
* by `chant graph --stacks` rather than a `describe-stacks | jq` pipeline
|
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
* capabilities. `service` archetype: build → publish → apply → verify, the
|
|
17
17
|
* fullest of the three archetypes.
|
|
18
18
|
*
|
|
19
|
+
* It declares no component-level `rollback` phase (#2576). It used to declare
|
|
20
|
+
* one holding a `rollback-previous` step with only `service` and `cluster`,
|
|
21
|
+
* which the aws capability turned into an `aws ecs update-service` with no
|
|
22
|
+
* changes. Taking an environment back to an earlier recorded release is
|
|
23
|
+
* `chant components rollback` (#2531).
|
|
24
|
+
*
|
|
19
25
|
* The JSON projection of this pilot is authoritative at
|
|
20
26
|
* ../__fixtures__/alb-ecs-service.json (already schema-validated by
|
|
21
27
|
* component-schema.test.ts) — this module is the real typed `Component`
|
|
@@ -54,10 +60,4 @@ export const searchService: Component = {
|
|
|
54
60
|
{ kind: "health-gate", path: "/healthz" },
|
|
55
61
|
]),
|
|
56
62
|
],
|
|
57
|
-
// No native `rollback` on ecs-update-service/cfn-deploy for an already-running
|
|
58
|
-
// service swap — the component declares an explicit compensation phase
|
|
59
|
-
// (auto/no-rollback axis: this is the "no automatic capability rollback,
|
|
60
|
-
// component supplies its own" side, contrasted with code-deploy's native
|
|
61
|
-
// automatic rollback in the Neo4j pilot).
|
|
62
|
-
rollback: [phase("Rollback", [{ kind: "rollback-previous", service: "search", cluster: "$env.cluster" }])],
|
|
63
63
|
};
|
|
@@ -4,10 +4,16 @@
|
|
|
4
4
|
* The named-composition builder for the ALB/ECS shape
|
|
5
5
|
* (`../pilots/alb-ecs.pilot.ts`): `docker-build` -> `publish-image` ->
|
|
6
6
|
* `cfn-deploy` (importing a shared ALB's cross-stack outputs) ->
|
|
7
|
-
* `ecs-update-service` -> `wait-steady-state` + `health-gate
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* `ecs-update-service` -> `wait-steady-state` + `health-gate`.
|
|
8
|
+
*
|
|
9
|
+
* There is no component-level `rollback` phase (#2576). The preset used to
|
|
10
|
+
* declare one holding a `rollback-previous` step with only `service` and
|
|
11
|
+
* `cluster`. The aws capability behind that kind rolls a service back to the
|
|
12
|
+
* `taskDefinition` it is given, and with none given it ran `aws ecs
|
|
13
|
+
* update-service` with no changes, so the phase restored nothing. The
|
|
14
|
+
* in-run unwind still calls each executed step's own rollback, and taking an
|
|
15
|
+
* environment back to an earlier recorded release is `chant components
|
|
16
|
+
* rollback <env> --component <name>` (#2531).
|
|
11
17
|
*
|
|
12
18
|
* This is Level 2 reuse over Level 1 raw capabilities: a component that looks
|
|
13
19
|
* like this composes `EcsFargateComponent({ ... })` directly; the odd case
|
|
@@ -98,6 +104,5 @@ export function EcsFargateComponent(config: EcsFargateComponentConfig): Componen
|
|
|
98
104
|
{ kind: "health-gate", path: config.healthPath },
|
|
99
105
|
]),
|
|
100
106
|
],
|
|
101
|
-
rollback: [phase("Rollback", [{ kind: "rollback-previous", service, cluster }])],
|
|
102
107
|
};
|
|
103
108
|
}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promote a release without rebuilding (#2530).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, test, expect } from "vitest";
|
|
6
|
+
import { CapabilityRegistry, type DeployContext } from "./capability";
|
|
7
|
+
import { memoryGateLedgerPort, type GateLedgerPort } from "../op/gate";
|
|
8
|
+
import type { GateResolutionRecord } from "../lifecycle/gate-ledger";
|
|
9
|
+
import type { DriverComponent } from "./driver";
|
|
10
|
+
import type { ReleaseRecord } from "../lifecycle/release-ledger";
|
|
11
|
+
import {
|
|
12
|
+
planPromotion,
|
|
13
|
+
planRollback,
|
|
14
|
+
rollbackRecord,
|
|
15
|
+
selectRelease,
|
|
16
|
+
withoutBuildSteps,
|
|
17
|
+
runPromotion,
|
|
18
|
+
promotionRecord,
|
|
19
|
+
gateApprover,
|
|
20
|
+
promoteArchivePaths,
|
|
21
|
+
type PromotionPlan,
|
|
22
|
+
} from "./promote";
|
|
23
|
+
|
|
24
|
+
const rec = (component: string, env: string, digest: string, timestamp: string, runId = `run-${timestamp}`): ReleaseRecord => ({
|
|
25
|
+
version: 1,
|
|
26
|
+
component,
|
|
27
|
+
env,
|
|
28
|
+
digest,
|
|
29
|
+
gitSha: "abc123",
|
|
30
|
+
runId,
|
|
31
|
+
timestamp,
|
|
32
|
+
actor: "ci",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** Build, publish, apply: the shape the pilots and the adopt example use. */
|
|
36
|
+
const service = (name: string, extra: Partial<DriverComponent> = {}): DriverComponent => ({
|
|
37
|
+
name,
|
|
38
|
+
deploy: [
|
|
39
|
+
{ phase: "Build", steps: [{ kind: "docker-build", context: ".", into: `${name}.tar` }] },
|
|
40
|
+
{ phase: "Publish", steps: [{ kind: "publish-image", from: `archive:${name}.tar`, to: "$env.registry" }] },
|
|
41
|
+
{ phase: "Apply", steps: [{ kind: "apply", imageRef: "@Publish.digest" }] },
|
|
42
|
+
],
|
|
43
|
+
...extra,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("selecting the source release", () => {
|
|
47
|
+
const records = [
|
|
48
|
+
rec("api", "staging", "sha256:old", "2026-01-01T00:00:00Z"),
|
|
49
|
+
rec("api", "staging", "sha256:new", "2026-01-02T00:00:00Z"),
|
|
50
|
+
rec("web", "staging", "sha256:web", "2026-01-01T00:00:00Z"),
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
test("the latest release by default", () => {
|
|
54
|
+
const picked = selectRelease(records, "api", "staging");
|
|
55
|
+
expect("record" in picked && picked.record.digest).toBe("sha256:new");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a named digest that the ledger recorded", () => {
|
|
59
|
+
const picked = selectRelease(records, "api", "staging", "sha256:old");
|
|
60
|
+
expect("record" in picked && picked.record.timestamp).toBe("2026-01-01T00:00:00Z");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a digest missing from the source ledger fails and names what is recorded", () => {
|
|
64
|
+
const picked = selectRelease(records, "api", "staging", "sha256:nope");
|
|
65
|
+
expect("error" in picked && picked.error).toMatch(/sha256:nope is not recorded for "api" in "staging"/);
|
|
66
|
+
expect("error" in picked && picked.error).toMatch(/sha256:old, sha256:new/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("a component with no release in the source fails", () => {
|
|
70
|
+
const picked = selectRelease(records, "db", "staging");
|
|
71
|
+
expect("error" in picked && picked.error).toMatch(/no release of "db" is recorded in "staging"/);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("planning", () => {
|
|
76
|
+
const records = [
|
|
77
|
+
rec("api", "staging", "sha256:api", "2026-01-02T00:00:00Z"),
|
|
78
|
+
rec("gone", "staging", "sha256:gone", "2026-01-02T00:00:00Z"),
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
test("every declared component with a source release, and the rest reported", () => {
|
|
82
|
+
const plan = planPromotion({ from: "staging", to: "prod", sourceRecords: records, declared: ["api", "infra"] });
|
|
83
|
+
expect("items" in plan).toBe(true);
|
|
84
|
+
const { items, notPromoted } = plan as PromotionPlan;
|
|
85
|
+
expect(items.map((i) => [i.component, i.digest])).toEqual([["api", "sha256:api"]]);
|
|
86
|
+
expect(notPromoted).toEqual([{ component: "gone", reason: "recorded but not declared in this checkout" }]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("refuses the same environment on both sides", () => {
|
|
90
|
+
const plan = planPromotion({ from: "prod", to: "prod", sourceRecords: records, declared: ["api"] });
|
|
91
|
+
expect("error" in plan && plan.error).toMatch(/same environment/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("--digest needs --component", () => {
|
|
95
|
+
const plan = planPromotion({ from: "staging", to: "prod", sourceRecords: records, declared: ["api"], digest: "sha256:api" });
|
|
96
|
+
expect("error" in plan && plan.error).toMatch(/needs --component/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("an empty source ledger is an error, not a no-op", () => {
|
|
100
|
+
const plan = planPromotion({ from: "staging", to: "prod", sourceRecords: [], declared: ["api"] });
|
|
101
|
+
expect("error" in plan && plan.error).toMatch(/nothing to promote/);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("taking the build out of a composition", () => {
|
|
106
|
+
test("build steps go, publish and apply stay", () => {
|
|
107
|
+
const prepared = withoutBuildSteps(service("api"));
|
|
108
|
+
expect("component" in prepared).toBe(true);
|
|
109
|
+
if (!("component" in prepared)) return;
|
|
110
|
+
expect(prepared.removed).toEqual(["docker-build"]);
|
|
111
|
+
expect(prepared.component.deploy.map((p) => p.phase)).toEqual(["Publish", "Apply"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("gates survive, even in a phase that loses its build step", () => {
|
|
115
|
+
const c = service("api");
|
|
116
|
+
c.deploy[0].steps.unshift({ kind: "gate", gate: "release" });
|
|
117
|
+
const prepared = withoutBuildSteps(c);
|
|
118
|
+
if (!("component" in prepared)) throw new Error(prepared.error);
|
|
119
|
+
expect(prepared.component.deploy[0]).toEqual({ phase: "Build", steps: [{ kind: "gate", gate: "release" }] });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("no publish step is refused", () => {
|
|
123
|
+
const c: DriverComponent = { name: "chart", deploy: [{ phase: "Apply", steps: [{ kind: "helm-upgrade" }] }] };
|
|
124
|
+
const prepared = withoutBuildSteps(c);
|
|
125
|
+
expect("error" in prepared && prepared.error).toMatch(/no publish step/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("two publish steps are refused", () => {
|
|
129
|
+
const c = service("api");
|
|
130
|
+
c.deploy[1].steps.push({ kind: "publish-artifact", from: "archive:x.jar" });
|
|
131
|
+
const prepared = withoutBuildSteps(c);
|
|
132
|
+
expect("error" in prepared && prepared.error).toMatch(/2 publish steps/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("a step reading a removed build step's output is refused", () => {
|
|
136
|
+
const c = service("api");
|
|
137
|
+
c.deploy[1].steps[0] = { kind: "publish-image", from: "@Build.archivePath", to: "$env.registry" };
|
|
138
|
+
const prepared = withoutBuildSteps(c);
|
|
139
|
+
expect("error" in prepared && prepared.error).toMatch(/reads "@Build.archivePath"/);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/** A registry of fakes. `publishes` is the digest publish-image returns per component. */
|
|
144
|
+
function fakeRegistry(publishes: Record<string, string>) {
|
|
145
|
+
const ran: string[] = [];
|
|
146
|
+
const registry = new CapabilityRegistry();
|
|
147
|
+
const step = (kind: string, run: (ctx: DeployContext, input: Record<string, unknown>) => unknown) =>
|
|
148
|
+
registry.register({ kind, async run(ctx: DeployContext, input: Record<string, unknown>) { ran.push(`${ctx.env}:${ctx.component}:${kind}`); return run(ctx, input); } } as never);
|
|
149
|
+
step("docker-build", () => ({ digest: "sha256:rebuilt" }));
|
|
150
|
+
step("publish-image", (ctx) => ({ digest: publishes[ctx.component], uri: `reg/${ctx.component}@${publishes[ctx.component]}` }));
|
|
151
|
+
step("apply", (_ctx, input) => ({ applied: input.imageRef }));
|
|
152
|
+
return { registry, ran };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function planFor(component: string, digest: string): PromotionPlan {
|
|
156
|
+
return {
|
|
157
|
+
from: "staging",
|
|
158
|
+
to: "prod",
|
|
159
|
+
items: [{ component, digest, source: rec(component, "staging", digest, "2026-01-02T00:00:00Z", "run-7") }],
|
|
160
|
+
notPromoted: [],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function prepared(c: DriverComponent): DriverComponent {
|
|
165
|
+
const p = withoutBuildSteps(c);
|
|
166
|
+
if ("error" in p) throw new Error(p.error);
|
|
167
|
+
return p.component;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
describe("running a promotion", () => {
|
|
171
|
+
test("deploys the recorded digest to the target without building", async () => {
|
|
172
|
+
const { registry, ran } = fakeRegistry({ api: "sha256:api" });
|
|
173
|
+
const run = await runPromotion({
|
|
174
|
+
plan: planFor("api", "sha256:api"),
|
|
175
|
+
components: [prepared(service("api"))],
|
|
176
|
+
registry,
|
|
177
|
+
gates: memoryGateLedgerPort(),
|
|
178
|
+
});
|
|
179
|
+
expect(run.status).toBe("ok");
|
|
180
|
+
expect(ran).toEqual(["prod:api:publish-image", "prod:api:apply"]);
|
|
181
|
+
const apply = run.results[0].records.find((r) => r.kind === "apply");
|
|
182
|
+
expect(apply?.output).toEqual({ applied: "sha256:api" });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("a publish that yields another digest fails before apply runs", async () => {
|
|
186
|
+
const { registry, ran } = fakeRegistry({ api: "sha256:different" });
|
|
187
|
+
const run = await runPromotion({
|
|
188
|
+
plan: planFor("api", "sha256:api"),
|
|
189
|
+
components: [prepared(service("api"))],
|
|
190
|
+
registry,
|
|
191
|
+
gates: memoryGateLedgerPort(),
|
|
192
|
+
});
|
|
193
|
+
expect(run.status).toBe("fail");
|
|
194
|
+
expect(ran).not.toContain("prod:api:apply");
|
|
195
|
+
const publish = run.results[0].records.find((r) => r.kind === "publish-image" && r.status === "fail");
|
|
196
|
+
expect(publish?.error).toMatch(/published sha256:different, but the release being promoted recorded sha256:api/);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("the target's gate still applies, and an approval lets the same promote through", async () => {
|
|
200
|
+
const gated = service("api");
|
|
201
|
+
gated.deploy[2].steps.unshift({ kind: "gate", gate: "prod-release" });
|
|
202
|
+
const resolutions: GateResolutionRecord[] = [];
|
|
203
|
+
const memory = memoryGateLedgerPort();
|
|
204
|
+
const gates: GateLedgerPort = {
|
|
205
|
+
async read(op) {
|
|
206
|
+
const read = await memory.read(op);
|
|
207
|
+
return { ...read, resolutions: [...resolutions] };
|
|
208
|
+
},
|
|
209
|
+
appendPending: (input) => memory.appendPending(input),
|
|
210
|
+
};
|
|
211
|
+
const { registry, ran } = fakeRegistry({ api: "sha256:api" });
|
|
212
|
+
const opts = { plan: planFor("api", "sha256:api"), components: [prepared(gated)], registry, gates };
|
|
213
|
+
|
|
214
|
+
const first = await runPromotion({ ...opts, now: "2026-01-03T00:00:00Z" });
|
|
215
|
+
expect(first.status).toBe("gated");
|
|
216
|
+
expect(first.gate?.gate).toBe("prod-release");
|
|
217
|
+
expect(ran).not.toContain("prod:api:apply");
|
|
218
|
+
|
|
219
|
+
resolutions.push({ version: 1, kind: "resolution", op: "api", gate: "prod-release", resolvedBy: "alice", timestamp: "2026-01-03T00:01:00Z" });
|
|
220
|
+
const second = await runPromotion({ ...opts, now: "2026-01-03T00:02:00Z" });
|
|
221
|
+
expect(second.status).toBe("ok");
|
|
222
|
+
expect(gateApprover(second.results[0])).toBe("alice");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("dependencies outside the promotion do not block it", async () => {
|
|
226
|
+
const { registry } = fakeRegistry({ api: "sha256:api" });
|
|
227
|
+
const run = await runPromotion({
|
|
228
|
+
plan: planFor("api", "sha256:api"),
|
|
229
|
+
components: [prepared(service("api", { dependsOn: ["shared-alb"] }))],
|
|
230
|
+
registry,
|
|
231
|
+
gates: memoryGateLedgerPort(),
|
|
232
|
+
});
|
|
233
|
+
expect(run.status).toBe("ok");
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("the target record", () => {
|
|
238
|
+
test("carries the source release it promoted", () => {
|
|
239
|
+
const plan = planFor("api", "sha256:api");
|
|
240
|
+
plan.items[0].source.manifestDigest = "sha256:manifest";
|
|
241
|
+
const record = promotionRecord(plan.items[0], "prod", {
|
|
242
|
+
runId: "run-9",
|
|
243
|
+
actor: "bob",
|
|
244
|
+
timestamp: "2026-01-04T00:00:00Z",
|
|
245
|
+
approver: "alice",
|
|
246
|
+
});
|
|
247
|
+
expect(record).toEqual({
|
|
248
|
+
component: "api",
|
|
249
|
+
env: "prod",
|
|
250
|
+
digest: "sha256:api",
|
|
251
|
+
gitSha: "abc123",
|
|
252
|
+
runId: "run-9",
|
|
253
|
+
timestamp: "2026-01-04T00:00:00Z",
|
|
254
|
+
actor: "bob",
|
|
255
|
+
approver: "alice",
|
|
256
|
+
manifestDigest: "sha256:manifest",
|
|
257
|
+
promotedFrom: { env: "staging", runId: "run-7", timestamp: "2026-01-02T00:00:00Z" },
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("choosing the release a rollback restores", () => {
|
|
263
|
+
const history = [
|
|
264
|
+
rec("api", "prod", "sha256:a", "2026-01-01T00:00:00Z"),
|
|
265
|
+
rec("api", "prod", "sha256:b", "2026-01-02T00:00:00Z"),
|
|
266
|
+
rec("api", "prod", "sha256:c", "2026-01-03T00:00:00Z"),
|
|
267
|
+
];
|
|
268
|
+
const plan = (input: Partial<Parameters<typeof planRollback>[0]>) =>
|
|
269
|
+
planRollback({ env: "prod", records: history, declared: ["api"], component: "api", ...input });
|
|
270
|
+
|
|
271
|
+
test("the release before the current one by default", () => {
|
|
272
|
+
const p = plan({});
|
|
273
|
+
expect("items" in p && p.items[0].digest).toBe("sha256:b");
|
|
274
|
+
expect("items" in p && p.from).toBe("prod");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("a redeploy of the current digest does not count as an earlier release", () => {
|
|
278
|
+
const p = plan({ records: [...history, rec("api", "prod", "sha256:c", "2026-01-04T00:00:00Z")] });
|
|
279
|
+
expect("items" in p && p.items[0].digest).toBe("sha256:b");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("a chosen digest", () => {
|
|
283
|
+
const p = plan({ digest: "sha256:a" });
|
|
284
|
+
expect("items" in p && p.items[0].source.timestamp).toBe("2026-01-01T00:00:00Z");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("a digest the environment never recorded is refused", () => {
|
|
288
|
+
const p = plan({ digest: "sha256:z" });
|
|
289
|
+
expect("error" in p && p.error).toMatch(/sha256:z is not recorded for "api" in "prod"/);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("the current release is refused", () => {
|
|
293
|
+
const p = plan({ digest: "sha256:c" });
|
|
294
|
+
expect("error" in p && p.error).toMatch(/already the current release/);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("a single release has nothing earlier", () => {
|
|
298
|
+
const p = plan({ records: [history[0]] });
|
|
299
|
+
expect("error" in p && p.error).toMatch(/no earlier release/);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe("pinning the publish step for a rollback", () => {
|
|
304
|
+
test("the publish step is replaced by the recorded digest", () => {
|
|
305
|
+
const p = withoutBuildSteps(service("api"), { pinDigest: "sha256:b" });
|
|
306
|
+
if ("error" in p) throw new Error(p.error);
|
|
307
|
+
expect(p.removed).toEqual(["docker-build", "publish-image"]);
|
|
308
|
+
expect(p.component.deploy[0].steps[0]).toMatchObject({ kind: "recorded-digest", digest: "sha256:b", replaces: "publish-image" });
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("a step reading another publish output is refused", () => {
|
|
312
|
+
const c = service("api");
|
|
313
|
+
c.deploy[2].steps[0] = { kind: "apply", image: "@Publish.uri" };
|
|
314
|
+
const p = withoutBuildSteps(c, { pinDigest: "sha256:b" });
|
|
315
|
+
expect("error" in p && p.error).toMatch(/reads "@Publish.uri", but a rollback knows only the recorded digest/);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("a rollback redeploys the recorded digest and publishes nothing", async () => {
|
|
319
|
+
const { registry, ran } = fakeRegistry({ api: "sha256:new" });
|
|
320
|
+
const p = withoutBuildSteps(service("api"), { pinDigest: "sha256:b" });
|
|
321
|
+
if ("error" in p) throw new Error(p.error);
|
|
322
|
+
const plan: PromotionPlan = {
|
|
323
|
+
from: "prod",
|
|
324
|
+
to: "prod",
|
|
325
|
+
items: [{ component: "api", digest: "sha256:b", source: rec("api", "prod", "sha256:b", "2026-01-02T00:00:00Z", "run-2") }],
|
|
326
|
+
notPromoted: [],
|
|
327
|
+
};
|
|
328
|
+
const run = await runPromotion({ plan, components: [p.component], registry, gates: memoryGateLedgerPort() });
|
|
329
|
+
expect(run.status).toBe("ok");
|
|
330
|
+
expect(ran).toEqual(["prod:api:apply"]);
|
|
331
|
+
expect(run.results[0].records.find((r) => r.kind === "apply")?.output).toEqual({ applied: "sha256:b" });
|
|
332
|
+
|
|
333
|
+
expect(rollbackRecord(plan.items[0], "prod", { runId: "run-5", actor: "bob", timestamp: "2026-01-05T00:00:00Z" })).toMatchObject({
|
|
334
|
+
env: "prod",
|
|
335
|
+
digest: "sha256:b",
|
|
336
|
+
restores: { env: "prod", runId: "run-2", timestamp: "2026-01-02T00:00:00Z" },
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe("the archive paths a generated promote job carries (#2575)", () => {
|
|
342
|
+
test("every build step's into, nested phases included, once each and in order", () => {
|
|
343
|
+
const component: DriverComponent = {
|
|
344
|
+
name: "api",
|
|
345
|
+
deploy: [
|
|
346
|
+
{ phase: "Build", steps: [
|
|
347
|
+
{ kind: "docker-build", context: ".", into: "dist/api.tar" },
|
|
348
|
+
{ kind: "generate-sbom", artifactType: "image", path: "dist/api.tar", into: "dist/api.sbom.json" },
|
|
349
|
+
{ phase: "Lambdas", parallel: true, steps: [{ kind: "zip-package", source: "fn", into: "dist/fn.zip" }] },
|
|
350
|
+
] },
|
|
351
|
+
{ phase: "Again", steps: [{ kind: "docker-build", context: ".", into: "dist/api.tar" }] },
|
|
352
|
+
{ phase: "Publish", steps: [{ kind: "publish-image", from: "archive:dist/api.tar" }] },
|
|
353
|
+
],
|
|
354
|
+
};
|
|
355
|
+
expect(promoteArchivePaths(component)).toEqual(["dist/api.tar", "dist/fn.zip"]);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("a component that builds nothing carries nothing", () => {
|
|
359
|
+
expect(promoteArchivePaths({ name: "infra", deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy" }] }] })).toEqual([]);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("an into that is only known at run time is refused by name", () => {
|
|
363
|
+
for (const into of ["@Params.path", "$env.archive", undefined]) {
|
|
364
|
+
const component: DriverComponent = {
|
|
365
|
+
name: "api",
|
|
366
|
+
deploy: [{ phase: "Build", steps: [{ kind: "jvm-build", into }] }],
|
|
367
|
+
};
|
|
368
|
+
expect(() => promoteArchivePaths(component)).toThrow(/component "api": the jvm-build step in phase "Build".*not a literal path/);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
});
|