@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
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
defineProvision, apply, plan, topoOrder, resolveParams, assertNoDrift, checkDrift,
|
|
4
|
+
memoryStore, memorySink, type Broker, type ProvisionConfig, type InstanceState,
|
|
5
|
+
} from "../src/index";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* C047 — the OSB provisioning framework, witnessed with MOCK brokers through the full lifecycle: catalog → provision
|
|
9
|
+
* (sync + async-with-polling) → bind → the BINDING CHAIN (a downstream `@ref.key` param resolving to a freshly
|
|
10
|
+
* provisioned value) → idempotent re-apply → drift-update → orphan deprovision. No real provider; the clock + sleep are
|
|
11
|
+
* injected so polling is deterministic.
|
|
12
|
+
*/
|
|
13
|
+
type Calls = { provision: string[]; bind: string[]; deprovision: string[]; lastOp: number };
|
|
14
|
+
function mockBroker(id: string, opts: { async?: boolean; bindable?: boolean; out?: (name: string) => Record<string, string> } = {}): Broker & { calls: Calls } {
|
|
15
|
+
const calls: Calls = { provision: [], bind: [], deprovision: [], lastOp: 0 };
|
|
16
|
+
let opState = 0;
|
|
17
|
+
return {
|
|
18
|
+
calls,
|
|
19
|
+
catalog: () => ({ services: [{ id, name: id, description: id, bindable: !!opts.bindable, plans: [{ id: "standard", name: "Standard" }] }] }),
|
|
20
|
+
async provision(req) {
|
|
21
|
+
calls.provision.push(req.ref);
|
|
22
|
+
const instanceId = `${id}:${req.name}`;
|
|
23
|
+
if (opts.async) {
|
|
24
|
+
opState = 0;
|
|
25
|
+
return { state: "in progress", operation: `op:${req.name}`, instanceId, outputs: opts.bindable ? {} : (opts.out?.(req.name) ?? {}) };
|
|
26
|
+
}
|
|
27
|
+
return { state: "succeeded", instanceId, outputs: opts.bindable ? {} : (opts.out?.(req.name) ?? {}) };
|
|
28
|
+
},
|
|
29
|
+
lastOperation: opts.async
|
|
30
|
+
? async () => {
|
|
31
|
+
calls.lastOp++;
|
|
32
|
+
opState++;
|
|
33
|
+
return { state: opState >= 2 ? "succeeded" : "in progress" }; // "in progress" once, then "succeeded"
|
|
34
|
+
}
|
|
35
|
+
: undefined,
|
|
36
|
+
bind: opts.bindable
|
|
37
|
+
? async (req) => {
|
|
38
|
+
calls.bind.push(req.ref);
|
|
39
|
+
return { outputs: opts.out?.(req.name) ?? { token: `${req.name}-tok` } };
|
|
40
|
+
}
|
|
41
|
+
: undefined,
|
|
42
|
+
async deprovision(req) {
|
|
43
|
+
calls.deprovision.push(req.ref);
|
|
44
|
+
return { state: "succeeded" };
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** db (async; its provision emits database_id) → token (sync, bindable; its scope param references @db.database_id). */
|
|
50
|
+
function fixture() {
|
|
51
|
+
const d1 = mockBroker("mock-d1", { async: true, out: (name) => ({ database_id: `${name}-uuid` }) });
|
|
52
|
+
const token = mockBroker("mock-token", { bindable: true, out: (name) => ({ token: `${name}-secret` }) });
|
|
53
|
+
const config: ProvisionConfig = {
|
|
54
|
+
instances: [
|
|
55
|
+
{ ref: "token", service: "mock-token", name: "d1-token", params: { scope: "@db.database_id" }, bind: { token: "CLOUDFLARE_D1_TOKEN" } },
|
|
56
|
+
{ ref: "db", service: "mock-d1", name: "app-db", bind: { database_id: "CLOUDFLARE_D1_ID" } },
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
return { d1, token, config, brokers: { "mock-d1": d1, "mock-token": token } };
|
|
60
|
+
}
|
|
61
|
+
const fastPoll = { sleep: async () => {}, intervalMs: 0 };
|
|
62
|
+
|
|
63
|
+
describe("the binding DAG", () => {
|
|
64
|
+
test("topoOrder puts a producer before its consumer regardless of config order", () => {
|
|
65
|
+
const { config } = fixture();
|
|
66
|
+
expect(topoOrder(config.instances).map((i) => i.ref)).toEqual(["db", "token"]); // token declared first, but depends on db
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("defineProvision rejects a duplicate ref, an unknown ref, and a cycle", () => {
|
|
70
|
+
expect(() => defineProvision({ instances: [{ ref: "a", service: "s", name: "a" }, { ref: "a", service: "s", name: "a2" }] })).toThrow(/duplicate/);
|
|
71
|
+
expect(() => defineProvision({ instances: [{ ref: "a", service: "s", name: "a", params: { x: "@ghost.id" } }] })).toThrow(/no instance "ghost"/);
|
|
72
|
+
expect(() => defineProvision({ instances: [
|
|
73
|
+
{ ref: "a", service: "s", name: "a", params: { x: "@b.id" } },
|
|
74
|
+
{ ref: "b", service: "s", name: "b", params: { y: "@a.id" } },
|
|
75
|
+
] })).toThrow(/cycle/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("resolveParams substitutes a ref and fails closed on a missing output", () => {
|
|
79
|
+
const spec = { ref: "c", service: "s", name: "c", params: { scope: "@db.database_id", literal: 7 } };
|
|
80
|
+
expect(resolveParams(spec, { db: { database_id: "abc" } })).toEqual({ scope: "abc", literal: 7 });
|
|
81
|
+
expect(() => resolveParams(spec, { db: {} })).toThrow(/no output "database_id"/);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("apply — the full OSB lifecycle", () => {
|
|
86
|
+
test("provisions in DAG order, polls the async op, resolves the binding chain, lands credentials in the sink", async () => {
|
|
87
|
+
const { config, brokers, d1, token } = fixture();
|
|
88
|
+
const store = memoryStore();
|
|
89
|
+
const sink = memorySink();
|
|
90
|
+
const res = await apply(config, { brokers, store, sink, poll: fastPoll });
|
|
91
|
+
|
|
92
|
+
// order: db before token; db was async (polled twice: "in progress" → "succeeded").
|
|
93
|
+
expect(res.steps.map((s) => `${s.action}:${s.ref}`)).toEqual(["create:db", "create:token"]);
|
|
94
|
+
expect(d1.calls.lastOp).toBe(2);
|
|
95
|
+
// the binding chain: token's scope param resolved to db's freshly provisioned database_id.
|
|
96
|
+
expect(token.calls.provision).toEqual(["token"]);
|
|
97
|
+
expect(res.outputsByRef.db).toEqual({ database_id: "app-db-uuid" });
|
|
98
|
+
// the sink got both mapped env vars.
|
|
99
|
+
expect(sink.values).toEqual({ CLOUDFLARE_D1_ID: "app-db-uuid", CLOUDFLARE_D1_TOKEN: "d1-token-secret" });
|
|
100
|
+
// state persisted for both.
|
|
101
|
+
expect(store.snapshot().map((s) => s.ref).sort()).toEqual(["db", "token"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("re-applying a settled config is ALL noops — touches no provider", async () => {
|
|
105
|
+
const { config, brokers, d1, token } = fixture();
|
|
106
|
+
const store = memoryStore();
|
|
107
|
+
await apply(config, { brokers, store, poll: fastPoll });
|
|
108
|
+
d1.calls.provision.length = 0;
|
|
109
|
+
token.calls.provision.length = 0;
|
|
110
|
+
const again = await apply(config, { brokers, store, poll: fastPoll });
|
|
111
|
+
expect(again.steps.every((s) => s.action === "noop")).toBe(true);
|
|
112
|
+
expect(d1.calls.provision).toEqual([]); // idempotent — no provider call on a clean re-apply
|
|
113
|
+
expect(token.calls.provision).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("a param change re-provisions ONLY the drifted instance (the producer stays noop)", async () => {
|
|
117
|
+
const { config, brokers, d1, token } = fixture();
|
|
118
|
+
const store = memoryStore();
|
|
119
|
+
await apply(config, { brokers, store, poll: fastPoll });
|
|
120
|
+
d1.calls.provision.length = 0;
|
|
121
|
+
token.calls.provision.length = 0;
|
|
122
|
+
// drift the token's params.
|
|
123
|
+
const drifted: ProvisionConfig = { instances: config.instances.map((i) => (i.ref === "token" ? { ...i, params: { scope: "@db.database_id", ttl: 3600 } } : i)) };
|
|
124
|
+
const res = await apply(drifted, { brokers, store, poll: fastPoll });
|
|
125
|
+
expect(res.steps.map((s) => `${s.action}:${s.ref}`)).toEqual(["noop:db", "update:token"]);
|
|
126
|
+
expect(d1.calls.provision).toEqual([]); // db unchanged
|
|
127
|
+
expect(token.calls.provision).toEqual(["token"]); // only the drifted one re-provisioned
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("orphan mitigation: an instance dropped from config is deprovisioned under --prune", async () => {
|
|
131
|
+
const { config, brokers, token } = fixture();
|
|
132
|
+
const store = memoryStore();
|
|
133
|
+
await apply(config, { brokers, store, poll: fastPoll });
|
|
134
|
+
const dbOnly: ProvisionConfig = { instances: config.instances.filter((i) => i.ref === "db") };
|
|
135
|
+
const res = await apply(dbOnly, { brokers, store, prune: true, poll: fastPoll });
|
|
136
|
+
expect(res.steps.find((s) => s.ref === "token")?.action).toBe("deprovision");
|
|
137
|
+
expect(token.calls.deprovision).toEqual(["token"]);
|
|
138
|
+
expect(store.snapshot().map((s) => s.ref)).toEqual(["db"]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("WITHOUT --prune an orphan is surfaced but NOT torn down", async () => {
|
|
142
|
+
const { config, brokers, token } = fixture();
|
|
143
|
+
const store = memoryStore();
|
|
144
|
+
await apply(config, { brokers, store, poll: fastPoll });
|
|
145
|
+
const dbOnly: ProvisionConfig = { instances: config.instances.filter((i) => i.ref === "db") };
|
|
146
|
+
const res = await apply(dbOnly, { brokers, store, poll: fastPoll }); // prune off
|
|
147
|
+
expect(token.calls.deprovision).toEqual([]); // destructive op never fires by default
|
|
148
|
+
expect(res.state.map((s) => s.ref).sort()).toEqual(["db", "token"]); // token state kept
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("plan + the drift gate", () => {
|
|
153
|
+
test("plan marks new instances create; checkDrift/assertNoDrift gate on pending work", () => {
|
|
154
|
+
const { config } = fixture();
|
|
155
|
+
const empty: InstanceState[] = [];
|
|
156
|
+
const p = plan(config, empty);
|
|
157
|
+
expect(p.clean).toBe(false);
|
|
158
|
+
expect(p.steps.map((s) => `${s.action}:${s.ref}`)).toEqual(["create:db", "create:token"]);
|
|
159
|
+
expect(checkDrift(config, empty).drift.length).toBe(2);
|
|
160
|
+
expect(() => assertNoDrift(config, empty)).toThrow(/drift detected/);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("assertNoDrift passes once everything is provisioned", async () => {
|
|
164
|
+
const { config, brokers } = fixture();
|
|
165
|
+
const store = memoryStore();
|
|
166
|
+
await apply(config, { brokers, store, poll: fastPoll });
|
|
167
|
+
expect(() => assertNoDrift(config, store.snapshot())).not.toThrow();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
diffSnapshots, generate, migrate, EMPTY_SNAPSHOT,
|
|
7
|
+
memoryMigrationStore, fileMigrationStore, memoryStore, memorySink, runCli, defineProvisionApp,
|
|
8
|
+
type Broker, type InstanceSpec,
|
|
9
|
+
} from "../src/index";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* C047 build #6 — the snapshot + migration model (repeatable, documentable steps like drizzle-kit). `generate` records a
|
|
13
|
+
* migration + snapshot from the config delta; `migrate` replays PENDING migrations in order via the engine.
|
|
14
|
+
*/
|
|
15
|
+
const DB: InstanceSpec = { ref: "db", service: "d1", name: "app-db" };
|
|
16
|
+
const KV: InstanceSpec = { ref: "kv", service: "kv", name: "sessions", params: { db: "@db.database_id" } }; // depends on db
|
|
17
|
+
|
|
18
|
+
function mkBroker(id: string): Broker & { provisioned: string[]; deprovisioned: string[] } {
|
|
19
|
+
const provisioned: string[] = [];
|
|
20
|
+
const deprovisioned: string[] = [];
|
|
21
|
+
return {
|
|
22
|
+
provisioned,
|
|
23
|
+
deprovisioned,
|
|
24
|
+
catalog: () => ({ services: [{ id, name: id, description: id, bindable: false, plans: [{ id: "standard", name: "S" }] }] }),
|
|
25
|
+
async provision(req) {
|
|
26
|
+
provisioned.push(req.ref);
|
|
27
|
+
return { state: "succeeded", instanceId: `${id}:${req.name}`, outputs: { database_id: `${req.name}-uuid` } };
|
|
28
|
+
},
|
|
29
|
+
async deprovision(req) {
|
|
30
|
+
deprovisioned.push(req.ref);
|
|
31
|
+
return { state: "succeeded" };
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const mk = () => {
|
|
36
|
+
const d1 = mkBroker("d1");
|
|
37
|
+
const kv = mkBroker("kv");
|
|
38
|
+
return { d1, kv, brokers: { d1, kv } };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe("diffSnapshots — the delta between snapshots", () => {
|
|
42
|
+
test("empty → creates in DAG order; a changed instance → update; a dropped one → deprovision; unchanged → nothing", () => {
|
|
43
|
+
const creates = diffSnapshots(EMPTY_SNAPSHOT, { instances: [KV, DB] }); // KV declared first but depends on DB
|
|
44
|
+
expect(creates.map((s) => `${s.action}:${s.ref}`)).toEqual(["create:db", "create:kv"]);
|
|
45
|
+
|
|
46
|
+
const prev = { version: "1", idx: 0, instances: [DB, KV] };
|
|
47
|
+
expect(diffSnapshots(prev, { instances: [DB, KV] })).toEqual([]); // unchanged
|
|
48
|
+
expect(diffSnapshots(prev, { instances: [{ ...DB, name: "app-db-2" }, KV] }).map((s) => s.action)).toEqual(["update"]);
|
|
49
|
+
expect(diffSnapshots(prev, { instances: [DB] }).map((s) => `${s.action}:${s.ref}`)).toEqual(["deprovision:kv"]); // dropped
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("generate — record the delta as a migration + snapshot", () => {
|
|
54
|
+
test("first generate writes 0000; a no-op generate returns null; a change writes the next delta only", async () => {
|
|
55
|
+
const m = memoryMigrationStore();
|
|
56
|
+
const m0 = await generate({ instances: [DB] }, m);
|
|
57
|
+
expect(m0?.tag).toBe("0000_migration");
|
|
58
|
+
expect(m0?.steps.map((s) => s.action)).toEqual(["create"]);
|
|
59
|
+
|
|
60
|
+
expect(await generate({ instances: [DB] }, m)).toBeNull(); // config == last snapshot
|
|
61
|
+
|
|
62
|
+
const m1 = await generate({ instances: [DB, KV] }, m, "add-kv");
|
|
63
|
+
expect(m1?.tag).toBe("0001_add-kv");
|
|
64
|
+
expect(m1?.steps.map((s) => `${s.action}:${s.ref}`)).toEqual(["create:kv"]); // ONLY the delta
|
|
65
|
+
expect((await m.lastSnapshot()).instances.map((i) => i.ref)).toEqual(["db", "kv"]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("migrate — replay pending migrations in order", () => {
|
|
70
|
+
test("applies pending via the engine, marks them applied, and is idempotent on re-run", async () => {
|
|
71
|
+
const { d1, kv, brokers } = mk();
|
|
72
|
+
const m = memoryMigrationStore();
|
|
73
|
+
const store = memoryStore();
|
|
74
|
+
await generate({ instances: [DB] }, m);
|
|
75
|
+
|
|
76
|
+
const r0 = await migrate({ brokers, store, migrations: m, sink: memorySink() });
|
|
77
|
+
expect(r0.applied).toEqual([0]);
|
|
78
|
+
expect(d1.provisioned).toEqual(["db"]);
|
|
79
|
+
expect(store.snapshot().map((s) => s.ref)).toEqual(["db"]);
|
|
80
|
+
|
|
81
|
+
const again = await migrate({ brokers, store, migrations: m });
|
|
82
|
+
expect(again.upToDate).toBe(true);
|
|
83
|
+
expect(d1.provisioned).toEqual(["db"]); // NOT re-provisioned — already applied
|
|
84
|
+
|
|
85
|
+
// add kv → generate 0001 → migrate provisions only kv (the binding chain resolves @db.database_id from the journal).
|
|
86
|
+
await generate({ instances: [DB, KV] }, m, "add-kv");
|
|
87
|
+
const r1 = await migrate({ brokers, store, migrations: m, sink: memorySink() });
|
|
88
|
+
expect(r1.applied).toEqual([1]);
|
|
89
|
+
expect(kv.provisioned).toEqual(["kv"]);
|
|
90
|
+
|
|
91
|
+
// drop kv → generate 0002 → migrate deprovisions it (the snapshot prunes).
|
|
92
|
+
await generate({ instances: [DB] }, m, "drop-kv");
|
|
93
|
+
await migrate({ brokers, store, migrations: m });
|
|
94
|
+
expect(kv.deprovisioned).toEqual(["kv"]);
|
|
95
|
+
expect(store.snapshot().map((s) => s.ref)).toEqual(["db"]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("fileMigrationStore — committed + repeatable across processes", () => {
|
|
100
|
+
test("writes the migration/snapshot/journal; a fresh store over the same dir sees them", async () => {
|
|
101
|
+
const dir = mkdtempSync(join(tmpdir(), "prov-mig-"));
|
|
102
|
+
const store = fileMigrationStore(dir);
|
|
103
|
+
await generate({ instances: [DB] }, store);
|
|
104
|
+
await generate({ instances: [DB, KV] }, store, "add-kv");
|
|
105
|
+
await store.markApplied(0);
|
|
106
|
+
|
|
107
|
+
const fresh = fileMigrationStore(dir); // a new process, same committed dir
|
|
108
|
+
const list = await fresh.listMigrations();
|
|
109
|
+
expect(list.map((mm) => mm.tag)).toEqual(["0000_migration", "0001_add-kv"]);
|
|
110
|
+
expect((await fresh.lastSnapshot()).instances.map((i) => i.ref)).toEqual(["db", "kv"]);
|
|
111
|
+
expect(await fresh.applied()).toEqual([0]); // env-local applied-ledger persisted
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("the CLI — generate + migrate", () => {
|
|
116
|
+
test("generate → migrate → migrate (up to date); no store → exit 2", async () => {
|
|
117
|
+
const { brokers } = mk();
|
|
118
|
+
const migrations = memoryMigrationStore();
|
|
119
|
+
const app = defineProvisionApp({ config: { instances: [DB] }, brokers, store: memoryStore(), sink: memorySink(), migrations });
|
|
120
|
+
|
|
121
|
+
const g = await runCli(app, ["generate", "--name", "init"]);
|
|
122
|
+
expect(g.output).toContain("0000_init");
|
|
123
|
+
|
|
124
|
+
expect((await runCli(app, ["migrate"])).output).toContain("applied 1 migration");
|
|
125
|
+
expect((await runCli(app, ["migrate"])).output).toContain("up to date");
|
|
126
|
+
|
|
127
|
+
const noMig = defineProvisionApp({ config: { instances: [DB] }, brokers, store: memoryStore() });
|
|
128
|
+
expect((await runCli(noMig, ["generate"])).exitCode).toBe(2);
|
|
129
|
+
});
|
|
130
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["bun"] }, "include": ["src", "test"] }
|