@caelo-cms/provisioning 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/caddy/Caddyfile.production +18 -0
- package/caddy/Caddyfile.staging +21 -0
- package/package.json +27 -0
- package/src/adapter.ts +103 -0
- package/src/bootstrap-token.ts +20 -0
- package/src/caddy.ts +93 -0
- package/src/cdn-copy.ts +84 -0
- package/src/cli.ts +674 -0
- package/src/compose.ts +123 -0
- package/src/index.test.ts +246 -0
- package/src/index.ts +52 -0
- package/src/redirects-emit.ts +166 -0
- package/stacks/aws/Pulumi.yaml +39 -0
- package/stacks/aws/README.md +80 -0
- package/stacks/aws/build-edge.ts +80 -0
- package/stacks/aws/edge-handler-bundle.js +21 -0
- package/stacks/aws/edge-handler.ts +87 -0
- package/stacks/aws/index.ts +412 -0
- package/stacks/azure/Pulumi.yaml +37 -0
- package/stacks/azure/README.md +69 -0
- package/stacks/azure/edge-handler.ts +88 -0
- package/stacks/azure/index.ts +309 -0
- package/stacks/gcp/Pulumi.yaml +36 -0
- package/stacks/gcp/README.md +78 -0
- package/stacks/gcp/edge-handler.ts +106 -0
- package/stacks/gcp/index.ts +483 -0
- package/stacks/self-hosted/Pulumi.yaml +27 -0
- package/stacks/self-hosted/README.md +43 -0
- package/stacks/self-hosted/index.ts +117 -0
- package/tsconfig.json +16 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* cms-provision — Caelo's self-hosted provisioning CLI.
|
|
6
|
+
*
|
|
7
|
+
* Sub-commands:
|
|
8
|
+
* init [--domain D --owner-email E] — first-time setup; generates
|
|
9
|
+
* secrets, writes .caelo/, runs
|
|
10
|
+
* `docker compose up -d`, prints
|
|
11
|
+
* the bootstrap token URL.
|
|
12
|
+
* up — re-runs against existing config.
|
|
13
|
+
* regenerate-caddy — re-emits Caddyfile from the
|
|
14
|
+
* domains table; runs `caddy reload`.
|
|
15
|
+
* backup --to <path> — pgBackRest full + MinIO mirror →
|
|
16
|
+
* single .tar.zst.
|
|
17
|
+
* restore --from <path> — wipes target + restores.
|
|
18
|
+
* status — prints container health + cert
|
|
19
|
+
* expiry per domain.
|
|
20
|
+
*
|
|
21
|
+
* Pulumi-driven cloud variants (GCP / AWS / Azure) land in P15 and
|
|
22
|
+
* plug into the same CLI via `--provider <name>`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { resolve } from "node:path";
|
|
27
|
+
import { generateBootstrapToken } from "./bootstrap-token.js";
|
|
28
|
+
import { type CaddyDomainSpec, generateCaddyfile } from "./caddy.js";
|
|
29
|
+
import { generateDockerCompose } from "./compose.js";
|
|
30
|
+
|
|
31
|
+
interface CaeloConfig {
|
|
32
|
+
domain: string;
|
|
33
|
+
ownerEmail: string;
|
|
34
|
+
postgresPassword: string;
|
|
35
|
+
minioRootUser: string;
|
|
36
|
+
minioRootPassword: string;
|
|
37
|
+
anthropicApiKey?: string;
|
|
38
|
+
resendApiKey?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const REPO_ROOT = resolve(import.meta.dir, "../../..");
|
|
42
|
+
const CAELO_DIR = resolve(REPO_ROOT, ".caelo");
|
|
43
|
+
const CONFIG_PATH = resolve(CAELO_DIR, "config.json");
|
|
44
|
+
const COMPOSE_PATH = resolve(CAELO_DIR, "docker-compose.yml");
|
|
45
|
+
const CADDYFILE_PATH = resolve(CAELO_DIR, "Caddyfile");
|
|
46
|
+
const PENDING_TOKEN_PATH = resolve(CAELO_DIR, "pending-token.json");
|
|
47
|
+
// P15 — `cms-provision init --provider <name>` writes here so subsequent
|
|
48
|
+
// commands route to the right Pulumi stack. Defaults to "self-hosted"
|
|
49
|
+
// when missing (preserves the P14 path).
|
|
50
|
+
const PROVIDER_PATH = resolve(CAELO_DIR, "provider.json");
|
|
51
|
+
|
|
52
|
+
type Provider = "self-hosted" | "gcp" | "aws" | "azure";
|
|
53
|
+
|
|
54
|
+
function loadProvider(): Provider {
|
|
55
|
+
if (!existsSync(PROVIDER_PATH)) return "self-hosted";
|
|
56
|
+
try {
|
|
57
|
+
const raw = JSON.parse(readFileSync(PROVIDER_PATH, "utf8")) as { provider?: string };
|
|
58
|
+
return (raw.provider as Provider) ?? "self-hosted";
|
|
59
|
+
} catch {
|
|
60
|
+
return "self-hosted";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function saveProvider(provider: Provider): void {
|
|
65
|
+
if (!existsSync(CAELO_DIR)) mkdirSync(CAELO_DIR, { recursive: true });
|
|
66
|
+
writeFileSync(PROVIDER_PATH, JSON.stringify({ provider }, null, 2));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* P15 review pass — guard for commands that only make sense on
|
|
71
|
+
* self-hosted (compose/caddy generation, backup/restore via docker
|
|
72
|
+
* exec). Cloud installs (gcp/aws/azure) get a Pulumi-flow message
|
|
73
|
+
* pointing at the right stack dir + the pulumi-output-sync follow-up.
|
|
74
|
+
* Exits the process when the active provider is non-self-hosted.
|
|
75
|
+
*/
|
|
76
|
+
function requireSelfHosted(commandName: string): void {
|
|
77
|
+
const provider = loadProvider();
|
|
78
|
+
if (provider === "self-hosted") return;
|
|
79
|
+
console.error(
|
|
80
|
+
`cms-provision ${commandName}: only available for --provider=self-hosted (active provider: ${provider}).\n\n` +
|
|
81
|
+
`For cloud installs, use Pulumi directly:\n` +
|
|
82
|
+
` cd packages/provisioning/stacks/${provider}\n` +
|
|
83
|
+
` pulumi up\n` +
|
|
84
|
+
` bunx cms-provision pulumi-output-sync\n`,
|
|
85
|
+
);
|
|
86
|
+
process.exit(2);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function randomSecret(bytes = 32): string {
|
|
90
|
+
const buf = new Uint8Array(bytes);
|
|
91
|
+
crypto.getRandomValues(buf);
|
|
92
|
+
return [...buf].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function loadConfig(): CaeloConfig | null {
|
|
96
|
+
if (!existsSync(CONFIG_PATH)) return null;
|
|
97
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as CaeloConfig;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function saveConfig(c: CaeloConfig): void {
|
|
101
|
+
if (!existsSync(CAELO_DIR)) mkdirSync(CAELO_DIR, { recursive: true });
|
|
102
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(c, null, 2));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function arg(name: string): string | undefined {
|
|
106
|
+
const idx = process.argv.indexOf(`--${name}`);
|
|
107
|
+
if (idx === -1) return undefined;
|
|
108
|
+
return process.argv[idx + 1];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function emitConfig(cfg: CaeloConfig, extraDomains: CaddyDomainSpec[] = []): void {
|
|
112
|
+
// Generate compose + Caddyfile from the canonical config.
|
|
113
|
+
const compose = generateDockerCompose({
|
|
114
|
+
domain: cfg.domain,
|
|
115
|
+
postgresPassword: cfg.postgresPassword,
|
|
116
|
+
minioRootUser: cfg.minioRootUser,
|
|
117
|
+
minioRootPassword: cfg.minioRootPassword,
|
|
118
|
+
anthropicApiKey: cfg.anthropicApiKey,
|
|
119
|
+
resendApiKey: cfg.resendApiKey,
|
|
120
|
+
diskSize: "20Gi",
|
|
121
|
+
});
|
|
122
|
+
writeFileSync(COMPOSE_PATH, compose);
|
|
123
|
+
// Seed the install with the operator's primary domain plus a staging
|
|
124
|
+
// sibling. Extra domains added via /security/domains are appended by
|
|
125
|
+
// regenerate-caddy when it queries the live domains table.
|
|
126
|
+
const baseDomains: CaddyDomainSpec[] = [
|
|
127
|
+
{ hostname: cfg.domain, kind: "admin", env: "production" },
|
|
128
|
+
{ hostname: cfg.domain, kind: "public", env: "production" },
|
|
129
|
+
{ hostname: `staging.${cfg.domain}`, kind: "public", env: "staging" },
|
|
130
|
+
];
|
|
131
|
+
const caddy = generateCaddyfile({
|
|
132
|
+
ownerEmail: cfg.ownerEmail,
|
|
133
|
+
publicSiteRoot: "/srv/caelo/output/production/current",
|
|
134
|
+
stagingSiteRoot: "/srv/caelo/output/staging/current",
|
|
135
|
+
adminPort: 5173,
|
|
136
|
+
gatewayPort: 8090,
|
|
137
|
+
domains: dedupeDomains([...baseDomains, ...extraDomains]),
|
|
138
|
+
});
|
|
139
|
+
writeFileSync(CADDYFILE_PATH, caddy);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function dedupeDomains(list: CaddyDomainSpec[]): CaddyDomainSpec[] {
|
|
143
|
+
// Two domain entries with the same (hostname, kind) collapse to the
|
|
144
|
+
// first occurrence — the seed wins over operator additions on a
|
|
145
|
+
// collision (which keeps admin routing intact even if someone adds
|
|
146
|
+
// their primary domain via the UI by mistake).
|
|
147
|
+
const seen = new Set<string>();
|
|
148
|
+
const out: CaddyDomainSpec[] = [];
|
|
149
|
+
for (const d of list) {
|
|
150
|
+
const key = `${d.hostname}::${d.kind}`;
|
|
151
|
+
if (seen.has(key)) continue;
|
|
152
|
+
seen.add(key);
|
|
153
|
+
out.push(d);
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function init(): Promise<void> {
|
|
159
|
+
const domain = arg("domain");
|
|
160
|
+
const ownerEmail = arg("owner-email");
|
|
161
|
+
const providerArg = (arg("provider") ?? "self-hosted") as Provider;
|
|
162
|
+
if (!["self-hosted", "gcp", "aws", "azure"].includes(providerArg)) {
|
|
163
|
+
console.error(`Unknown --provider ${providerArg}. Choose self-hosted | gcp | aws | azure.`);
|
|
164
|
+
process.exit(2);
|
|
165
|
+
}
|
|
166
|
+
if (!domain || !ownerEmail) {
|
|
167
|
+
console.error(
|
|
168
|
+
"Usage: cms-provision init [--provider gcp|aws|azure|self-hosted] --domain example.com --owner-email me@example.com",
|
|
169
|
+
);
|
|
170
|
+
process.exit(2);
|
|
171
|
+
}
|
|
172
|
+
if (existsSync(CONFIG_PATH)) {
|
|
173
|
+
console.error(`config already exists at ${CONFIG_PATH}; run \`cms-provision up\` to re-deploy`);
|
|
174
|
+
process.exit(2);
|
|
175
|
+
}
|
|
176
|
+
saveProvider(providerArg);
|
|
177
|
+
if (providerArg !== "self-hosted") {
|
|
178
|
+
// Cloud providers run via Pulumi at packages/provisioning/stacks/<provider>/.
|
|
179
|
+
// The CLI doesn't generate compose/caddy for them — point the operator at
|
|
180
|
+
// the Pulumi flow directly.
|
|
181
|
+
console.log(`Provider: ${providerArg}`);
|
|
182
|
+
console.log(
|
|
183
|
+
`\nNext steps:\n cd packages/provisioning/stacks/${providerArg}\n pulumi stack init prod\n pulumi config set caelo-${providerArg}:domain ${domain}\n pulumi config set caelo-${providerArg}:ownerEmail ${ownerEmail}\n pulumi up\n bunx cms-provision pulumi-output-sync\n`,
|
|
184
|
+
);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const cfg: CaeloConfig = {
|
|
188
|
+
domain,
|
|
189
|
+
ownerEmail,
|
|
190
|
+
postgresPassword: randomSecret(32),
|
|
191
|
+
minioRootUser: "caelo",
|
|
192
|
+
minioRootPassword: randomSecret(32),
|
|
193
|
+
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
|
194
|
+
resendApiKey: process.env.RESEND_API_KEY,
|
|
195
|
+
};
|
|
196
|
+
saveConfig(cfg);
|
|
197
|
+
emitConfig(cfg);
|
|
198
|
+
|
|
199
|
+
// Mint the bootstrap token and stage it for hooks.server.ts to insert
|
|
200
|
+
// on first request. The CLI doesn't talk to Postgres directly because
|
|
201
|
+
// the DB hasn't started yet; the admin app picks the token up on its
|
|
202
|
+
// first boot, calls owner_bootstrap_tokens.insert, then deletes the
|
|
203
|
+
// staging file.
|
|
204
|
+
const tok = generateBootstrapToken();
|
|
205
|
+
writeFileSync(PENDING_TOKEN_PATH, JSON.stringify(tok, null, 2));
|
|
206
|
+
|
|
207
|
+
// P15.1 — mint the shared HMAC secret for /api/internal/* endpoints.
|
|
208
|
+
// Same secret is read by:
|
|
209
|
+
// - admin app (process.env.CAELO_INTERNAL_SECRET, set by docker-compose
|
|
210
|
+
// or the Pulumi cloud stack via secret env var),
|
|
211
|
+
// - cms-provision pulumi-output-sync (when running in CI / by hand),
|
|
212
|
+
// - any future internal-only orchestration.
|
|
213
|
+
// Long enough to defeat brute-force; rotation via `pulumi config set
|
|
214
|
+
// --secret caelo-internal-secret <new>` + `pulumi up` for cloud installs.
|
|
215
|
+
const internalSecret = randomSecret(48);
|
|
216
|
+
const internalSecretPath = resolve(CAELO_DIR, "internal-secret.json");
|
|
217
|
+
writeFileSync(internalSecretPath, JSON.stringify({ secret: internalSecret }, null, 2));
|
|
218
|
+
console.log(`Wrote ${COMPOSE_PATH}`);
|
|
219
|
+
console.log(`Wrote ${CADDYFILE_PATH}`);
|
|
220
|
+
console.log(`Wrote ${PENDING_TOKEN_PATH} (admin will insert on first boot)`);
|
|
221
|
+
console.log("");
|
|
222
|
+
console.log("Next steps:");
|
|
223
|
+
console.log(` cd ${CAELO_DIR}`);
|
|
224
|
+
console.log(" docker compose up -d");
|
|
225
|
+
console.log(" # Wait ~30s for Postgres + Caddy + ACME");
|
|
226
|
+
console.log("");
|
|
227
|
+
console.log("Then visit:");
|
|
228
|
+
console.log(` https://${domain}/setup?token=${tok.token}`);
|
|
229
|
+
console.log(` (token expires ${tok.expiresAt})`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function up(): Promise<void> {
|
|
233
|
+
requireSelfHosted("up");
|
|
234
|
+
const cfg = loadConfig();
|
|
235
|
+
if (!cfg) {
|
|
236
|
+
console.error("no config — run `cms-provision init` first");
|
|
237
|
+
process.exit(2);
|
|
238
|
+
}
|
|
239
|
+
emitConfig(cfg, await tryFetchExtraDomains(cfg));
|
|
240
|
+
console.log(`Re-emitted ${COMPOSE_PATH} + ${CADDYFILE_PATH}`);
|
|
241
|
+
console.log("Run `docker compose -f .caelo/docker-compose.yml up -d` to apply.");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
interface DomainRow {
|
|
245
|
+
hostname: string;
|
|
246
|
+
kind: "admin" | "public" | "locale-public";
|
|
247
|
+
env: "production" | "staging";
|
|
248
|
+
localeCode?: string | null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Pull operator-added domains directly from Postgres so the regenerated
|
|
253
|
+
* Caddyfile picks them up. Uses `docker compose exec` so we don't need
|
|
254
|
+
* the operator's host to have psql installed. Fails open: a DB-down
|
|
255
|
+
* install still emits the seed Caddyfile so the admin stays reachable.
|
|
256
|
+
*/
|
|
257
|
+
async function tryFetchExtraDomains(cfg: CaeloConfig): Promise<CaddyDomainSpec[]> {
|
|
258
|
+
try {
|
|
259
|
+
const composeFile = COMPOSE_PATH;
|
|
260
|
+
const sql =
|
|
261
|
+
"SELECT hostname, kind, env, locale_code FROM domains WHERE removed_at IS NULL ORDER BY hostname";
|
|
262
|
+
const proc = Bun.spawn(
|
|
263
|
+
[
|
|
264
|
+
"docker",
|
|
265
|
+
"compose",
|
|
266
|
+
"-f",
|
|
267
|
+
composeFile,
|
|
268
|
+
"exec",
|
|
269
|
+
"-T",
|
|
270
|
+
"postgres",
|
|
271
|
+
"psql",
|
|
272
|
+
"-U",
|
|
273
|
+
"caelo",
|
|
274
|
+
"-d",
|
|
275
|
+
"caelo",
|
|
276
|
+
"-At",
|
|
277
|
+
"-F",
|
|
278
|
+
"|",
|
|
279
|
+
"-c",
|
|
280
|
+
sql,
|
|
281
|
+
],
|
|
282
|
+
{ stdout: "pipe", stderr: "pipe", env: { ...process.env, PGPASSWORD: cfg.postgresPassword } },
|
|
283
|
+
);
|
|
284
|
+
const stdout = await new Response(proc.stdout).text();
|
|
285
|
+
await proc.exited;
|
|
286
|
+
if (proc.exitCode !== 0) {
|
|
287
|
+
console.warn("regenerate-caddy: could not query domains table (DB down?); using seed only");
|
|
288
|
+
return [];
|
|
289
|
+
}
|
|
290
|
+
const rows: DomainRow[] = [];
|
|
291
|
+
for (const line of stdout.split("\n")) {
|
|
292
|
+
if (!line.trim()) continue;
|
|
293
|
+
const [hostname, kind, env, localeCode] = line.split("|");
|
|
294
|
+
if (!hostname || !kind || !env) continue;
|
|
295
|
+
rows.push({
|
|
296
|
+
hostname,
|
|
297
|
+
kind: kind as DomainRow["kind"],
|
|
298
|
+
env: env as DomainRow["env"],
|
|
299
|
+
localeCode: localeCode || null,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return rows.map<CaddyDomainSpec>((r) =>
|
|
303
|
+
r.kind === "locale-public" && r.localeCode
|
|
304
|
+
? { hostname: r.hostname, kind: "locale-public", localeCode: r.localeCode, env: r.env }
|
|
305
|
+
: { hostname: r.hostname, kind: r.kind as "admin" | "public", env: r.env },
|
|
306
|
+
);
|
|
307
|
+
} catch (e) {
|
|
308
|
+
console.warn("regenerate-caddy: domains lookup threw (DB unreachable?); using seed only", e);
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function regenerateCaddy(): Promise<void> {
|
|
314
|
+
requireSelfHosted("regenerate-caddy");
|
|
315
|
+
const cfg = loadConfig();
|
|
316
|
+
if (!cfg) {
|
|
317
|
+
console.error("no config — run `cms-provision init` first");
|
|
318
|
+
process.exit(2);
|
|
319
|
+
}
|
|
320
|
+
const extra = await tryFetchExtraDomains(cfg);
|
|
321
|
+
emitConfig(cfg, extra);
|
|
322
|
+
console.log(`Re-emitted ${CADDYFILE_PATH} (${extra.length} domain(s) from DB).`);
|
|
323
|
+
// Reload caddy in-place — no downtime, picks up cert provisioning for
|
|
324
|
+
// any new domains automatically via ACME.
|
|
325
|
+
const proc = Bun.spawn(
|
|
326
|
+
[
|
|
327
|
+
"docker",
|
|
328
|
+
"compose",
|
|
329
|
+
"-f",
|
|
330
|
+
COMPOSE_PATH,
|
|
331
|
+
"exec",
|
|
332
|
+
"caddy",
|
|
333
|
+
"caddy",
|
|
334
|
+
"reload",
|
|
335
|
+
"--config",
|
|
336
|
+
"/etc/caddy/Caddyfile",
|
|
337
|
+
],
|
|
338
|
+
{ stdout: "inherit", stderr: "inherit" },
|
|
339
|
+
);
|
|
340
|
+
await proc.exited;
|
|
341
|
+
if (proc.exitCode !== 0) {
|
|
342
|
+
console.warn(
|
|
343
|
+
"caddy reload exited non-zero — Caddyfile written, run reload manually once the container is up.",
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function status(): Promise<void> {
|
|
349
|
+
const provider = loadProvider();
|
|
350
|
+
console.log(`Active provider: ${provider}`);
|
|
351
|
+
if (provider !== "self-hosted") {
|
|
352
|
+
console.log(`Stack dir: packages/provisioning/stacks/${provider}`);
|
|
353
|
+
console.log(`State + outputs: pulumi stack output (run from the stack dir)`);
|
|
354
|
+
console.log(`Cert + DNS: visit /security/dns in the admin`);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const cfg = loadConfig();
|
|
358
|
+
if (!cfg) {
|
|
359
|
+
console.error("no config");
|
|
360
|
+
process.exit(2);
|
|
361
|
+
}
|
|
362
|
+
console.log(`Caelo install: ${cfg.domain}`);
|
|
363
|
+
console.log(`Owner email: ${cfg.ownerEmail}`);
|
|
364
|
+
console.log(`Config path: ${CONFIG_PATH}`);
|
|
365
|
+
console.log("Container health: run `docker compose -f .caelo/docker-compose.yml ps`");
|
|
366
|
+
console.log("Cert status: visit /security/domains in the admin");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function backup(): Promise<void> {
|
|
370
|
+
requireSelfHosted("backup");
|
|
371
|
+
const cfg = loadConfig();
|
|
372
|
+
if (!cfg) {
|
|
373
|
+
console.error("no config");
|
|
374
|
+
process.exit(2);
|
|
375
|
+
}
|
|
376
|
+
const to = arg("to") ?? resolve(process.cwd(), `caelo-backup-${Date.now()}.tar.zst`);
|
|
377
|
+
console.log(`Backup target: ${to}`);
|
|
378
|
+
|
|
379
|
+
// 1. pgBackRest full backup inside the sidecar container.
|
|
380
|
+
console.log("Step 1/3: pgBackRest full backup …");
|
|
381
|
+
const pgb = Bun.spawn(
|
|
382
|
+
[
|
|
383
|
+
"docker",
|
|
384
|
+
"compose",
|
|
385
|
+
"-f",
|
|
386
|
+
COMPOSE_PATH,
|
|
387
|
+
"exec",
|
|
388
|
+
"-T",
|
|
389
|
+
"pgbackrest",
|
|
390
|
+
"pgbackrest",
|
|
391
|
+
"--type=full",
|
|
392
|
+
"--stanza=caelo",
|
|
393
|
+
"backup",
|
|
394
|
+
],
|
|
395
|
+
{ stdout: "inherit", stderr: "inherit" },
|
|
396
|
+
);
|
|
397
|
+
await pgb.exited;
|
|
398
|
+
if (pgb.exitCode !== 0) {
|
|
399
|
+
console.error("pgBackRest backup failed; aborting");
|
|
400
|
+
process.exit(pgb.exitCode ?? 1);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// 2. MinIO mirror to a tmp inside the minio container.
|
|
404
|
+
console.log("Step 2/3: mirroring MinIO bucket …");
|
|
405
|
+
const mc = Bun.spawn(
|
|
406
|
+
[
|
|
407
|
+
"docker",
|
|
408
|
+
"compose",
|
|
409
|
+
"-f",
|
|
410
|
+
COMPOSE_PATH,
|
|
411
|
+
"exec",
|
|
412
|
+
"-T",
|
|
413
|
+
"minio",
|
|
414
|
+
"mc",
|
|
415
|
+
"mirror",
|
|
416
|
+
"--overwrite",
|
|
417
|
+
"/data",
|
|
418
|
+
"/tmp/minio-backup",
|
|
419
|
+
],
|
|
420
|
+
{ stdout: "inherit", stderr: "inherit" },
|
|
421
|
+
);
|
|
422
|
+
await mc.exited;
|
|
423
|
+
if (mc.exitCode !== 0) {
|
|
424
|
+
console.error("MinIO mirror failed; aborting");
|
|
425
|
+
process.exit(mc.exitCode ?? 1);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// 3. tar+zstd from named volumes onto the host.
|
|
429
|
+
console.log(`Step 3/3: archiving → ${to}`);
|
|
430
|
+
const tar = Bun.spawn(
|
|
431
|
+
[
|
|
432
|
+
"docker",
|
|
433
|
+
"run",
|
|
434
|
+
"--rm",
|
|
435
|
+
"-v",
|
|
436
|
+
"caelo_caelo-pg-backups:/pgb:ro",
|
|
437
|
+
"-v",
|
|
438
|
+
"caelo_caelo-minio:/minio:ro",
|
|
439
|
+
"-v",
|
|
440
|
+
`${resolve(to, "..")}:/out`,
|
|
441
|
+
"alpine:3",
|
|
442
|
+
"sh",
|
|
443
|
+
"-c",
|
|
444
|
+
`apk add --no-cache zstd tar >/dev/null && tar --zstd -cf /out/${to.split("/").pop()} -C /pgb . -C /minio .`,
|
|
445
|
+
],
|
|
446
|
+
{ stdout: "inherit", stderr: "inherit" },
|
|
447
|
+
);
|
|
448
|
+
await tar.exited;
|
|
449
|
+
if (tar.exitCode !== 0) {
|
|
450
|
+
console.error("tar archive failed");
|
|
451
|
+
process.exit(tar.exitCode ?? 1);
|
|
452
|
+
}
|
|
453
|
+
console.log(`Backup complete: ${to}`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function restore(): Promise<void> {
|
|
457
|
+
requireSelfHosted("restore");
|
|
458
|
+
const from = arg("from");
|
|
459
|
+
if (!from) {
|
|
460
|
+
console.error("Usage: cms-provision restore --from <path> [--yes]");
|
|
461
|
+
process.exit(2);
|
|
462
|
+
}
|
|
463
|
+
if (!process.argv.includes("--yes")) {
|
|
464
|
+
console.error(
|
|
465
|
+
`DESTRUCTIVE: would restore from ${from}, wiping current pgbackrest + minio volumes. Re-run with --yes to confirm.`,
|
|
466
|
+
);
|
|
467
|
+
process.exit(1);
|
|
468
|
+
}
|
|
469
|
+
const cfg = loadConfig();
|
|
470
|
+
if (!cfg) {
|
|
471
|
+
console.error("no config");
|
|
472
|
+
process.exit(2);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
console.log(`Restoring from ${from} …`);
|
|
476
|
+
// 1. Stop dependent services so they don't write while we restore.
|
|
477
|
+
console.log("Step 1/4: stopping admin/gateway/orchestrator/runner …");
|
|
478
|
+
await Bun.spawn(
|
|
479
|
+
[
|
|
480
|
+
"docker",
|
|
481
|
+
"compose",
|
|
482
|
+
"-f",
|
|
483
|
+
COMPOSE_PATH,
|
|
484
|
+
"stop",
|
|
485
|
+
"caelo-admin",
|
|
486
|
+
"caelo-gateway",
|
|
487
|
+
"caelo-orchestrator",
|
|
488
|
+
"caelo-runner",
|
|
489
|
+
],
|
|
490
|
+
{ stdout: "inherit", stderr: "inherit" },
|
|
491
|
+
).exited;
|
|
492
|
+
|
|
493
|
+
// 2. Untar into the named volumes.
|
|
494
|
+
console.log("Step 2/4: extracting archive into volumes …");
|
|
495
|
+
const tarDir = resolve(from, "..");
|
|
496
|
+
const tarFile = from.split("/").pop();
|
|
497
|
+
const ex = Bun.spawn(
|
|
498
|
+
[
|
|
499
|
+
"docker",
|
|
500
|
+
"run",
|
|
501
|
+
"--rm",
|
|
502
|
+
"-v",
|
|
503
|
+
"caelo_caelo-pg-backups:/pgb",
|
|
504
|
+
"-v",
|
|
505
|
+
"caelo_caelo-minio:/minio",
|
|
506
|
+
"-v",
|
|
507
|
+
`${tarDir}:/in:ro`,
|
|
508
|
+
"alpine:3",
|
|
509
|
+
"sh",
|
|
510
|
+
"-c",
|
|
511
|
+
`apk add --no-cache zstd tar >/dev/null && rm -rf /pgb/* /minio/* && tar --zstd -xf /in/${tarFile} -C /pgb && tar --zstd -xf /in/${tarFile} -C /minio`,
|
|
512
|
+
],
|
|
513
|
+
{ stdout: "inherit", stderr: "inherit" },
|
|
514
|
+
);
|
|
515
|
+
await ex.exited;
|
|
516
|
+
if (ex.exitCode !== 0) {
|
|
517
|
+
console.error("extract failed");
|
|
518
|
+
process.exit(ex.exitCode ?? 1);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// 3. pgBackRest restore.
|
|
522
|
+
console.log("Step 3/4: pgBackRest restore …");
|
|
523
|
+
const pgr = Bun.spawn(
|
|
524
|
+
[
|
|
525
|
+
"docker",
|
|
526
|
+
"compose",
|
|
527
|
+
"-f",
|
|
528
|
+
COMPOSE_PATH,
|
|
529
|
+
"exec",
|
|
530
|
+
"-T",
|
|
531
|
+
"pgbackrest",
|
|
532
|
+
"pgbackrest",
|
|
533
|
+
"--stanza=caelo",
|
|
534
|
+
"restore",
|
|
535
|
+
],
|
|
536
|
+
{ stdout: "inherit", stderr: "inherit" },
|
|
537
|
+
);
|
|
538
|
+
await pgr.exited;
|
|
539
|
+
if (pgr.exitCode !== 0) {
|
|
540
|
+
console.error("pgBackRest restore failed");
|
|
541
|
+
process.exit(pgr.exitCode ?? 1);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// 4. Bring services back up.
|
|
545
|
+
console.log("Step 4/4: restarting services …");
|
|
546
|
+
await Bun.spawn(["docker", "compose", "-f", COMPOSE_PATH, "up", "-d"], {
|
|
547
|
+
stdout: "inherit",
|
|
548
|
+
stderr: "inherit",
|
|
549
|
+
}).exited;
|
|
550
|
+
console.log("Restore complete.");
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const cmd = process.argv[2];
|
|
554
|
+
/**
|
|
555
|
+
* P15 — `pulumi-output-sync`. Reads `pulumi stack output --json` from
|
|
556
|
+
* the active stack (cwd or --stack-dir), writes the rendered outputs
|
|
557
|
+
* into cms_admin.provisioning_outputs via the local `psql` shell-out.
|
|
558
|
+
* Run after every `pulumi up` so the admin's /security/dns page picks
|
|
559
|
+
* up the latest snapshot.
|
|
560
|
+
*
|
|
561
|
+
* Self-hosted installs auto-skip — there's nothing useful to sync since
|
|
562
|
+
* the outputs are static (single VM, single domain).
|
|
563
|
+
*/
|
|
564
|
+
async function pulumiOutputSync(): Promise<void> {
|
|
565
|
+
const provider = loadProvider();
|
|
566
|
+
if (provider === "self-hosted") {
|
|
567
|
+
console.log("provider=self-hosted; nothing to sync (no Pulumi outputs)");
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const stackDir = arg("stack-dir") ?? `packages/provisioning/stacks/${provider}`;
|
|
571
|
+
const env = (arg("environment") ?? "production") as "dev" | "staging" | "production";
|
|
572
|
+
if (!["dev", "staging", "production"].includes(env)) {
|
|
573
|
+
console.error(`--environment must be dev | staging | production`);
|
|
574
|
+
process.exit(2);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// 1. Read pulumi outputs.
|
|
578
|
+
console.log(`Reading pulumi outputs from ${stackDir} …`);
|
|
579
|
+
const pulumi = Bun.spawn(["pulumi", "stack", "output", "--json"], {
|
|
580
|
+
cwd: resolve(REPO_ROOT, stackDir),
|
|
581
|
+
stdout: "pipe",
|
|
582
|
+
stderr: "inherit",
|
|
583
|
+
});
|
|
584
|
+
const stdout = await new Response(pulumi.stdout).text();
|
|
585
|
+
await pulumi.exited;
|
|
586
|
+
if (pulumi.exitCode !== 0) {
|
|
587
|
+
console.error("pulumi stack output failed; aborting");
|
|
588
|
+
process.exit(pulumi.exitCode ?? 1);
|
|
589
|
+
}
|
|
590
|
+
let outputs: Record<string, unknown>;
|
|
591
|
+
try {
|
|
592
|
+
outputs = JSON.parse(stdout);
|
|
593
|
+
} catch (e) {
|
|
594
|
+
console.error("pulumi stack output returned invalid JSON:", e);
|
|
595
|
+
process.exit(1);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// 2. Mint a signed-JWT for the /api/internal/* endpoint.
|
|
599
|
+
// P15.1 — admin's HTTP boundary requires a bearer token (CALEO_INTERNAL_SECRET
|
|
600
|
+
// shared between Pulumi + admin). One short-lived token per sync call.
|
|
601
|
+
const internalSecret = process.env.CAELO_INTERNAL_SECRET;
|
|
602
|
+
const adminBaseUrl = process.env.CAELO_ADMIN_URL;
|
|
603
|
+
if (!internalSecret || internalSecret.length < 32) {
|
|
604
|
+
console.error(
|
|
605
|
+
"CAELO_INTERNAL_SECRET not set or too short (need ≥32 chars). Pulumi mints this in `cms-provision init`; export it (or `pulumi stack output --show-secrets internalSecretOut`) and re-run.",
|
|
606
|
+
);
|
|
607
|
+
process.exit(2);
|
|
608
|
+
}
|
|
609
|
+
if (!adminBaseUrl) {
|
|
610
|
+
console.error(
|
|
611
|
+
"CAELO_ADMIN_URL not set (e.g. https://example.com). Required so the CLI knows where the admin lives.",
|
|
612
|
+
);
|
|
613
|
+
process.exit(2);
|
|
614
|
+
}
|
|
615
|
+
const iat = Date.now();
|
|
616
|
+
const exp = iat + 5 * 60 * 1000; // 5min replay window
|
|
617
|
+
const tokenScope = "provisioning-outputs.sync";
|
|
618
|
+
const tokenMessage = `${iat}:${exp}:${tokenScope}`;
|
|
619
|
+
const key = await crypto.subtle.importKey(
|
|
620
|
+
"raw",
|
|
621
|
+
new TextEncoder().encode(internalSecret),
|
|
622
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
623
|
+
false,
|
|
624
|
+
["sign"],
|
|
625
|
+
);
|
|
626
|
+
const sigBuf = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(tokenMessage));
|
|
627
|
+
const token = `${Buffer.from(tokenMessage).toString("base64url")}.${Buffer.from(sigBuf).toString("base64url")}`;
|
|
628
|
+
|
|
629
|
+
// 3. POST to /api/internal/provisioning-outputs/sync.
|
|
630
|
+
console.log(`Posting outputs to ${adminBaseUrl}/api/internal/provisioning-outputs/sync …`);
|
|
631
|
+
const res = await fetch(`${adminBaseUrl}/api/internal/provisioning-outputs/sync`, {
|
|
632
|
+
method: "POST",
|
|
633
|
+
headers: {
|
|
634
|
+
authorization: `Bearer ${token}`,
|
|
635
|
+
"content-type": "application/json",
|
|
636
|
+
},
|
|
637
|
+
body: JSON.stringify({ provider, environment: env, outputs }),
|
|
638
|
+
});
|
|
639
|
+
if (!res.ok) {
|
|
640
|
+
const txt = await res.text().catch(() => "");
|
|
641
|
+
console.error(`sync failed: ${res.status} ${res.statusText} — ${txt}`);
|
|
642
|
+
process.exit(1);
|
|
643
|
+
}
|
|
644
|
+
const result = (await res.json()) as { ok?: boolean; updated?: boolean };
|
|
645
|
+
console.log(`Synced ${provider}/${env} → ${result.updated === false ? "inserted" : "updated"}.`);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function version(): Promise<void> {
|
|
649
|
+
// P17.0 — single source of truth lives in @caelo-cms/shared/version.ts.
|
|
650
|
+
// Imported lazily so the CLI's startup cost stays small.
|
|
651
|
+
const { CALEO_VERSION } = await import("@caelo-cms/shared");
|
|
652
|
+
console.log(`cms-provision (Caelo v${CALEO_VERSION})`);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const handlers: Record<string, () => Promise<void>> = {
|
|
656
|
+
init,
|
|
657
|
+
up,
|
|
658
|
+
"regenerate-caddy": regenerateCaddy,
|
|
659
|
+
status,
|
|
660
|
+
backup,
|
|
661
|
+
restore,
|
|
662
|
+
"pulumi-output-sync": pulumiOutputSync,
|
|
663
|
+
version,
|
|
664
|
+
"--version": version,
|
|
665
|
+
"-v": version,
|
|
666
|
+
};
|
|
667
|
+
const handler = cmd ? handlers[cmd] : undefined;
|
|
668
|
+
if (!handler) {
|
|
669
|
+
console.log(
|
|
670
|
+
"Usage: cms-provision <init|up|regenerate-caddy|status|backup|restore|pulumi-output-sync> [options]",
|
|
671
|
+
);
|
|
672
|
+
process.exit(cmd ? 2 : 0);
|
|
673
|
+
}
|
|
674
|
+
await handler();
|