@suluk/provision 0.1.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/bin/provision.ts +30 -0
- package/package.json +35 -0
- package/spec.md +1969 -0
- package/src/app.ts +29 -0
- package/src/apply.ts +124 -0
- package/src/brokers/cloudflare-domains.ts +53 -0
- package/src/brokers/cloudflare.ts +111 -0
- package/src/check.ts +31 -0
- package/src/cli.ts +140 -0
- package/src/config.ts +23 -0
- package/src/dag.ts +53 -0
- package/src/env-sink.ts +29 -0
- package/src/file-store.ts +25 -0
- package/src/generate.ts +20 -0
- package/src/index.ts +39 -0
- package/src/memory.ts +31 -0
- package/src/migrate.ts +51 -0
- package/src/migration-store.ts +110 -0
- package/src/migration.ts +48 -0
- package/src/plan.ts +60 -0
- package/src/poll.ts +37 -0
- package/src/pull.ts +92 -0
- package/src/refs.ts +60 -0
- package/src/snapshot.ts +25 -0
- package/src/teardown.ts +69 -0
- package/src/types.ts +145 -0
- package/test/brokers-async.test.ts +83 -0
- package/test/brokers.test.ts +93 -0
- package/test/careful.test.ts +152 -0
- package/test/cli.test.ts +75 -0
- package/test/lifecycle.test.ts +169 -0
- package/test/migrations.test.ts +130 -0
- package/tsconfig.json +1 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Open Service Broker API, recast as a TypeScript seam (C047). Each service a Suluk app needs (a Cloudflare D1
|
|
3
|
+
* database, a KV namespace, an R2 bucket, Worker secrets, a Stripe account's products/webhooks, a custom domain, a
|
|
4
|
+
* scoped token) is a {@link Broker} — it advertises a {@link Catalog}, then `provision` / `bind` / `deprovision` a
|
|
5
|
+
* Service Instance, exactly the OSB lifecycle. The platform (this package) is the OSB *client*: it reads a declared
|
|
6
|
+
* desired-state, diffs it against live state, and walks the brokers. Brokers are PURE of the orchestration — they hold
|
|
7
|
+
* only their own provider call (e.g. @suluk/cloudflare's `provisionD1`), so the lifecycle logic can never drift per
|
|
8
|
+
* service. See `spec.md` (the OSB v2 master) for the contract these types mirror.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** OSB last-operation state for an ASYNC provision/deprovision (a database that takes seconds, a cert that takes minutes). */
|
|
12
|
+
export type OperationState = "in progress" | "succeeded" | "failed";
|
|
13
|
+
|
|
14
|
+
/** A plan tier within an offering (OSB Service Plan). Most infra has a single "standard" plan; `free` marks $0 tiers. */
|
|
15
|
+
export interface ServicePlan {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
free?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** What a broker can provision (OSB Service Offering). `bindable` = provisioning yields credentials/config to bind. */
|
|
23
|
+
export interface ServiceOffering {
|
|
24
|
+
/** the stable broker id used in a config's `service`, e.g. "cloudflare-d1". */
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
description: string;
|
|
28
|
+
bindable: boolean;
|
|
29
|
+
plans: ServicePlan[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The set of offerings a broker advertises (OSB Catalog). */
|
|
33
|
+
export interface Catalog {
|
|
34
|
+
services: ServiceOffering[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A declared instance the platform WANTS (the desired state — one entry in provision.config). */
|
|
38
|
+
export interface InstanceSpec {
|
|
39
|
+
/** a unique handle within the config, referenced by other instances' params (e.g. "db", "kv-sessions"). */
|
|
40
|
+
ref: string;
|
|
41
|
+
/** the broker id that provisions it (must match a catalog offering's id), e.g. "cloudflare-d1". */
|
|
42
|
+
service: string;
|
|
43
|
+
/** the plan id; defaults to the offering's first plan. */
|
|
44
|
+
plan?: string;
|
|
45
|
+
/** the provider-facing name, e.g. "toolfactory-db". */
|
|
46
|
+
name: string;
|
|
47
|
+
/** provision params (broker-specific). A string value of the form `@<ref>.<key>` is a BINDING REFERENCE, resolved at
|
|
48
|
+
* apply time from that producer instance's outputs — this is what wires the provisioning DAG. */
|
|
49
|
+
params?: Record<string, unknown>;
|
|
50
|
+
/** binding outputs → env var names: where this instance's credentials/ids LAND (the binding-chain sink). e.g.
|
|
51
|
+
* `{ database_id: "CLOUDFLARE_D1_ID" }`. */
|
|
52
|
+
bind?: Record<string, string>;
|
|
53
|
+
/** guard a stateful resource (a database, a bucket) from destruction: `prune` + `teardown` SKIP it unless forced.
|
|
54
|
+
* The terraform `prevent_destroy` analog — the safety rail for the resources whose loss is unrecoverable. */
|
|
55
|
+
protected?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The live record of a provisioned instance (the journal `plan` diffs against — like drizzle's migration meta). */
|
|
59
|
+
export interface InstanceState {
|
|
60
|
+
ref: string;
|
|
61
|
+
service: string;
|
|
62
|
+
plan?: string;
|
|
63
|
+
name: string;
|
|
64
|
+
/** the provider's instance id (e.g. the D1 uuid, the KV namespace id). */
|
|
65
|
+
instanceId: string;
|
|
66
|
+
/** the binding outputs captured at provision/bind time (so downstream refs resolve without re-calling the provider). */
|
|
67
|
+
outputs: Record<string, string>;
|
|
68
|
+
/** a stable fingerprint of (name + plan + params), to detect drift → an `update` step. */
|
|
69
|
+
fingerprint: string;
|
|
70
|
+
/** carried from the spec so `teardown`/`prune` (which work off the journal) honour the destroy guard. */
|
|
71
|
+
protected?: boolean;
|
|
72
|
+
provisionedAt: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The resolved request handed to a broker's `provision` (param refs already substituted). */
|
|
76
|
+
export interface ProvisionRequest {
|
|
77
|
+
ref: string;
|
|
78
|
+
name: string;
|
|
79
|
+
plan?: string;
|
|
80
|
+
params: Record<string, unknown>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A broker's provision outcome — sync (ready now) or async (poll `lastOperation` with `operation`). An async ack MAY
|
|
84
|
+
* already carry `outputs` (e.g. a D1 create returns the database_id immediately even though the DB takes a moment to be
|
|
85
|
+
* queryable); they're threaded once the op settles, alongside any from `bind`. */
|
|
86
|
+
export type ProvisionResult =
|
|
87
|
+
| { state: "succeeded"; instanceId: string; outputs?: Record<string, string> }
|
|
88
|
+
| { state: "in progress"; operation: string; instanceId?: string; outputs?: Record<string, string> };
|
|
89
|
+
|
|
90
|
+
/** The resolved request handed to a broker's `bind`. */
|
|
91
|
+
export interface BindRequest {
|
|
92
|
+
ref: string;
|
|
93
|
+
name: string;
|
|
94
|
+
instanceId: string;
|
|
95
|
+
params: Record<string, unknown>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A broker's bind outcome — the credentials/config the platform + downstream instances consume. */
|
|
99
|
+
export interface BindResult {
|
|
100
|
+
outputs: Record<string, string>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The request handed to `lastOperation` / `deprovision`. */
|
|
104
|
+
export interface OperationRequest {
|
|
105
|
+
ref: string;
|
|
106
|
+
name: string;
|
|
107
|
+
instanceId?: string;
|
|
108
|
+
operation: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The OSB-shaped broker every service implements. Provision MUST be idempotent (re-running reconciles, never duplicates —
|
|
113
|
+
* OSB's "200 vs 201" rule). `lastOperation`/`bind`/`deprovision` are optional: a synchronous, non-bindable, or
|
|
114
|
+
* never-torn-down service simply omits them.
|
|
115
|
+
*/
|
|
116
|
+
export interface Broker {
|
|
117
|
+
/** OSB Catalog — what this broker can provision. */
|
|
118
|
+
catalog(): Catalog | Promise<Catalog>;
|
|
119
|
+
/** Provision (idempotent): create the Service Instance, or reconcile an existing one. Sync or async. */
|
|
120
|
+
provision(req: ProvisionRequest): Promise<ProvisionResult>;
|
|
121
|
+
/** Poll an async provision (OSB last-operation). Required only for brokers that return `state: "in progress"`. */
|
|
122
|
+
lastOperation?(req: OperationRequest): Promise<{ state: OperationState; description?: string }>;
|
|
123
|
+
/** Bind (OSB): generate the credentials / config the platform + downstream instances consume. Optional (non-bindable). */
|
|
124
|
+
bind?(req: BindRequest): Promise<BindResult>;
|
|
125
|
+
/** Deprovision (OSB): tear down the Service Instance. Optional — orphan mitigation, `apply --prune`, + `teardown` call it. */
|
|
126
|
+
deprovision?(req: OperationRequest): Promise<{ state: OperationState; operation?: string }>;
|
|
127
|
+
/** Fetch a Service Instance (OSB): the live state of a KNOWN instance — used by `pull` to detect EXTERNAL drift (a
|
|
128
|
+
* resource deleted/changed in the provider's dashboard, behind the config's back). Optional; absent → "unknown". */
|
|
129
|
+
fetch?(req: OperationRequest): Promise<{ exists: boolean; outputs?: Record<string, string> }>;
|
|
130
|
+
/** Discover existing instances of this service — used by `pull --discover` to ADOPT untracked resources into the
|
|
131
|
+
* journal. Optional; absent → discovery skipped for this service. */
|
|
132
|
+
list?(): Promise<Array<{ name: string; instanceId: string; outputs?: Record<string, string> }>>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Where bound credentials LAND. The default sink writes the @suluk/env manifest (typed + post-quantum-encrypted +
|
|
136
|
+
* commit-safe); a test passes an in-memory sink. `mapping` is the instance's `bind` (output key → env var name). */
|
|
137
|
+
export interface BindingSink {
|
|
138
|
+
write(outputs: Record<string, string>, mapping: Record<string, string>): Promise<void> | void;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The persisted provision journal (desired-vs-live diffing). The default store is a JSON file; a test passes memory. */
|
|
142
|
+
export interface StateStore {
|
|
143
|
+
load(): Promise<InstanceState[]> | InstanceState[];
|
|
144
|
+
save(state: InstanceState[]): Promise<void> | void;
|
|
145
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { cloudflareToken, cloudflarePagesDomain, apply, memoryStore, memorySink, type ProvisionConfig } from "../src/index";
|
|
3
|
+
import type { CloudflareClient } from "@suluk/cloudflare";
|
|
4
|
+
|
|
5
|
+
/** C047 build #4 — the scoped-token broker + the ASYNC Pages-domain broker (the OSB last-operation showcase). */
|
|
6
|
+
type Call = { method: string; path: string; opts?: { json?: unknown } };
|
|
7
|
+
|
|
8
|
+
/** A mock CF whose GET .../domains returns "not attached" on the FIRST call (provision's check) then the domain with a
|
|
9
|
+
* status that flips pending → active across polls — so an `apply` actually polls the cert to "active". */
|
|
10
|
+
function mockCfDomains(host: string): CloudflareClient & { calls: Call[] } {
|
|
11
|
+
const calls: Call[] = [];
|
|
12
|
+
let getDomains = 0;
|
|
13
|
+
const cf = {
|
|
14
|
+
resolveAccountId: async () => "acct1",
|
|
15
|
+
request: async (method: string, path: string, opts?: { json?: unknown }) => {
|
|
16
|
+
calls.push({ method, path, opts });
|
|
17
|
+
if (method === "GET" && path.endsWith("/domains")) {
|
|
18
|
+
getDomains++;
|
|
19
|
+
if (getDomains === 1) return []; // provision attach-check: not yet attached
|
|
20
|
+
return [{ name: host, status: getDomains >= 3 ? "active" : "pending" }]; // poll #1 pending, poll #2 active
|
|
21
|
+
}
|
|
22
|
+
return {};
|
|
23
|
+
},
|
|
24
|
+
calls,
|
|
25
|
+
};
|
|
26
|
+
return cf as unknown as CloudflareClient & { calls: Call[] };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mockCfToken(): CloudflareClient & { calls: Call[] } {
|
|
30
|
+
const calls: Call[] = [];
|
|
31
|
+
const cf = {
|
|
32
|
+
resolveAccountId: async () => "acct1",
|
|
33
|
+
request: async (method: string, path: string, opts?: { json?: unknown }) => {
|
|
34
|
+
calls.push({ method, path, opts });
|
|
35
|
+
if (method === "POST" && path.endsWith("/tokens")) return { id: "tok-id", value: "tok-secret" };
|
|
36
|
+
return {};
|
|
37
|
+
},
|
|
38
|
+
calls,
|
|
39
|
+
};
|
|
40
|
+
return cf as unknown as CloudflareClient & { calls: Call[] };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("cloudflare-token (least-privilege binding)", () => {
|
|
44
|
+
test("mints a scoped token; the value rides out as the binding; deprovision revokes by id", async () => {
|
|
45
|
+
const cf = mockCfToken();
|
|
46
|
+
const broker = cloudflareToken(cf);
|
|
47
|
+
const res = await broker.provision({ ref: "d1tok", name: "app-d1-token", params: { permissionGroups: ["pg-d1-write"] } });
|
|
48
|
+
expect(res).toEqual({ state: "succeeded", instanceId: "tok-id", outputs: { token: "tok-secret", token_id: "tok-id" } });
|
|
49
|
+
const post = cf.calls.find((c) => c.method === "POST" && c.path.endsWith("/tokens"))!;
|
|
50
|
+
expect(post.opts?.json).toMatchObject({ name: "app-d1-token", policies: [{ effect: "allow", permission_groups: [{ id: "pg-d1-write" }] }] });
|
|
51
|
+
await broker.deprovision!({ ref: "d1tok", name: "app-d1-token", instanceId: "tok-id", operation: "deprovision" });
|
|
52
|
+
expect(cf.calls.at(-1)).toMatchObject({ method: "DELETE", path: "/accounts/acct1/tokens/tok-id" });
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("cloudflare-pages-domain (async last-operation)", () => {
|
|
57
|
+
test("provision attaches + returns in-progress; lastOperation settles pending → active", async () => {
|
|
58
|
+
const cf = mockCfDomains("app.example.com");
|
|
59
|
+
const broker = cloudflarePagesDomain(cf);
|
|
60
|
+
const res = await broker.provision({ ref: "domain", name: "app.example.com", params: { project: "site" } });
|
|
61
|
+
expect(res).toEqual({ state: "in progress", operation: "site::app.example.com", instanceId: "site::app.example.com", outputs: { hostname: "app.example.com", url: "https://app.example.com" } });
|
|
62
|
+
expect(cf.calls.some((c) => c.method === "POST" && c.path.endsWith("/domains"))).toBe(true); // attached
|
|
63
|
+
const req = { ref: "domain", name: "app.example.com", instanceId: "site::app.example.com", operation: "site::app.example.com" };
|
|
64
|
+
expect((await broker.lastOperation!(req)).state).toBe("in progress"); // poll #1 → pending
|
|
65
|
+
expect((await broker.lastOperation!(req)).state).toBe("succeeded"); // poll #2 → active
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("apply drives the async create to done (the framework polls the cert to active)", async () => {
|
|
69
|
+
const cf = mockCfDomains("app.example.com");
|
|
70
|
+
const config: ProvisionConfig = { instances: [{ ref: "domain", service: "cloudflare-pages-domain", name: "app.example.com", params: { project: "site" }, bind: { url: "BASE_URL" } }] };
|
|
71
|
+
const store = memoryStore();
|
|
72
|
+
const sink = memorySink();
|
|
73
|
+
const res = await apply(config, { brokers: { "cloudflare-pages-domain": cloudflarePagesDomain(cf) }, store, sink, poll: { sleep: async () => {}, intervalMs: 0 } });
|
|
74
|
+
expect(res.steps[0]).toMatchObject({ ref: "domain", action: "create" });
|
|
75
|
+
expect(res.outputsByRef.domain).toEqual({ hostname: "app.example.com", url: "https://app.example.com" });
|
|
76
|
+
expect(sink.values).toEqual({ BASE_URL: "https://app.example.com" }); // the binding landed after the poll settled
|
|
77
|
+
expect(store.snapshot()[0].instanceId).toBe("site::app.example.com");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("provision needs a params.project", async () => {
|
|
81
|
+
await expect(cloudflarePagesDomain(mockCfDomains("h")).provision({ ref: "d", name: "h", params: {} })).rejects.toThrow(/needs a params.project/);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { cloudflareD1, cloudflareKv, cloudflareR2, cloudflareSecrets, envSink, fileStore, type InstanceState } from "../src/index";
|
|
7
|
+
import type { CloudflareClient } from "@suluk/cloudflare";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* C047 build #2 — the Cloudflare brokers (over a MOCK CloudflareClient: we assert each maps its provisioner onto a
|
|
11
|
+
* binding output + hits the right delete), the @suluk/env sink (a temp .env), and the file-backed journal (a temp file).
|
|
12
|
+
*/
|
|
13
|
+
type Call = { method: string; path: string; opts?: { json?: unknown } };
|
|
14
|
+
function mockCf(): CloudflareClient & { calls: Call[] } {
|
|
15
|
+
const calls: Call[] = [];
|
|
16
|
+
const handler = (method: string, path: string): unknown => {
|
|
17
|
+
if (method === "GET" && path.includes("/d1/database")) return [];
|
|
18
|
+
if (method === "POST" && path.includes("/d1/database")) return { uuid: "db-uuid", name: "app-db" };
|
|
19
|
+
if (method === "GET" && path.includes("/storage/kv/namespaces")) return [];
|
|
20
|
+
if (method === "POST" && path.includes("/storage/kv/namespaces")) return { id: "kv-id", title: "sessions" };
|
|
21
|
+
if (method === "GET" && path.includes("/r2/buckets")) return { buckets: [] };
|
|
22
|
+
if (method === "POST" && path.includes("/r2/buckets")) return { name: "media" };
|
|
23
|
+
return {};
|
|
24
|
+
};
|
|
25
|
+
const cf = {
|
|
26
|
+
resolveAccountId: async () => "acct1",
|
|
27
|
+
request: async (method: string, path: string, opts?: { json?: unknown }) => {
|
|
28
|
+
calls.push({ method, path, opts });
|
|
29
|
+
return handler(method, path);
|
|
30
|
+
},
|
|
31
|
+
calls,
|
|
32
|
+
};
|
|
33
|
+
return cf as unknown as CloudflareClient & { calls: Call[] };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("the Cloudflare brokers", () => {
|
|
37
|
+
test("cloudflare-d1: create-or-get → database_id; deprovision deletes by id", async () => {
|
|
38
|
+
const cf = mockCf();
|
|
39
|
+
const broker = cloudflareD1(cf);
|
|
40
|
+
expect((await broker.catalog()).services[0].id).toBe("cloudflare-d1");
|
|
41
|
+
const res = await broker.provision({ ref: "db", name: "app-db", params: {} });
|
|
42
|
+
expect(res).toEqual({ state: "succeeded", instanceId: "db-uuid", outputs: { database_id: "db-uuid" } });
|
|
43
|
+
await broker.deprovision!({ ref: "db", name: "app-db", instanceId: "db-uuid", operation: "deprovision" });
|
|
44
|
+
expect(cf.calls.at(-1)).toMatchObject({ method: "DELETE", path: "/accounts/acct1/d1/database/db-uuid" });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("cloudflare-kv → namespace_id; cloudflare-r2 → bucket_name", async () => {
|
|
48
|
+
const kv = await cloudflareKv(mockCf()).provision({ ref: "kv", name: "sessions", params: {} });
|
|
49
|
+
expect(kv).toEqual({ state: "succeeded", instanceId: "kv-id", outputs: { namespace_id: "kv-id" } });
|
|
50
|
+
const r2 = await cloudflareR2(mockCf()).provision({ ref: "media", name: "media", params: {} });
|
|
51
|
+
expect(r2).toEqual({ state: "succeeded", instanceId: "media", outputs: { bucket_name: "media" } });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("cloudflare-secrets: pushes the resolved secret set (skips empty), reports the names; needs a script", async () => {
|
|
55
|
+
const cf = mockCf();
|
|
56
|
+
const broker = cloudflareSecrets(cf);
|
|
57
|
+
const res = await broker.provision({ ref: "secrets", name: "worker-secrets", params: { script: "toolfactory-api", secrets: { A: "1", B: "2", C: undefined } } });
|
|
58
|
+
expect(res.outputs).toEqual({ secrets_set: "A,B" }); // C skipped (empty)
|
|
59
|
+
const puts = cf.calls.filter((c) => c.method === "PUT" && c.path.includes("/secrets"));
|
|
60
|
+
expect(puts.length).toBe(2);
|
|
61
|
+
expect(puts[0].opts?.json).toMatchObject({ name: "A", text: "1", type: "secret_text" });
|
|
62
|
+
await expect(broker.provision({ ref: "secrets", name: "s", params: { secrets: {} } })).rejects.toThrow(/needs a params.script/);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("the @suluk/env sink", () => {
|
|
67
|
+
test("lands each (output → env var) into a .env (plaintext here for the witness)", async () => {
|
|
68
|
+
const dir = mkdtempSync(join(tmpdir(), "prov-env-"));
|
|
69
|
+
const envPath = join(dir, ".env");
|
|
70
|
+
const sink = envSink({ envPath, plain: () => true });
|
|
71
|
+
await sink.write(
|
|
72
|
+
{ database_id: "db-uuid", token: "tok-secret", unused: "x" },
|
|
73
|
+
{ database_id: "CLOUDFLARE_D1_ID", token: "CLOUDFLARE_D1_TOKEN" },
|
|
74
|
+
);
|
|
75
|
+
const content = await readFile(envPath, "utf8");
|
|
76
|
+
expect(content).toContain('CLOUDFLARE_D1_ID="db-uuid"');
|
|
77
|
+
expect(content).toContain('CLOUDFLARE_D1_TOKEN="tok-secret"');
|
|
78
|
+
expect(content).not.toContain("unused"); // only mapped outputs are written
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("the file-backed journal", () => {
|
|
83
|
+
test("a missing file reads as empty; save then load round-trips", async () => {
|
|
84
|
+
const dir = mkdtempSync(join(tmpdir(), "prov-store-"));
|
|
85
|
+
const store = fileStore(join(dir, "sub", "provision.json")); // nested → mkdir -p
|
|
86
|
+
expect(await store.load()).toEqual([]);
|
|
87
|
+
const state: InstanceState[] = [
|
|
88
|
+
{ ref: "db", service: "cloudflare-d1", name: "app-db", instanceId: "db-uuid", outputs: { database_id: "db-uuid" }, fingerprint: "fp", provisionedAt: 1 },
|
|
89
|
+
];
|
|
90
|
+
await store.save(state);
|
|
91
|
+
expect(await store.load()).toEqual(state);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
pull, reconcile, discover, teardown, plan, apply, runCli, defineProvisionApp,
|
|
4
|
+
memoryStore, memorySink, type Broker, type InstanceSpec, type InstanceState, type ProvisionConfig,
|
|
5
|
+
} from "../src/index";
|
|
6
|
+
|
|
7
|
+
/** C047 build #5 — the careful features: `pull` (external-drift introspection), `teardown` (guarded destruction), and
|
|
8
|
+
* the `protected` rail. */
|
|
9
|
+
const st = (ref: string, service: string, outputs: Record<string, string>, extra: Partial<InstanceState> = {}): InstanceState => ({
|
|
10
|
+
ref, service, name: `${ref}-name`, instanceId: `${service}:${ref}`, outputs, fingerprint: "fp", provisionedAt: 1, ...extra,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function broker(id: string, impl: Partial<Broker> = {}): Broker {
|
|
14
|
+
return {
|
|
15
|
+
catalog: () => ({ services: [{ id, name: id, description: id, bindable: false, plans: [{ id: "standard", name: "S" }] }] }),
|
|
16
|
+
async provision(req) {
|
|
17
|
+
return { state: "succeeded", instanceId: `${id}:${req.name}`, outputs: {} };
|
|
18
|
+
},
|
|
19
|
+
async deprovision() {
|
|
20
|
+
return { state: "succeeded" };
|
|
21
|
+
},
|
|
22
|
+
...impl,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("pull — external drift (journal vs provider)", () => {
|
|
27
|
+
test("classifies live / missing / drifted / unknown, and reconcile folds it back", async () => {
|
|
28
|
+
const state = [st("db", "d1", { database_id: "x" }), st("gone", "d1", { database_id: "y" }), st("moved", "d1", { database_id: "z" }), st("opaque", "mystery", { k: "v" })];
|
|
29
|
+
const brokers = {
|
|
30
|
+
d1: broker("d1", {
|
|
31
|
+
async fetch(req) {
|
|
32
|
+
if (req.ref === "gone") return { exists: false }; // deleted in the dashboard
|
|
33
|
+
if (req.ref === "moved") return { exists: true, outputs: { database_id: "z-NEW" } }; // id changed
|
|
34
|
+
return { exists: true, outputs: { database_id: "x" } }; // db: matches
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
mystery: broker("mystery"), // no fetch → unknown
|
|
38
|
+
};
|
|
39
|
+
const report = await pull(state, brokers);
|
|
40
|
+
expect(report.entries.map((e) => `${e.ref}:${e.status}`)).toEqual(["db:live", "gone:missing", "moved:drifted", "opaque:unknown"]);
|
|
41
|
+
expect(report.clean).toBe(false);
|
|
42
|
+
expect(report.missing).toEqual(["gone"]);
|
|
43
|
+
expect(report.drifted).toEqual(["moved"]);
|
|
44
|
+
|
|
45
|
+
const fixed = reconcile(state, report);
|
|
46
|
+
expect(fixed.map((s) => s.ref)).toEqual(["db", "moved", "opaque"]); // "gone" dropped → next apply re-creates
|
|
47
|
+
expect(fixed.find((s) => s.ref === "moved")?.outputs).toEqual({ database_id: "z-NEW" }); // drifted output updated
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("discover finds live resources the journal doesn't track", async () => {
|
|
51
|
+
const state = [st("db", "d1", { database_id: "known" })];
|
|
52
|
+
const brokers = {
|
|
53
|
+
d1: broker("d1", {
|
|
54
|
+
async list() {
|
|
55
|
+
return [
|
|
56
|
+
{ name: "app-db", instanceId: "d1:db", outputs: { database_id: "known" } }, // already tracked
|
|
57
|
+
{ name: "stray-db", instanceId: "d1:stray", outputs: { database_id: "stray" } }, // untracked
|
|
58
|
+
];
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
61
|
+
};
|
|
62
|
+
expect(await discover(state, brokers)).toEqual([{ service: "d1", name: "stray-db", instanceId: "d1:stray", outputs: { database_id: "stray" } }]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("teardown — guarded destruction", () => {
|
|
67
|
+
const twoInstances = [st("db", "d1", {}), st("token", "tok", {})]; // journal order = provision order
|
|
68
|
+
const recording = () => {
|
|
69
|
+
const order: string[] = [];
|
|
70
|
+
const rec = (req: { ref: string }) => {
|
|
71
|
+
order.push(req.ref);
|
|
72
|
+
return Promise.resolve({ state: "succeeded" as const });
|
|
73
|
+
};
|
|
74
|
+
return { order, brokers: { d1: broker("d1", { deprovision: rec }), tok: broker("tok", { deprovision: rec }) } };
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
test("tears down CONSUMERS-FIRST (reverse journal order) and empties the journal", async () => {
|
|
78
|
+
const { order, brokers } = recording();
|
|
79
|
+
const store = memoryStore(twoInstances);
|
|
80
|
+
const res = await teardown({ brokers, store });
|
|
81
|
+
expect(order).toEqual(["token", "db"]); // reverse of [db, token]
|
|
82
|
+
expect(res.torn).toEqual(["token", "db"]);
|
|
83
|
+
expect(store.snapshot()).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("dry run previews without calling any provider or saving", async () => {
|
|
87
|
+
const { order, brokers } = recording();
|
|
88
|
+
const store = memoryStore(twoInstances);
|
|
89
|
+
const res = await teardown({ brokers, store, dryRun: true });
|
|
90
|
+
expect(order).toEqual([]); // no provider touched
|
|
91
|
+
expect(res.torn).toEqual(["token", "db"]); // what WOULD go
|
|
92
|
+
expect(store.snapshot().length).toBe(2); // journal untouched
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a PROTECTED instance is kept unless --force", async () => {
|
|
96
|
+
const withProtected = [st("db", "d1", {}, { protected: true }), st("token", "tok", {})];
|
|
97
|
+
const r1 = recording();
|
|
98
|
+
const store1 = memoryStore(withProtected);
|
|
99
|
+
const kept = await teardown({ brokers: r1.brokers, store: store1 });
|
|
100
|
+
expect(r1.order).toEqual(["token"]); // db (protected) skipped
|
|
101
|
+
expect(kept.kept).toEqual([{ ref: "db", reason: "protected" }]);
|
|
102
|
+
expect(store1.snapshot().map((s) => s.ref)).toEqual(["db"]); // protected survives
|
|
103
|
+
|
|
104
|
+
const r2 = recording();
|
|
105
|
+
const store2 = memoryStore(withProtected);
|
|
106
|
+
await teardown({ brokers: r2.brokers, store: store2, force: true });
|
|
107
|
+
expect(r2.order).toEqual(["token", "db"]); // force destroys the protected one too
|
|
108
|
+
expect(store2.snapshot()).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("protected in plan/prune", () => {
|
|
113
|
+
test("a protected orphan is kept (not deprovisioned) under --prune, and doesn't count as drift", () => {
|
|
114
|
+
const config: ProvisionConfig = { instances: [] }; // everything is an orphan
|
|
115
|
+
const state = [st("db", "d1", {}, { protected: true }), st("cache", "kv", {})];
|
|
116
|
+
const p = plan(config, state, true);
|
|
117
|
+
expect(p.steps.find((s) => s.ref === "db")?.action).toBe("noop"); // protected → kept
|
|
118
|
+
expect(p.steps.find((s) => s.ref === "cache")?.action).toBe("deprovision"); // unprotected orphan → gone
|
|
119
|
+
expect(p.clean).toBe(false); // the unprotected orphan is still pending work
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("the CLI — pull + teardown gates", () => {
|
|
124
|
+
const instances: InstanceSpec[] = [{ ref: "db", service: "mock", name: "app-db", protected: true }, { ref: "kv", service: "mock", name: "sessions" }];
|
|
125
|
+
const mkBrokers = () => ({ mock: broker("mock", { async provision(req) { return { state: "succeeded", instanceId: `mock:${req.name}`, outputs: { id: req.name } }; } }) });
|
|
126
|
+
const appWith = (store: ReturnType<typeof memoryStore>) => defineProvisionApp({ config: { instances }, brokers: mkBrokers(), store, sink: memorySink() });
|
|
127
|
+
|
|
128
|
+
test("`teardown` without --yes is a DRY RUN; `teardown --yes` destroys (protected needs --force)", async () => {
|
|
129
|
+
const store = memoryStore();
|
|
130
|
+
await runCli(appWith(store), ["apply"]); // seed the journal (db is protected)
|
|
131
|
+
|
|
132
|
+
const dry = await runCli(appWith(store), ["teardown"]);
|
|
133
|
+
expect(dry.output).toContain("DRY RUN");
|
|
134
|
+
expect(store.snapshot().length).toBe(2); // nothing destroyed
|
|
135
|
+
|
|
136
|
+
const yes = await runCli(appWith(store), ["teardown", "--yes"]);
|
|
137
|
+
expect(yes.output).toContain("torn down");
|
|
138
|
+
expect(store.snapshot().map((s) => s.ref)).toEqual(["db"]); // kv gone, db (protected) survives
|
|
139
|
+
|
|
140
|
+
await runCli(appWith(store), ["teardown", "--yes", "--force"]);
|
|
141
|
+
expect(store.snapshot()).toEqual([]); // --force destroys the protected db too
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("`pull` reports a clean journal after apply", async () => {
|
|
145
|
+
const store = memoryStore();
|
|
146
|
+
const brokers = { mock: broker("mock", { async provision(req) { return { state: "succeeded", instanceId: `mock:${req.name}`, outputs: {} }; }, async fetch() { return { exists: true }; } }) };
|
|
147
|
+
const app = defineProvisionApp({ config: { instances }, brokers, store, sink: memorySink() });
|
|
148
|
+
await runCli(app, ["apply"]);
|
|
149
|
+
const r = await runCli(app, ["pull"]);
|
|
150
|
+
expect(r.output).toContain("journal matches the provider");
|
|
151
|
+
});
|
|
152
|
+
});
|
package/test/cli.test.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { runCli, defineProvisionApp, memoryStore, memorySink, type Broker, type InstanceSpec, type ProvisionConfig, type StateStore } from "../src/index";
|
|
3
|
+
|
|
4
|
+
/** C047 build #3 — the drizzle-kit-style CLI (plan / apply / check / status), witnessed via runCli (process-pure). */
|
|
5
|
+
function sync(id: string, out: (name: string) => Record<string, string>): Broker {
|
|
6
|
+
return {
|
|
7
|
+
catalog: () => ({ services: [{ id, name: id, description: id, bindable: false, plans: [{ id: "standard", name: "Standard" }] }] }),
|
|
8
|
+
async provision(req) {
|
|
9
|
+
return { state: "succeeded", instanceId: `${id}:${req.name}`, outputs: out(req.name) };
|
|
10
|
+
},
|
|
11
|
+
async deprovision() {
|
|
12
|
+
return { state: "succeeded" };
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const brokers = {
|
|
17
|
+
"mock-d1": sync("mock-d1", () => ({ database_id: "db-uuid" })),
|
|
18
|
+
"mock-kv": sync("mock-kv", () => ({ namespace_id: "kv-id" })),
|
|
19
|
+
};
|
|
20
|
+
const baseInstances: InstanceSpec[] = [
|
|
21
|
+
{ ref: "db", service: "mock-d1", name: "app-db", bind: { database_id: "DB_ID" } },
|
|
22
|
+
{ ref: "kv", service: "mock-kv", name: "sessions", bind: { namespace_id: "KV_ID" } },
|
|
23
|
+
];
|
|
24
|
+
const appWith = (config: ProvisionConfig, store: StateStore) => defineProvisionApp({ config, brokers, store, sink: memorySink() });
|
|
25
|
+
|
|
26
|
+
describe("the provision CLI", () => {
|
|
27
|
+
test("plan on a fresh project lists creates", async () => {
|
|
28
|
+
const r = await runCli(appWith({ instances: baseInstances }, memoryStore()), ["plan"]);
|
|
29
|
+
expect(r.exitCode).toBe(0);
|
|
30
|
+
expect(r.output).toContain("+ create");
|
|
31
|
+
expect(r.output).toContain("plan: 2 create");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("apply → check is clean → plan is in sync (the drizzle-kit loop)", async () => {
|
|
35
|
+
const store = memoryStore();
|
|
36
|
+
const apply = await runCli(appWith({ instances: baseInstances }, store), ["apply"]);
|
|
37
|
+
expect(apply.exitCode).toBe(0);
|
|
38
|
+
expect(apply.output).toContain("✓ applied:");
|
|
39
|
+
|
|
40
|
+
const check = await runCli(appWith({ instances: baseInstances }, store), ["check"]);
|
|
41
|
+
expect(check.exitCode).toBe(0);
|
|
42
|
+
expect(check.output).toContain("no drift");
|
|
43
|
+
|
|
44
|
+
const plan = await runCli(appWith({ instances: baseInstances }, store), ["plan"]);
|
|
45
|
+
expect(plan.output).toContain("✓ in sync");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("check FAILS (exit 1) when the config drifts from the journal — the CI gate", async () => {
|
|
49
|
+
const store = memoryStore();
|
|
50
|
+
await runCli(appWith({ instances: baseInstances }, store), ["apply"]);
|
|
51
|
+
const drifted = baseInstances.map((i) => (i.ref === "kv" ? { ...i, name: "sessions-v2" } : i));
|
|
52
|
+
const r = await runCli(appWith({ instances: drifted }, store), ["check"]);
|
|
53
|
+
expect(r.exitCode).toBe(1);
|
|
54
|
+
expect(r.output).toContain("drift detected");
|
|
55
|
+
expect(r.output).toContain("kv");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("status lists the provisioned instances + their bound outputs", async () => {
|
|
59
|
+
const store = memoryStore();
|
|
60
|
+
await runCli(appWith({ instances: baseInstances }, store), ["apply"]);
|
|
61
|
+
const r = await runCli(appWith({ instances: baseInstances }, store), ["status"]);
|
|
62
|
+
expect(r.output).toContain("db (mock-d1 · app-db) → database_id=db-uuid");
|
|
63
|
+
expect(r.output).toContain("kv (mock-kv · sessions) → namespace_id=kv-id");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("apply --prune deprovisions an orphan; an unknown command exits 2", async () => {
|
|
67
|
+
const store = memoryStore();
|
|
68
|
+
await runCli(appWith({ instances: baseInstances }, store), ["apply"]);
|
|
69
|
+
const pruned = await runCli(appWith({ instances: baseInstances.filter((i) => i.ref === "db") }, store), ["apply", "--prune"]);
|
|
70
|
+
expect(pruned.output).toContain("-kv");
|
|
71
|
+
|
|
72
|
+
const bad = await runCli(appWith({ instances: baseInstances }, store), ["frobnicate"]);
|
|
73
|
+
expect(bad.exitCode).toBe(2);
|
|
74
|
+
});
|
|
75
|
+
});
|