@caelo-cms/provisioning 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter.d.ts +95 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +3 -0
- package/dist/adapter.js.map +1 -0
- package/dist/bootstrap-token.d.ts +11 -0
- package/dist/bootstrap-token.d.ts.map +1 -0
- package/dist/bootstrap-token.js +9 -0
- package/dist/bootstrap-token.js.map +1 -0
- package/dist/caddy.d.ts +34 -0
- package/dist/caddy.d.ts.map +1 -0
- package/dist/caddy.js +53 -0
- package/dist/caddy.js.map +1 -0
- package/{src/cdn-copy.ts → dist/cdn-copy.d.ts} +11 -42
- package/dist/cdn-copy.d.ts.map +1 -0
- package/dist/cdn-copy.js +48 -0
- package/dist/cdn-copy.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +670 -0
- package/dist/cli.js.map +1 -0
- package/dist/compose.d.ts +27 -0
- package/dist/compose.d.ts.map +1 -0
- package/{src/compose.ts → dist/compose.js} +15 -35
- package/dist/compose.js.map +1 -0
- package/dist/dns/cloudflare.d.ts +9 -0
- package/dist/dns/cloudflare.d.ts.map +1 -0
- package/dist/dns/cloudflare.js +160 -0
- package/dist/dns/cloudflare.js.map +1 -0
- package/dist/dns/index.d.ts +12 -0
- package/dist/dns/index.d.ts.map +1 -0
- package/dist/dns/index.js +42 -0
- package/dist/dns/index.js.map +1 -0
- package/dist/dns/manual.d.ts +5 -0
- package/dist/dns/manual.d.ts.map +1 -0
- package/dist/dns/manual.js +96 -0
- package/dist/dns/manual.js.map +1 -0
- package/dist/dns/types.d.ts +23 -0
- package/dist/dns/types.d.ts.map +1 -0
- package/dist/dns/types.js +3 -0
- package/dist/dns/types.js.map +1 -0
- package/dist/gcloud.d.ts +42 -0
- package/dist/gcloud.d.ts.map +1 -0
- package/dist/gcloud.js +187 -0
- package/dist/gcloud.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/install-state.d.ts +54 -0
- package/dist/install-state.d.ts.map +1 -0
- package/dist/install-state.js +118 -0
- package/dist/install-state.js.map +1 -0
- package/dist/lifecycle.d.ts +19 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/lifecycle.js +589 -0
- package/dist/lifecycle.js.map +1 -0
- package/dist/migration-runner.d.ts +15 -0
- package/dist/migration-runner.d.ts.map +1 -0
- package/dist/migration-runner.js +174 -0
- package/dist/migration-runner.js.map +1 -0
- package/dist/redirects-emit.d.ts +65 -0
- package/dist/redirects-emit.d.ts.map +1 -0
- package/dist/redirects-emit.js +92 -0
- package/dist/redirects-emit.js.map +1 -0
- package/dist/wizard.d.ts +35 -0
- package/dist/wizard.d.ts.map +1 -0
- package/dist/wizard.js +160 -0
- package/dist/wizard.js.map +1 -0
- package/dist/wizards/gcp-cost.d.ts +27 -0
- package/dist/wizards/gcp-cost.d.ts.map +1 -0
- package/dist/wizards/gcp-cost.js +77 -0
- package/dist/wizards/gcp-cost.js.map +1 -0
- package/dist/wizards/gcp-pulumi.d.ts +37 -0
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -0
- package/dist/wizards/gcp-pulumi.js +100 -0
- package/dist/wizards/gcp-pulumi.js.map +1 -0
- package/dist/wizards/gcp.d.ts +9 -0
- package/dist/wizards/gcp.d.ts.map +1 -0
- package/dist/wizards/gcp.js +895 -0
- package/dist/wizards/gcp.js.map +1 -0
- package/package.json +34 -7
- package/stacks/aws/index.ts +6 -7
- package/stacks/azure/index.ts +11 -11
- package/stacks/gcp/Pulumi.production.yaml +16 -0
- package/stacks/gcp/Pulumi.yaml +52 -6
- package/stacks/gcp/index.ts +569 -188
- package/stacks/self-hosted/index.ts +3 -3
- package/static/welcome.html +155 -0
- package/src/adapter.ts +0 -103
- package/src/bootstrap-token.ts +0 -20
- package/src/caddy.ts +0 -93
- package/src/cli.ts +0 -674
- package/src/index.test.ts +0 -246
- package/src/index.ts +0 -52
- package/src/redirects-emit.ts +0 -166
- package/tsconfig.json +0 -16
|
@@ -0,0 +1,895 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
/**
|
|
3
|
+
* GCP provider wizard — end-to-end automation per §11.C:
|
|
4
|
+
* 1. Detects active gcloud account; prompts `gcloud auth login` if none
|
|
5
|
+
* 2. Lists billing accounts; user picks
|
|
6
|
+
* 3. Captures GCP project id (default suggested from domain)
|
|
7
|
+
* 4. Creates the project (skips if exists)
|
|
8
|
+
* 5. Links billing
|
|
9
|
+
* 6. Enables 13 APIs in one call
|
|
10
|
+
* 7. Creates the provisioner SA + grants 15 IAM roles
|
|
11
|
+
* 8. Mints a JSON SA key into `~/.caelo-<install-id>/secrets/sa-key.json`
|
|
12
|
+
* 9. Captures Anthropic API key (input-hidden) → secrets/anthropic-api-key
|
|
13
|
+
* 10. Generates Pulumi passphrase if absent → secrets/pulumi-passphrase
|
|
14
|
+
* 11. Pre-flight cost-estimate table; single y/N confirm
|
|
15
|
+
* 12. Pulumi up via the Automation SDK; streams progress
|
|
16
|
+
* 13. Prints DNS records + bootstrap URL (IAP runs on the LB
|
|
17
|
+
* BackendService; no post-up gcloud step needed)
|
|
18
|
+
*/
|
|
19
|
+
import { randomBytes } from "node:crypto";
|
|
20
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { cancel, confirm, isCancel, log, note, password, select, spinner, text, } from "@clack/prompts";
|
|
24
|
+
import { bold, cyan, dim, green, red, yellow } from "kleur/colors";
|
|
25
|
+
import { pickDnsAdapter } from "../dns/index.js";
|
|
26
|
+
import { activeAccount, createProject, createServiceAccount, createServiceAccountKey, enableApis, gcloud, grantProvisionerRoles, linkBilling, listBillingAccounts, PROVISIONER_ROLE_LIST, projectExists, REQUIRED_API_LIST, serviceAccountExists, } from "../gcloud.js";
|
|
27
|
+
import { ensureInstallDir, installRoot, isStepDone, markStepDone, readMetadata, readSecret, writeMetadata, writeSecret, } from "../install-state.js";
|
|
28
|
+
import { estimateGcpCost } from "./gcp-cost.js";
|
|
29
|
+
import { pulumiUpGcp } from "./gcp-pulumi.js";
|
|
30
|
+
const SA_ACCOUNT_ID = "caelo-provisioner";
|
|
31
|
+
export async function runGcpWizard(opts) {
|
|
32
|
+
const { installId, domain, ownerEmail } = opts;
|
|
33
|
+
const { secretsDir } = ensureInstallDir(installId);
|
|
34
|
+
// === 1. gcloud auth ===
|
|
35
|
+
await stepActiveGcloud();
|
|
36
|
+
// === 2. Project id ===
|
|
37
|
+
const projectId = await stepProjectId(opts);
|
|
38
|
+
// Persist the project id once we have it.
|
|
39
|
+
const meta = readMetadata(installId);
|
|
40
|
+
if (meta) {
|
|
41
|
+
const updated = { ...meta, projectId };
|
|
42
|
+
writeMetadata(installId, updated);
|
|
43
|
+
}
|
|
44
|
+
// === 3. Project create ===
|
|
45
|
+
await stepProjectCreate(installId, projectId);
|
|
46
|
+
// === 4. Billing link ===
|
|
47
|
+
await stepBillingLink(installId, projectId, opts.nonInteractive);
|
|
48
|
+
// === 5. Enable APIs ===
|
|
49
|
+
await stepEnableApis(installId, projectId);
|
|
50
|
+
// === 6. Service account + roles + key ===
|
|
51
|
+
const saEmail = `${SA_ACCOUNT_ID}@${projectId}.iam.gserviceaccount.com`;
|
|
52
|
+
await stepServiceAccount(installId, projectId, saEmail);
|
|
53
|
+
await stepGrantRoles(installId, projectId, saEmail);
|
|
54
|
+
const keyPath = await stepMintKey(installId, projectId, saEmail, secretsDir);
|
|
55
|
+
// === 7. Anthropic API key + Pulumi passphrase ===
|
|
56
|
+
const anthropicKey = await stepAnthropicKey(installId, opts.nonInteractive);
|
|
57
|
+
const pulumiPassphrase = stepPulumiPassphrase(installId);
|
|
58
|
+
const region = "europe-west1";
|
|
59
|
+
if (meta)
|
|
60
|
+
writeMetadata(installId, { ...meta, projectId, region });
|
|
61
|
+
// === 8. Cost-estimate pre-flight ===
|
|
62
|
+
const costInputs = {
|
|
63
|
+
cloudSqlTier: "db-f1-micro",
|
|
64
|
+
cloudSqlHa: false,
|
|
65
|
+
adminMinInstances: 0,
|
|
66
|
+
gatewayMinInstances: 0,
|
|
67
|
+
wafAdaptiveProtection: false,
|
|
68
|
+
};
|
|
69
|
+
const estimate = estimateGcpCost(costInputs);
|
|
70
|
+
note([
|
|
71
|
+
bold("Estimated monthly cost (resource floor — actual usage adds AI calls + egress + storage growth)"),
|
|
72
|
+
"",
|
|
73
|
+
...estimate.lines.map((l) => ` ${dim(l.name.padEnd(40))} ${green(`$${l.monthlyUsd}`.padStart(5))}/mo ${dim(l.notes ?? "")}`),
|
|
74
|
+
"",
|
|
75
|
+
` ${bold("TOTAL".padEnd(40))} ${bold(`$${estimate.totalUsd}`.padStart(5))}/mo`,
|
|
76
|
+
].join("\n"), "Pre-flight");
|
|
77
|
+
if (!opts.nonInteractive) {
|
|
78
|
+
const proceed = await confirm({
|
|
79
|
+
message: `Provision ~${estimate.totalUsd} USD/mo on GCP project ${bold(projectId)}?`,
|
|
80
|
+
initialValue: true,
|
|
81
|
+
});
|
|
82
|
+
if (isCancel(proceed) || !proceed) {
|
|
83
|
+
cancel("Cancelled at cost confirmation.");
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// === 9. Resolve image digests so each pulumi up rolls Cloud Run ===
|
|
88
|
+
// Cloud Run keys revisions by image reference; if the reference is
|
|
89
|
+
// a floating tag like ":main", a fresh image push doesn't trigger a
|
|
90
|
+
// new revision because the reference text is unchanged. Resolve the
|
|
91
|
+
// floating tag to its current sha256 digest so each provisioning run
|
|
92
|
+
// pulls the freshest published image.
|
|
93
|
+
const imageDigests = await resolveImageDigests(["admin", "gateway"]);
|
|
94
|
+
// === 9.5. Drain the legacy `caelo_admin` SQL user before pulumi-up ===
|
|
95
|
+
// The 3a81c37 rename (caelo_admin → admin_role) leaves the old role
|
|
96
|
+
// in pg_roles owning N objects (sequences/grants survive ALTER TABLE
|
|
97
|
+
// OWNER). Pulumi's User-resource delete fails with "role cannot be
|
|
98
|
+
// dropped because some objects depend on it" (Postgres 400). REASSIGN
|
|
99
|
+
// OWNED + DROP OWNED clears the dependencies first; the DO block
|
|
100
|
+
// is a no-op when caelo_admin already doesn't exist (fresh installs +
|
|
101
|
+
// post-cleanup re-runs both pass through cleanly).
|
|
102
|
+
await stepDrainLegacyCaeloAdminUser(projectId, region);
|
|
103
|
+
// === 10. Pulumi up via Automation SDK ===
|
|
104
|
+
// Pre-built signed images live at the Caelo-team public AR repo
|
|
105
|
+
// (caelo-website/caelo-cms-images by default). Cloud Run reads them
|
|
106
|
+
// directly with no operator-side IAM binding — anonymous public pull.
|
|
107
|
+
await stepPulumiUp(installId, {
|
|
108
|
+
projectId,
|
|
109
|
+
domain,
|
|
110
|
+
ownerEmail,
|
|
111
|
+
region,
|
|
112
|
+
saKeyPath: keyPath,
|
|
113
|
+
pulumiPassphrase,
|
|
114
|
+
anthropicApiKey: anthropicKey,
|
|
115
|
+
cloudSqlTier: costInputs.cloudSqlTier,
|
|
116
|
+
cloudSqlHa: costInputs.cloudSqlHa,
|
|
117
|
+
adminMinInstances: costInputs.adminMinInstances,
|
|
118
|
+
gatewayMinInstances: costInputs.gatewayMinInstances,
|
|
119
|
+
wafAdaptiveProtection: costInputs.wafAdaptiveProtection,
|
|
120
|
+
iapAllowlist: [`user:${ownerEmail}`],
|
|
121
|
+
imageDigests,
|
|
122
|
+
});
|
|
123
|
+
// === 10. Wait for managed cert to flip from PROVISIONING → ACTIVE ===
|
|
124
|
+
// Pulumi reports the cert "created" the moment GCP queues it; the
|
|
125
|
+
// actual issuance + DNS validation takes 5-30 min depending on
|
|
126
|
+
// load + DNS propagation. The bootstrap URL is meaningless until
|
|
127
|
+
// ACTIVE — without this step the wizard would tell the operator
|
|
128
|
+
// "you're done" while HTTPS still ERR_CONNECTION_CLOSEDs.
|
|
129
|
+
await stepWaitForCertActive(projectId);
|
|
130
|
+
// === 11. Apply DB migrations against Cloud SQL via a one-shot Job ===
|
|
131
|
+
// Cloud SQL lives on a private VPC IP that's only reachable from
|
|
132
|
+
// inside the VPC. Spawn a Cloud Run Job using the same admin image
|
|
133
|
+
// (which carries packages/migrations) to apply admin + public schema.
|
|
134
|
+
// Idempotent — drizzle's __drizzle_migrations table tracks applied
|
|
135
|
+
// versions, so re-runs only apply NEW migrations on subsequent ups.
|
|
136
|
+
// P21 ship 3 — shared with `cms-provision upgrade`. Aborts the
|
|
137
|
+
// wizard with a clear error if migrations fail (vs. silently
|
|
138
|
+
// continuing with a half-migrated DB).
|
|
139
|
+
{
|
|
140
|
+
const { runMigrationsViaCloudRunJob } = await import("../migration-runner.js");
|
|
141
|
+
const r = await runMigrationsViaCloudRunJob({ projectId, region });
|
|
142
|
+
if (!r.ok) {
|
|
143
|
+
cancel(`Migrations failed: ${r.error}. Inspect the Cloud Run Job logs and re-run.`);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// === 12. Upload the fresh-install placeholder to the static bucket ===
|
|
148
|
+
// Without this, https://<domain>/ returns the raw GCS NoSuchKey XML
|
|
149
|
+
// until the operator publishes their first deploy. Idempotent: skips
|
|
150
|
+
// if any object already exists at the bucket root (i.e. the static
|
|
151
|
+
// generator has already published).
|
|
152
|
+
await stepUploadStaticPlaceholder(installId, projectId, domain);
|
|
153
|
+
// === 12. DNS records + bootstrap URL ===
|
|
154
|
+
// IAP is enabled directly on the LB BackendService (Pulumi-managed);
|
|
155
|
+
// no post-up gcloud step needed.
|
|
156
|
+
await stepFinalize(installId);
|
|
157
|
+
// Reference unused params to silence the linter.
|
|
158
|
+
void region;
|
|
159
|
+
void domain;
|
|
160
|
+
void ownerEmail;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Resolve the floating `:main` tag on each service's public AR image
|
|
164
|
+
* to its current sha256 digest, so the Pulumi stack can pin Cloud Run
|
|
165
|
+
* to the exact image rather than the mutable tag. Without this, a
|
|
166
|
+
* fresh release-images push doesn't trigger a new Cloud Run revision
|
|
167
|
+
* because the image reference in the stack (":main") is unchanged.
|
|
168
|
+
*/
|
|
169
|
+
async function resolveImageDigests(services) {
|
|
170
|
+
const project = "caelo-website";
|
|
171
|
+
const region = "europe-west1";
|
|
172
|
+
const repo = "caelo-cms-images";
|
|
173
|
+
const out = {};
|
|
174
|
+
for (const service of services) {
|
|
175
|
+
const s = spinner();
|
|
176
|
+
s.start(`Resolving ${service}:main → digest...`);
|
|
177
|
+
// gcloud's --filter='tag=main' is in a transitional state (warns + matches
|
|
178
|
+
// nothing) and 'tag:main' substring-matches main-<sha> too. List all tags
|
|
179
|
+
// and grep for the exact-match row in JS.
|
|
180
|
+
const r = await gcloud([
|
|
181
|
+
"artifacts",
|
|
182
|
+
"docker",
|
|
183
|
+
"tags",
|
|
184
|
+
"list",
|
|
185
|
+
`${region}-docker.pkg.dev/${project}/${repo}/${service}`,
|
|
186
|
+
"--format=value(tag,version)",
|
|
187
|
+
]);
|
|
188
|
+
if (!r.ok) {
|
|
189
|
+
s.stop(red(`Could not list ${service} tags: ${r.stderr.trim() || "(no output)"}`));
|
|
190
|
+
cancel("Aborted.");
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
const exact = r.stdout
|
|
194
|
+
.split("\n")
|
|
195
|
+
.map((line) => line.trim().split(/\s+/))
|
|
196
|
+
.find(([tag]) => tag === "main");
|
|
197
|
+
const digest = exact?.[1] ?? "";
|
|
198
|
+
if (!/^sha256:[0-9a-f]{64}$/.test(digest)) {
|
|
199
|
+
s.stop(red(`Unexpected digest format for ${service}: ${digest}`));
|
|
200
|
+
cancel("Aborted.");
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
s.stop(green(`${service}: ${dim(`${digest.slice(0, 19)}...`)}`));
|
|
204
|
+
out[service] = digest;
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
// =========================================================================
|
|
209
|
+
// Per-step helpers
|
|
210
|
+
// =========================================================================
|
|
211
|
+
async function stepActiveGcloud() {
|
|
212
|
+
const stepName = "gcloud-active";
|
|
213
|
+
const account = await activeAccount();
|
|
214
|
+
if (!account) {
|
|
215
|
+
log.error(red("No active gcloud account."));
|
|
216
|
+
log.warn(`Run ${bold("gcloud auth login")} in another terminal, then re-run ${bold("bunx @caelo-cms/provisioning")}.`);
|
|
217
|
+
cancel("Aborted — no gcloud auth.");
|
|
218
|
+
process.exit(2);
|
|
219
|
+
}
|
|
220
|
+
log.success(`gcloud auth: ${bold(account)}`);
|
|
221
|
+
void stepName;
|
|
222
|
+
}
|
|
223
|
+
async function stepProjectId(opts) {
|
|
224
|
+
if (opts.projectId) {
|
|
225
|
+
log.info(`Project id: ${bold(opts.projectId)} ${dim("(supplied via --project-id)")}`);
|
|
226
|
+
return opts.projectId;
|
|
227
|
+
}
|
|
228
|
+
const guess = opts.domain.split(".")[0]?.replace(/[^a-z0-9-]/g, "-") ?? "caelo";
|
|
229
|
+
const value = await text({
|
|
230
|
+
message: "GCP project id (will be created if absent)",
|
|
231
|
+
placeholder: guess,
|
|
232
|
+
defaultValue: guess,
|
|
233
|
+
validate: (v) => {
|
|
234
|
+
if (!v || v.length < 6 || v.length > 30) {
|
|
235
|
+
return "GCP project id must be 6–30 chars";
|
|
236
|
+
}
|
|
237
|
+
if (!/^[a-z][a-z0-9-]*[a-z0-9]$/.test(v)) {
|
|
238
|
+
return "GCP project id: lowercase letters, digits, hyphens; start with letter; no trailing hyphen";
|
|
239
|
+
}
|
|
240
|
+
return undefined;
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
if (isCancel(value)) {
|
|
244
|
+
cancel("Cancelled.");
|
|
245
|
+
process.exit(0);
|
|
246
|
+
}
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
249
|
+
async function stepProjectCreate(installId, projectId) {
|
|
250
|
+
const stepName = `project-create-${projectId}`;
|
|
251
|
+
if (isStepDone(installId, stepName)) {
|
|
252
|
+
log.success(`Project ${bold(projectId)} ${dim("(already created)")}`);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (await projectExists(projectId)) {
|
|
256
|
+
log.success(`Project ${bold(projectId)} ${dim("(exists)")}`);
|
|
257
|
+
markStepDone(installId, stepName, { existed: true });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const s = spinner();
|
|
261
|
+
s.start(`Creating GCP project ${projectId}...`);
|
|
262
|
+
const r = await createProject(projectId, "Caelo CMS");
|
|
263
|
+
if (!r.ok) {
|
|
264
|
+
s.stop(red(`Failed: ${r.stderr.trim()}`));
|
|
265
|
+
log.error(`gcloud projects create failed. Check that the id ${bold(projectId)} is globally unique + that you have project-create rights on the org.`);
|
|
266
|
+
cancel("Aborted.");
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
s.stop(green(`Project ${bold(projectId)} created`));
|
|
270
|
+
markStepDone(installId, stepName, { created: true });
|
|
271
|
+
}
|
|
272
|
+
async function stepBillingLink(installId, projectId, nonInteractive) {
|
|
273
|
+
const stepName = `billing-link-${projectId}`;
|
|
274
|
+
if (isStepDone(installId, stepName)) {
|
|
275
|
+
log.success(`Billing already linked ${dim("(checkpoint)")}`);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const accounts = await listBillingAccounts();
|
|
279
|
+
const open = accounts.filter((a) => a.open);
|
|
280
|
+
if (open.length === 0) {
|
|
281
|
+
log.error(red("No open billing accounts found."));
|
|
282
|
+
log.warn(`Open one at https://console.cloud.google.com/billing then re-run ${bold("bunx @caelo-cms/provisioning")}.`);
|
|
283
|
+
cancel("Aborted.");
|
|
284
|
+
process.exit(1);
|
|
285
|
+
}
|
|
286
|
+
let chosen;
|
|
287
|
+
if (open.length === 1) {
|
|
288
|
+
[chosen] = open;
|
|
289
|
+
log.info(`Billing account: ${bold(chosen.displayName)} ${dim(`(${chosen.id})`)}`);
|
|
290
|
+
}
|
|
291
|
+
else if (nonInteractive) {
|
|
292
|
+
log.error(red(`Multiple billing accounts but --non-interactive passed. Re-run with --billing-account=<id> (one of: ${open.map((a) => a.id).join(", ")}).`));
|
|
293
|
+
cancel("Aborted.");
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
const choice = await select({
|
|
298
|
+
message: "Pick a billing account",
|
|
299
|
+
options: open.map((a) => ({
|
|
300
|
+
value: a.id,
|
|
301
|
+
label: a.displayName,
|
|
302
|
+
hint: a.id,
|
|
303
|
+
})),
|
|
304
|
+
});
|
|
305
|
+
if (isCancel(choice)) {
|
|
306
|
+
cancel("Cancelled.");
|
|
307
|
+
process.exit(0);
|
|
308
|
+
}
|
|
309
|
+
const found = open.find((a) => a.id === choice);
|
|
310
|
+
if (!found) {
|
|
311
|
+
cancel("Picked account not found in list — internal error.");
|
|
312
|
+
process.exit(1);
|
|
313
|
+
}
|
|
314
|
+
chosen = found;
|
|
315
|
+
}
|
|
316
|
+
const s = spinner();
|
|
317
|
+
s.start(`Linking billing ${chosen.id}...`);
|
|
318
|
+
const r = await linkBilling(projectId, chosen.id);
|
|
319
|
+
if (!r.ok) {
|
|
320
|
+
s.stop(red(`Failed: ${r.stderr.trim()}`));
|
|
321
|
+
cancel("Aborted.");
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
s.stop(green("Billing linked"));
|
|
325
|
+
markStepDone(installId, stepName, { billingAccountId: chosen.id });
|
|
326
|
+
}
|
|
327
|
+
async function stepEnableApis(installId, projectId) {
|
|
328
|
+
const stepName = `enable-apis-${projectId}`;
|
|
329
|
+
if (isStepDone(installId, stepName)) {
|
|
330
|
+
log.success(`APIs enabled ${dim(`(${REQUIRED_API_LIST.length} services, checkpointed)`)}`);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const s = spinner();
|
|
334
|
+
s.start(`Enabling ${REQUIRED_API_LIST.length} GCP APIs (15-30s)...`);
|
|
335
|
+
const r = await enableApis(projectId);
|
|
336
|
+
if (!r.ok) {
|
|
337
|
+
s.stop(red(`Failed: ${r.stderr.trim()}`));
|
|
338
|
+
cancel("Aborted.");
|
|
339
|
+
process.exit(1);
|
|
340
|
+
}
|
|
341
|
+
s.stop(green(`${REQUIRED_API_LIST.length} APIs enabled`));
|
|
342
|
+
markStepDone(installId, stepName, { count: REQUIRED_API_LIST.length });
|
|
343
|
+
}
|
|
344
|
+
async function stepServiceAccount(installId, projectId, saEmail) {
|
|
345
|
+
const stepName = `service-account-${SA_ACCOUNT_ID}`;
|
|
346
|
+
if (isStepDone(installId, stepName)) {
|
|
347
|
+
log.success(`Provisioner SA ${dim("(already created)")}`);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (await serviceAccountExists(projectId, saEmail)) {
|
|
351
|
+
log.success(`Provisioner SA ${bold(saEmail)} ${dim("(exists)")}`);
|
|
352
|
+
markStepDone(installId, stepName, { existed: true, saEmail });
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const s = spinner();
|
|
356
|
+
s.start(`Creating provisioner service account...`);
|
|
357
|
+
const r = await createServiceAccount(projectId, SA_ACCOUNT_ID, "Caelo provisioner");
|
|
358
|
+
if (!r.ok) {
|
|
359
|
+
s.stop(red(`Failed: ${r.stderr.trim()}`));
|
|
360
|
+
cancel("Aborted.");
|
|
361
|
+
process.exit(1);
|
|
362
|
+
}
|
|
363
|
+
s.stop(green(`Provisioner SA ${bold(saEmail)} created`));
|
|
364
|
+
markStepDone(installId, stepName, { created: true, saEmail });
|
|
365
|
+
}
|
|
366
|
+
async function stepGrantRoles(installId, projectId, saEmail) {
|
|
367
|
+
const stepName = `grant-roles-${projectId}`;
|
|
368
|
+
if (isStepDone(installId, stepName)) {
|
|
369
|
+
log.success(`IAM roles granted ${dim(`(${PROVISIONER_ROLE_LIST.length} roles, checkpointed)`)}`);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const s = spinner();
|
|
373
|
+
s.start(`Granting ${PROVISIONER_ROLE_LIST.length} IAM roles to the provisioner SA...`);
|
|
374
|
+
const { granted, failed } = await grantProvisionerRoles(projectId, saEmail);
|
|
375
|
+
if (failed.length > 0) {
|
|
376
|
+
s.stop(red(`Granted ${granted}; failed ${failed.length}: ${failed.join(", ")}`));
|
|
377
|
+
log.error(`Some role bindings failed. You can re-run safely (idempotent), or grant the failed roles manually via gcloud.`);
|
|
378
|
+
cancel("Aborted.");
|
|
379
|
+
process.exit(1);
|
|
380
|
+
}
|
|
381
|
+
s.stop(green(`${granted} IAM roles granted`));
|
|
382
|
+
markStepDone(installId, stepName, { granted });
|
|
383
|
+
}
|
|
384
|
+
async function stepMintKey(installId, projectId, saEmail, secretsDir) {
|
|
385
|
+
const stepName = `mint-key-${projectId}`;
|
|
386
|
+
const keyPath = join(secretsDir, "sa-key.json");
|
|
387
|
+
if (isStepDone(installId, stepName) && existsSync(keyPath)) {
|
|
388
|
+
log.success(`SA key ${dim("(already minted, kept)")}`);
|
|
389
|
+
return keyPath;
|
|
390
|
+
}
|
|
391
|
+
const s = spinner();
|
|
392
|
+
s.start(`Minting SA key → ${dim(keyPath)}`);
|
|
393
|
+
const r = await createServiceAccountKey(saEmail, keyPath);
|
|
394
|
+
if (!r.ok) {
|
|
395
|
+
s.stop(red(`Failed: ${r.stderr.trim()}`));
|
|
396
|
+
cancel("Aborted.");
|
|
397
|
+
process.exit(1);
|
|
398
|
+
}
|
|
399
|
+
s.stop(green(`SA key minted (mode 600)`));
|
|
400
|
+
markStepDone(installId, stepName, { path: keyPath });
|
|
401
|
+
return keyPath;
|
|
402
|
+
}
|
|
403
|
+
async function stepAnthropicKey(installId, nonInteractive) {
|
|
404
|
+
const existing = readSecret(installId, "anthropic-api-key");
|
|
405
|
+
if (existing) {
|
|
406
|
+
log.success(`Anthropic API key ${dim("(reused from secrets/, not re-prompted)")}`);
|
|
407
|
+
return existing;
|
|
408
|
+
}
|
|
409
|
+
if (nonInteractive) {
|
|
410
|
+
log.error(red("Missing Anthropic API key + --non-interactive — cannot proceed."));
|
|
411
|
+
log.warn(`Write the key to ${bold(`~/.caelo-${installId}/secrets/anthropic-api-key`)} (mode 600) then re-run.`);
|
|
412
|
+
cancel("Aborted.");
|
|
413
|
+
process.exit(1);
|
|
414
|
+
}
|
|
415
|
+
const value = await password({
|
|
416
|
+
message: "Anthropic API key (input hidden; saved to secrets/anthropic-api-key)",
|
|
417
|
+
validate: (v) => {
|
|
418
|
+
if (!v || v.length < 20)
|
|
419
|
+
return "Looks too short — Anthropic keys start with sk-ant-";
|
|
420
|
+
if (!v.startsWith("sk-"))
|
|
421
|
+
return "Should start with sk-";
|
|
422
|
+
return undefined;
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
if (isCancel(value)) {
|
|
426
|
+
cancel("Cancelled.");
|
|
427
|
+
process.exit(0);
|
|
428
|
+
}
|
|
429
|
+
const key = value.trim();
|
|
430
|
+
writeSecret(installId, "anthropic-api-key", key);
|
|
431
|
+
log.success(`Anthropic key saved → ${dim(`~/.caelo-${installId}/secrets/anthropic-api-key`)}`);
|
|
432
|
+
return key;
|
|
433
|
+
}
|
|
434
|
+
function stepPulumiPassphrase(installId) {
|
|
435
|
+
const existing = readSecret(installId, "pulumi-passphrase");
|
|
436
|
+
if (existing) {
|
|
437
|
+
log.success(`Pulumi passphrase ${dim("(reused from secrets/, not regenerated)")}`);
|
|
438
|
+
return existing;
|
|
439
|
+
}
|
|
440
|
+
const passphrase = randomBytes(32).toString("hex");
|
|
441
|
+
writeSecret(installId, "pulumi-passphrase", passphrase);
|
|
442
|
+
log.success(`Pulumi passphrase generated → ${dim(`~/.caelo-${installId}/secrets/pulumi-passphrase`)}`);
|
|
443
|
+
return passphrase;
|
|
444
|
+
}
|
|
445
|
+
async function stepPulumiUp(installId, opts) {
|
|
446
|
+
const stepName = `pulumi-up-${opts.projectId}`;
|
|
447
|
+
if (isStepDone(installId, stepName)) {
|
|
448
|
+
log.success(`Pulumi up ${dim("(checkpointed — re-running for drift refresh)")}`);
|
|
449
|
+
}
|
|
450
|
+
const { secretsDir } = ensureInstallDir(installId);
|
|
451
|
+
const root = installRoot(installId);
|
|
452
|
+
log.info(`Pulumi up — wall-clock 8–15 min (Cloud SQL is the long pole). Streaming progress...`);
|
|
453
|
+
let resourceCount = 0;
|
|
454
|
+
const result = await pulumiUpGcp({
|
|
455
|
+
installId,
|
|
456
|
+
installRoot: root,
|
|
457
|
+
secretsDir,
|
|
458
|
+
...opts,
|
|
459
|
+
}, (kind, message) => {
|
|
460
|
+
if (kind === "resource") {
|
|
461
|
+
resourceCount++;
|
|
462
|
+
if (resourceCount % 5 === 0) {
|
|
463
|
+
process.stdout.write(`\r${dim(` ${resourceCount} resources updated`)}`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
else if (kind === "error") {
|
|
467
|
+
process.stdout.write("\n");
|
|
468
|
+
log.error(red(message));
|
|
469
|
+
}
|
|
470
|
+
// logs: silent — too noisy for a live progress UI; pulumi
|
|
471
|
+
// diagnostics will print on failure via the SDK's onOutput.
|
|
472
|
+
});
|
|
473
|
+
process.stdout.write("\n");
|
|
474
|
+
log.success(green(`Pulumi up complete — ${result.resourceCount.created} created, ${result.resourceCount.updated} updated, ${result.resourceCount.deleted} deleted`));
|
|
475
|
+
markStepDone(installId, stepName, { outputs: result.outputs });
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Poll the LB managed SSL cert until both domains report ACTIVE.
|
|
479
|
+
* Pulumi reports the cert "created" the moment GCP queues it; the actual
|
|
480
|
+
* ACME-style validation against the LB takes 5-30 min. Without this
|
|
481
|
+
* step the wizard would tell the operator "Done. Welcome to Caelo CMS"
|
|
482
|
+
* while HTTPS still ERR_CONNECTION_CLOSEDs and the bootstrap URL is
|
|
483
|
+
* useless. Times out after 35 min with a clear escalation note.
|
|
484
|
+
*/
|
|
485
|
+
async function stepWaitForCertActive(projectId) {
|
|
486
|
+
const s = spinner();
|
|
487
|
+
s.start("Waiting for managed TLS cert to validate (typically 5-15 min)...");
|
|
488
|
+
const deadline = Date.now() + 35 * 60 * 1000;
|
|
489
|
+
let lastStatus = "";
|
|
490
|
+
while (Date.now() < deadline) {
|
|
491
|
+
const r = await gcloud([
|
|
492
|
+
"compute",
|
|
493
|
+
"target-https-proxies",
|
|
494
|
+
"list",
|
|
495
|
+
"--project",
|
|
496
|
+
projectId,
|
|
497
|
+
"--format=value(sslCertificates)",
|
|
498
|
+
]);
|
|
499
|
+
if (!r.ok) {
|
|
500
|
+
s.stop(red(`gcloud target-https-proxies list failed: ${r.stderr.trim()}`));
|
|
501
|
+
cancel("Aborted.");
|
|
502
|
+
process.exit(1);
|
|
503
|
+
}
|
|
504
|
+
const certUrl = r.stdout.trim().split(/\s+/)[0] ?? "";
|
|
505
|
+
const certName = certUrl.split("/").pop() ?? "";
|
|
506
|
+
if (!certName) {
|
|
507
|
+
s.stop(red("Could not resolve managed cert name from HTTPS proxy."));
|
|
508
|
+
cancel("Aborted.");
|
|
509
|
+
process.exit(1);
|
|
510
|
+
}
|
|
511
|
+
const desc = await gcloud([
|
|
512
|
+
"compute",
|
|
513
|
+
"ssl-certificates",
|
|
514
|
+
"describe",
|
|
515
|
+
certName,
|
|
516
|
+
"--global",
|
|
517
|
+
"--project",
|
|
518
|
+
projectId,
|
|
519
|
+
"--format=value(managed.status,managed.domainStatus)",
|
|
520
|
+
]);
|
|
521
|
+
const text = (desc.stdout || "").trim();
|
|
522
|
+
if (text !== lastStatus) {
|
|
523
|
+
s.message(`Cert ${certName}: ${text || "(no status yet)"}`);
|
|
524
|
+
lastStatus = text;
|
|
525
|
+
}
|
|
526
|
+
// Status format: "ACTIVE\t{'caelo-cms.com': 'ACTIVE', 'admin.caelo-cms.com': 'ACTIVE'}"
|
|
527
|
+
if (/^ACTIVE\b/.test(text) && !/FAILED/.test(text) && !/PROVISIONING/.test(text)) {
|
|
528
|
+
s.stop(green(`Managed TLS cert is ACTIVE for all domains`));
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (/FAILED_NOT_VISIBLE/.test(text)) {
|
|
532
|
+
s.message(`${cyan(certName)} validation can't reach the LB yet — check DNS A records resolve to LB IP. Will keep polling.`);
|
|
533
|
+
}
|
|
534
|
+
await new Promise((res) => setTimeout(res, 30 * 1000));
|
|
535
|
+
}
|
|
536
|
+
s.stop(yellow("Cert still not ACTIVE after 35 min."));
|
|
537
|
+
log.warn([
|
|
538
|
+
"The cert may still finish on its own — Google Managed Cert validation can take up to 60 min.",
|
|
539
|
+
"Check status anytime:",
|
|
540
|
+
` ${bold(`gcloud compute ssl-certificates list --global --project=${projectId}`)}`,
|
|
541
|
+
"Common causes if it never flips ACTIVE:",
|
|
542
|
+
" - DNS A records don't resolve to the LB IP yet (check `dig +short caelo-cms.com`)",
|
|
543
|
+
" - The LB's HTTP port-80 listener isn't reachable from the public internet",
|
|
544
|
+
].join("\n"));
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Run REASSIGN OWNED BY caelo_admin TO admin_role + DROP OWNED BY
|
|
548
|
+
* caelo_admin in both cms_admin + cms_public, via a one-shot Cloud Run
|
|
549
|
+
* Job. Idempotent: the SQL is wrapped in DO IF EXISTS so fresh installs
|
|
550
|
+
* (no caelo_admin) + post-cleanup re-runs both pass through cleanly.
|
|
551
|
+
*
|
|
552
|
+
* Why this exists: the SQL-user rename in commit 3a81c37 left
|
|
553
|
+
* `caelo_admin` in pg_roles owning N sequences/grants the prior
|
|
554
|
+
* ALTER TABLE OWNER fix didn't reach. Pulumi's User-delete subsequently
|
|
555
|
+
* fails with "role cannot be dropped because some objects depend on
|
|
556
|
+
* it." REASSIGN/DROP OWNED clears them first.
|
|
557
|
+
*
|
|
558
|
+
* Skipped silently when no admin Cloud Run service exists (fresh
|
|
559
|
+
* installs that haven't booted the admin yet).
|
|
560
|
+
*/
|
|
561
|
+
async function stepDrainLegacyCaeloAdminUser(projectId, region) {
|
|
562
|
+
const s = spinner();
|
|
563
|
+
s.start("Draining legacy caelo_admin postgres user (idempotent)...");
|
|
564
|
+
// Resolve current admin image; skip if missing (fresh install).
|
|
565
|
+
const adminDescr = await gcloud([
|
|
566
|
+
"run",
|
|
567
|
+
"services",
|
|
568
|
+
"describe",
|
|
569
|
+
"caelo-production-admin-3efcfea",
|
|
570
|
+
"--region",
|
|
571
|
+
region,
|
|
572
|
+
"--project",
|
|
573
|
+
projectId,
|
|
574
|
+
"--format=json",
|
|
575
|
+
]);
|
|
576
|
+
if (!adminDescr.ok) {
|
|
577
|
+
s.stop(green("No admin service yet — nothing to drain (fresh install)"));
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
let adminImg = "";
|
|
581
|
+
let networkRef = "";
|
|
582
|
+
let subnetRef = "";
|
|
583
|
+
try {
|
|
584
|
+
const d = JSON.parse(adminDescr.stdout);
|
|
585
|
+
adminImg = d.spec.template.spec.containers[0]?.image ?? "";
|
|
586
|
+
// Cloud Run v2 with Direct VPC stores network refs in the
|
|
587
|
+
// `run.googleapis.com/network-interfaces` annotation (JSON-encoded
|
|
588
|
+
// array), NOT in spec.template.spec.vpcAccess. Read both — newer
|
|
589
|
+
// installs use the annotation; older ones use vpcAccess.
|
|
590
|
+
const annotations = d.spec.template.metadata?.annotations ?? {};
|
|
591
|
+
const niAnnotation = annotations["run.googleapis.com/network-interfaces"];
|
|
592
|
+
if (niAnnotation) {
|
|
593
|
+
try {
|
|
594
|
+
const parsed = JSON.parse(niAnnotation);
|
|
595
|
+
networkRef = parsed[0]?.network ?? "";
|
|
596
|
+
subnetRef = parsed[0]?.subnetwork ?? "";
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
// fall through to vpcAccess path
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (!networkRef || !subnetRef) {
|
|
603
|
+
const ni = d.spec.template.spec.vpcAccess?.networkInterfaces?.[0];
|
|
604
|
+
networkRef ||= ni?.network ?? "";
|
|
605
|
+
subnetRef ||= ni?.subnetwork ?? "";
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
s.stop(yellow("Could not parse admin service describe — skipping drain"));
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (!adminImg) {
|
|
613
|
+
s.stop(green("No admin image to spawn drain job — skipping"));
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (!networkRef || !subnetRef) {
|
|
617
|
+
s.stop(yellow("Could not resolve VPC network from admin service — skipping drain"));
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
// Read postgres superuser password from Secret Manager.
|
|
621
|
+
const secretFetch = await gcloud([
|
|
622
|
+
"secrets",
|
|
623
|
+
"versions",
|
|
624
|
+
"access",
|
|
625
|
+
"latest",
|
|
626
|
+
"--secret",
|
|
627
|
+
"caelo-production-postgres-password",
|
|
628
|
+
"--project",
|
|
629
|
+
projectId,
|
|
630
|
+
]);
|
|
631
|
+
if (!secretFetch.ok) {
|
|
632
|
+
s.stop(yellow("Could not read postgres-password secret — skipping drain"));
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const pgPass = secretFetch.stdout.trim();
|
|
636
|
+
// Sync the SQL `postgres` user's password to the Secret Manager value
|
|
637
|
+
// (Pulumi may have rotated the secret without applying to the user yet).
|
|
638
|
+
await gcloud([
|
|
639
|
+
"sql",
|
|
640
|
+
"users",
|
|
641
|
+
"set-password",
|
|
642
|
+
"postgres",
|
|
643
|
+
"--instance",
|
|
644
|
+
"caelo-production-pg-1d65811",
|
|
645
|
+
"--project",
|
|
646
|
+
projectId,
|
|
647
|
+
"--password",
|
|
648
|
+
pgPass,
|
|
649
|
+
]);
|
|
650
|
+
// Resolve the SQL instance's private IP from gcloud (avoids reading
|
|
651
|
+
// Pulumi outputs from inside the wizard).
|
|
652
|
+
const sqlDescr = await gcloud([
|
|
653
|
+
"sql",
|
|
654
|
+
"instances",
|
|
655
|
+
"describe",
|
|
656
|
+
"caelo-production-pg-1d65811",
|
|
657
|
+
"--project",
|
|
658
|
+
projectId,
|
|
659
|
+
"--format=value(ipAddresses[0].ipAddress)",
|
|
660
|
+
]);
|
|
661
|
+
const pgHost = (sqlDescr.stdout || "").trim();
|
|
662
|
+
if (!pgHost) {
|
|
663
|
+
s.stop(yellow("Could not resolve Cloud SQL private IP — skipping drain"));
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
const adminPg = `postgres://postgres:${pgPass}@${pgHost}:5432/cms_admin`;
|
|
667
|
+
const publicPg = `postgres://postgres:${pgPass}@${pgHost}:5432/cms_public`;
|
|
668
|
+
// The cleanup script — uses bun's globalThis.Bun.SQL (already proven
|
|
669
|
+
// pattern in hooks.server.ts + the migration runner). DO block makes
|
|
670
|
+
// it safe to re-run.
|
|
671
|
+
//
|
|
672
|
+
// Per-step rationale (validated against caelo-website production):
|
|
673
|
+
// - WITH INHERIT TRUE on both grants: PG 16 default is NOINHERIT,
|
|
674
|
+
// so plain "GRANT caelo_admin TO postgres" makes postgres a member
|
|
675
|
+
// but doesn't give it caelo_admin's privileges. REASSIGN OWNED
|
|
676
|
+
// fails with "Only roles with privileges of role caelo_admin may
|
|
677
|
+
// reassign objects owned by it." WITH INHERIT TRUE fixes it.
|
|
678
|
+
// - ALTER SCHEMA public OWNER TO admin_role: in PG 16 the default
|
|
679
|
+
// `public` schema is owned by cloudsqladmin (Cloud SQL) /
|
|
680
|
+
// pg_database_owner (vanilla). The REASSIGN's per-function ACL
|
|
681
|
+
// check ("permission denied for schema public") fails until
|
|
682
|
+
// admin_role owns it.
|
|
683
|
+
// - We count pg_proc (not pg_class): the legacy caelo_admin role
|
|
684
|
+
// left behind owned FUNCTIONS, not tables. The before count was
|
|
685
|
+
// 31 on the caelo-website install; after was 0.
|
|
686
|
+
const script = `const SQL = globalThis.Bun.SQL; async function fix(name, u){ const s = new SQL(u); console.log(\`[\${name}] connected\`); const before = await s.unsafe(\`SELECT count(*)::int AS n FROM pg_proc p JOIN pg_roles r ON p.proowner=r.oid WHERE r.rolname = 'caelo_admin';\`); console.log(\`[\${name}] caelo_admin functions=\`, before); await s.unsafe(\`DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname='caelo_admin') THEN EXECUTE 'GRANT caelo_admin TO CURRENT_USER WITH INHERIT TRUE'; EXECUTE 'GRANT admin_role TO CURRENT_USER WITH INHERIT TRUE'; EXECUTE 'ALTER SCHEMA public OWNER TO admin_role'; EXECUTE 'REASSIGN OWNED BY caelo_admin TO admin_role'; EXECUTE 'DROP OWNED BY caelo_admin'; END IF; END $$;\`); const after = await s.unsafe(\`SELECT count(*)::int AS n FROM pg_proc p JOIN pg_roles r ON p.proowner=r.oid WHERE r.rolname = 'caelo_admin';\`); console.log(\`[\${name}] after functions=\`, after); await s.end(); } await fix('cms_admin', process.env.ADMIN_PG); await fix('cms_public', process.env.PUBLIC_PG); console.log('drained');`;
|
|
687
|
+
// Delete-then-create so we always run with the freshest config.
|
|
688
|
+
await gcloud([
|
|
689
|
+
"run",
|
|
690
|
+
"jobs",
|
|
691
|
+
"delete",
|
|
692
|
+
"caelo-drain-legacy-user",
|
|
693
|
+
"--region",
|
|
694
|
+
region,
|
|
695
|
+
"--project",
|
|
696
|
+
projectId,
|
|
697
|
+
"--quiet",
|
|
698
|
+
]);
|
|
699
|
+
const create = await gcloud([
|
|
700
|
+
"run",
|
|
701
|
+
"jobs",
|
|
702
|
+
"create",
|
|
703
|
+
"caelo-drain-legacy-user",
|
|
704
|
+
`--image=${adminImg}`,
|
|
705
|
+
"--region",
|
|
706
|
+
region,
|
|
707
|
+
"--project",
|
|
708
|
+
projectId,
|
|
709
|
+
"--service-account",
|
|
710
|
+
`caelo-production-run-sa@${projectId}.iam.gserviceaccount.com`,
|
|
711
|
+
"--network",
|
|
712
|
+
networkRef.split("/").pop() ?? networkRef,
|
|
713
|
+
"--subnet",
|
|
714
|
+
subnetRef.split("/").pop() ?? subnetRef,
|
|
715
|
+
"--vpc-egress=private-ranges-only",
|
|
716
|
+
"--command=bun",
|
|
717
|
+
// ^|^ delimiter form — JS contains commas so we can't use the default.
|
|
718
|
+
// Wrap in async IIFE so top-level `await` in SCRIPT works (sync eval()
|
|
719
|
+
// doesn't support TLA; new Function('return (async () => { ... })()')
|
|
720
|
+
// does, and avoids needing to write SCRIPT to disk).
|
|
721
|
+
`--args=^|^--bun|-e|(new Function('return (async () => { ' + process.env.SCRIPT + ' })()'))()`,
|
|
722
|
+
`--set-env-vars=^|^SCRIPT=${script}|ADMIN_PG=${adminPg}|PUBLIC_PG=${publicPg}`,
|
|
723
|
+
"--max-retries=0",
|
|
724
|
+
"--task-timeout=2m",
|
|
725
|
+
"--quiet",
|
|
726
|
+
]);
|
|
727
|
+
if (!create.ok) {
|
|
728
|
+
s.stop(yellow(`Drain job create failed: ${create.stderr.trim()} — continuing`));
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
const exec = await gcloud([
|
|
732
|
+
"run",
|
|
733
|
+
"jobs",
|
|
734
|
+
"execute",
|
|
735
|
+
"caelo-drain-legacy-user",
|
|
736
|
+
"--region",
|
|
737
|
+
region,
|
|
738
|
+
"--project",
|
|
739
|
+
projectId,
|
|
740
|
+
"--wait",
|
|
741
|
+
]);
|
|
742
|
+
if (!exec.ok) {
|
|
743
|
+
s.stop(red(`Drain job failed: ${exec.stderr.trim()}`));
|
|
744
|
+
cancel("Could not drain legacy caelo_admin user. Inspect the Cloud Run Job logs " +
|
|
745
|
+
"(`gcloud logging read 'resource.type=cloud_run_job AND " +
|
|
746
|
+
"resource.labels.job_name=caelo-drain-legacy-user' --project=" +
|
|
747
|
+
projectId +
|
|
748
|
+
"`) and re-run the wizard. Aborting before pulumi-up to avoid the " +
|
|
749
|
+
"predictable pg user-delete failure downstream.");
|
|
750
|
+
process.exit(1);
|
|
751
|
+
}
|
|
752
|
+
s.stop(green("Legacy caelo_admin user drained"));
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Upload the static placeholder so https://<domain>/ shows a friendly
|
|
756
|
+
* "Coming soon" landing instead of GCS's raw NoSuchKey XML. Skipped
|
|
757
|
+
* if anything is already in the bucket root (the static-generator's
|
|
758
|
+
* first publish replaces this transparently).
|
|
759
|
+
*/
|
|
760
|
+
async function stepUploadStaticPlaceholder(installId, projectId, domain) {
|
|
761
|
+
const stepName = `static-placeholder-${projectId}`;
|
|
762
|
+
if (isStepDone(installId, stepName)) {
|
|
763
|
+
log.success(`Static placeholder uploaded ${dim("(checkpointed)")}`);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
// Resolve bucket name from Pulumi outputs.
|
|
767
|
+
const lsRoot = await gcloud([
|
|
768
|
+
"storage",
|
|
769
|
+
"buckets",
|
|
770
|
+
"list",
|
|
771
|
+
"--project",
|
|
772
|
+
projectId,
|
|
773
|
+
"--filter",
|
|
774
|
+
"name~caelo-production-static",
|
|
775
|
+
"--format=value(name)",
|
|
776
|
+
]);
|
|
777
|
+
const bucketName = (lsRoot.stdout || "").trim().split(/\s+/)[0];
|
|
778
|
+
if (!bucketName) {
|
|
779
|
+
log.warn(yellow("Static bucket not found; skipping placeholder upload."));
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
// Idempotency: skip if anything is in the bucket root already.
|
|
783
|
+
const existing = await gcloud([
|
|
784
|
+
"storage",
|
|
785
|
+
"ls",
|
|
786
|
+
`gs://${bucketName}/index.html`,
|
|
787
|
+
"--project",
|
|
788
|
+
projectId,
|
|
789
|
+
]);
|
|
790
|
+
if (existing.ok) {
|
|
791
|
+
log.success(`Static bucket already populated ${dim("(skipping placeholder)")}`);
|
|
792
|
+
markStepDone(installId, stepName, { skipped: "exists" });
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
// Resolve the placeholder asset relative to this file (works in dev
|
|
796
|
+
// from src/ and in the published tarball from dist/).
|
|
797
|
+
const here = new URL(import.meta.url).pathname;
|
|
798
|
+
const candidates = [
|
|
799
|
+
join(here, "..", "..", "..", "static", "welcome.html"),
|
|
800
|
+
join(here, "..", "..", "..", "..", "static", "welcome.html"),
|
|
801
|
+
];
|
|
802
|
+
let html = "";
|
|
803
|
+
for (const path of candidates) {
|
|
804
|
+
if (existsSync(path)) {
|
|
805
|
+
html = readFileSync(path, "utf8");
|
|
806
|
+
break;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
if (!html) {
|
|
810
|
+
log.warn(yellow("welcome.html template not found; skipping placeholder upload."));
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
// Substitute the admin URL.
|
|
814
|
+
html = html.replaceAll("{{ADMIN_URL}}", `https://admin.${domain}`);
|
|
815
|
+
const s = spinner();
|
|
816
|
+
s.start(`Uploading welcome page → gs://${bucketName}/index.html`);
|
|
817
|
+
// Pipe via stdin so we don't write a temp file.
|
|
818
|
+
const { spawn } = await import("node:child_process");
|
|
819
|
+
const upload = await new Promise((resolve) => {
|
|
820
|
+
const child = spawn("gcloud", [
|
|
821
|
+
"storage",
|
|
822
|
+
"cp",
|
|
823
|
+
"--content-type",
|
|
824
|
+
"text/html; charset=utf-8",
|
|
825
|
+
"--cache-control",
|
|
826
|
+
"no-cache, max-age=60",
|
|
827
|
+
"-",
|
|
828
|
+
`gs://${bucketName}/index.html`,
|
|
829
|
+
"--project",
|
|
830
|
+
projectId,
|
|
831
|
+
], { stdio: ["pipe", "inherit", "pipe"] });
|
|
832
|
+
let stderr = "";
|
|
833
|
+
child.stderr.on("data", (d) => {
|
|
834
|
+
stderr += d.toString();
|
|
835
|
+
});
|
|
836
|
+
child.on("close", (code) => resolve({ ok: code === 0, stderr }));
|
|
837
|
+
child.stdin.write(html);
|
|
838
|
+
child.stdin.end();
|
|
839
|
+
});
|
|
840
|
+
if (!upload.ok) {
|
|
841
|
+
s.stop(red(`Upload failed: ${upload.stderr.trim()}`));
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
s.stop(green(`Welcome page live at https://${domain}/`));
|
|
845
|
+
markStepDone(installId, stepName, { bucket: bucketName });
|
|
846
|
+
}
|
|
847
|
+
async function stepFinalize(installId) {
|
|
848
|
+
const meta = readMetadata(installId);
|
|
849
|
+
const progress = readSecret(installId, "pulumi-passphrase"); // touch to verify state still present
|
|
850
|
+
void progress;
|
|
851
|
+
if (!meta)
|
|
852
|
+
return;
|
|
853
|
+
const upStep = `pulumi-up-${meta.projectId}`;
|
|
854
|
+
const upPayload = (await import("../install-state.js")).getStepPayload(installId, upStep);
|
|
855
|
+
const outputs = upPayload?.outputs ?? {};
|
|
856
|
+
const lbIp = String(outputs.lbIpOut ?? "");
|
|
857
|
+
const bootstrapUrl = outputs.bootstrapUrlOut ?? "<unknown>";
|
|
858
|
+
const adminDomainOut = String(outputs.adminDomainOut ?? `admin.${meta.domain}`);
|
|
859
|
+
// Auto-create DNS via Cloudflare if CLOUDFLARE_API_TOKEN is set;
|
|
860
|
+
// otherwise the manual adapter prints + verify-polls.
|
|
861
|
+
if (lbIp.length > 0 && lbIp !== "<unknown>") {
|
|
862
|
+
const stepName = `dns-${meta.projectId}`;
|
|
863
|
+
if (!isStepDone(installId, stepName)) {
|
|
864
|
+
const adapter = await pickDnsAdapter({ domain: meta.domain });
|
|
865
|
+
log.info(`DNS adapter: ${bold(adapter.name)}`);
|
|
866
|
+
try {
|
|
867
|
+
await adapter.applyRecords([
|
|
868
|
+
{ hostname: meta.domain, type: "A", value: lbIp },
|
|
869
|
+
{ hostname: adminDomainOut, type: "CNAME", value: "ghs.googlehosted.com." },
|
|
870
|
+
]);
|
|
871
|
+
markStepDone(installId, stepName, { adapter: adapter.name });
|
|
872
|
+
}
|
|
873
|
+
catch (e) {
|
|
874
|
+
log.warn(yellow(`DNS auto-create did not fully succeed: ${e instanceof Error ? e.message : String(e)}`));
|
|
875
|
+
log.warn(`Verify your DNS records manually + re-run ${bold("bunx @caelo-cms/provisioning")} to resume.`);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
note([
|
|
880
|
+
green(`✓ ${meta.domain} provisioned.`),
|
|
881
|
+
"",
|
|
882
|
+
bold("Owner setup (open in your browser):"),
|
|
883
|
+
` ${cyan(String(bootstrapUrl))}`,
|
|
884
|
+
"",
|
|
885
|
+
bold("Lifecycle commands:"),
|
|
886
|
+
` ${dim("bunx @caelo-cms/provisioning status")} — health check + monthly cost`,
|
|
887
|
+
` ${dim("bunx @caelo-cms/provisioning upgrade")} — pull latest images + roll Cloud Run`,
|
|
888
|
+
` ${dim("bunx @caelo-cms/provisioning destroy")} — tear everything down (irreversible)`,
|
|
889
|
+
].join("\n"), "Done");
|
|
890
|
+
void homedir; // unused-import guard
|
|
891
|
+
void readFileSync;
|
|
892
|
+
}
|
|
893
|
+
// kleur unused-import guard for the yellow color helper kept for future warnings.
|
|
894
|
+
void yellow;
|
|
895
|
+
//# sourceMappingURL=gcp.js.map
|