@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/app.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provision "app" (C047) — what a `provision.config.ts` exports, the drizzle-kit `drizzle.config.ts` analog. It binds
|
|
3
|
+
* the declarative config (the desired instances) to the runtime wiring the CLI needs: the broker registry (service id →
|
|
4
|
+
* the executor that talks to the provider), the state journal, and the binding sink. `plan`/`check`/`status` need only
|
|
5
|
+
* the config + journal; `apply` needs the brokers + sink. `defineProvisionApp` validates the config up front.
|
|
6
|
+
*/
|
|
7
|
+
import type { Broker, BindingSink, StateStore } from "./types";
|
|
8
|
+
import type { MigrationStore } from "./migration-store";
|
|
9
|
+
import { defineProvision, type ProvisionConfig } from "./config";
|
|
10
|
+
|
|
11
|
+
export interface ProvisionApp {
|
|
12
|
+
/** the desired instances (+ pruneOrphans default). */
|
|
13
|
+
config: ProvisionConfig;
|
|
14
|
+
/** service id → broker (the executors `apply` dispatches to). */
|
|
15
|
+
brokers: Record<string, Broker>;
|
|
16
|
+
/** the journal (defaults to a file store in a real config). */
|
|
17
|
+
store: StateStore;
|
|
18
|
+
/** where bound credentials land (defaults to the @suluk/env sink). Optional. */
|
|
19
|
+
sink?: BindingSink;
|
|
20
|
+
/** the committed migration history — enables `generate` + `migrate` (the drizzle-style repeatable path). Optional;
|
|
21
|
+
* a real config points it at `fileMigrationStore("provision")`. */
|
|
22
|
+
migrations?: MigrationStore;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Validate + return a provision app config (the CLI imports this as the config file's default export). */
|
|
26
|
+
export function defineProvisionApp(app: ProvisionApp): ProvisionApp {
|
|
27
|
+
defineProvision(app.config); // throws on dup ref / unknown ref / cycle
|
|
28
|
+
return app;
|
|
29
|
+
}
|
package/src/apply.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The executor (C047) — drizzle-kit's `push` for infrastructure. Walks a {@link ProvisionPlan} in binding-DAG order and
|
|
3
|
+
* calls the OSB verbs: `provision` (idempotent), poll `lastOperation` until an async create settles, `bind` for the
|
|
4
|
+
* credentials, land them in the {@link BindingSink}, and accumulate each instance's outputs so a downstream `@ref.key`
|
|
5
|
+
* param resolves to a freshly-provisioned value. Orphans (when pruning) are deprovisioned last. Provider calls live in
|
|
6
|
+
* the brokers; this is the pure orchestration over them — the clock + sleep are injected so it's deterministically
|
|
7
|
+
* testable.
|
|
8
|
+
*/
|
|
9
|
+
import type { Broker, BindingSink, InstanceState, ProvisionResult, StateStore } from "./types";
|
|
10
|
+
import type { ProvisionConfig } from "./config";
|
|
11
|
+
import type { PlanStep, StepAction } from "./plan";
|
|
12
|
+
import { plan } from "./plan";
|
|
13
|
+
import { resolveParams, fingerprint } from "./refs";
|
|
14
|
+
import { pollToDone, type PollOptions } from "./poll";
|
|
15
|
+
|
|
16
|
+
export interface ApplyOptions {
|
|
17
|
+
/** broker id → broker (the catalog of executors). A step whose `service` is absent here is an error. */
|
|
18
|
+
brokers: Record<string, Broker>;
|
|
19
|
+
/** the journal load/save (a JSON file in prod; memory in tests). */
|
|
20
|
+
store: StateStore;
|
|
21
|
+
/** where bound credentials land (the @suluk/env manifest in prod; memory in tests). Optional — omit to skip sinking. */
|
|
22
|
+
sink?: BindingSink;
|
|
23
|
+
/** deprovision orphans (state − config). Defaults to the config's `pruneOrphans`. */
|
|
24
|
+
prune?: boolean;
|
|
25
|
+
/** async-poll tuning + seams (see {@link PollOptions}). */
|
|
26
|
+
poll?: PollOptions;
|
|
27
|
+
log?: (msg: string) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AppliedStep {
|
|
31
|
+
ref: string;
|
|
32
|
+
action: StepAction;
|
|
33
|
+
instanceId?: string;
|
|
34
|
+
outputs?: Record<string, string>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ApplyResult {
|
|
38
|
+
steps: AppliedStep[];
|
|
39
|
+
state: InstanceState[];
|
|
40
|
+
/** every instance's resolved outputs after the run (for assertions + downstream tooling). */
|
|
41
|
+
outputsByRef: Record<string, Record<string, string>>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Resolve the instance id from a provision result, settling an async op first via polling. */
|
|
45
|
+
async function settle(
|
|
46
|
+
broker: Broker,
|
|
47
|
+
spec: { ref: string; name: string },
|
|
48
|
+
result: ProvisionResult,
|
|
49
|
+
poll: PollOptions,
|
|
50
|
+
log: (m: string) => void,
|
|
51
|
+
): Promise<{ instanceId: string; outputs: Record<string, string> }> {
|
|
52
|
+
if (result.state === "succeeded") return { instanceId: result.instanceId, outputs: result.outputs ?? {} };
|
|
53
|
+
// async: the ack must carry the id (often known at submit time, like a D1 uuid); poll until the op settles, then
|
|
54
|
+
// thread any outputs the ack already surfaced.
|
|
55
|
+
if (!result.instanceId) throw new Error(`provision: ${spec.ref} async provision must return an instanceId alongside the operation`);
|
|
56
|
+
await pollToDone(broker, { ref: spec.ref, name: spec.name, instanceId: result.instanceId, operation: result.operation }, poll, log);
|
|
57
|
+
return { instanceId: result.instanceId, outputs: result.outputs ?? {} };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Execute the plan for `config`. Idempotent end-to-end: re-running a settled config is all-noops, touches no provider. */
|
|
61
|
+
export async function apply(config: ProvisionConfig, opts: ApplyOptions): Promise<ApplyResult> {
|
|
62
|
+
const log = opts.log ?? (() => {});
|
|
63
|
+
const poll = opts.poll ?? {};
|
|
64
|
+
const now = poll.now ?? Date.now;
|
|
65
|
+
const prune = opts.prune ?? config.pruneOrphans ?? false;
|
|
66
|
+
|
|
67
|
+
const prior = await opts.store.load();
|
|
68
|
+
const p = plan(config, prior, prune);
|
|
69
|
+
const specByRef = new Map(config.instances.map((i) => [i.ref, i]));
|
|
70
|
+
const stateByRef = new Map(prior.map((s) => [s.ref, s]));
|
|
71
|
+
// seed downstream-ref resolution with EXISTING outputs (a noop producer still feeds a changed consumer).
|
|
72
|
+
const outputsByRef: Record<string, Record<string, string>> = Object.fromEntries(prior.map((s) => [s.ref, s.outputs]));
|
|
73
|
+
const applied: AppliedStep[] = [];
|
|
74
|
+
|
|
75
|
+
for (const step of p.steps) {
|
|
76
|
+
const broker = opts.brokers[step.service];
|
|
77
|
+
if (!broker && step.action !== "noop") throw new Error(`provision: no broker registered for service "${step.service}" (instance ${step.ref})`);
|
|
78
|
+
|
|
79
|
+
if (step.action === "noop") {
|
|
80
|
+
applied.push({ ref: step.ref, action: "noop", instanceId: stateByRef.get(step.ref)?.instanceId, outputs: outputsByRef[step.ref] });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (step.action === "deprovision") {
|
|
85
|
+
const s = stateByRef.get(step.ref)!;
|
|
86
|
+
if (broker.deprovision) {
|
|
87
|
+
const res = await broker.deprovision({ ref: s.ref, name: s.name, instanceId: s.instanceId, operation: "deprovision" });
|
|
88
|
+
if (res.state === "in progress") await pollToDone(broker, { ref: s.ref, name: s.name, instanceId: s.instanceId, operation: res.operation ?? "deprovision" }, poll, log);
|
|
89
|
+
}
|
|
90
|
+
stateByRef.delete(step.ref);
|
|
91
|
+
delete outputsByRef[step.ref];
|
|
92
|
+
applied.push({ ref: step.ref, action: "deprovision" });
|
|
93
|
+
log(`✗ deprovisioned ${step.ref} (${step.name})`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// create | update
|
|
98
|
+
const spec = specByRef.get(step.ref)!;
|
|
99
|
+
const params = resolveParams(spec, outputsByRef);
|
|
100
|
+
log(`${step.action === "create" ? "+" : "~"} ${step.action} ${step.ref} (${spec.service} · ${spec.name})`);
|
|
101
|
+
const result = await broker.provision({ ref: spec.ref, name: spec.name, plan: spec.plan, params });
|
|
102
|
+
const { instanceId, outputs: provisionOutputs } = await settle(broker, spec, result, poll, log);
|
|
103
|
+
|
|
104
|
+
let outputs = { ...provisionOutputs };
|
|
105
|
+
if (broker.bind) {
|
|
106
|
+
const bound = await broker.bind({ ref: spec.ref, name: spec.name, instanceId, params });
|
|
107
|
+
outputs = { ...outputs, ...bound.outputs };
|
|
108
|
+
}
|
|
109
|
+
outputsByRef[spec.ref] = outputs;
|
|
110
|
+
if (opts.sink && spec.bind && Object.keys(spec.bind).length) await opts.sink.write(outputs, spec.bind);
|
|
111
|
+
|
|
112
|
+
stateByRef.set(spec.ref, {
|
|
113
|
+
ref: spec.ref, service: spec.service, plan: spec.plan, name: spec.name,
|
|
114
|
+
instanceId, outputs, fingerprint: fingerprint(spec), protected: spec.protected, provisionedAt: now(),
|
|
115
|
+
});
|
|
116
|
+
applied.push({ ref: spec.ref, action: step.action as StepAction, instanceId, outputs });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const state = [...stateByRef.values()];
|
|
120
|
+
await opts.store.save(state);
|
|
121
|
+
return { steps: applied, state, outputsByRef };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type { PlanStep };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Cloudflare Pages custom-domain broker (C047) — the ASYNC OSB showcase (this is the core of `provision-domains.ts`).
|
|
3
|
+
* Attaching a custom hostname to a Pages project is idempotent, but the edge CERT issues over seconds-to-minutes, so this
|
|
4
|
+
* is the textbook OSB last-operation flow: `provision` attaches + returns `in progress`, then `lastOperation` polls the
|
|
5
|
+
* domain status until it's `active` (cert live). The project + hostname are encoded in the instance id (the OSB operation
|
|
6
|
+
* anchor), so `lastOperation` + `deprovision` are self-contained — they need no params. The redirect-rule / DNS policy
|
|
7
|
+
* that's specific to one app stays in that app's config, not here.
|
|
8
|
+
*/
|
|
9
|
+
import type { CloudflareClient } from "@suluk/cloudflare";
|
|
10
|
+
import type { Broker, Catalog, OperationState } from "../types";
|
|
11
|
+
|
|
12
|
+
interface PagesDomain {
|
|
13
|
+
name: string;
|
|
14
|
+
status?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ANCHOR = "::"; // instanceId = `${project}::${hostname}`
|
|
18
|
+
const catalog = (): Catalog => ({
|
|
19
|
+
services: [{ id: "cloudflare-pages-domain", name: "Cloudflare Pages Custom Domain", description: "A custom hostname on a Pages project (cert issued asynchronously)", bindable: false, plans: [{ id: "standard", name: "Standard", free: true }] }],
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export function cloudflarePagesDomain(cf: CloudflareClient): Broker {
|
|
23
|
+
const domainsPath = (acct: string, project: string) => `/accounts/${acct}/pages/projects/${project}/domains`;
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
catalog,
|
|
27
|
+
async provision(req) {
|
|
28
|
+
const project = req.params.project as string | undefined;
|
|
29
|
+
if (!project) throw new Error(`provision: cloudflare-pages-domain ${req.ref} needs a params.project`);
|
|
30
|
+
const hostname = req.name;
|
|
31
|
+
const acct = await cf.resolveAccountId();
|
|
32
|
+
const list = (await cf.request<PagesDomain[]>("GET", domainsPath(acct, project))) ?? [];
|
|
33
|
+
if (!list.some((d) => d.name === hostname)) await cf.request("POST", domainsPath(acct, project), { json: { name: hostname } });
|
|
34
|
+
// cert issuance is async → poll lastOperation until "active". outputs are known now (the hostname + its URL).
|
|
35
|
+
const instanceId = `${project}${ANCHOR}${hostname}`;
|
|
36
|
+
return { state: "in progress", operation: instanceId, instanceId, outputs: { hostname, url: `https://${hostname}` } };
|
|
37
|
+
},
|
|
38
|
+
async lastOperation(req) {
|
|
39
|
+
const [project, hostname] = (req.instanceId ?? req.operation).split(ANCHOR);
|
|
40
|
+
const acct = await cf.resolveAccountId();
|
|
41
|
+
const list = (await cf.request<PagesDomain[]>("GET", domainsPath(acct, project))) ?? [];
|
|
42
|
+
const status = list.find((d) => d.name === hostname)?.status ?? "pending";
|
|
43
|
+
const state: OperationState = status === "active" ? "succeeded" : "in progress";
|
|
44
|
+
return { state, description: status };
|
|
45
|
+
},
|
|
46
|
+
async deprovision(req) {
|
|
47
|
+
const [project, hostname] = (req.instanceId ?? "").split(ANCHOR);
|
|
48
|
+
const acct = await cf.resolveAccountId();
|
|
49
|
+
await cf.request("DELETE", `${domainsPath(acct, project)}/${hostname}`);
|
|
50
|
+
return { state: "succeeded" };
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Cloudflare brokers (C047) — D1, KV, R2, and Worker secrets, each an OSB {@link Broker} that WRAPS @suluk/cloudflare's
|
|
3
|
+
* already-witnessed idempotent provisioners. The broker is thin on purpose: it advertises a catalog, maps a provision
|
|
4
|
+
* request onto the provisioner, and surfaces the resource id as a binding output (a D1 `database_id`, a KV `namespace_id`,
|
|
5
|
+
* an R2 `bucket_name`) for the binding chain. The provider calls all live in @suluk/cloudflare; these never re-implement
|
|
6
|
+
* them. Construct each with a configured `CloudflareClient` (the credentials seam).
|
|
7
|
+
*/
|
|
8
|
+
import type { CloudflareClient } from "@suluk/cloudflare";
|
|
9
|
+
import { provisionD1, provisionKvNamespace, provisionR2Bucket, applyMigrations, putSecrets, type Migration } from "@suluk/cloudflare";
|
|
10
|
+
import type { Broker, Catalog, OperationState } from "../types";
|
|
11
|
+
|
|
12
|
+
const onePlan = (id: string, name: string, description: string, bindable: boolean): Catalog => ({
|
|
13
|
+
services: [{ id, name, description, bindable, plans: [{ id: "standard", name: "Standard", free: true }] }],
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/** Best-effort delete via the Cloudflare REST API; maps to an OSB sync deprovision. */
|
|
17
|
+
async function del(cf: CloudflareClient, path: string): Promise<{ state: OperationState }> {
|
|
18
|
+
const acct = await cf.resolveAccountId();
|
|
19
|
+
await cf.request("DELETE", `/accounts/${acct}${path}`);
|
|
20
|
+
return { state: "succeeded" };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** D1 database. Provision is create-or-get; when `params.migrations` (a `Migration[]`) is present they're applied through
|
|
24
|
+
* the @suluk/cloudflare ledger (each runs at most once). Output: `database_id`. */
|
|
25
|
+
export function cloudflareD1(cf: CloudflareClient): Broker {
|
|
26
|
+
return {
|
|
27
|
+
catalog: () => onePlan("cloudflare-d1", "Cloudflare D1", "A serverless SQLite database", false),
|
|
28
|
+
async provision(req) {
|
|
29
|
+
const db = await provisionD1(cf, req.name);
|
|
30
|
+
const migrations = req.params.migrations as Migration[] | undefined;
|
|
31
|
+
if (migrations?.length) await applyMigrations(cf, db.uuid, migrations);
|
|
32
|
+
return { state: "succeeded", instanceId: db.uuid, outputs: { database_id: db.uuid } };
|
|
33
|
+
},
|
|
34
|
+
async fetch(req) {
|
|
35
|
+
if (!req.instanceId) return { exists: false };
|
|
36
|
+
const acct = await cf.resolveAccountId();
|
|
37
|
+
try {
|
|
38
|
+
await cf.request("GET", `/accounts/${acct}/d1/database/${req.instanceId}`);
|
|
39
|
+
return { exists: true, outputs: { database_id: req.instanceId } };
|
|
40
|
+
} catch {
|
|
41
|
+
return { exists: false }; // 404 / deleted in the dashboard → external drift
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
async list() {
|
|
45
|
+
const acct = await cf.resolveAccountId();
|
|
46
|
+
const dbs = (await cf.request<{ uuid: string; name: string }[]>("GET", `/accounts/${acct}/d1/database`)) ?? [];
|
|
47
|
+
return dbs.map((d) => ({ name: d.name, instanceId: d.uuid, outputs: { database_id: d.uuid } }));
|
|
48
|
+
},
|
|
49
|
+
deprovision: (req) => del(cf, `/d1/database/${req.instanceId}`),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Workers KV namespace. Provision is create-or-get. Output: `namespace_id`. */
|
|
54
|
+
export function cloudflareKv(cf: CloudflareClient): Broker {
|
|
55
|
+
return {
|
|
56
|
+
catalog: () => onePlan("cloudflare-kv", "Cloudflare KV", "A Workers KV namespace", false),
|
|
57
|
+
async provision(req) {
|
|
58
|
+
const ns = await provisionKvNamespace(cf, req.name);
|
|
59
|
+
return { state: "succeeded", instanceId: ns.id, outputs: { namespace_id: ns.id } };
|
|
60
|
+
},
|
|
61
|
+
deprovision: (req) => del(cf, `/storage/kv/namespaces/${req.instanceId}`),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** R2 bucket. Provision is create-or-get. Output: `bucket_name` (R2's id IS its name). */
|
|
66
|
+
export function cloudflareR2(cf: CloudflareClient): Broker {
|
|
67
|
+
return {
|
|
68
|
+
catalog: () => onePlan("cloudflare-r2", "Cloudflare R2", "An R2 object-storage bucket", false),
|
|
69
|
+
async provision(req) {
|
|
70
|
+
const bucket = await provisionR2Bucket(cf, req.name);
|
|
71
|
+
return { state: "succeeded", instanceId: bucket.name, outputs: { bucket_name: bucket.name } };
|
|
72
|
+
},
|
|
73
|
+
deprovision: (req) => del(cf, `/r2/buckets/${req.instanceId}`),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A scoped, least-privilege Cloudflare API token (this is `mint-service-tokens.ts`). `params.permissionGroups` is the
|
|
78
|
+
* permission-group id list; `params.resources` defaults to the whole account. The token VALUE is returned only at
|
|
79
|
+
* creation, so it rides out as the `token` binding on provision (the framework's noop on re-apply never re-mints).
|
|
80
|
+
* deprovision revokes it. The minting parent credential is the broker's own `CloudflareClient`. */
|
|
81
|
+
export function cloudflareToken(cf: CloudflareClient): Broker {
|
|
82
|
+
return {
|
|
83
|
+
catalog: () => onePlan("cloudflare-token", "Cloudflare API Token", "A scoped, least-privilege API token", true),
|
|
84
|
+
async provision(req) {
|
|
85
|
+
const acct = await cf.resolveAccountId();
|
|
86
|
+
const groups = (req.params.permissionGroups ?? []) as string[];
|
|
87
|
+
const resources = (req.params.resources ?? { [`com.cloudflare.api.account.${acct}`]: "*" }) as Record<string, string>;
|
|
88
|
+
const body = { name: req.name, policies: [{ effect: "allow", resources, permission_groups: groups.map((id) => ({ id })) }] };
|
|
89
|
+
const tok = await cf.request<{ id?: string; value?: string }>("POST", `/accounts/${acct}/tokens`, { json: body });
|
|
90
|
+
if (!tok?.id || !tok?.value) throw new Error(`provision: cloudflare-token ${req.ref} mint returned no value`);
|
|
91
|
+
return { state: "succeeded", instanceId: tok.id, outputs: { token: tok.value, token_id: tok.id } };
|
|
92
|
+
},
|
|
93
|
+
deprovision: (req) => del(cf, `/tokens/${req.instanceId}`),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Worker secrets — the runtime-secret SINK as a broker (this is `sync-secrets.ts`). `params.script` is the Worker name;
|
|
98
|
+
* `params.secrets` is a `Record<string,string>` of secret name → value (resolved from upstream `@ref.key` bindings).
|
|
99
|
+
* Provision is an idempotent `wrangler secret put` for the whole set. Output: `secrets_set` (the names pushed). */
|
|
100
|
+
export function cloudflareSecrets(cf: CloudflareClient): Broker {
|
|
101
|
+
return {
|
|
102
|
+
catalog: () => onePlan("cloudflare-secrets", "Cloudflare Worker Secrets", "Encrypted Worker runtime secrets", false),
|
|
103
|
+
async provision(req) {
|
|
104
|
+
const script = req.params.script as string | undefined;
|
|
105
|
+
const secrets = (req.params.secrets ?? {}) as Record<string, string | undefined>;
|
|
106
|
+
if (!script) throw new Error(`provision: cloudflare-secrets ${req.ref} needs a params.script (the Worker name)`);
|
|
107
|
+
const set = await putSecrets(cf, script, secrets);
|
|
108
|
+
return { state: "succeeded", instanceId: `${script}:secrets`, outputs: { secrets_set: set.join(",") } };
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
package/src/check.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The drift gate (C047) — drizzle-kit's `check` for infrastructure. `checkDrift` diffs config against the journal and
|
|
3
|
+
* returns the non-noop steps; `assertNoDrift` throws when any exist. Compose it into a CI gate (or @suluk/cockpit
|
|
4
|
+
* conformance) so a PR that adds an instance to the config without provisioning it, or leaves an orphan behind, fails.
|
|
5
|
+
*/
|
|
6
|
+
import type { InstanceState } from "./types";
|
|
7
|
+
import type { ProvisionConfig } from "./config";
|
|
8
|
+
import { plan, type PlanStep } from "./plan";
|
|
9
|
+
|
|
10
|
+
export interface DriftReport {
|
|
11
|
+
clean: boolean;
|
|
12
|
+
/** the steps that would change something (create/update/deprovision) — empty when in sync. */
|
|
13
|
+
drift: PlanStep[];
|
|
14
|
+
orphans: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Report whether live state matches the config (orphans counted only when pruning is the config default). */
|
|
18
|
+
export function checkDrift(config: ProvisionConfig, state: InstanceState[]): DriftReport {
|
|
19
|
+
const p = plan(config, state);
|
|
20
|
+
return { clean: p.clean, drift: p.steps.filter((s) => s.action !== "noop"), orphans: p.orphans };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Fail-closed: throw when there's any drift (the CI gate). */
|
|
24
|
+
export function assertNoDrift(config: ProvisionConfig, state: InstanceState[]): void {
|
|
25
|
+
const r = checkDrift(config, state);
|
|
26
|
+
if (!r.clean) {
|
|
27
|
+
const lines = r.drift.map((s) => ` ${s.action} ${s.ref} (${s.reason})`);
|
|
28
|
+
if (r.orphans.length) lines.push(` orphans: ${r.orphans.join(", ")}`);
|
|
29
|
+
throw new Error(`provision: infrastructure drift detected\n${lines.join("\n")}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI (C047) — drizzle-kit's command surface for infrastructure: `plan` (diff desired vs live), `apply`/`push`
|
|
3
|
+
* (provision + bind + sink), `check` (the drift CI gate, non-zero on drift), `status`/`studio` (the live inventory).
|
|
4
|
+
* `runCli` is PURE of the process — it returns `{ output, exitCode }` (no `process.exit`, no direct console), so it's
|
|
5
|
+
* unit-testable; `bin/provision.ts` is the thin shell that loads the config file, prints, and exits.
|
|
6
|
+
*/
|
|
7
|
+
import type { ProvisionApp } from "./app";
|
|
8
|
+
import { plan, type ProvisionPlan, type PlanStep } from "./plan";
|
|
9
|
+
import { apply, type AppliedStep } from "./apply";
|
|
10
|
+
import { checkDrift } from "./check";
|
|
11
|
+
import { pull, reconcile } from "./pull";
|
|
12
|
+
import { teardown } from "./teardown";
|
|
13
|
+
import { generate } from "./generate";
|
|
14
|
+
import { migrate } from "./migrate";
|
|
15
|
+
import type { InstanceState } from "./types";
|
|
16
|
+
|
|
17
|
+
export interface CliResult {
|
|
18
|
+
output: string;
|
|
19
|
+
exitCode: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SYM: Record<PlanStep["action"], string> = { create: "+", update: "~", noop: "=", deprovision: "-" };
|
|
23
|
+
const APPLIED_SYM: Record<AppliedStep["action"], string> = { create: "+", update: "~", noop: "=", deprovision: "-" };
|
|
24
|
+
|
|
25
|
+
function renderPlan(p: ProvisionPlan, out: (s: string) => void): void {
|
|
26
|
+
if (!p.steps.length) return out("No instances declared.");
|
|
27
|
+
for (const s of p.steps) out(` ${SYM[s.action]} ${s.action.padEnd(11)} ${s.ref} (${s.service} · ${s.name}) — ${s.reason}`);
|
|
28
|
+
const counts = p.steps.reduce<Record<string, number>>((a, s) => ({ ...a, [s.action]: (a[s.action] ?? 0) + 1 }), {});
|
|
29
|
+
const summary = (["create", "update", "noop", "deprovision"] as const).filter((k) => counts[k]).map((k) => `${counts[k]} ${k}`).join(", ");
|
|
30
|
+
out(`\n${p.clean ? "✓ in sync" : `plan: ${summary}`}${!p.orphans.length ? "" : ` · orphans: ${p.orphans.join(", ")}`}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function runCli(app: ProvisionApp, argv: string[]): Promise<CliResult> {
|
|
34
|
+
const cmd = argv[0] ?? "plan";
|
|
35
|
+
const prune = argv.includes("--prune");
|
|
36
|
+
const lines: string[] = [];
|
|
37
|
+
const out = (s: string) => lines.push(s);
|
|
38
|
+
const done = (exitCode = 0): CliResult => ({ output: lines.join("\n"), exitCode });
|
|
39
|
+
|
|
40
|
+
switch (cmd) {
|
|
41
|
+
case "plan": {
|
|
42
|
+
const state = await app.store.load();
|
|
43
|
+
out(`── provision plan ${prune ? "(--prune)" : ""}──`);
|
|
44
|
+
renderPlan(plan(app.config, state, prune), out);
|
|
45
|
+
return done();
|
|
46
|
+
}
|
|
47
|
+
case "apply":
|
|
48
|
+
case "push": {
|
|
49
|
+
out(`── provision apply ${prune ? "(--prune)" : ""}──`);
|
|
50
|
+
const res = await apply(app.config, { brokers: app.brokers, store: app.store, sink: app.sink, prune, log: out });
|
|
51
|
+
const changed = res.steps.filter((s) => s.action !== "noop");
|
|
52
|
+
out(`\n✓ applied: ${changed.length ? changed.map((s) => `${APPLIED_SYM[s.action]}${s.ref}`).join(" ") : "nothing (all in sync)"}`);
|
|
53
|
+
return done();
|
|
54
|
+
}
|
|
55
|
+
case "check": {
|
|
56
|
+
const state = await app.store.load();
|
|
57
|
+
const r = checkDrift(app.config, state);
|
|
58
|
+
if (r.clean) {
|
|
59
|
+
out("✓ infrastructure in sync — no drift");
|
|
60
|
+
return done(0);
|
|
61
|
+
}
|
|
62
|
+
out("✗ infrastructure drift detected:");
|
|
63
|
+
for (const s of r.drift) out(` ${SYM[s.action]} ${s.ref} (${s.reason})`);
|
|
64
|
+
if (r.orphans.length) out(` orphans: ${r.orphans.join(", ")}`);
|
|
65
|
+
return done(1); // the CI gate
|
|
66
|
+
}
|
|
67
|
+
case "status":
|
|
68
|
+
case "studio": {
|
|
69
|
+
const state = (await app.store.load()) as InstanceState[];
|
|
70
|
+
if (!state.length) {
|
|
71
|
+
out("(nothing provisioned yet)");
|
|
72
|
+
return done();
|
|
73
|
+
}
|
|
74
|
+
out("── provisioned ──");
|
|
75
|
+
for (const s of state) {
|
|
76
|
+
const outs = Object.keys(s.outputs).length ? ` → ${Object.entries(s.outputs).map(([k, v]) => `${k}=${v}`).join(", ")}` : "";
|
|
77
|
+
out(` ${s.ref} (${s.service} · ${s.name})${s.protected ? " 🔒" : ""}${outs}`);
|
|
78
|
+
}
|
|
79
|
+
return done();
|
|
80
|
+
}
|
|
81
|
+
case "pull": {
|
|
82
|
+
const state = await app.store.load();
|
|
83
|
+
const report = await pull(state, app.brokers);
|
|
84
|
+
out("── provision pull (live vs journal) ──");
|
|
85
|
+
for (const e of report.entries) out(` ${e.status.padEnd(8)} ${e.ref} (${e.service} · ${e.name})`);
|
|
86
|
+
if (report.clean) {
|
|
87
|
+
out("\n✓ journal matches the provider");
|
|
88
|
+
return done();
|
|
89
|
+
}
|
|
90
|
+
out(`\ndrift: ${report.missing.length} missing, ${report.drifted.length} changed`);
|
|
91
|
+
if (argv.includes("--reconcile")) {
|
|
92
|
+
await app.store.save(reconcile(state, report));
|
|
93
|
+
out("✓ reconciled the journal (externally-deleted dropped, drifted outputs updated)");
|
|
94
|
+
} else {
|
|
95
|
+
out("run `pull --reconcile` to fold this into the journal");
|
|
96
|
+
}
|
|
97
|
+
return done();
|
|
98
|
+
}
|
|
99
|
+
case "teardown": {
|
|
100
|
+
const yes = argv.includes("--yes");
|
|
101
|
+
const force = argv.includes("--force");
|
|
102
|
+
const res = await teardown({ brokers: app.brokers, store: app.store, force, dryRun: !yes, log: out });
|
|
103
|
+
if (!yes) {
|
|
104
|
+
out(`\n⚠ DRY RUN — would tear down ${res.torn.length} instance(s)${res.kept.length ? `, keep ${res.kept.length}` : ""}. Re-run with --yes to DESTROY${res.kept.some((k) => k.reason === "protected") ? " (--force to include protected)" : ""}.`);
|
|
105
|
+
return done();
|
|
106
|
+
}
|
|
107
|
+
out(`\n✗ torn down ${res.torn.length} instance(s)${res.kept.length ? `; kept ${res.kept.map((k) => `${k.ref} (${k.reason})`).join(", ")}` : ""}.`);
|
|
108
|
+
return done();
|
|
109
|
+
}
|
|
110
|
+
case "generate": {
|
|
111
|
+
if (!app.migrations) {
|
|
112
|
+
out("no migration store configured — set `migrations` in the config to use generate/migrate");
|
|
113
|
+
return done(2);
|
|
114
|
+
}
|
|
115
|
+
const ni = argv.indexOf("--name");
|
|
116
|
+
const name = ni >= 0 ? argv[ni + 1] : undefined;
|
|
117
|
+
const m = await generate(app.config, app.migrations, name);
|
|
118
|
+
if (!m) {
|
|
119
|
+
out("✓ no changes — the config matches the last snapshot");
|
|
120
|
+
return done();
|
|
121
|
+
}
|
|
122
|
+
out(`✓ generated ${m.tag}:`);
|
|
123
|
+
for (const s of m.steps) out(` ${SYM[s.action]} ${s.action.padEnd(11)} ${s.ref} (${s.service} · ${s.name})`);
|
|
124
|
+
return done();
|
|
125
|
+
}
|
|
126
|
+
case "migrate": {
|
|
127
|
+
if (!app.migrations) {
|
|
128
|
+
out("no migration store configured — set `migrations` in the config to use generate/migrate");
|
|
129
|
+
return done(2);
|
|
130
|
+
}
|
|
131
|
+
out("── provision migrate ──");
|
|
132
|
+
const res = await migrate({ brokers: app.brokers, store: app.store, migrations: app.migrations, sink: app.sink, log: out });
|
|
133
|
+
out(res.upToDate ? "\n✓ up to date — no pending migrations" : `\n✓ applied ${res.applied.length} migration(s)`);
|
|
134
|
+
return done();
|
|
135
|
+
}
|
|
136
|
+
default:
|
|
137
|
+
out(`unknown command "${cmd}". Commands: plan | apply (push) | generate | migrate | check | status | pull | teardown. Flags: --prune --reconcile --yes --force --name`);
|
|
138
|
+
return done(2);
|
|
139
|
+
}
|
|
140
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declarative provision config (C047) — drizzle-kit's `drizzle.config.ts` for infrastructure. You list the instances
|
|
3
|
+
* you want (each an OSB broker + name + plan + params + binding map); `plan`/`apply`/`check` are pure functions of THIS
|
|
4
|
+
* plus live state. `defineProvision` validates the static shape (unique refs, an acyclic binding DAG) at authoring time,
|
|
5
|
+
* so an unreferenceable ref or a binding loop is caught before any provider is called.
|
|
6
|
+
*/
|
|
7
|
+
import type { InstanceSpec } from "./types";
|
|
8
|
+
import { topoOrder } from "./dag";
|
|
9
|
+
|
|
10
|
+
export interface ProvisionConfig {
|
|
11
|
+
/** the instances to provision (desired state). Order is free — the binding DAG decides apply order. */
|
|
12
|
+
instances: InstanceSpec[];
|
|
13
|
+
/** orphan mitigation default: deprovision instances in state but not in config. DEFAULT false (destructive — opt in
|
|
14
|
+
* here or per-apply). `apply --prune` / `check` honour it. */
|
|
15
|
+
pruneOrphans?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Validate + return a provision config. Throws on a duplicate ref, an undeclared-ref reference, or a binding cycle
|
|
19
|
+
* (via {@link topoOrder}) — all the static errors, surfaced before `apply` touches a provider. */
|
|
20
|
+
export function defineProvision(config: ProvisionConfig): ProvisionConfig {
|
|
21
|
+
topoOrder(config.instances); // throws on dup ref / unknown ref / cycle
|
|
22
|
+
return config;
|
|
23
|
+
}
|
package/src/dag.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Topological ordering of the declared instances (C047) by their binding edges (from `@ref.key` params), so every
|
|
3
|
+
* instance is provisioned AFTER the producers its params reference — the binding chain (create D1 → its id feeds the
|
|
4
|
+
* Worker; mint a scoped token → it feeds the secrets push). Kahn's algorithm; throws on a cycle or an unknown ref.
|
|
5
|
+
*/
|
|
6
|
+
import type { InstanceSpec } from "./types";
|
|
7
|
+
import { depsOf } from "./refs";
|
|
8
|
+
|
|
9
|
+
/** Order `instances` so each comes after its binding producers. Stable (config order breaks ties). Throws on a cycle or
|
|
10
|
+
* a reference to an undeclared instance. */
|
|
11
|
+
export function topoOrder(instances: InstanceSpec[]): InstanceSpec[] {
|
|
12
|
+
const byRef = new Map(instances.map((i) => [i.ref, i]));
|
|
13
|
+
const seen = new Set<string>();
|
|
14
|
+
const refs = instances.map((i) => i.ref);
|
|
15
|
+
if (new Set(refs).size !== refs.length) {
|
|
16
|
+
const dupe = refs.find((r, idx) => refs.indexOf(r) !== idx);
|
|
17
|
+
throw new Error(`provision: duplicate instance ref "${dupe}" — refs must be unique`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const indegree = new Map<string, number>();
|
|
21
|
+
const dependents = new Map<string, string[]>(); // producer ref → the refs that depend on it
|
|
22
|
+
for (const i of instances) {
|
|
23
|
+
indegree.set(i.ref, 0);
|
|
24
|
+
dependents.set(i.ref, []);
|
|
25
|
+
}
|
|
26
|
+
for (const i of instances) {
|
|
27
|
+
for (const dep of depsOf(i)) {
|
|
28
|
+
if (!byRef.has(dep)) throw new Error(`provision: ${i.ref} references @${dep}.* but no instance "${dep}" is declared`);
|
|
29
|
+
indegree.set(i.ref, (indegree.get(i.ref) ?? 0) + 1);
|
|
30
|
+
dependents.get(dep)!.push(i.ref);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Seed the queue in config order (stable output) with the zero-indegree instances.
|
|
35
|
+
const queue = instances.filter((i) => (indegree.get(i.ref) ?? 0) === 0).map((i) => i.ref);
|
|
36
|
+
const ordered: InstanceSpec[] = [];
|
|
37
|
+
while (queue.length) {
|
|
38
|
+
const ref = queue.shift()!;
|
|
39
|
+
if (seen.has(ref)) continue;
|
|
40
|
+
seen.add(ref);
|
|
41
|
+
ordered.push(byRef.get(ref)!);
|
|
42
|
+
for (const d of dependents.get(ref) ?? []) {
|
|
43
|
+
indegree.set(d, (indegree.get(d) ?? 0) - 1);
|
|
44
|
+
if ((indegree.get(d) ?? 0) === 0) queue.push(d);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (ordered.length !== instances.length) {
|
|
49
|
+
const cyclic = instances.filter((i) => !seen.has(i.ref)).map((i) => i.ref);
|
|
50
|
+
throw new Error(`provision: binding cycle among instances [${cyclic.join(", ")}] — a chain of @ref.key params loops`);
|
|
51
|
+
}
|
|
52
|
+
return ordered;
|
|
53
|
+
}
|
package/src/env-sink.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The @suluk/env binding sink (C047) — where provisioned credentials LAND: each (output → env var) goes through
|
|
3
|
+
* @suluk/env/node's `setVar`, which writes the `.env` and POST-QUANTUM-ENCRYPTS secret values so the file stays
|
|
4
|
+
* commit-safe. This closes the binding chain: `apply` resolves a token/id, the sink persists it as a typed, encrypted
|
|
5
|
+
* env var the app + the next instance read. By default every binding is encrypted (the safe default); pass `plain` to
|
|
6
|
+
* mark the non-secret ones (a database_id, a bucket name) readable.
|
|
7
|
+
*/
|
|
8
|
+
import { setVar } from "@suluk/env/node";
|
|
9
|
+
import type { BindingSink } from "./types";
|
|
10
|
+
|
|
11
|
+
type SetVarOpts = NonNullable<Parameters<typeof setVar>[2]>;
|
|
12
|
+
|
|
13
|
+
export interface EnvSinkOptions extends Omit<SetVarOpts, "plain"> {
|
|
14
|
+
/** predicate: which env vars are written PLAINTEXT (non-secret). Default: none — every binding is encrypted. */
|
|
15
|
+
plain?: (envVar: string) => boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A {@link BindingSink} that persists bindings into a `.env` via @suluk/env (encrypted by default; commit-safe). */
|
|
19
|
+
export function envSink(opts: EnvSinkOptions = {}): BindingSink {
|
|
20
|
+
const { plain, ...fileOpts } = opts;
|
|
21
|
+
return {
|
|
22
|
+
async write(outputs, mapping) {
|
|
23
|
+
for (const [outKey, envVar] of Object.entries(mapping)) {
|
|
24
|
+
if (!(outKey in outputs)) continue;
|
|
25
|
+
await setVar(envVar, outputs[outKey], { ...fileOpts, plain: plain?.(envVar) ?? false });
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The file-backed {@link StateStore} (C047) — the provision journal on disk (default `.suluk/provision.json`), the
|
|
3
|
+
* record `plan` diffs against, like drizzle's `meta/_journal.json`. A missing file reads as empty state (a first
|
|
4
|
+
* provision); a save writes pretty JSON (reviewable in a PR). Commit it: it's the source of truth for what's live.
|
|
5
|
+
*/
|
|
6
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
7
|
+
import { dirname } from "node:path";
|
|
8
|
+
import type { InstanceState, StateStore } from "./types";
|
|
9
|
+
|
|
10
|
+
export function fileStore(path = ".suluk/provision.json"): StateStore {
|
|
11
|
+
return {
|
|
12
|
+
async load(): Promise<InstanceState[]> {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(await readFile(path, "utf8")) as InstanceState[];
|
|
15
|
+
} catch (e) {
|
|
16
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") return []; // first run — no journal yet
|
|
17
|
+
throw e;
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
async save(state: InstanceState[]): Promise<void> {
|
|
21
|
+
await mkdir(dirname(path), { recursive: true });
|
|
22
|
+
await writeFile(path, `${JSON.stringify(state, null, 2)}\n`);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
package/src/generate.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `generate` (C047) — drizzle-kit's `generate` for infrastructure. Diffs the current config against the last committed
|
|
3
|
+
* snapshot; if anything changed, writes the next migration (the ordered delta) + its snapshot and appends the journal.
|
|
4
|
+
* Pure of any provider — this only records the intended change (repeatable + reviewable in a PR); `migrate` applies it.
|
|
5
|
+
* Returns the migration, or null when the config already matches the last snapshot (nothing to generate).
|
|
6
|
+
*/
|
|
7
|
+
import type { ProvisionConfig } from "./config";
|
|
8
|
+
import type { MigrationStore } from "./migration-store";
|
|
9
|
+
import { diffSnapshots, migrationTag, type Migration } from "./migration";
|
|
10
|
+
import { snapshot } from "./snapshot";
|
|
11
|
+
|
|
12
|
+
export async function generate(config: ProvisionConfig, store: MigrationStore, name?: string): Promise<Migration | null> {
|
|
13
|
+
const prev = await store.lastSnapshot();
|
|
14
|
+
const steps = diffSnapshots(prev, config);
|
|
15
|
+
if (!steps.length) return null; // config == last snapshot → nothing to record
|
|
16
|
+
const idx = prev.idx + 1;
|
|
17
|
+
const migration: Migration = { idx, tag: migrationTag(idx, name), steps };
|
|
18
|
+
await store.write(migration, snapshot(idx, config));
|
|
19
|
+
return migration;
|
|
20
|
+
}
|