@intentius/chant 0.45.0 → 0.49.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 +21 -3
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +3 -2
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/build.d.ts +3 -3
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon.d.ts +14 -0
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/lexicon-surface-diff.d.ts +6 -0
- package/dist/cli/commands/lexicon-surface-diff.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +13 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
- package/dist/cli/mcp/tools/explain.d.ts +6 -0
- package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +1 -1
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +7 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/cli/reporters/stylish.d.ts +15 -1
- package/dist/cli/reporters/stylish.d.ts.map +1 -1
- package/dist/codegen/lexicon-regen.d.ts +11 -0
- package/dist/codegen/lexicon-regen.d.ts.map +1 -1
- package/dist/codegen/validate.d.ts +10 -0
- package/dist/codegen/validate.d.ts.map +1 -1
- package/dist/components/auto-release.d.ts +4 -0
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/starter-plugin.d.ts +2 -0
- package/dist/components/starter-plugin.d.ts.map +1 -1
- package/dist/components/verbs/ensure-secret.d.ts +50 -0
- package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
- package/dist/components/verbs/index.d.ts +8 -0
- package/dist/components/verbs/index.d.ts.map +1 -1
- package/dist/components/verbs/r2-sync.d.ts +76 -0
- package/dist/components/verbs/r2-sync.d.ts.map +1 -0
- package/dist/components/verbs/wrangler.d.ts +108 -0
- package/dist/components/verbs/wrangler.d.ts.map +1 -0
- package/dist/config.d.ts +54 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +14 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/effect-receipt.d.ts +177 -0
- package/dist/effect-receipt.d.ts.map +1 -0
- package/dist/env.d.ts +12 -1
- package/dist/env.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +15 -2
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +223 -2
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +33 -5
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +3 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/observation-baseline.d.ts +21 -3
- package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
- package/dist/lifecycle/receipt-plan.d.ts +62 -0
- package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
- package/dist/lifecycle/release-ledger.d.ts +20 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/teardown.d.ts +132 -0
- package/dist/lifecycle/teardown.d.ts.map +1 -0
- package/dist/lifecycle/unobserved-gate.d.ts +67 -0
- package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
- package/dist/lint/engine.d.ts +6 -2
- package/dist/lint/engine.d.ts.map +1 -1
- package/dist/lint/knowledge-checks.d.ts +48 -0
- package/dist/lint/knowledge-checks.d.ts.map +1 -0
- package/dist/lint/output-checks.d.ts +5 -0
- package/dist/lint/output-checks.d.ts.map +1 -0
- package/dist/lint/pipeline-change-gate.d.ts +101 -0
- package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
- package/dist/lint/post-synth.d.ts +12 -0
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/lint/receipt-checks.d.ts +9 -0
- package/dist/lint/receipt-checks.d.ts.map +1 -0
- package/dist/lint/rule.d.ts +31 -0
- package/dist/lint/rule.d.ts.map +1 -1
- package/dist/lint/rules/cor021-env-literal-name.d.ts +3 -0
- package/dist/lint/rules/cor021-env-literal-name.d.ts.map +1 -0
- package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
- package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
- package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
- package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
- package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
- package/dist/lint/rules/index.d.ts +4 -1
- package/dist/lint/rules/index.d.ts.map +1 -1
- package/dist/okf-read.d.ts +78 -0
- package/dist/okf-read.d.ts.map +1 -0
- package/dist/op/builders.d.ts +134 -8
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/index.d.ts +4 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +2 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/receipt-store.d.ts +138 -0
- package/dist/op/receipt-store.d.ts.map +1 -0
- package/dist/op/types.d.ts +31 -1
- package/dist/op/types.d.ts.map +1 -1
- package/dist/secret-materialization.d.ts +138 -0
- package/dist/secret-materialization.d.ts.map +1 -0
- package/dist/secret-provenance.d.ts +218 -0
- package/dist/secret-provenance.d.ts.map +1 -0
- package/dist/serializer.d.ts +11 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/testing.d.ts +136 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +9 -1
- package/src/audit/core.test.ts +57 -0
- package/src/audit/core.ts +0 -0
- package/src/audit/detect-bundle.test.ts +1 -1
- package/src/audit/discover.test.ts +24 -0
- package/src/audit/discover.ts +11 -2
- package/src/audit/rules-doc.ts +11 -1
- package/src/build.test.ts +41 -0
- package/src/build.ts +34 -6
- package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
- package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
- package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
- package/src/cli/commands/audit.test.ts +53 -0
- package/src/cli/commands/audit.ts +1 -1
- package/src/cli/commands/build.test.ts +211 -0
- package/src/cli/commands/build.ts +126 -8
- package/src/cli/commands/check-lexicon.test.ts +45 -1
- package/src/cli/commands/check-lexicon.ts +45 -0
- package/src/cli/commands/lexicon-surface-diff.ts +9 -0
- package/src/cli/commands/lexicon-surface-diff.update.test.ts +112 -0
- package/src/cli/commands/lint.ts +32 -7
- package/src/cli/handlers/explain.test.ts +70 -1
- package/src/cli/handlers/graph.ts +4 -4
- package/src/cli/handlers/lifecycle.test.ts +345 -1
- package/src/cli/handlers/lifecycle.ts +300 -11
- package/src/cli/handlers/search.ts +5 -2
- package/src/cli/main.ts +12 -1
- package/src/cli/mcp/resource-handlers.ts +38 -1
- package/src/cli/mcp/server.test.ts +58 -1
- package/src/cli/mcp/tools/explain.ts +51 -2
- package/src/cli/plugins.ts +4 -2
- package/src/cli/registry.ts +7 -0
- package/src/cli/reporters/stylish.test.ts +154 -0
- package/src/cli/reporters/stylish.ts +154 -33
- package/src/codegen/lexicon-regen.ts +19 -1
- package/src/codegen/validate.test.ts +33 -0
- package/src/codegen/validate.ts +21 -2
- package/src/components/auto-release.ts +6 -0
- package/src/components/registry.test.ts +7 -2
- package/src/components/starter-plugin.ts +17 -0
- package/src/components/verbs/ensure-secret.test.ts +130 -0
- package/src/components/verbs/ensure-secret.ts +79 -0
- package/src/components/verbs/index.ts +8 -0
- package/src/components/verbs/r2-sync.test.ts +107 -0
- package/src/components/verbs/r2-sync.ts +124 -0
- package/src/components/verbs/wrangler.test.ts +170 -0
- package/src/components/verbs/wrangler.ts +241 -0
- package/src/config.test.ts +55 -0
- package/src/config.ts +88 -1
- package/src/deep-observation.test.ts +19 -0
- package/src/deep-observation.ts +17 -0
- package/src/effect-receipt-exclusion.test.ts +190 -0
- package/src/effect-receipt.test.ts +419 -0
- package/src/effect-receipt.ts +412 -0
- package/src/env.test.ts +35 -1
- package/src/env.ts +17 -3
- package/src/fold/subset.test.ts +26 -0
- package/src/fold/subset.ts +45 -19
- package/src/index.ts +4 -0
- package/src/lexicon.ts +227 -2
- package/src/lifecycle/change-set.ts +46 -7
- package/src/lifecycle/index.ts +3 -0
- package/src/lifecycle/observation-baseline.test.ts +46 -0
- package/src/lifecycle/observation-baseline.ts +33 -1
- package/src/lifecycle/receipt-plan.test.ts +250 -0
- package/src/lifecycle/receipt-plan.ts +249 -0
- package/src/lifecycle/release-ledger.ts +20 -0
- package/src/lifecycle/teardown.test.ts +568 -0
- package/src/lifecycle/teardown.ts +359 -0
- package/src/lifecycle/unobserved-gate.test.ts +109 -0
- package/src/lifecycle/unobserved-gate.ts +102 -0
- package/src/lint/engine.ts +7 -1
- package/src/lint/knowledge-checks.test.ts +80 -0
- package/src/lint/knowledge-checks.ts +74 -0
- package/src/lint/output-checks.test.ts +85 -0
- package/src/lint/output-checks.ts +99 -0
- package/src/lint/pipeline-change-gate.test.ts +144 -0
- package/src/lint/pipeline-change-gate.ts +153 -0
- package/src/lint/post-synth.ts +15 -0
- package/src/lint/receipt-checks.test.ts +101 -0
- package/src/lint/receipt-checks.ts +93 -0
- package/src/lint/rule.ts +23 -0
- package/src/lint/rules/cor021-env-literal-name.test.ts +128 -0
- package/src/lint/rules/cor021-env-literal-name.ts +114 -0
- package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
- package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
- package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
- package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
- package/src/lint/rules/evl001-non-literal-expression.test.ts +27 -0
- package/src/lint/rules/evl001-non-literal-expression.ts +8 -1
- package/src/lint/rules/index.ts +10 -1
- package/src/okf-read.test.ts +149 -0
- package/src/okf-read.ts +197 -0
- package/src/op/builders.ts +179 -8
- package/src/op/effect-step.test.ts +311 -0
- package/src/op/index.ts +10 -3
- package/src/op/local-executor.ts +172 -25
- package/src/op/op.test.ts +25 -2
- package/src/op/receipt-store.ts +211 -0
- package/src/op/types.ts +33 -1
- package/src/secret-materialization.test.ts +199 -0
- package/src/secret-materialization.ts +235 -0
- package/src/secret-provenance.test.ts +388 -0
- package/src/secret-provenance.ts +475 -0
- package/src/serializer.ts +12 -0
- package/src/testing.test.ts +261 -0
- package/src/testing.ts +338 -0
- package/src/yaml.test.ts +88 -0
- package/src/yaml.ts +76 -6
|
@@ -41,6 +41,10 @@ import {
|
|
|
41
41
|
waitEndpointCapability,
|
|
42
42
|
healthGateCapability,
|
|
43
43
|
shellCapability,
|
|
44
|
+
ensureSecretCapability,
|
|
45
|
+
wranglerDeployCapability,
|
|
46
|
+
wranglerVersionsPromoteCapability,
|
|
47
|
+
r2SyncCapability,
|
|
44
48
|
} from "./verbs/index";
|
|
45
49
|
import { ownPackageVersion, type CapabilityPlugin } from "./capability-plugin";
|
|
46
50
|
|
|
@@ -64,7 +68,16 @@ export const STARTER_VERB_FAMILIES = {
|
|
|
64
68
|
// cloud-specific waits (`wait-for-stack`/`wait-steady-state`/`wait-job`) are
|
|
65
69
|
// contributed by the relevant lexicon (aws) — see docs/components/cloud-boundary.
|
|
66
70
|
waitVerify: ["wait-cluster-healthy", "wait-endpoint", "health-gate"],
|
|
71
|
+
// generated-once secret materialization (#1829, epic #1365): read-then-write,
|
|
72
|
+
// present means done; the k8s store adapter is #1830.
|
|
73
|
+
secrets: ["ensure-secret"],
|
|
67
74
|
escapeHatch: ["shell"],
|
|
75
|
+
// The Cloudflare Workers apply leaves (#1293, epic #1296) — placed in core
|
|
76
|
+
// rather than a cloudflare lexicon (decided explicitly, see ./verbs/wrangler.ts's
|
|
77
|
+
// module doc): the epic cedes the Workers plane to `wrangler` entirely, and
|
|
78
|
+
// there is no cloudflare lexicon for these three verbs to live in. Revisit
|
|
79
|
+
// if/when the zone-plane lexicon (#1294) lands.
|
|
80
|
+
cloudflare: ["wrangler-deploy", "wrangler-versions-promote", "r2-sync"],
|
|
68
81
|
} as const;
|
|
69
82
|
|
|
70
83
|
/**
|
|
@@ -88,7 +101,11 @@ function starterCapabilities(): Array<Capability<never, unknown>> {
|
|
|
88
101
|
waitClusterHealthyCapability,
|
|
89
102
|
waitEndpointCapability,
|
|
90
103
|
healthGateCapability,
|
|
104
|
+
ensureSecretCapability,
|
|
91
105
|
shellCapability,
|
|
106
|
+
wranglerDeployCapability,
|
|
107
|
+
wranglerVersionsPromoteCapability,
|
|
108
|
+
r2SyncCapability,
|
|
92
109
|
] as unknown as Array<Capability<never, unknown>>;
|
|
93
110
|
}
|
|
94
111
|
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ensure-secret` capability surface tests (#1829, epic #1365 decision 3):
|
|
3
|
+
* the verb is the same engine as the `ensureSecret(...)` op builder, its
|
|
4
|
+
* rollback disposition is `needs-opt-out`, and COMP003 fires on a component
|
|
5
|
+
* that uses it without acknowledging the compensation gap — through the same
|
|
6
|
+
* registry-derived policy map `chant lint` builds, not a hard-coded list.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it, vi } from "vitest";
|
|
10
|
+
import type { DeployContext } from "../capability";
|
|
11
|
+
import { CapabilityNotImplementedError, type RollbackPolicy } from "../capability";
|
|
12
|
+
import { createCapabilityRegistry } from "../registry";
|
|
13
|
+
import { createEnsureSecretCapability, ensureSecretCapability } from "./ensure-secret";
|
|
14
|
+
import {
|
|
15
|
+
SecretMaterial,
|
|
16
|
+
SecretContractMismatchError,
|
|
17
|
+
consumeSecretMaterial,
|
|
18
|
+
type SecretMaterialGenerator,
|
|
19
|
+
type SecretStoreAdapter,
|
|
20
|
+
} from "../../secret-materialization";
|
|
21
|
+
import { loadComponentChecks } from "../../lint/rules/comp";
|
|
22
|
+
|
|
23
|
+
const ctx: DeployContext = { env: "dev", component: "fountain" };
|
|
24
|
+
|
|
25
|
+
function fakeStore(seed?: Record<string, Record<string, string>>) {
|
|
26
|
+
const secrets = new Map<string, Record<string, string>>(Object.entries(seed ?? {}));
|
|
27
|
+
const adapter: SecretStoreAdapter = {
|
|
28
|
+
exists: vi.fn(async (name: string) => secrets.has(name)),
|
|
29
|
+
describe: vi.fn(async (name: string) => ({ keys: Object.keys(secrets.get(name) ?? {}) })),
|
|
30
|
+
create: vi.fn(async (name: string, keys: readonly string[], generate: SecretMaterialGenerator) => {
|
|
31
|
+
const data: Record<string, string> = {};
|
|
32
|
+
for (const key of keys) data[key] = consumeSecretMaterial(await generate(key));
|
|
33
|
+
secrets.set(name, data);
|
|
34
|
+
}),
|
|
35
|
+
};
|
|
36
|
+
return { adapter, secrets };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("ensure-secret capability", () => {
|
|
40
|
+
it("creates an absent secret, then treats present-and-matching as done (create fires once across reruns)", async () => {
|
|
41
|
+
const { adapter } = fakeStore();
|
|
42
|
+
const capability = createEnsureSecretCapability({
|
|
43
|
+
store: adapter,
|
|
44
|
+
generator: (key) => SecretMaterial.mint(`v:${key}`),
|
|
45
|
+
});
|
|
46
|
+
const first = await capability.run(ctx, { name: "app-secret", keys: ["token"] });
|
|
47
|
+
const second = await capability.run(ctx, { name: "app-secret", keys: ["token"] });
|
|
48
|
+
expect(first.outcome).toBe("created");
|
|
49
|
+
expect(second.outcome).toBe("present");
|
|
50
|
+
expect(adapter.create).toHaveBeenCalledTimes(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("propagates the loud mismatch from the shared engine", async () => {
|
|
54
|
+
const { adapter } = fakeStore({ "app-secret": { other: "x" } });
|
|
55
|
+
const capability = createEnsureSecretCapability({ store: adapter });
|
|
56
|
+
await expect(capability.run(ctx, { name: "app-secret", keys: ["token"] })).rejects.toThrowError(
|
|
57
|
+
SecretContractMismatchError,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("output carries names only — no field of the outcome can hold material", async () => {
|
|
62
|
+
const { adapter } = fakeStore();
|
|
63
|
+
const capability = createEnsureSecretCapability({
|
|
64
|
+
store: adapter,
|
|
65
|
+
generator: (key) => SecretMaterial.mint(`v:${key}`),
|
|
66
|
+
});
|
|
67
|
+
const outcome = await capability.run(ctx, { name: "app-secret", keys: ["token"] });
|
|
68
|
+
expect(outcome).toEqual({ outcome: "created", name: "app-secret", keys: ["token"] });
|
|
69
|
+
expect(JSON.stringify(outcome)).not.toContain("v:token");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("is registered in the starter set as a typed stub until a provider row (#1830) wires an adapter", async () => {
|
|
73
|
+
const registry = createCapabilityRegistry();
|
|
74
|
+
const starter = registry.resolve("ensure-secret");
|
|
75
|
+
expect(starter.rollbackPolicy).toBe("needs-opt-out");
|
|
76
|
+
await expect(starter.run(ctx as never, {} as never)).rejects.toThrowError(CapabilityNotImplementedError);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("COMP003 over ensure-secret", () => {
|
|
81
|
+
/** The same derivation chant lint's resolveRegistryContext performs. */
|
|
82
|
+
function registryRollbackPolicies(): Map<string, RollbackPolicy> {
|
|
83
|
+
const registry = createCapabilityRegistry();
|
|
84
|
+
const policies = new Map<string, RollbackPolicy>();
|
|
85
|
+
for (const kind of registry.kinds()) {
|
|
86
|
+
const capability = registry.resolve(kind);
|
|
87
|
+
policies.set(kind, capability.rollbackPolicy ?? (capability.rollback ? "native" : "none-by-design"));
|
|
88
|
+
}
|
|
89
|
+
return policies;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const [comp003] = loadComponentChecks().filter((c) => c.id === "COMP003");
|
|
93
|
+
|
|
94
|
+
function checkComponent(step: Record<string, unknown>) {
|
|
95
|
+
const checkCtx = {
|
|
96
|
+
rollbackPolicies: registryRollbackPolicies(),
|
|
97
|
+
components: new Map([
|
|
98
|
+
[
|
|
99
|
+
"fountain",
|
|
100
|
+
{
|
|
101
|
+
component: {
|
|
102
|
+
name: "fountain",
|
|
103
|
+
dependsOn: [],
|
|
104
|
+
deploy: [{ phase: "Secrets", steps: [step] }],
|
|
105
|
+
},
|
|
106
|
+
filePath: "fountain.component.ts",
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
]),
|
|
110
|
+
};
|
|
111
|
+
return comp003.check(checkCtx as never);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
it("flags a bare ensure-secret step — minting has no safe undo, so the gap needs an explicit opt-out", () => {
|
|
115
|
+
const diagnostics = checkComponent({ kind: "ensure-secret", name: "master-key", keys: ["MASTER_SECRETS_KEY"] });
|
|
116
|
+
expect(diagnostics).toHaveLength(1);
|
|
117
|
+
expect(diagnostics[0].checkId).toBe("COMP003");
|
|
118
|
+
expect(diagnostics[0].message).toContain("ensure-secret");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("accepts the step once the component acknowledges the gap via noRollback", () => {
|
|
122
|
+
const diagnostics = checkComponent({
|
|
123
|
+
kind: "ensure-secret",
|
|
124
|
+
name: "master-key",
|
|
125
|
+
keys: ["MASTER_SECRETS_KEY"],
|
|
126
|
+
noRollback: "generated-once: un-creating would destroy the only copy of the material",
|
|
127
|
+
});
|
|
128
|
+
expect(diagnostics).toHaveLength(0);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* secrets family — `ensure-secret` (#1829, epic #1365 decision 3). The
|
|
3
|
+
* capability surface over the one generated-once materialization engine
|
|
4
|
+
* (../../secret-materialization.ts); the op step builder `ensureSecret(...)`
|
|
5
|
+
* (../../op/builders.ts) is the other surface over the same engine.
|
|
6
|
+
*
|
|
7
|
+
* Contract (read-then-write): present means done. The verb never mints over
|
|
8
|
+
* an existing value and never rotates implicitly; an existing secret that
|
|
9
|
+
* does not match the declared key-set/metadata stops the apply with a
|
|
10
|
+
* `SecretContractMismatchError` naming key names and metadata keys — never
|
|
11
|
+
* values. There is no safe undo for minting a secret others may already have
|
|
12
|
+
* consumed, and un-creating it would destroy the only copy of the material —
|
|
13
|
+
* so `rollbackPolicy: "needs-opt-out"`: COMP003 requires a component using
|
|
14
|
+
* this verb to acknowledge the compensation gap explicitly.
|
|
15
|
+
*
|
|
16
|
+
* The store adapter is per-provider (#1830 is the k8s row). Core registers a
|
|
17
|
+
* typed stub; a provider builds the real capability with
|
|
18
|
+
* {@link createEnsureSecretCapability}.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { Capability } from "../capability";
|
|
22
|
+
import { CapabilityNotImplementedError } from "../capability";
|
|
23
|
+
import {
|
|
24
|
+
ensureSecretMaterialization,
|
|
25
|
+
defaultSecretMaterialGenerator,
|
|
26
|
+
type EnsureSecretOutcome,
|
|
27
|
+
type SecretMaterialGenerator,
|
|
28
|
+
type SecretStoreAdapter,
|
|
29
|
+
} from "../../secret-materialization";
|
|
30
|
+
|
|
31
|
+
export interface EnsureSecretInput {
|
|
32
|
+
/** The secret's name as the store knows it. */
|
|
33
|
+
name: string;
|
|
34
|
+
/** The declared key-set. Creation mints one value per key; verification compares names only. */
|
|
35
|
+
keys: string[];
|
|
36
|
+
/** Declared metadata an existing secret must carry. Mismatches are reported by KEY. */
|
|
37
|
+
metadata?: Record<string, string>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Names only — no field of the output can carry material. */
|
|
41
|
+
export type EnsureSecretOutput = EnsureSecretOutcome;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build the real `ensure-secret` capability over a provider's
|
|
45
|
+
* {@link SecretStoreAdapter}. The generator seam defaults to 32 CSPRNG bytes
|
|
46
|
+
* per key; the material it mints flows straight through the adapter to the
|
|
47
|
+
* store and is never returned, logged, or retained (see
|
|
48
|
+
* ../../secret-materialization.ts).
|
|
49
|
+
*/
|
|
50
|
+
export function createEnsureSecretCapability(deps: {
|
|
51
|
+
store: SecretStoreAdapter;
|
|
52
|
+
generator?: SecretMaterialGenerator;
|
|
53
|
+
}): Capability<EnsureSecretInput, EnsureSecretOutput> {
|
|
54
|
+
return {
|
|
55
|
+
kind: "ensure-secret",
|
|
56
|
+
rollbackPolicy: "needs-opt-out",
|
|
57
|
+
async run(_ctx, input) {
|
|
58
|
+
return ensureSecretMaterialization(
|
|
59
|
+
deps.store,
|
|
60
|
+
{ name: input.name, keys: input.keys, metadata: input.metadata },
|
|
61
|
+
deps.generator ?? defaultSecretMaterialGenerator,
|
|
62
|
+
);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The starter-set registration: fully typed, correct `rollbackPolicy` for
|
|
69
|
+
* COMP003, but no store adapter — running it throws
|
|
70
|
+
* `CapabilityNotImplementedError` until a provider row (#1830, k8s) wires a
|
|
71
|
+
* real adapter via {@link createEnsureSecretCapability}.
|
|
72
|
+
*/
|
|
73
|
+
export const ensureSecretCapability: Capability<EnsureSecretInput, EnsureSecretOutput> = {
|
|
74
|
+
kind: "ensure-secret",
|
|
75
|
+
rollbackPolicy: "needs-opt-out",
|
|
76
|
+
async run(): Promise<EnsureSecretOutput> {
|
|
77
|
+
throw new CapabilityNotImplementedError("ensure-secret");
|
|
78
|
+
},
|
|
79
|
+
};
|
|
@@ -26,6 +26,11 @@
|
|
|
26
26
|
* `ProvenanceLink` material. `verify` (#622, ./verify.ts) is the matching
|
|
27
27
|
* deploy-time gate — `cosign verify`/`verify-attestation` against a
|
|
28
28
|
* configured identity policy, mirroring ../../lint/policy.ts's `policyGate`.
|
|
29
|
+
* `wrangler-deploy`/`wrangler-versions-promote` (#1293, epic #1296,
|
|
30
|
+
* ./wrangler.ts) and `r2-sync` (#1293, ./r2-sync.ts) are the Cloudflare
|
|
31
|
+
* Workers apply leaves — real implementations over the same injectable
|
|
32
|
+
* `ProcessRunner` `sign` uses, shelling to `wrangler`/`rclone` rather than an
|
|
33
|
+
* AWS-shaped SDK client.
|
|
29
34
|
*/
|
|
30
35
|
|
|
31
36
|
export * from "./build-archive";
|
|
@@ -39,11 +44,14 @@ export * from "./component-bom";
|
|
|
39
44
|
export * from "./reproducibility";
|
|
40
45
|
export * from "./sign";
|
|
41
46
|
export * from "./verify";
|
|
47
|
+
export * from "./wrangler";
|
|
48
|
+
export * from "./r2-sync";
|
|
42
49
|
export * from "./vuln-scan";
|
|
43
50
|
export * from "./vex";
|
|
44
51
|
export * from "./license-policy";
|
|
45
52
|
export * from "./vuln-gate";
|
|
46
53
|
export * from "./wait-verify";
|
|
47
54
|
export * from "./shell";
|
|
55
|
+
export * from "./ensure-secret";
|
|
48
56
|
export * from "./cloud-executor";
|
|
49
57
|
export * from "./process-runner";
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests `r2-sync` (#1293, epic #1296, ./r2-sync.ts) against a
|
|
3
|
+
* `MockProcessRunner` — no live `rclone`, no network call, ever. Asserts the
|
|
4
|
+
* `r2://` destination shape mirrors `s3-sync`'s `s3://` shape, the
|
|
5
|
+
* copy-vs-sync dispatch on `delete`, the uploaded/deleted count parsing, and
|
|
6
|
+
* the `needs-opt-out` rollback disposition `s3-sync` also carries.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import {
|
|
11
|
+
buildR2SyncArgs,
|
|
12
|
+
createR2SyncCapability,
|
|
13
|
+
parseRcloneStats,
|
|
14
|
+
R2SyncInvalidDestinationError,
|
|
15
|
+
toRcloneDest,
|
|
16
|
+
} from "./r2-sync";
|
|
17
|
+
import { ToolNotAvailableError } from "./process-runner";
|
|
18
|
+
import { createMockProcessRunner } from "./__tests__/mock-process-runner";
|
|
19
|
+
|
|
20
|
+
const ctx = { env: "prod", component: "assets" };
|
|
21
|
+
|
|
22
|
+
describe("toRcloneDest", () => {
|
|
23
|
+
it("translates r2://bucket/prefix to the default 'r2' rclone remote", () => {
|
|
24
|
+
expect(toRcloneDest("r2://my-bucket/assets")).toBe("r2:my-bucket/assets");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("translates a bare bucket with no prefix", () => {
|
|
28
|
+
expect(toRcloneDest("r2://my-bucket")).toBe("r2:my-bucket");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("honours an explicit remote name override", () => {
|
|
32
|
+
expect(toRcloneDest("r2://my-bucket/assets", "cf-r2")).toBe("cf-r2:my-bucket/assets");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("throws R2SyncInvalidDestinationError for a non-r2:// URI (e.g. an s3:// one passed by mistake)", () => {
|
|
36
|
+
expect(() => toRcloneDest("s3://my-bucket/assets")).toThrow(R2SyncInvalidDestinationError);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("buildR2SyncArgs", () => {
|
|
41
|
+
it("uses 'rclone copy' by default — never deletes destination-only keys, matching s3-sync's default", () => {
|
|
42
|
+
const cmd = buildR2SyncArgs({ from: "dist/assets", to: "r2://my-bucket/assets" });
|
|
43
|
+
expect(cmd).toBe(`rclone copy 'dist/assets' 'r2:my-bucket/assets' -v`);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("uses 'rclone sync' when delete: true — matching s3-sync's opt-in delete", () => {
|
|
47
|
+
const cmd = buildR2SyncArgs({ from: "dist/assets", to: "r2://my-bucket/assets", delete: true });
|
|
48
|
+
expect(cmd).toBe(`rclone sync 'dist/assets' 'r2:my-bucket/assets' -v`);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("parseRcloneStats", () => {
|
|
53
|
+
it("counts new and replaced uploads together as 'uploaded'", () => {
|
|
54
|
+
const stdout = [
|
|
55
|
+
"2026/08/24 10:00:00 INFO : a.txt: Copied (new)",
|
|
56
|
+
"2026/08/24 10:00:00 INFO : b.txt: Copied (replaced existing)",
|
|
57
|
+
"2026/08/24 10:00:00 INFO : c.txt: Copied (new)",
|
|
58
|
+
].join("\n");
|
|
59
|
+
expect(parseRcloneStats(stdout)).toEqual({ uploaded: 3, deleted: 0 });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("counts deletions separately", () => {
|
|
63
|
+
const stdout = [
|
|
64
|
+
"2026/08/24 10:00:00 INFO : a.txt: Copied (new)",
|
|
65
|
+
"2026/08/24 10:00:00 INFO : stale.txt: Deleted",
|
|
66
|
+
].join("\n");
|
|
67
|
+
expect(parseRcloneStats(stdout)).toEqual({ uploaded: 1, deleted: 1 });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns zeros for a no-op sync", () => {
|
|
71
|
+
expect(parseRcloneStats("2026/08/24 10:00:00 INFO : There was nothing to transfer\n")).toEqual({
|
|
72
|
+
uploaded: 0,
|
|
73
|
+
deleted: 0,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("r2-sync capability", () => {
|
|
79
|
+
it("round-trips a directory upload and reports the object count", async () => {
|
|
80
|
+
const mock = createMockProcessRunner({
|
|
81
|
+
responses: {
|
|
82
|
+
"rclone copy": "2026/08/24 10:00:00 INFO : a.txt: Copied (new)\n2026/08/24 10:00:00 INFO : b.txt: Copied (new)\n",
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const capability = createR2SyncCapability(mock.runner);
|
|
86
|
+
|
|
87
|
+
const output = await capability.run(ctx, { from: "dist/assets", to: "r2://my-bucket/assets" });
|
|
88
|
+
|
|
89
|
+
expect(output).toEqual({ uploaded: 2, deleted: 0 });
|
|
90
|
+
expect(mock.calls.some((c) => c.command === `rclone copy 'dist/assets' 'r2:my-bucket/assets' -v`)).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("throws ToolNotAvailableError when rclone is absent, rather than silently skipping the sync", async () => {
|
|
94
|
+
const mock = createMockProcessRunner({ tools: { rclone: false } });
|
|
95
|
+
const capability = createR2SyncCapability(mock.runner);
|
|
96
|
+
|
|
97
|
+
await expect(capability.run(ctx, { from: "dist/assets", to: "r2://my-bucket/assets" })).rejects.toBeInstanceOf(
|
|
98
|
+
ToolNotAvailableError,
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("declares rollbackPolicy: needs-opt-out — a mutating, destructive-capable sync with no native undo, same as s3-sync", () => {
|
|
103
|
+
const capability = createR2SyncCapability(createMockProcessRunner().runner);
|
|
104
|
+
expect(capability.rollbackPolicy).toBe("needs-opt-out");
|
|
105
|
+
expect(capability.rollback).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `r2-sync` — bulk object upload to a Cloudflare R2 bucket (chant #1293,
|
|
3
|
+
* epic #1296), the direct analogue of `s3-sync`
|
|
4
|
+
* (lexicons/aws/src/components/apply.ts): same input shape (`from`/`to`/
|
|
5
|
+
* `delete`), same mutating-no-native-rollback disposition
|
|
6
|
+
* (`rollbackPolicy: "needs-opt-out"`, ../../lint/rules/comp/comp003-mutating-no-rollback.ts),
|
|
7
|
+
* learnable as the same idea per #1293's own verification bullet. See
|
|
8
|
+
* ./wrangler.ts's module doc for why this lives in core rather than a
|
|
9
|
+
* cloudflare lexicon.
|
|
10
|
+
*
|
|
11
|
+
* R2 has no first-party bulk-sync CLI the way S3 has `aws s3 sync`, but its
|
|
12
|
+
* API is S3-compatible, so it takes any S3-compatible sync tool — this wraps
|
|
13
|
+
* `rclone` (its `sync`/`copy` distinguish "mirror, deleting extras" from
|
|
14
|
+
* "upload only" exactly the way `s3-sync`'s `delete` flag does) through the
|
|
15
|
+
* same injectable `ProcessRunner` (./process-runner.ts) `./sign.ts` and
|
|
16
|
+
* `./wrangler.ts` use. No live `rclone`, no network, ever, in a test run —
|
|
17
|
+
* every test substitutes `MockProcessRunner`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { Capability } from "../capability";
|
|
21
|
+
import { defaultProcessRunner, q, requireTool, type ProcessRunner } from "./process-runner";
|
|
22
|
+
|
|
23
|
+
const RCLONE_TOOL = "rclone";
|
|
24
|
+
|
|
25
|
+
/** Matches an `r2://bucket[/prefix]` destination — mirrors `s3-sync`'s `s3://bucket/prefix` shape. */
|
|
26
|
+
const R2_URI_RE = /^r2:\/\/([^/]+)(?:\/(.*))?$/;
|
|
27
|
+
|
|
28
|
+
/** Thrown when `R2SyncInput.to` is not an `r2://bucket[/prefix]` URI. */
|
|
29
|
+
export class R2SyncInvalidDestinationError extends Error {
|
|
30
|
+
constructor(public readonly to: string) {
|
|
31
|
+
super(
|
|
32
|
+
`r2-sync: destination "${to}" is not an "r2://bucket[/prefix]" URI — mirror s3-sync's "s3://bucket/prefix" shape.`,
|
|
33
|
+
);
|
|
34
|
+
this.name = "R2SyncInvalidDestinationError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Translate an `r2://bucket/prefix` destination to rclone's `<remote>:<path>`
|
|
40
|
+
* form. `remote` is the name of an rclone remote already configured against
|
|
41
|
+
* R2's S3-compatible endpoint (`<account-id>.r2.cloudflarestorage.com`) —
|
|
42
|
+
* out of this capability's scope the same way `s3-sync` doesn't configure
|
|
43
|
+
* AWS credentials; default `"r2"` is the conventional alias. Exported for
|
|
44
|
+
* tests, mirroring ./sign.ts's `buildSignArgs`.
|
|
45
|
+
*/
|
|
46
|
+
export function toRcloneDest(to: string, remote = "r2"): string {
|
|
47
|
+
const match = R2_URI_RE.exec(to);
|
|
48
|
+
if (!match) throw new R2SyncInvalidDestinationError(to);
|
|
49
|
+
const [, bucket, prefix] = match;
|
|
50
|
+
return `${remote}:${bucket}${prefix ? `/${prefix}` : ""}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface R2SyncInput {
|
|
54
|
+
/** Local path, or archive-relative path, to sync from. */
|
|
55
|
+
from: string;
|
|
56
|
+
/** Destination bucket URI, `"r2://bucket/prefix"` — mirrors `s3-sync`'s `S3SyncInput.to` shape. */
|
|
57
|
+
to: string;
|
|
58
|
+
/** Delete destination keys not present in the source. Default: false. */
|
|
59
|
+
delete?: boolean;
|
|
60
|
+
/** rclone remote name pointed at R2. Default: `"r2"`. */
|
|
61
|
+
remote?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface R2SyncOutput {
|
|
65
|
+
/** Number of objects uploaded (new or changed). */
|
|
66
|
+
uploaded: number;
|
|
67
|
+
/** Number of objects deleted (only possible when `delete: true`). */
|
|
68
|
+
deleted: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build the `rclone` invocation for `input`. `rclone sync` deletes
|
|
73
|
+
* destination-only files by default (matching `s3-sync`'s opt-in `delete:
|
|
74
|
+
* true`); `rclone copy` never deletes (matching the `s3-sync` default) — so
|
|
75
|
+
* the verb dispatches on `input.delete` rather than passing a `--delete`
|
|
76
|
+
* flag rclone doesn't have. `-v` gives the per-file action lines
|
|
77
|
+
* `parseRcloneStats` reads. Exported for tests, mirroring ./sign.ts's
|
|
78
|
+
* `buildSignArgs`.
|
|
79
|
+
*/
|
|
80
|
+
export function buildR2SyncArgs(input: R2SyncInput): string {
|
|
81
|
+
const dest = toRcloneDest(input.to, input.remote);
|
|
82
|
+
const verb = input.delete ? "sync" : "copy";
|
|
83
|
+
return `rclone ${verb} ${q(input.from)} ${q(dest)} -v`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// rclone's `-v` per-file log lines end e.g. "path/to/file: Copied (new)",
|
|
87
|
+
// "path/to/file: Copied (replaced existing)", "path/to/file: Deleted".
|
|
88
|
+
const COPIED_NEW_RE = /: Copied \(new\)/g;
|
|
89
|
+
const COPIED_REPLACED_RE = /: Copied \(replaced existing\)/g;
|
|
90
|
+
const DELETED_RE = /: Deleted$/gm;
|
|
91
|
+
|
|
92
|
+
/** Count uploaded/deleted objects out of `rclone -v`'s stdout. Exported for tests. */
|
|
93
|
+
export function parseRcloneStats(stdout: string): R2SyncOutput {
|
|
94
|
+
const uploaded = (stdout.match(COPIED_NEW_RE) ?? []).length + (stdout.match(COPIED_REPLACED_RE) ?? []).length;
|
|
95
|
+
const deleted = (stdout.match(DELETED_RE) ?? []).length;
|
|
96
|
+
return { uploaded, deleted };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Bulk-sync a directory of objects to an R2 bucket via `rclone`
|
|
101
|
+
* (endpoint-aware only through the rclone remote's own config — this
|
|
102
|
+
* capability never touches R2 credentials directly). Mutating, no native
|
|
103
|
+
* undo (an overwritten or deleted object is gone), so — like `s3-sync` —
|
|
104
|
+
* `rollbackPolicy: "needs-opt-out"`: COMP003 requires the component to
|
|
105
|
+
* acknowledge the compensation gap explicitly (a `noRollback` reason, a
|
|
106
|
+
* component-level `rollback` phase, or a sibling `rollback-previous`/
|
|
107
|
+
* `snapshot-before` step).
|
|
108
|
+
*/
|
|
109
|
+
export function createR2SyncCapability(
|
|
110
|
+
processRunner: ProcessRunner = defaultProcessRunner(),
|
|
111
|
+
): Capability<R2SyncInput, R2SyncOutput> {
|
|
112
|
+
return {
|
|
113
|
+
kind: "r2-sync",
|
|
114
|
+
rollbackPolicy: "needs-opt-out",
|
|
115
|
+
async run(_ctx, input) {
|
|
116
|
+
await requireTool(processRunner, RCLONE_TOOL, `sync ${input.from} to ${input.to}`);
|
|
117
|
+
const { stdout } = await processRunner.run(buildR2SyncArgs(input));
|
|
118
|
+
return parseRcloneStats(stdout);
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Default `r2-sync` capability, backed by the real `ProcessRunner`. */
|
|
124
|
+
export const r2SyncCapability: Capability<R2SyncInput, R2SyncOutput> = createR2SyncCapability();
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests `wrangler-deploy`/`wrangler-versions-promote` (#1293, epic #1296,
|
|
3
|
+
* ./wrangler.ts) against a `MockProcessRunner` — no live `wrangler`, no
|
|
4
|
+
* network call, ever. Asserts the constructed invocations, the version-id
|
|
5
|
+
* extraction, the wired output -> promote input handoff, and the
|
|
6
|
+
* captured-previous-version rollback (the "no hand-written compensation"
|
|
7
|
+
* claim from #1293's verification section).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
import {
|
|
12
|
+
createWranglerDeployCapability,
|
|
13
|
+
createWranglerVersionsPromoteCapability,
|
|
14
|
+
parseWranglerVersionId,
|
|
15
|
+
WranglerVersionIdNotFoundError,
|
|
16
|
+
} from "./wrangler";
|
|
17
|
+
import { ToolNotAvailableError } from "./process-runner";
|
|
18
|
+
import { createMockProcessRunner } from "./__tests__/mock-process-runner";
|
|
19
|
+
|
|
20
|
+
const ctx = { env: "prod", component: "api-worker" };
|
|
21
|
+
const VERSION_A = "07bcb198-9633-4172-a1f0-b09f6f1a1a11";
|
|
22
|
+
const VERSION_B = "aa11bb22-cc33-dd44-ee55-ff6677889900";
|
|
23
|
+
|
|
24
|
+
describe("wrangler-deploy", () => {
|
|
25
|
+
it("shells `wrangler deploy` with --config, extracts the Version ID from stdout", async () => {
|
|
26
|
+
const mock = createMockProcessRunner({
|
|
27
|
+
responses: { "wrangler deploy": `Uploaded api-worker (1.23 sec)\nVersion ID: ${VERSION_A}\n` },
|
|
28
|
+
});
|
|
29
|
+
const capability = createWranglerDeployCapability(mock.runner);
|
|
30
|
+
|
|
31
|
+
const output = await capability.run(ctx, { config: "api/wrangler.jsonc" });
|
|
32
|
+
|
|
33
|
+
expect(output).toEqual({ versionId: VERSION_A });
|
|
34
|
+
const deployCall = mock.calls.find((c) => c.command.startsWith("wrangler deploy"))!;
|
|
35
|
+
expect(deployCall.command).toBe(`wrangler deploy --config 'api/wrangler.jsonc'`);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("passes --env through when supplied", async () => {
|
|
39
|
+
const mock = createMockProcessRunner({
|
|
40
|
+
responses: { "wrangler deploy": `Version ID: ${VERSION_A}\n` },
|
|
41
|
+
});
|
|
42
|
+
const capability = createWranglerDeployCapability(mock.runner);
|
|
43
|
+
|
|
44
|
+
await capability.run(ctx, { config: "api/wrangler.jsonc", env: "staging" });
|
|
45
|
+
|
|
46
|
+
const deployCall = mock.calls.find((c) => c.command.startsWith("wrangler deploy"))!;
|
|
47
|
+
expect(deployCall.command).toBe(`wrangler deploy --config 'api/wrangler.jsonc' --env 'staging'`);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("throws WranglerVersionIdNotFoundError when stdout carries no Version ID", async () => {
|
|
51
|
+
const mock = createMockProcessRunner({ responses: { "wrangler deploy": "Uploaded, nothing else.\n" } });
|
|
52
|
+
const capability = createWranglerDeployCapability(mock.runner);
|
|
53
|
+
|
|
54
|
+
await expect(capability.run(ctx, { config: "api/wrangler.jsonc" })).rejects.toBeInstanceOf(
|
|
55
|
+
WranglerVersionIdNotFoundError,
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("throws ToolNotAvailableError when wrangler is absent, rather than silently no-op'ing", async () => {
|
|
60
|
+
const mock = createMockProcessRunner({ tools: { wrangler: false } });
|
|
61
|
+
const capability = createWranglerDeployCapability(mock.runner);
|
|
62
|
+
|
|
63
|
+
await expect(capability.run(ctx, { config: "api/wrangler.jsonc" })).rejects.toBeInstanceOf(
|
|
64
|
+
ToolNotAvailableError,
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("declares a rollback (native rollback disposition, no rollbackPolicy override needed)", () => {
|
|
69
|
+
const capability = createWranglerDeployCapability(createMockProcessRunner().runner);
|
|
70
|
+
expect(typeof capability.rollback).toBe("function");
|
|
71
|
+
expect(capability.rollbackPolicy).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("rollback is a no-op on a first deploy — nothing was live before it", async () => {
|
|
75
|
+
const mock = createMockProcessRunner({
|
|
76
|
+
responses: { "versions list": "[]", "wrangler deploy": `Version ID: ${VERSION_A}\n` },
|
|
77
|
+
});
|
|
78
|
+
const capability = createWranglerDeployCapability(mock.runner);
|
|
79
|
+
await capability.run(ctx, { config: "api/wrangler.jsonc" });
|
|
80
|
+
|
|
81
|
+
await capability.rollback!(ctx, { config: "api/wrangler.jsonc" });
|
|
82
|
+
|
|
83
|
+
expect(mock.calls.some((c) => c.command.includes("versions deploy"))).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("rollback promotes back to whichever version was live before this deploy — the wrangler-versions-promote back-to-prior-version claim, with no hand-written compensation", async () => {
|
|
87
|
+
const mock = createMockProcessRunner({
|
|
88
|
+
responses: {
|
|
89
|
+
"versions list": JSON.stringify([{ id: VERSION_A, percentage: 100 }]),
|
|
90
|
+
"wrangler deploy": `Version ID: ${VERSION_B}\n`,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
const capability = createWranglerDeployCapability(mock.runner);
|
|
94
|
+
await capability.run(ctx, { config: "api/wrangler.jsonc" });
|
|
95
|
+
|
|
96
|
+
await capability.rollback!(ctx, { config: "api/wrangler.jsonc" });
|
|
97
|
+
|
|
98
|
+
const promoteCall = mock.calls.find((c) => c.command.startsWith("wrangler versions deploy"))!;
|
|
99
|
+
expect(promoteCall.command).toBe(
|
|
100
|
+
`wrangler versions deploy '${VERSION_A}@100' --config 'api/wrangler.jsonc' --yes`,
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("wrangler-versions-promote", () => {
|
|
106
|
+
it("shells `wrangler versions deploy <id>@<pct> --yes`, defaulting percentage to 100", async () => {
|
|
107
|
+
const mock = createMockProcessRunner();
|
|
108
|
+
const capability = createWranglerVersionsPromoteCapability(mock.runner);
|
|
109
|
+
|
|
110
|
+
const output = await capability.run(ctx, { config: "api/wrangler.jsonc", versionId: VERSION_A });
|
|
111
|
+
|
|
112
|
+
expect(output).toEqual({ versionId: VERSION_A, percentage: 100 });
|
|
113
|
+
const promoteCall = mock.calls.find((c) => c.command.startsWith("wrangler versions deploy"))!;
|
|
114
|
+
expect(promoteCall.command).toBe(
|
|
115
|
+
`wrangler versions deploy '${VERSION_A}@100' --config 'api/wrangler.jsonc' --yes`,
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("consumes a wired wrangler-deploy output as its versionId input", async () => {
|
|
120
|
+
const mock = createMockProcessRunner({
|
|
121
|
+
responses: { "wrangler deploy": `Version ID: ${VERSION_A}\n` },
|
|
122
|
+
});
|
|
123
|
+
const deploy = createWranglerDeployCapability(mock.runner);
|
|
124
|
+
const promote = createWranglerVersionsPromoteCapability(mock.runner);
|
|
125
|
+
|
|
126
|
+
const { versionId } = await deploy.run(ctx, { config: "api/wrangler.jsonc" });
|
|
127
|
+
const promoted = await promote.run(ctx, { config: "api/wrangler.jsonc", versionId, percentage: 10 });
|
|
128
|
+
|
|
129
|
+
expect(promoted).toEqual({ versionId: VERSION_A, percentage: 10 });
|
|
130
|
+
const promoteCall = mock.calls.find((c) => c.command.startsWith("wrangler versions deploy"))!;
|
|
131
|
+
expect(promoteCall.command).toContain(`'${VERSION_A}@10'`);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("declares a rollback (native): re-promotes to whichever version was live before this promote call", async () => {
|
|
135
|
+
const mock = createMockProcessRunner({
|
|
136
|
+
responses: { "versions list": JSON.stringify([{ id: VERSION_A, percentage: 100 }]) },
|
|
137
|
+
});
|
|
138
|
+
const capability = createWranglerVersionsPromoteCapability(mock.runner);
|
|
139
|
+
await capability.run(ctx, { config: "api/wrangler.jsonc", versionId: VERSION_B });
|
|
140
|
+
|
|
141
|
+
await capability.rollback!(ctx, { config: "api/wrangler.jsonc", versionId: VERSION_B });
|
|
142
|
+
|
|
143
|
+
const calls = mock.calls.filter((c) => c.command.startsWith("wrangler versions deploy"));
|
|
144
|
+
expect(calls).toHaveLength(2);
|
|
145
|
+
expect(calls[0]!.command).toContain(`'${VERSION_B}@100'`);
|
|
146
|
+
expect(calls[1]!.command).toContain(`'${VERSION_A}@100'`);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("throws ToolNotAvailableError when wrangler is absent", async () => {
|
|
150
|
+
const mock = createMockProcessRunner({ tools: { wrangler: false } });
|
|
151
|
+
const capability = createWranglerVersionsPromoteCapability(mock.runner);
|
|
152
|
+
|
|
153
|
+
await expect(
|
|
154
|
+
capability.run(ctx, { config: "api/wrangler.jsonc", versionId: VERSION_A }),
|
|
155
|
+
).rejects.toBeInstanceOf(ToolNotAvailableError);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("parseWranglerVersionId", () => {
|
|
160
|
+
it("accepts both 'Version ID:' and bare 'Version:' wording", () => {
|
|
161
|
+
expect(parseWranglerVersionId(`Version ID: ${VERSION_A}`)).toBe(VERSION_A);
|
|
162
|
+
expect(parseWranglerVersionId(`Version: ${VERSION_A}`)).toBe(VERSION_A);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("throws on stdout with no UUID-shaped version token", () => {
|
|
166
|
+
expect(() => parseWranglerVersionId("Version: 2 (a plain counter, not a version id)")).toThrow(
|
|
167
|
+
WranglerVersionIdNotFoundError,
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
});
|