@caelo-cms/provisioning 0.10.29 → 0.10.31
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/admin-domain.d.ts +54 -0
- package/dist/admin-domain.d.ts.map +1 -0
- package/dist/admin-domain.js +122 -0
- package/dist/admin-domain.js.map +1 -0
- package/dist/cli.js +71 -7
- package/dist/cli.js.map +1 -1
- package/dist/compose.d.ts +7 -9
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +17 -1
- package/dist/compose.js.map +1 -1
- package/dist/deployed-release.d.ts +84 -0
- package/dist/deployed-release.d.ts.map +1 -0
- package/dist/deployed-release.js +182 -0
- package/dist/deployed-release.js.map +1 -0
- package/dist/gcloud-retry.d.ts +16 -0
- package/dist/gcloud-retry.d.ts.map +1 -0
- package/dist/gcloud-retry.js +17 -0
- package/dist/gcloud-retry.js.map +1 -0
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +2 -0
- package/dist/gcloud.js.map +1 -1
- package/dist/gcp-names.d.ts +58 -0
- package/dist/gcp-names.d.ts.map +1 -0
- package/dist/gcp-names.js +88 -0
- package/dist/gcp-names.js.map +1 -0
- package/dist/install-state.d.ts +31 -0
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +38 -0
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts +5 -0
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +340 -50
- package/dist/lifecycle.js.map +1 -1
- package/dist/mcp-iap.d.ts +5 -26
- package/dist/mcp-iap.d.ts.map +1 -1
- package/dist/mcp-iap.js +6 -18
- package/dist/mcp-iap.js.map +1 -1
- package/dist/migration-runner.d.ts +25 -0
- package/dist/migration-runner.d.ts.map +1 -1
- package/dist/migration-runner.js +60 -19
- package/dist/migration-runner.js.map +1 -1
- package/dist/operator-access.d.ts +83 -0
- package/dist/operator-access.d.ts.map +1 -0
- package/dist/operator-access.js +562 -0
- package/dist/operator-access.js.map +1 -0
- package/dist/region-data.d.ts +30 -0
- package/dist/region-data.d.ts.map +1 -0
- package/dist/region-data.js +406 -0
- package/dist/region-data.js.map +1 -0
- package/dist/regions.d.ts +130 -0
- package/dist/regions.d.ts.map +1 -0
- package/dist/regions.js +247 -0
- package/dist/regions.js.map +1 -0
- package/dist/runtime-secrets.d.ts +128 -0
- package/dist/runtime-secrets.d.ts.map +1 -0
- package/dist/runtime-secrets.js +444 -0
- package/dist/runtime-secrets.js.map +1 -0
- package/dist/stack-contract.d.ts +231 -0
- package/dist/stack-contract.d.ts.map +1 -0
- package/dist/stack-contract.js +455 -0
- package/dist/stack-contract.js.map +1 -0
- package/dist/stack-converge.d.ts +183 -0
- package/dist/stack-converge.d.ts.map +1 -0
- package/dist/stack-converge.js +513 -0
- package/dist/stack-converge.js.map +1 -0
- package/dist/wizard.d.ts +7 -0
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +11 -1
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp-cost.d.ts +10 -10
- package/dist/wizards/gcp-cost.d.ts.map +1 -1
- package/dist/wizards/gcp-cost.js +40 -3
- package/dist/wizards/gcp-cost.js.map +1 -1
- package/dist/wizards/gcp-firebase.d.ts +2 -0
- package/dist/wizards/gcp-firebase.d.ts.map +1 -1
- package/dist/wizards/gcp-firebase.js +1 -0
- package/dist/wizards/gcp-firebase.js.map +1 -1
- package/dist/wizards/gcp-pulumi.d.ts +51 -1
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +89 -6
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts +2 -0
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +171 -23
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +3 -3
- package/stacks/aws/Pulumi.yaml +5 -2
- package/stacks/aws/README.md +3 -1
- package/stacks/aws/index.ts +21 -1
- package/stacks/azure/Pulumi.yaml +6 -3
- package/stacks/azure/README.md +1 -1
- package/stacks/azure/index.ts +22 -3
- package/stacks/gcp/Pulumi.yaml +5 -5
- package/stacks/gcp/README.md +15 -3
- package/stacks/gcp/index.ts +204 -141
- package/stacks/gcp-firebase/Pulumi.yaml +5 -2
- package/stacks/gcp-firebase/index.ts +169 -76
- package/stacks/self-hosted/Pulumi.yaml +4 -0
- package/stacks/self-hosted/index.ts +3 -0
package/stacks/gcp/index.ts
CHANGED
|
@@ -22,12 +22,34 @@ import * as pulumi from "@pulumi/pulumi";
|
|
|
22
22
|
import * as random from "@pulumi/random";
|
|
23
23
|
import type { CloudAdapterOutputs, DnsRecord } from "../../dist/adapter.js";
|
|
24
24
|
import { generateBootstrapToken } from "../../dist/bootstrap-token.js";
|
|
25
|
+
import {
|
|
26
|
+
gatewayServiceAccountId,
|
|
27
|
+
gcpBucketName,
|
|
28
|
+
gcpSecretId,
|
|
29
|
+
MCP_IAP_SERVICE_ACCOUNT_ID,
|
|
30
|
+
runServiceAccountId,
|
|
31
|
+
staticPublisherServiceAccountId,
|
|
32
|
+
} from "../../dist/gcp-names.js";
|
|
33
|
+
import {
|
|
34
|
+
ADMIN_MEMORY_DEFAULT,
|
|
35
|
+
adminEnvContract,
|
|
36
|
+
type CloudRunEnvVar,
|
|
37
|
+
type CloudRunSlug,
|
|
38
|
+
databaseUrls,
|
|
39
|
+
gatewayEnvContract,
|
|
40
|
+
type RuntimeSecret,
|
|
41
|
+
runtimeSecretBindings,
|
|
42
|
+
STATIC_CDN_POLICY,
|
|
43
|
+
} from "../../dist/stack-contract.js";
|
|
25
44
|
|
|
26
45
|
const cfg = new pulumi.Config();
|
|
27
46
|
const domain = cfg.require("domain");
|
|
28
47
|
const ownerEmail = cfg.require("ownerEmail");
|
|
29
48
|
const project = cfg.require("project");
|
|
30
|
-
|
|
49
|
+
// #607 — chosen by the operator in the wizard and fixed after install; no
|
|
50
|
+
// default, so a stack run without it fails instead of landing in a region
|
|
51
|
+
// nobody picked.
|
|
52
|
+
const region = cfg.require("region");
|
|
31
53
|
|
|
32
54
|
// Where Secret Manager keeps the secret payloads. Fixed at create time —
|
|
33
55
|
// changing it replaces every secret — so the wizard pins it per stack:
|
|
@@ -57,6 +79,9 @@ const cloudSqlEdition = cfg.get("cloudSqlEdition") ?? "ENTERPRISE";
|
|
|
57
79
|
// wants to `caelo-cms destroy` shouldn't hit a wall).
|
|
58
80
|
const deletionProtection = cfg.getBoolean("deletionProtection") ?? false;
|
|
59
81
|
const adminMinInstances = Number.parseInt(cfg.get("adminMinInstances") ?? "0", 10);
|
|
82
|
+
// #553 — the admin also runs the Lighthouse quality audit; same knob and
|
|
83
|
+
// default on every adapter (stack-contract.ts ADMIN_MEMORY_DEFAULT).
|
|
84
|
+
const adminMemory = cfg.get("adminMemory") ?? ADMIN_MEMORY_DEFAULT;
|
|
60
85
|
// adminMaxInstances default is intentionally low. Each admin process holds
|
|
61
86
|
// TWO Bun SQL pools (admin_role on cms_admin + admin_role on cms_public),
|
|
62
87
|
// each with default pool-max=10 → up to ~20 connections per instance under
|
|
@@ -140,7 +165,7 @@ const sqlPeering = new gcp.servicenetworking.Connection(
|
|
|
140
165
|
);
|
|
141
166
|
|
|
142
167
|
// =========================================================================
|
|
143
|
-
// Secret Manager — never env-var literal;
|
|
168
|
+
// Secret Manager — never env-var literal; Cloud Run reads each via secretKeyRef
|
|
144
169
|
// =========================================================================
|
|
145
170
|
|
|
146
171
|
// v0.2.81 — secrets MUST be generated by a Pulumi resource that
|
|
@@ -160,15 +185,9 @@ const sqlPeering = new gcp.servicenetworking.Connection(
|
|
|
160
185
|
// one — don't pass `opts` (which carries gcpProvider). Pulumi
|
|
161
186
|
// auto-loads the random provider on first reference.
|
|
162
187
|
const pgPasswordBytes = new random.RandomBytes(`${namePrefix}-pg-password-bytes`, { length: 32 });
|
|
163
|
-
const csrfSecretBytes = new random.RandomBytes(`${namePrefix}-csrf-secret-bytes`, { length: 32 });
|
|
164
|
-
const cookieSecretBytes = new random.RandomBytes(`${namePrefix}-cookie-secret-bytes`, {
|
|
165
|
-
length: 32,
|
|
166
|
-
});
|
|
167
188
|
const kekBytes = new random.RandomBytes(`${namePrefix}-secret-kek-bytes`, { length: 32 });
|
|
168
189
|
|
|
169
190
|
const postgresPassword = pulumi.secret(pgPasswordBytes.hex);
|
|
170
|
-
const csrfSecret = pulumi.secret(csrfSecretBytes.hex);
|
|
171
|
-
const cookieSecret = pulumi.secret(cookieSecretBytes.hex);
|
|
172
191
|
// P18 — project KEK (32 bytes hex) encrypts AI provider API keys
|
|
173
192
|
// + future at-rest secrets in cms_admin. Stable across `pulumi up`
|
|
174
193
|
// (see comment above). Cloud Run binds it as CAELO_SECRET_KEK env
|
|
@@ -181,25 +200,30 @@ const caeloSecretKek = pulumi.secret(kekBytes.hex);
|
|
|
181
200
|
// Secret + SecretVersion, but the Cloud Run service never read it
|
|
182
201
|
// (no env-var mount existed). Dead code path removed.
|
|
183
202
|
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
203
|
+
// csrf-secret, cookie-secret and resend-api-key were dropped for the same
|
|
204
|
+
// reason: CSRF secrets are per session (in cms_admin), the gateway's cookie
|
|
205
|
+
// secret lives in site_settings, and the Resend key in the email config —
|
|
206
|
+
// no service ever read the three Secret Manager secrets. `pulumi up` deletes
|
|
207
|
+
// them; `cms-provision upgrade` leaves them in place (nothing reads them).
|
|
208
|
+
//
|
|
209
|
+
// internal-secret (CAELO_INTERNAL_SECRET) and tool-approval-secret
|
|
210
|
+
// (CAELO_TOOL_APPROVAL_SECRET) are generated by the CLI, not here
|
|
211
|
+
// (src/runtime-secrets.ts, stack-contract.ts CLI_GENERATED_SECRETS): the
|
|
212
|
+
// wizard creates them before `pulumi up`, `upgrade` on installs that predate
|
|
213
|
+
// them. This program only references them by id, so a plain `pulumi up`
|
|
214
|
+
// needs them to exist (run the wizard or `cms-provision upgrade` once).
|
|
188
215
|
|
|
189
216
|
interface MadeSecret {
|
|
190
217
|
resource: gcp.secretmanager.Secret;
|
|
191
|
-
version: gcp.secretmanager.SecretVersion
|
|
218
|
+
version: gcp.secretmanager.SecretVersion;
|
|
192
219
|
}
|
|
193
220
|
|
|
194
|
-
function makeSecret(name: string, value: pulumi.Output<string>
|
|
221
|
+
function makeSecret(name: string, value: pulumi.Output<string>): MadeSecret {
|
|
195
222
|
const resource = new gcp.secretmanager.Secret(
|
|
196
223
|
`${namePrefix}-${name}`,
|
|
197
|
-
{ secretId:
|
|
224
|
+
{ secretId: gcpSecretId(env, name), replication: secretReplication },
|
|
198
225
|
opts,
|
|
199
226
|
);
|
|
200
|
-
if (value === null) {
|
|
201
|
-
return { resource, version: null };
|
|
202
|
-
}
|
|
203
227
|
const version = new gcp.secretmanager.SecretVersion(
|
|
204
228
|
`${namePrefix}-${name}-v1`,
|
|
205
229
|
{ secret: resource.name, secretData: value },
|
|
@@ -209,17 +233,17 @@ function makeSecret(name: string, value: pulumi.Output<string> | null): MadeSecr
|
|
|
209
233
|
}
|
|
210
234
|
|
|
211
235
|
const pgSecret = makeSecret("postgres-password", postgresPassword);
|
|
212
|
-
const csrfSecretRes = makeSecret("csrf-secret", csrfSecret);
|
|
213
|
-
const cookieSecretRes = makeSecret("cookie-secret", cookieSecret);
|
|
214
236
|
const kekSecret = makeSecret("secret-kek", caeloSecretKek);
|
|
215
|
-
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
237
|
+
|
|
238
|
+
// The id each runtime secret is bound by: the Pulumi resource where this
|
|
239
|
+
// program owns the secret (so bindings wait for it), the CLI-generated id
|
|
240
|
+
// otherwise.
|
|
241
|
+
const runtimeSecretIds: Record<RuntimeSecret, pulumi.Input<string>> = {
|
|
242
|
+
"postgres-password": pgSecret.resource.secretId,
|
|
243
|
+
"secret-kek": kekSecret.resource.secretId,
|
|
244
|
+
"internal-secret": gcpSecretId(env, "internal-secret"),
|
|
245
|
+
"tool-approval-secret": gcpSecretId(env, "tool-approval-secret"),
|
|
246
|
+
};
|
|
223
247
|
|
|
224
248
|
// =========================================================================
|
|
225
249
|
// Tier 4 — Cloud SQL Postgres (private IP only; HA + retention configurable)
|
|
@@ -242,7 +266,12 @@ const sqlInstance = new gcp.sql.DatabaseInstance(
|
|
|
242
266
|
// pool-max=10, two pools per admin process). Override at
|
|
243
267
|
// instance level so the cap matches the Cloud Run scale-out
|
|
244
268
|
// headroom defined by adminMaxInstances + gatewayMaxInstances.
|
|
245
|
-
databaseFlags: [
|
|
269
|
+
databaseFlags: [
|
|
270
|
+
{ name: "max_connections", value: maxConnections.toString() },
|
|
271
|
+
// The operator-access sync job logs in as its service account (Cloud
|
|
272
|
+
// SQL IAM database authentication, no password). No restart needed.
|
|
273
|
+
{ name: "cloudsql.iam_authentication", value: "on" },
|
|
274
|
+
],
|
|
246
275
|
backupConfiguration: {
|
|
247
276
|
enabled: true,
|
|
248
277
|
pointInTimeRecoveryEnabled: cloudSqlHa,
|
|
@@ -291,21 +320,10 @@ const cmsPublicDb = new gcp.sql.Database(
|
|
|
291
320
|
opts,
|
|
292
321
|
);
|
|
293
322
|
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
// admin_role on cms_public — admin's second pool: cross-DB reads of
|
|
299
|
-
// plugin data + DDL for plugin schemas + migration runs. Wired to the
|
|
300
|
-
// admin (and migration job) via PUBLIC_ADMIN_DATABASE_URL.
|
|
301
|
-
const publicAdminDatabaseUrl = pulumi
|
|
302
|
-
.all([sqlInstance.privateIpAddress, postgresPassword])
|
|
303
|
-
.apply(([host, pw]) => `postgresql://admin_role:${pw}@${host}:5432/cms_public?sslmode=require`);
|
|
304
|
-
// public_role on cms_public — write-limited gateway role. Wired to the
|
|
305
|
-
// gateway via PUBLIC_DATABASE_URL.
|
|
306
|
-
const publicDatabaseUrl = pulumi
|
|
307
|
-
.all([sqlInstance.privateIpAddress, postgresPassword])
|
|
308
|
-
.apply(([host, pw]) => `postgresql://public_role:${pw}@${host}:5432/cms_public?sslmode=require`);
|
|
323
|
+
// Password-less connection URLs (stack-contract.ts databaseUrls); the
|
|
324
|
+
// services get the password as a Secret Manager reference
|
|
325
|
+
// (SERVICE_SECRET_ENV), so no revision ever carries it.
|
|
326
|
+
const dbUrls = sqlInstance.privateIpAddress.apply(databaseUrls);
|
|
309
327
|
|
|
310
328
|
// Reference pgPublicUser so Pulumi knows it's part of the dependency
|
|
311
329
|
// graph (Cloud Run env-var deps don't see this directly).
|
|
@@ -318,7 +336,7 @@ void pgPublicUser;
|
|
|
318
336
|
const mediaBucket = new gcp.storage.Bucket(
|
|
319
337
|
`${namePrefix}-media`,
|
|
320
338
|
{
|
|
321
|
-
name:
|
|
339
|
+
name: gcpBucketName(project, env, "media"),
|
|
322
340
|
location: region.toUpperCase(),
|
|
323
341
|
uniformBucketLevelAccess: true,
|
|
324
342
|
forceDestroy: env !== "production",
|
|
@@ -330,7 +348,7 @@ const mediaBucket = new gcp.storage.Bucket(
|
|
|
330
348
|
const staticBucket = new gcp.storage.Bucket(
|
|
331
349
|
`${namePrefix}-static`,
|
|
332
350
|
{
|
|
333
|
-
name:
|
|
351
|
+
name: gcpBucketName(project, env, "static"),
|
|
334
352
|
location: region.toUpperCase(),
|
|
335
353
|
uniformBucketLevelAccess: true,
|
|
336
354
|
forceDestroy: env !== "production",
|
|
@@ -366,7 +384,7 @@ new gcp.storage.BucketIAMMember(
|
|
|
366
384
|
const stagingBucket = new gcp.storage.Bucket(
|
|
367
385
|
`${namePrefix}-staging`,
|
|
368
386
|
{
|
|
369
|
-
name:
|
|
387
|
+
name: gcpBucketName(project, env, "staging"),
|
|
370
388
|
location: region.toUpperCase(),
|
|
371
389
|
uniformBucketLevelAccess: true,
|
|
372
390
|
forceDestroy: env !== "production",
|
|
@@ -384,32 +402,48 @@ const stagingBucket = new gcp.storage.Bucket(
|
|
|
384
402
|
const runSa = new gcp.serviceaccount.Account(
|
|
385
403
|
`${namePrefix}-run-sa`,
|
|
386
404
|
{
|
|
387
|
-
accountId:
|
|
405
|
+
accountId: runServiceAccountId(env),
|
|
388
406
|
displayName: `Caelo ${env} Cloud Run service account`,
|
|
389
407
|
},
|
|
390
408
|
opts,
|
|
391
409
|
);
|
|
392
410
|
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
{
|
|
399
|
-
{
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
411
|
+
// The gateway runs as its own SA so it can read only the secrets it needs
|
|
412
|
+
// (stack-contract.ts SERVICE_SECRET_ENV) — never the KEK or the admin-only
|
|
413
|
+
// secrets. `cms-provision upgrade` creates it on older installs, hence
|
|
414
|
+
// createIgnoreAlreadyExists.
|
|
415
|
+
const gatewaySa = new gcp.serviceaccount.Account(
|
|
416
|
+
`${namePrefix}-gateway-sa`,
|
|
417
|
+
{
|
|
418
|
+
accountId: gatewayServiceAccountId(env),
|
|
419
|
+
displayName: `Caelo ${env} gateway`,
|
|
420
|
+
createIgnoreAlreadyExists: true,
|
|
421
|
+
},
|
|
422
|
+
opts,
|
|
423
|
+
);
|
|
424
|
+
const serviceSa: Record<CloudRunSlug, gcp.serviceaccount.Account> = {
|
|
425
|
+
admin: runSa,
|
|
426
|
+
gateway: gatewaySa,
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
// secretAccessor for each service's SA on exactly the secrets its env reads.
|
|
430
|
+
// Cloud Run refuses to deploy a revision whose SA can't read a referenced
|
|
431
|
+
// secret, so each service waits for its bindings (secretBindings below).
|
|
432
|
+
const secretBindings: Record<CloudRunSlug, gcp.secretmanager.SecretIamMember[]> = {
|
|
433
|
+
admin: [],
|
|
434
|
+
gateway: [],
|
|
435
|
+
};
|
|
436
|
+
for (const binding of runtimeSecretBindings()) {
|
|
437
|
+
secretBindings[binding.service].push(
|
|
438
|
+
new gcp.secretmanager.SecretIamMember(
|
|
439
|
+
`${namePrefix}-${binding.stackResource}`,
|
|
440
|
+
{
|
|
441
|
+
secretId: runtimeSecretIds[binding.secret],
|
|
442
|
+
role: "roles/secretmanager.secretAccessor",
|
|
443
|
+
member: pulumi.interpolate`serviceAccount:${serviceSa[binding.service].email}`,
|
|
444
|
+
},
|
|
445
|
+
opts,
|
|
446
|
+
),
|
|
413
447
|
);
|
|
414
448
|
}
|
|
415
449
|
|
|
@@ -455,6 +489,49 @@ new gcp.storage.BucketIAMMember(
|
|
|
455
489
|
opts,
|
|
456
490
|
);
|
|
457
491
|
|
|
492
|
+
// Cloud Run telemetry IAM (the gcp-firebase stack has had these since
|
|
493
|
+
// v0.6.6). A Cloud Run service running as a CUSTOM SA gets no telemetry
|
|
494
|
+
// roles implicitly — only the default Compute SA does:
|
|
495
|
+
// - roles/logging.logWriter: container stdout → Cloud Logging
|
|
496
|
+
// - roles/monitoring.metricWriter: CPU/memory metrics → autoscaler
|
|
497
|
+
// Project-scoped (telemetry endpoints are project-level resources).
|
|
498
|
+
new gcp.projects.IAMMember(
|
|
499
|
+
`${namePrefix}-run-log-writer`,
|
|
500
|
+
{
|
|
501
|
+
project,
|
|
502
|
+
role: "roles/logging.logWriter",
|
|
503
|
+
member: pulumi.interpolate`serviceAccount:${runSa.email}`,
|
|
504
|
+
},
|
|
505
|
+
opts,
|
|
506
|
+
);
|
|
507
|
+
new gcp.projects.IAMMember(
|
|
508
|
+
`${namePrefix}-run-metric-writer`,
|
|
509
|
+
{
|
|
510
|
+
project,
|
|
511
|
+
role: "roles/monitoring.metricWriter",
|
|
512
|
+
member: pulumi.interpolate`serviceAccount:${runSa.email}`,
|
|
513
|
+
},
|
|
514
|
+
opts,
|
|
515
|
+
);
|
|
516
|
+
new gcp.projects.IAMMember(
|
|
517
|
+
`${namePrefix}-gateway-log-writer`,
|
|
518
|
+
{
|
|
519
|
+
project,
|
|
520
|
+
role: "roles/logging.logWriter",
|
|
521
|
+
member: pulumi.interpolate`serviceAccount:${gatewaySa.email}`,
|
|
522
|
+
},
|
|
523
|
+
opts,
|
|
524
|
+
);
|
|
525
|
+
new gcp.projects.IAMMember(
|
|
526
|
+
`${namePrefix}-gateway-metric-writer`,
|
|
527
|
+
{
|
|
528
|
+
project,
|
|
529
|
+
role: "roles/monitoring.metricWriter",
|
|
530
|
+
member: pulumi.interpolate`serviceAccount:${gatewaySa.email}`,
|
|
531
|
+
},
|
|
532
|
+
opts,
|
|
533
|
+
);
|
|
534
|
+
|
|
458
535
|
// `static-publisher` SA used by `bunx @caelo-cms/provisioning deploy` to
|
|
459
536
|
// upload the static-generator output to the bucket. Pre-v0.2.78 this
|
|
460
537
|
// was the only path; admin Cloud Run now does the publish in-process
|
|
@@ -462,11 +539,10 @@ new gcp.storage.BucketIAMMember(
|
|
|
462
539
|
// a manual gsutil rsync from CI as a backup or for bulk re-uploads.
|
|
463
540
|
// Account-id max 30 chars; collapse "production" → "prod" so dev/staging/prod
|
|
464
541
|
// all fit within the budget.
|
|
465
|
-
const envShort = env === "production" ? "prod" : env === "staging" ? "stg" : "dev";
|
|
466
542
|
const staticPublisherSa = new gcp.serviceaccount.Account(
|
|
467
543
|
`${namePrefix}-static-publisher`,
|
|
468
544
|
{
|
|
469
|
-
accountId:
|
|
545
|
+
accountId: staticPublisherServiceAccountId(env),
|
|
470
546
|
displayName: `Caelo ${env} static publisher`,
|
|
471
547
|
},
|
|
472
548
|
opts,
|
|
@@ -518,7 +594,7 @@ function imageTag(service: string): pulumi.Output<string> {
|
|
|
518
594
|
}
|
|
519
595
|
|
|
520
596
|
interface CloudRunArgs {
|
|
521
|
-
readonly serviceName:
|
|
597
|
+
readonly serviceName: "admin" | "gateway";
|
|
522
598
|
readonly minInstances: number;
|
|
523
599
|
readonly maxInstances: number;
|
|
524
600
|
readonly memory: string;
|
|
@@ -530,9 +606,25 @@ interface CloudRunArgs {
|
|
|
530
606
|
* legitimately need longer.
|
|
531
607
|
*/
|
|
532
608
|
readonly timeout?: string;
|
|
533
|
-
|
|
609
|
+
/**
|
|
610
|
+
* The service's complete env — plain values and Secret Manager
|
|
611
|
+
* references: its env contract (src/stack-contract.ts).
|
|
612
|
+
*/
|
|
613
|
+
readonly contractEnv: ReadonlyArray<CloudRunEnvVar<pulumi.Input<string>>>;
|
|
534
614
|
}
|
|
535
615
|
|
|
616
|
+
// Inputs of the admin + gateway env contracts. The contracts are shared with
|
|
617
|
+
// `cms-provision upgrade`, which applies them to installs it rolls — add new
|
|
618
|
+
// env vars and secrets there, not here.
|
|
619
|
+
const envContractInputs = {
|
|
620
|
+
provider: "gcp",
|
|
621
|
+
projectId: project,
|
|
622
|
+
env,
|
|
623
|
+
domain,
|
|
624
|
+
region,
|
|
625
|
+
databaseUrls: { admin: dbUrls.admin, publicAdmin: dbUrls.publicAdmin, public: dbUrls.public },
|
|
626
|
+
} as const;
|
|
627
|
+
|
|
536
628
|
function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
|
|
537
629
|
return new gcp.cloudrunv2.Service(
|
|
538
630
|
`${namePrefix}-${args.serviceName}`,
|
|
@@ -553,7 +645,7 @@ function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
|
|
|
553
645
|
// config knob to true.
|
|
554
646
|
deletionProtection,
|
|
555
647
|
template: {
|
|
556
|
-
serviceAccount:
|
|
648
|
+
serviceAccount: serviceSa[args.serviceName].email,
|
|
557
649
|
scaling: {
|
|
558
650
|
minInstanceCount: args.minInstances,
|
|
559
651
|
maxInstanceCount: args.maxInstances,
|
|
@@ -562,24 +654,7 @@ function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
|
|
|
562
654
|
containers: [
|
|
563
655
|
{
|
|
564
656
|
image: imageTag(args.serviceName),
|
|
565
|
-
envs: [
|
|
566
|
-
{ name: "CAELO_PROVIDER", value: "gcp" },
|
|
567
|
-
{ name: "CAELO_ENV", value: env },
|
|
568
|
-
{ name: "ADMIN_DATABASE_URL", value: adminDatabaseUrl },
|
|
569
|
-
{ name: "MEDIA_STORAGE_URL", value: pulumi.interpolate`gs://${mediaBucket.name}` },
|
|
570
|
-
// P18 — project KEK is mounted from Secret Manager so AI
|
|
571
|
-
// provider keys (and any future at-rest secrets) can be
|
|
572
|
-
// decrypted at runtime. Failing to bind would surface as
|
|
573
|
-
// "CAELO_SECRET_KEK is not set" the moment the resolver
|
|
574
|
-
// tries to decrypt — clear, loud failure.
|
|
575
|
-
{
|
|
576
|
-
name: "CAELO_SECRET_KEK",
|
|
577
|
-
valueSource: {
|
|
578
|
-
secretKeyRef: { secret: kekSecret.resource.secretId, version: "latest" },
|
|
579
|
-
},
|
|
580
|
-
},
|
|
581
|
-
...(args.extraEnv ?? []),
|
|
582
|
-
],
|
|
657
|
+
envs: [...args.contractEnv],
|
|
583
658
|
resources: { limits: { cpu: "1", memory: args.memory } },
|
|
584
659
|
},
|
|
585
660
|
],
|
|
@@ -595,22 +670,26 @@ function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
|
|
|
595
670
|
pgAdminUser,
|
|
596
671
|
cmsAdminDb,
|
|
597
672
|
cmsPublicDb,
|
|
598
|
-
|
|
599
|
-
//
|
|
600
|
-
|
|
673
|
+
// The env contract names the buckets as strings; keep them ordered
|
|
674
|
+
// before the services that use them.
|
|
675
|
+
mediaBucket,
|
|
676
|
+
staticBucket,
|
|
677
|
+
stagingBucket,
|
|
678
|
+
// Referenced secrets must have a version, and the service's SA
|
|
679
|
+
// must be able to read them, before Cloud Run deploys a revision.
|
|
680
|
+
pgSecret.version,
|
|
681
|
+
kekSecret.version,
|
|
682
|
+
...secretBindings[args.serviceName],
|
|
601
683
|
],
|
|
602
684
|
},
|
|
603
685
|
);
|
|
604
686
|
}
|
|
605
687
|
|
|
606
|
-
// Issue #37 — deterministic, so the admin env can carry it as a plain string.
|
|
607
|
-
const mcpServiceAccountEmail = `caelo-mcp@${project}.iam.gserviceaccount.com`;
|
|
608
|
-
|
|
609
688
|
const adminSvc = cloudRunService({
|
|
610
689
|
serviceName: "admin",
|
|
611
690
|
minInstances: adminMinInstances,
|
|
612
691
|
maxInstances: adminMaxInstances,
|
|
613
|
-
memory:
|
|
692
|
+
memory: adminMemory,
|
|
614
693
|
// 1 hour — covers the longest realistic SSE chat session (multi-tool
|
|
615
694
|
// compose-page runs that orchestrate 10+ tool calls). Cloud Run's
|
|
616
695
|
// 300s default truncated mid-stream and surfaced to operators as
|
|
@@ -618,36 +697,17 @@ const adminSvc = cloudRunService({
|
|
|
618
697
|
// in Cloud Run logs. 3600s is the platform max; no cost penalty for
|
|
619
698
|
// unused timeout (Cloud Run bills running time, not allocated time).
|
|
620
699
|
timeout: "3600s",
|
|
621
|
-
|
|
622
|
-
// + DDL + migrations. NOT public_role (write-limited).
|
|
623
|
-
// v0.2.78 — CAELO_STATIC_BUCKET / CAELO_STAGING_BUCKET drive the
|
|
624
|
-
// GCS StaticPublisher (Stage uploads to staging, Confirm-publish
|
|
625
|
-
// copies to static). CAELO_GENERATOR_CLI points at the absolute
|
|
626
|
-
// path the Dockerfile shipped the static-generator source to —
|
|
627
|
-
// the cwd-walk fallback in resolveGeneratorCli landed at
|
|
628
|
-
// /app/apps/apps/static-generator/... (doubled apps/) before
|
|
629
|
-
// v0.2.78 because the SSR bundle's import.meta.dirname doesn't
|
|
630
|
-
// sit where the walk expects.
|
|
631
|
-
extraEnv: [
|
|
632
|
-
{ name: "PUBLIC_ADMIN_DATABASE_URL", value: publicAdminDatabaseUrl },
|
|
633
|
-
// #551 — the public site URL; the admin seeds site_defaults.site_base_url
|
|
634
|
-
// from it (canonical, og:url, sitemap) when it is not configured yet.
|
|
635
|
-
{ name: "CAELO_SITE_URL", value: `https://${domain}` },
|
|
636
|
-
{ name: "CAELO_STATIC_BUCKET", value: staticBucket.name },
|
|
637
|
-
{ name: "CAELO_STAGING_BUCKET", value: stagingBucket.name },
|
|
638
|
-
{ name: "CAELO_GENERATOR_CLI", value: "/app/apps/static-generator/src/cli.ts" },
|
|
639
|
-
// Issue #37 — shown in the /security/mcp `claude mcp add` command.
|
|
640
|
-
{ name: "CAELO_MCP_IAP_SERVICE_ACCOUNT", value: mcpServiceAccountEmail },
|
|
641
|
-
],
|
|
700
|
+
contractEnv: adminEnvContract(envContractInputs),
|
|
642
701
|
});
|
|
643
702
|
const gatewaySvc = cloudRunService({
|
|
644
703
|
serviceName: "gateway",
|
|
645
704
|
minInstances: gatewayMinInstances,
|
|
646
705
|
maxInstances: 100,
|
|
647
706
|
memory: "512Mi",
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
|
|
707
|
+
// public_role on cms_public for visitor writes — plus, for now, an
|
|
708
|
+
// admin_role pool (see the known gap on stack-contract.ts
|
|
709
|
+
// SERVICE_SECRET_ENV.gateway).
|
|
710
|
+
contractEnv: gatewayEnvContract(envContractInputs),
|
|
651
711
|
});
|
|
652
712
|
|
|
653
713
|
// =========================================================================
|
|
@@ -725,27 +785,15 @@ const wafPolicy = new gcp.compute.SecurityPolicy(
|
|
|
725
785
|
opts,
|
|
726
786
|
);
|
|
727
787
|
|
|
728
|
-
// Tier 1 backend — static GCS bucket via Cloud CDN
|
|
729
|
-
//
|
|
730
|
-
//
|
|
731
|
-
// (static-publisher-gcs.ts): content-hashed fonts / plugin bundles are
|
|
732
|
-
// `max-age=31536000, immutable`, pages `max-age=60` + SWR. In
|
|
733
|
-
// CACHE_ALL_STATIC mode `clientTtl` CLAMPS the max-age sent to
|
|
734
|
-
// browsers and `maxTtl` caps the edge TTL, so both must allow a year
|
|
735
|
-
// or the immutable policy is silently cut back to the old 1h. Objects
|
|
736
|
-
// that state a shorter max-age keep it; `defaultTtl` still applies only
|
|
737
|
-
// to responses without one.
|
|
788
|
+
// Tier 1 backend — static GCS bucket via Cloud CDN. The CDN policy (one-year
|
|
789
|
+
// client/edge TTL so immutable assets stay immutable, #555) is shared with
|
|
790
|
+
// `cms-provision upgrade`, which applies it to existing installs.
|
|
738
791
|
const staticBackendBucket = new gcp.compute.BackendBucket(
|
|
739
792
|
`${namePrefix}-static-backend`,
|
|
740
793
|
{
|
|
741
794
|
bucketName: staticBucket.name,
|
|
742
795
|
enableCdn: true,
|
|
743
|
-
cdnPolicy: {
|
|
744
|
-
cacheMode: "CACHE_ALL_STATIC",
|
|
745
|
-
defaultTtl: 3600,
|
|
746
|
-
maxTtl: 31536000,
|
|
747
|
-
clientTtl: 31536000,
|
|
748
|
-
},
|
|
796
|
+
cdnPolicy: { ...STATIC_CDN_POLICY },
|
|
749
797
|
},
|
|
750
798
|
opts,
|
|
751
799
|
);
|
|
@@ -856,7 +904,7 @@ const mcpServiceAccount = new gcp.serviceaccount.Account(
|
|
|
856
904
|
`${namePrefix}-mcp-sa`,
|
|
857
905
|
{
|
|
858
906
|
project,
|
|
859
|
-
accountId:
|
|
907
|
+
accountId: MCP_IAP_SERVICE_ACCOUNT_ID,
|
|
860
908
|
displayName: "Caelo MCP (IAP ingress)",
|
|
861
909
|
description:
|
|
862
910
|
"Signs the IAP credential external MCP clients use to reach the admin (issue #37).",
|
|
@@ -892,6 +940,20 @@ new gcp.iap.WebBackendServiceIamMember(
|
|
|
892
940
|
opts,
|
|
893
941
|
);
|
|
894
942
|
|
|
943
|
+
// =========================================================================
|
|
944
|
+
// Operator access — deliberately NOT here
|
|
945
|
+
// =========================================================================
|
|
946
|
+
//
|
|
947
|
+
// Who passes IAP beyond the allowlist above follows the Caelo user list. A
|
|
948
|
+
// Cloud Run job with its own service account keeps the IAP binding and the
|
|
949
|
+
// caelo-mcp token-creator binding in step (admin-core
|
|
950
|
+
// security/operator-access/sync-job.ts); the admin's run SA holds no IAM
|
|
951
|
+
// policy rights and may only start that job. The CLI owns the job, its
|
|
952
|
+
// account, its grants and its schedule (packages/provisioning/src/
|
|
953
|
+
// operator-access.ts, run by the wizard after migrations and by `upgrade`),
|
|
954
|
+
// because the job's database user can only be assigned its role after the
|
|
955
|
+
// migrations have created that role.
|
|
956
|
+
|
|
895
957
|
// URL map: routes by host header.
|
|
896
958
|
// admin.<domain> → admin backend (IAP-gated)
|
|
897
959
|
// <domain> → default static bucket; /api/* → gateway
|
|
@@ -1034,8 +1096,9 @@ const dnsRecordsRequired: pulumi.Output<DnsRecord[]> = lbIp.address.apply((ip):
|
|
|
1034
1096
|
]);
|
|
1035
1097
|
|
|
1036
1098
|
const out: CloudAdapterOutputs = {
|
|
1037
|
-
|
|
1038
|
-
|
|
1099
|
+
// Password-less — the password lives only in Secret Manager.
|
|
1100
|
+
adminDatabaseUrl: dbUrls.admin as unknown as string,
|
|
1101
|
+
publicDatabaseUrl: dbUrls.public as unknown as string,
|
|
1039
1102
|
mediaStorageUrl: pulumi.interpolate`gs://${mediaBucket.name}` as unknown as string,
|
|
1040
1103
|
mediaCdnBaseUrl: pulumi.interpolate`https://${domain}/media` as unknown as string,
|
|
1041
1104
|
bootstrapUrl:
|
|
@@ -35,8 +35,7 @@ config:
|
|
|
35
35
|
# The 7 cross-provider knobs (subset of gcp's 8 — no Cloud Armor adaptive protection here)
|
|
36
36
|
caelo-gcp-firebase:region:
|
|
37
37
|
type: string
|
|
38
|
-
|
|
39
|
-
description: Primary GCP region (Cloud SQL, Cloud Run).
|
|
38
|
+
description: Primary GCP region (Cloud SQL, Cloud Run). Required, no default; chosen in the wizard and fixed after install (#607).
|
|
40
39
|
caelo-gcp-firebase:cloudSqlTier:
|
|
41
40
|
type: string
|
|
42
41
|
default: db-f1-micro
|
|
@@ -53,6 +52,10 @@ config:
|
|
|
53
52
|
type: string
|
|
54
53
|
default: "0"
|
|
55
54
|
description: Admin Cloud Run min instances.
|
|
55
|
+
caelo-gcp-firebase:adminMemory:
|
|
56
|
+
type: string
|
|
57
|
+
default: "2Gi"
|
|
58
|
+
description: Admin Cloud Run memory. 2Gi leaves room for the Lighthouse quality audit next to editor requests; billed only while an instance runs.
|
|
56
59
|
caelo-gcp-firebase:adminMaxInstances:
|
|
57
60
|
type: string
|
|
58
61
|
default: "5"
|