@caelo-cms/provisioning 0.10.52 → 0.10.55
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/agent/command.js +1 -0
- package/dist/agent/command.js.map +1 -1
- package/dist/agent-db.js +198 -102
- package/dist/audit-job-setup.d.ts +69 -0
- package/dist/audit-job-setup.d.ts.map +1 -0
- package/dist/audit-job-setup.js +317 -0
- package/dist/audit-job-setup.js.map +1 -0
- package/dist/aws-task-roles.d.ts +1 -1
- package/dist/aws-task-roles.d.ts.map +1 -1
- package/dist/aws-task-roles.js +0 -1
- package/dist/aws-task-roles.js.map +1 -1
- package/dist/caddy.d.ts +9 -0
- package/dist/caddy.d.ts.map +1 -1
- package/dist/caddy.js +38 -0
- package/dist/caddy.js.map +1 -1
- package/dist/cli-routing.d.ts +20 -0
- package/dist/cli-routing.d.ts.map +1 -1
- package/dist/cli-routing.js +106 -0
- package/dist/cli-routing.js.map +1 -1
- package/dist/cli.js +37 -14
- package/dist/cli.js.map +1 -1
- package/dist/compose.d.ts +4 -3
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +3 -2
- package/dist/compose.js.map +1 -1
- package/dist/gateway-credentials.d.ts +3 -5
- package/dist/gateway-credentials.d.ts.map +1 -1
- package/dist/gateway-credentials.js +8 -18
- package/dist/gateway-credentials.js.map +1 -1
- package/dist/gcp-names.d.ts +16 -1
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +23 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.d.ts.map +1 -1
- package/dist/infra-contract.js +14 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/private-file.d.ts +26 -0
- package/dist/private-file.d.ts.map +1 -0
- package/dist/private-file.js +49 -0
- package/dist/private-file.js.map +1 -0
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +3 -0
- package/dist/release-contract.js.map +1 -1
- package/dist/runtime-secrets.d.ts +1 -3
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +2 -6
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/self-hosted-roles.d.ts +9 -8
- package/dist/self-hosted-roles.d.ts.map +1 -1
- package/dist/self-hosted-roles.js +9 -9
- package/dist/self-hosted-roles.js.map +1 -1
- package/dist/self-hosted-upgrade.d.ts +7 -1
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +17 -4
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-contract.d.ts +60 -3
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +49 -37
- package/dist/stack-contract.js.map +1 -1
- package/dist/tar-entries.d.ts.map +1 -1
- package/dist/tar-entries.js +84 -9
- package/dist/tar-entries.js.map +1 -1
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +35 -0
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/audit-job-roll.d.ts +38 -0
- package/dist/upgrade/audit-job-roll.d.ts.map +1 -0
- package/dist/upgrade/audit-job-roll.js +43 -0
- package/dist/upgrade/audit-job-roll.js.map +1 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +38 -15
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrader-setup.d.ts.map +1 -1
- package/dist/upgrader-setup.js +9 -3
- package/dist/upgrader-setup.js.map +1 -1
- package/dist/wizards/gcp-cost.js +3 -3
- package/dist/wizards/gcp-cost.js.map +1 -1
- package/dist/wizards/gcp-pulumi.d.ts +6 -0
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +7 -0
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +32 -30
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +4 -4
- package/src/admin-memory.test.ts +1 -1
- package/src/agent/command.ts +1 -0
- package/src/agent/run.test.ts +1 -0
- package/src/audit-job-setup.test.ts +234 -0
- package/src/audit-job-setup.ts +360 -0
- package/src/aws-task-roles.ts +0 -2
- package/src/caddy-redirects.docker.test.ts +19 -6
- package/src/caddy-redirects.test.ts +1 -1
- package/src/caddy.test.ts +30 -0
- package/src/caddy.ts +43 -0
- package/src/checkpoint-secrets.test.ts +0 -2
- package/src/cli-init-validation.test.ts +44 -0
- package/src/cli-routing.test.ts +27 -1
- package/src/cli-routing.ts +131 -0
- package/src/cli.ts +41 -15
- package/src/compose.ts +13 -5
- package/src/gateway-credentials.test.ts +0 -29
- package/src/gateway-credentials.ts +11 -25
- package/src/gateway-env-isolation.test.ts +7 -2
- package/src/gcp-names.ts +28 -1
- package/src/index.test.ts +7 -6
- package/src/infra-contract.test.ts +4 -3
- package/src/infra-contract.ts +14 -1
- package/src/private-file.test.ts +80 -0
- package/src/private-file.ts +50 -0
- package/src/release-contract.ts +3 -0
- package/src/runtime-secrets.test.ts +9 -81
- package/src/runtime-secrets.ts +2 -6
- package/src/self-hosted-compose.test.ts +1 -1
- package/src/self-hosted-roles.integration.test.ts +23 -38
- package/src/self-hosted-roles.test.ts +11 -7
- package/src/self-hosted-roles.ts +17 -16
- package/src/self-hosted-upgrade.test.ts +28 -1
- package/src/self-hosted-upgrade.ts +29 -4
- package/src/stack-contract.test.ts +19 -11
- package/src/stack-contract.ts +54 -5
- package/src/stack-converge-secrets.test.ts +2 -0
- package/src/stack-converge.test.ts +3 -1
- package/src/tar-entries.test.ts +149 -7
- package/src/tar-entries.ts +90 -9
- package/src/upgrade/app-roll.ts +39 -0
- package/src/upgrade/audit-job-roll.ts +68 -0
- package/src/upgrade/converge.ts +47 -20
- package/src/upgrade/upgrade.test.ts +76 -2
- package/src/upgrader-assess.test.ts +1 -0
- package/src/upgrader-job.test.ts +1 -0
- package/src/upgrader-setup.test.ts +1 -0
- package/src/upgrader-setup.ts +9 -2
- package/src/wizards/gcp-cost.ts +3 -3
- package/src/wizards/gcp-pulumi.ts +7 -0
- package/src/wizards/gcp.ts +38 -31
- package/stacks/aws/README.md +1 -1
- package/stacks/aws/index.ts +9 -6
- package/stacks/azure/README.md +1 -1
- package/stacks/azure/index.ts +18 -6
- package/stacks/gcp/README.md +1 -1
- package/stacks/gcp/index.ts +14 -4
- package/stacks/gcp-firebase/index.ts +14 -4
- package/stacks/self-hosted/index.ts +4 -11
package/src/upgrade/app-roll.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
rollService,
|
|
43
43
|
serviceRollArgs,
|
|
44
44
|
} from "../stack-converge.js";
|
|
45
|
+
import { describeAuditJobImage, setAuditJobImage } from "./audit-job-roll.js";
|
|
45
46
|
import { restoreCommand, type SqlBackup } from "./backup.js";
|
|
46
47
|
import {
|
|
47
48
|
describeServiceJson,
|
|
@@ -366,7 +367,17 @@ export async function executeAppRoll(
|
|
|
366
367
|
}
|
|
367
368
|
|
|
368
369
|
const rolled: RollPlan[] = [];
|
|
370
|
+
/** The audit job's image before this roll moved it (#875); null = not moved. */
|
|
371
|
+
let auditJobPriorImage: string | null = null;
|
|
369
372
|
const rollBack = async (alsoCurrent?: RollPlan): Promise<void> => {
|
|
373
|
+
if (auditJobPriorImage) {
|
|
374
|
+
const back = await setAuditJobImage(run, install, auditJobPriorImage);
|
|
375
|
+
if (back.ok) r.info(` audit job image restored to ${auditJobPriorImage.slice(-19)}`);
|
|
376
|
+
else
|
|
377
|
+
r.error(
|
|
378
|
+
` audit job rollback FAILED (${back.error}) — set its image to ${auditJobPriorImage} by hand`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
370
381
|
const services = alsoCurrent ? [...rolled, alsoCurrent] : rolled;
|
|
371
382
|
if (services.length === 0) return;
|
|
372
383
|
r.warn(`Rolling back ${services.length} service(s) to the revisions that served before...`);
|
|
@@ -447,6 +458,34 @@ export async function executeAppRoll(
|
|
|
447
458
|
rolled.push(p);
|
|
448
459
|
}
|
|
449
460
|
|
|
461
|
+
// #875 — the audit job runs the admin's image; it follows the admin. A
|
|
462
|
+
// failure here does not undo the release: audits then fail loudly with
|
|
463
|
+
// the version mismatch and the fix, until the next upgrade moves it.
|
|
464
|
+
const sAudit = r.step("Moving the quality-audit job onto the admin's image...");
|
|
465
|
+
const described = await describeAuditJobImage(run, install);
|
|
466
|
+
const auditPrior = described.ok ? described.image : null;
|
|
467
|
+
if (!described.ok) {
|
|
468
|
+
sAudit.warned(
|
|
469
|
+
`Could not read the quality-audit job (${described.error}); Stage audits fail until the next upgrade moves it.`,
|
|
470
|
+
);
|
|
471
|
+
} else if (!auditPrior) {
|
|
472
|
+
sAudit.warned(
|
|
473
|
+
"The install has no quality-audit job yet; Stage audits fail until `cms-provision upgrade` sets it up.",
|
|
474
|
+
);
|
|
475
|
+
} else if (auditPrior === admin.imageRef) {
|
|
476
|
+
sAudit.done("Quality-audit job already on the admin's image");
|
|
477
|
+
} else {
|
|
478
|
+
const moved = await setAuditJobImage(run, install, admin.imageRef);
|
|
479
|
+
if (moved.ok) {
|
|
480
|
+
auditJobPriorImage = auditPrior;
|
|
481
|
+
sAudit.done(`Quality-audit job on ${admin.digest.slice(0, 19)}...`);
|
|
482
|
+
} else {
|
|
483
|
+
sAudit.warned(
|
|
484
|
+
`Quality-audit job not moved (${moved.error}); Stage audits fail until the next upgrade moves it.`,
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
450
489
|
if (deps.health) {
|
|
451
490
|
const s = r.step("Deep health check (database, migrations, version)...");
|
|
452
491
|
// A probe that throws is a failed check: roll back, never leave the
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #875 — the quality-audit job runs the admin image (admin-core
|
|
5
|
+
* quality/audit-task.ts) and refuses a batch from an admin on another
|
|
6
|
+
* version. So every app-roll moves the job onto the admin's new image right
|
|
7
|
+
* after the services rolled, and a rollback moves it back.
|
|
8
|
+
*
|
|
9
|
+
* `run jobs update --image` changes nothing else of the job (identity, env,
|
|
10
|
+
* parallelism), so this stays inside the app-roll's rights: the upgrader's
|
|
11
|
+
* `roles/run.developer` plus actAs on the job's service account
|
|
12
|
+
* (upgrader-setup.ts).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { GcloudRunner } from "../gcloud-retry.js";
|
|
16
|
+
import { auditJobName } from "../gcp-names.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The job's image now; `image: null` only when the install has no audit job
|
|
20
|
+
* yet. Any other gcloud failure (permissions, API, network) is an error, not
|
|
21
|
+
* "no job", so the app-roll reports it instead of a misleading hint.
|
|
22
|
+
*/
|
|
23
|
+
export async function describeAuditJobImage(
|
|
24
|
+
run: GcloudRunner,
|
|
25
|
+
install: { readonly projectId: string; readonly region: string; readonly env: string },
|
|
26
|
+
): Promise<
|
|
27
|
+
| { readonly ok: true; readonly image: string | null }
|
|
28
|
+
| { readonly ok: false; readonly error: string }
|
|
29
|
+
> {
|
|
30
|
+
const r = await run([
|
|
31
|
+
"run",
|
|
32
|
+
"jobs",
|
|
33
|
+
"describe",
|
|
34
|
+
auditJobName(install.env),
|
|
35
|
+
`--region=${install.region}`,
|
|
36
|
+
`--project=${install.projectId}`,
|
|
37
|
+
"--format=value(spec.template.spec.template.spec.containers[0].image)",
|
|
38
|
+
]);
|
|
39
|
+
if (!r.ok) {
|
|
40
|
+
return /NOT_FOUND|not found|could not be found/i.test(r.stderr)
|
|
41
|
+
? { ok: true, image: null }
|
|
42
|
+
: { ok: false, error: r.stderr.trim() };
|
|
43
|
+
}
|
|
44
|
+
const image = r.stdout.trim();
|
|
45
|
+
if (image.length === 0) {
|
|
46
|
+
return { ok: false, error: "the job's description names no container image" };
|
|
47
|
+
}
|
|
48
|
+
return { ok: true, image };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Point the audit job at `imageRef`. */
|
|
52
|
+
export async function setAuditJobImage(
|
|
53
|
+
run: GcloudRunner,
|
|
54
|
+
install: { readonly projectId: string; readonly region: string; readonly env: string },
|
|
55
|
+
imageRef: string,
|
|
56
|
+
): Promise<{ readonly ok: boolean; readonly error?: string }> {
|
|
57
|
+
const r = await run([
|
|
58
|
+
"run",
|
|
59
|
+
"jobs",
|
|
60
|
+
"update",
|
|
61
|
+
auditJobName(install.env),
|
|
62
|
+
`--image=${imageRef}`,
|
|
63
|
+
`--region=${install.region}`,
|
|
64
|
+
`--project=${install.projectId}`,
|
|
65
|
+
"--quiet",
|
|
66
|
+
]);
|
|
67
|
+
return r.ok ? { ok: true } : { ok: false, error: r.stderr.trim() };
|
|
68
|
+
}
|
package/src/upgrade/converge.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* CLI-generated runtime secrets, the stack's IAM/CDN/firewall invariants,
|
|
8
8
|
* MCP access through IAP, public_role's Cloud SQL role memberships (#633),
|
|
9
9
|
* the gateway's and public_role's database logins, the operator-access sync
|
|
10
|
-
* job, and dropping bindings nothing reads any
|
|
10
|
+
* job, the quality-audit job (#875), and dropping bindings nothing reads any
|
|
11
|
+
* more.
|
|
11
12
|
*
|
|
12
13
|
* It needs the rights of whoever provisioned the install, so only
|
|
13
14
|
* `cms-provision upgrade` (the operator's own identity) runs it — never the
|
|
@@ -24,11 +25,8 @@
|
|
|
24
25
|
* {@link AppRollHooks} the app-roll calls in between, and a part after it.
|
|
25
26
|
*/
|
|
26
27
|
|
|
27
|
-
import {
|
|
28
|
-
|
|
29
|
-
findSqlInstance,
|
|
30
|
-
planPublicRoleSwitch,
|
|
31
|
-
} from "../gateway-credentials.js";
|
|
28
|
+
import { ensureAuditJob, resolveAdminImage } from "../audit-job-setup.js";
|
|
29
|
+
import { findSqlInstance, planPublicRoleSwitch } from "../gateway-credentials.js";
|
|
32
30
|
import type { GcloudRunner, Sleep } from "../gcloud-retry.js";
|
|
33
31
|
import { upgraderServiceAccountEmail } from "../gcp-names.js";
|
|
34
32
|
import type { InstallMetadata } from "../install-state.js";
|
|
@@ -44,6 +42,8 @@ import {
|
|
|
44
42
|
} from "../runtime-secrets.js";
|
|
45
43
|
import { selfCommand } from "../self-command.js";
|
|
46
44
|
import {
|
|
45
|
+
AUDIT_JOB_ENV_VAR,
|
|
46
|
+
AUDIT_RESULTS_BUCKET_ENV_VAR,
|
|
47
47
|
MCP_ENV_VAR,
|
|
48
48
|
OPERATOR_ACCESS_JOB_ENV_VAR,
|
|
49
49
|
UPGRADER_JOB_ENV_VAR,
|
|
@@ -272,6 +272,42 @@ export function startConverge(
|
|
|
272
272
|
incomplete = true;
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
async function auditJob(): Promise<void> {
|
|
276
|
+
// #875 — the job the quality audit fans its Lighthouse runs out to.
|
|
277
|
+
// Deployed with the admin's CURRENT image (the admin still runs it until
|
|
278
|
+
// the app-roll moves both together, and the job refuses a batch from
|
|
279
|
+
// another version). A failure only costs Stage audits, so it warns and
|
|
280
|
+
// the admin keeps its current env.
|
|
281
|
+
const s = r.step("Ensuring the quality-audit job (parallel Lighthouse runs)...");
|
|
282
|
+
const image = await resolveAdminImage(
|
|
283
|
+
{ projectId: install.projectId, region: install.region, env: install.env },
|
|
284
|
+
{ run },
|
|
285
|
+
);
|
|
286
|
+
const aj = image.ok
|
|
287
|
+
? await ensureAuditJob(
|
|
288
|
+
{
|
|
289
|
+
provider: install.provider,
|
|
290
|
+
projectId: install.projectId,
|
|
291
|
+
region: install.region,
|
|
292
|
+
env: install.env,
|
|
293
|
+
adminImageRef: image.imageRef,
|
|
294
|
+
},
|
|
295
|
+
runDeps,
|
|
296
|
+
)
|
|
297
|
+
: { ok: false as const, done: [], error: image.error };
|
|
298
|
+
for (const step of aj.done) r.info(` ${step}`);
|
|
299
|
+
if (aj.ok) {
|
|
300
|
+
s.done("Quality-audit job ready (Stage audits run in parallel)");
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
s.warned(`Quality-audit job not set up: ${aj.error}`);
|
|
304
|
+
r.warn(
|
|
305
|
+
"The upgrade continues; Stage audits fail with the missing job until this succeeds. Re-run upgrade once the cause is fixed.",
|
|
306
|
+
);
|
|
307
|
+
incomplete = true;
|
|
308
|
+
leaveUntouched.push(AUDIT_JOB_ENV_VAR, AUDIT_RESULTS_BUCKET_ENV_VAR);
|
|
309
|
+
}
|
|
310
|
+
|
|
275
311
|
/** The admin's IAP resource: the service on gcp-firebase, the LB backend on gcp. */
|
|
276
312
|
async function adminIapResource(): Promise<IapResource | null> {
|
|
277
313
|
if (install.provider === "gcp-firebase") {
|
|
@@ -291,10 +327,9 @@ export function startConverge(
|
|
|
291
327
|
}
|
|
292
328
|
|
|
293
329
|
async function databaseLogins(): Promise<boolean> {
|
|
294
|
-
// #613 —
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
const s = r.step("Ensuring the gateway's database login (gateway_role)...");
|
|
330
|
+
// #613 — public_role moves onto its own password right before the
|
|
331
|
+
// gateway's roll. The gateway has no other database login (#635).
|
|
332
|
+
const s = r.step("Preparing the gateway's database login (public_role)...");
|
|
298
333
|
const sqlInstance = await findSqlInstance(install, { run });
|
|
299
334
|
if (!sqlInstance) {
|
|
300
335
|
s.failed("Could not find the Cloud SQL instance. Aborting upgrade.");
|
|
@@ -303,16 +338,7 @@ export function startConverge(
|
|
|
303
338
|
}
|
|
304
339
|
const roleTarget = { projectId: install.projectId, env: install.env, sqlInstance };
|
|
305
340
|
const roleDeps = { ...runDeps, ...(deps.http ? { http: deps.http } : {}) };
|
|
306
|
-
|
|
307
|
-
if (gatewayRole.status === "failed") {
|
|
308
|
-
s.failed("gateway_role could not be given its password. Aborting upgrade.");
|
|
309
|
-
r.error(` ${gatewayRole.error ?? ""}`);
|
|
310
|
-
r.warn(
|
|
311
|
-
"Migrations ran (0248 creates gateway_role); no traffic was shifted. Fix the above and re-run upgrade.",
|
|
312
|
-
);
|
|
313
|
-
return false;
|
|
314
|
-
}
|
|
315
|
-
s.done("gateway_role ready");
|
|
341
|
+
s.done("public_role ready to switch");
|
|
316
342
|
publicRole = planPublicRoleSwitch(roleTarget, service("gateway").liveEnv, roleDeps);
|
|
317
343
|
return true;
|
|
318
344
|
}
|
|
@@ -420,6 +446,7 @@ export function startConverge(
|
|
|
420
446
|
if (!(await publicRoleIsolation())) return false;
|
|
421
447
|
await mcpAccess();
|
|
422
448
|
await urlMapRedirectsAccess();
|
|
449
|
+
await auditJob();
|
|
423
450
|
return true;
|
|
424
451
|
},
|
|
425
452
|
|
|
@@ -60,7 +60,9 @@ const serviceJson = (image: string, env: unknown[]) =>
|
|
|
60
60
|
},
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
/** The admin image the install runs before the upgrade (digest-pinned, as deployed). */
|
|
64
|
+
const OLD_ADMIN_IMAGE = `registry/admin@sha256:${"0".repeat(64)}`;
|
|
65
|
+
const ADMIN_JSON = serviceJson(OLD_ADMIN_IMAGE, [
|
|
64
66
|
{ name: "CAELO_FIREBASE_SITE", value: "caelo-production-site-abc123" },
|
|
65
67
|
{
|
|
66
68
|
name: "ADMIN_DATABASE_URL",
|
|
@@ -98,6 +100,10 @@ function fakeInstall(events: string[], overrides: [RegExp, Answer][] = []) {
|
|
|
98
100
|
[new RegExp(`describe ${ADMIN} .*--format=json`), ok(ADMIN_JSON)],
|
|
99
101
|
[new RegExp(`describe ${GATEWAY} .*--format=json`), ok(GATEWAY_JSON)],
|
|
100
102
|
[/describe (\S+) .*status\.url/, (l) => ok(`https://${l.split(" ")[3]}-xyz.a.run.app\n`)],
|
|
103
|
+
[
|
|
104
|
+
/^run jobs describe caelo-production-audit .*containers\[0\]\.image/,
|
|
105
|
+
ok(`${OLD_ADMIN_IMAGE}\n`),
|
|
106
|
+
],
|
|
101
107
|
[/^sql instances list/, ok(`${SQL}\n`)],
|
|
102
108
|
[/^sql backups list/, ok(`${BACKUP_ID}\n`)],
|
|
103
109
|
[
|
|
@@ -223,7 +229,7 @@ const CONVERGE_WRITE =
|
|
|
223
229
|
/add-iam-policy-binding|remove-iam-policy-binding|set-iam-policy|service-accounts create|secrets create|secrets versions add|services enable|roles create|roles update|roles undelete|firewall-rules delete|users assign-roles/;
|
|
224
230
|
/** Calls that move a service or run migrations — app-roll-only. */
|
|
225
231
|
const APP_ROLL_WRITE =
|
|
226
|
-
/^run services update |^run services update-traffic|jobs execute caelo-migrate/;
|
|
232
|
+
/^run services update |^run services update-traffic|jobs execute caelo-migrate|^run jobs update caelo-production-audit /;
|
|
227
233
|
/** Any gcloud call that changes something. */
|
|
228
234
|
const ANY_WRITE =
|
|
229
235
|
/\b(create|update|update-traffic|delete|add-iam-policy-binding|remove-iam-policy-binding|set-iam-policy|execute|enable|add|restore)\b/;
|
|
@@ -285,6 +291,69 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
|
|
|
285
291
|
);
|
|
286
292
|
});
|
|
287
293
|
|
|
294
|
+
it("sets up the quality-audit job on the CURRENT admin image before the roll, then moves it with the admin (#875)", async () => {
|
|
295
|
+
const events: string[] = [];
|
|
296
|
+
const { deps, lines } = upgradeDeps(events);
|
|
297
|
+
expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
|
|
298
|
+
const deploy = indexOf(events, /^gcloud run jobs deploy caelo-production-audit /);
|
|
299
|
+
const adminRoll = indexOf(events, new RegExp(`^gcloud run services update ${ADMIN} `));
|
|
300
|
+
const move = indexOf(events, /^gcloud run jobs update caelo-production-audit /);
|
|
301
|
+
const deep = indexOf(events, /^health deep/);
|
|
302
|
+
expect(events[deploy]).toContain(`--image=${OLD_ADMIN_IMAGE}`);
|
|
303
|
+
expect(deploy < adminRoll && adminRoll < move && move < deep).toBe(true);
|
|
304
|
+
expect(events[move]).toContain(`--image=${IMAGES.admin.imageRef}`);
|
|
305
|
+
expect(lines).toContain("done: Quality-audit job ready (Stage audits run in parallel)");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("a failed audit-job setup leaves the converge incomplete and the admin's audit env untouched", async () => {
|
|
309
|
+
const events: string[] = [];
|
|
310
|
+
const { deps, recorded, lines } = upgradeDeps(events, {
|
|
311
|
+
overrides: [[/^run jobs deploy caelo-production-audit/, fail("PERMISSION_DENIED")]],
|
|
312
|
+
});
|
|
313
|
+
expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
|
|
314
|
+
expect(recorded.contract).toEqual([]);
|
|
315
|
+
expect(lines.some((l) => l.includes("Quality-audit job not set up"))).toBe(true);
|
|
316
|
+
expect(
|
|
317
|
+
events.some((e) => /services update caelo-production-admin.*CAELO_AUDIT_JOB=/.test(e)),
|
|
318
|
+
).toBe(false);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("an unreadable audit job is reported as an error, not as a missing job, and is not moved", async () => {
|
|
322
|
+
const events: string[] = [];
|
|
323
|
+
const { deps, lines } = upgradeDeps(events, {
|
|
324
|
+
overrides: [
|
|
325
|
+
[
|
|
326
|
+
/^run jobs describe caelo-production-audit .*containers\[0\]\.image/,
|
|
327
|
+
fail("PERMISSION_DENIED"),
|
|
328
|
+
],
|
|
329
|
+
],
|
|
330
|
+
});
|
|
331
|
+
expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
|
|
332
|
+
expect(
|
|
333
|
+
lines.some((l) => l.includes("Could not read the quality-audit job (PERMISSION_DENIED)")),
|
|
334
|
+
).toBe(true);
|
|
335
|
+
expect(lines.some((l) => l.includes("has no quality-audit job yet"))).toBe(false);
|
|
336
|
+
expect(events.some((e) => /^gcloud run jobs update caelo-production-audit /.test(e))).toBe(
|
|
337
|
+
false,
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("a missing audit job is reported as missing", async () => {
|
|
342
|
+
const events: string[] = [];
|
|
343
|
+
const { deps, lines } = upgradeDeps(events, {
|
|
344
|
+
overrides: [
|
|
345
|
+
[
|
|
346
|
+
/^run jobs describe caelo-production-audit .*containers\[0\]\.image/,
|
|
347
|
+
fail(
|
|
348
|
+
"ERROR: (gcloud.run.jobs.describe) Cannot find job [caelo-production-audit]. NOT_FOUND",
|
|
349
|
+
),
|
|
350
|
+
],
|
|
351
|
+
],
|
|
352
|
+
});
|
|
353
|
+
expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
|
|
354
|
+
expect(lines.some((l) => l.includes("has no quality-audit job yet"))).toBe(true);
|
|
355
|
+
});
|
|
356
|
+
|
|
288
357
|
it("without an ops image the upgrader step fails, the roll goes on, the converge is incomplete", async () => {
|
|
289
358
|
const events: string[] = [];
|
|
290
359
|
const { deps, recorded, lines } = upgradeDeps(events);
|
|
@@ -378,6 +447,11 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
|
|
|
378
447
|
`${ADMIN} --region europe-west1 --project acme --to-revisions ${ADMIN}-00001-old=100`,
|
|
379
448
|
),
|
|
380
449
|
]);
|
|
450
|
+
// The audit job follows the admin back to its prior image (#875).
|
|
451
|
+
const jobBack = events
|
|
452
|
+
.slice(deep)
|
|
453
|
+
.filter((e) => e.startsWith("gcloud run jobs update caelo-production-audit "));
|
|
454
|
+
expect(jobBack).toEqual([expect.stringContaining(`--image=${OLD_ADMIN_IMAGE}`)]);
|
|
381
455
|
expect(recorded.digests).toEqual([]);
|
|
382
456
|
expect(recorded.contract).toEqual([]);
|
|
383
457
|
const restore = `gcloud sql backups restore ${BACKUP_ID} --restore-instance=${SQL} --project=acme`;
|
package/src/upgrader-job.test.ts
CHANGED
|
@@ -83,6 +83,7 @@ describe("ensureUpgraderJob", () => {
|
|
|
83
83
|
"iam service-accounts add-iam-policy-binding caelo-production-gateway-sa@acme.iam.gserviceaccount.com → roles/iam.serviceAccountUser",
|
|
84
84
|
"iam service-accounts add-iam-policy-binding caelo-production-upgrader@acme.iam.gserviceaccount.com → roles/iam.serviceAccountUser",
|
|
85
85
|
"iam service-accounts add-iam-policy-binding caelo-mcp@acme.iam.gserviceaccount.com → roles/iam.serviceAccountTokenCreator",
|
|
86
|
+
"iam service-accounts add-iam-policy-binding caelo-production-audit@acme.iam.gserviceaccount.com → roles/iam.serviceAccountUser",
|
|
86
87
|
"storage buckets add-iam-policy-binding gs://acme-caelo-ops → roles/storage.objectUser",
|
|
87
88
|
"storage buckets add-iam-policy-binding gs://acme-caelo-ops → projects/acme/roles/caeloUpgraderOpsList",
|
|
88
89
|
].sort(),
|
package/src/upgrader-setup.ts
CHANGED
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
* move traffic, recreate the migration job, update its own job image.
|
|
22
22
|
* It includes no `*.setIamPolicy`.
|
|
23
23
|
* - `roles/iam.serviceAccountUser` on exactly the admin's and the
|
|
24
|
-
* gateway's runtime service accounts (a revision runs as them)
|
|
25
|
-
* itself (its own job)
|
|
24
|
+
* gateway's runtime service accounts (a revision runs as them), on
|
|
25
|
+
* itself (its own job) and on the quality-audit job's account (the
|
|
26
|
+
* app-roll moves that job's image, #875) — not on the project.
|
|
26
27
|
* - the custom role `caeloUpgraderSqlBackup`: create/read Cloud SQL
|
|
27
28
|
* backups and read the instance — no restore, no instance changes.
|
|
28
29
|
* - `roles/cloudsql.instanceUser` + a Cloud SQL IAM database user that
|
|
@@ -51,6 +52,7 @@ import { ensureCloudSqlIamUser } from "./cloudsql-iam-user.js";
|
|
|
51
52
|
import { gcloud as defaultGcloud, type GcloudResult } from "./gcloud.js";
|
|
52
53
|
import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
|
|
53
54
|
import {
|
|
55
|
+
auditServiceAccountEmail,
|
|
54
56
|
gatewayServiceAccountEmail,
|
|
55
57
|
mcpIapServiceAccountEmail,
|
|
56
58
|
runServiceAccountEmail,
|
|
@@ -256,6 +258,11 @@ export async function ensureUpgraderJob(
|
|
|
256
258
|
why: "gateway revisions run as it",
|
|
257
259
|
},
|
|
258
260
|
{ account: sa, role: "roles/iam.serviceAccountUser", why: "the job updates its own image" },
|
|
261
|
+
{
|
|
262
|
+
account: auditServiceAccountEmail(projectId, env),
|
|
263
|
+
role: "roles/iam.serviceAccountUser",
|
|
264
|
+
why: "the app-roll moves the quality-audit job, which runs as it, onto the new image",
|
|
265
|
+
},
|
|
259
266
|
{
|
|
260
267
|
account: mcpIapServiceAccountEmail(projectId),
|
|
261
268
|
role: "roles/iam.serviceAccountTokenCreator",
|
package/src/wizards/gcp-cost.ts
CHANGED
|
@@ -101,10 +101,10 @@ export function estimateGcpCost(inputs: CostEstimateInputs): {
|
|
|
101
101
|
: `${inputs.adminMinInstances} min-instance${inputs.adminMinInstances > 1 ? "s" : ""} (no cold start)`,
|
|
102
102
|
},
|
|
103
103
|
{
|
|
104
|
-
name: "Quality checks (
|
|
105
|
-
monthlyUsd:
|
|
104
|
+
name: "Quality checks (Cloud Run audit job)",
|
|
105
|
+
monthlyUsd: 2,
|
|
106
106
|
notes:
|
|
107
|
-
"
|
|
107
|
+
"one container per Lighthouse run, all in parallel (up to 5 pages x 3 runs, ~90 s at 2 vCPU each): ~$0.05 per audited Stage, partly in the free tier; results bucket ~$0",
|
|
108
108
|
},
|
|
109
109
|
{
|
|
110
110
|
name: "Cloud Run gateway",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { resolve as resolvePath } from "node:path";
|
|
15
15
|
import * as pulumi from "@pulumi/pulumi/automation";
|
|
16
|
+
import { AUDIT_PARALLELISM_DEFAULT } from "../audit-job-setup.js";
|
|
16
17
|
import { checkpointableOutputs } from "../checkpoint-secrets.js";
|
|
17
18
|
import { GCP_STACK_ENV } from "../gcp-names.js";
|
|
18
19
|
import type { ImageDigests } from "../install-state.js";
|
|
@@ -36,6 +37,12 @@ export const GCP_STACK_KNOB_DEFAULTS = {
|
|
|
36
37
|
adminMemory: ADMIN_MEMORY_DEFAULT,
|
|
37
38
|
gatewayMinInstances: 0,
|
|
38
39
|
wafAdaptiveProtection: false,
|
|
40
|
+
/**
|
|
41
|
+
* #875 — audit tasks at once. Not stack config: the audit job is
|
|
42
|
+
* CLI-owned (audit-job-setup.ts), so the wizard passes it to the job and
|
|
43
|
+
* `upgrade` keeps whatever the job has.
|
|
44
|
+
*/
|
|
45
|
+
auditParallelism: AUDIT_PARALLELISM_DEFAULT,
|
|
39
46
|
} as const;
|
|
40
47
|
|
|
41
48
|
export interface PulumiUpInputs {
|
package/src/wizards/gcp.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { join } from "node:path";
|
|
|
33
33
|
import { cancel, confirm, isCancel, log, note, select, spinner } from "@clack/prompts";
|
|
34
34
|
import { bold, cyan, dim, green, red, yellow } from "kleur/colors";
|
|
35
35
|
import { checkAdcImpersonation } from "../adc-impersonation.js";
|
|
36
|
+
import { ensureAuditJob, resolveAdminImage } from "../audit-job-setup.js";
|
|
36
37
|
import { CERT_WAIT_TIMEOUT_MS, lbCertProbe, waitForCert } from "../cert-wait.js";
|
|
37
38
|
import { redactCredentials } from "../checkpoint-secrets.js";
|
|
38
39
|
import { removeDefaultFirewallRules } from "../default-firewall.js";
|
|
@@ -45,7 +46,6 @@ import {
|
|
|
45
46
|
import { formatDnsRecordGroups, groupDnsRecords, type InstallDnsRecord } from "../dns/groups.js";
|
|
46
47
|
import { pickDnsAdapter } from "../dns/index.js";
|
|
47
48
|
import type { DnsRecord } from "../dns/types.js";
|
|
48
|
-
import { ensureGatewayRolePassword, findSqlInstance } from "../gateway-credentials.js";
|
|
49
49
|
import {
|
|
50
50
|
activeAccount,
|
|
51
51
|
type BillingAccount,
|
|
@@ -453,13 +453,11 @@ export async function runGcpConverge(opts: GcpConvergeOpts): Promise<void> {
|
|
|
453
453
|
}
|
|
454
454
|
}
|
|
455
455
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
|
|
461
|
-
phase("Finishing: database logins, operator access, welcome page");
|
|
462
|
-
await stepGatewayRolePassword(projectId);
|
|
456
|
+
phase("Finishing: operator access, welcome page");
|
|
457
|
+
|
|
458
|
+
// === 11.3. The quality-audit job: parallel Lighthouse runs (#875) ===
|
|
459
|
+
// Before the upgrader, which gets actAs on the job's service account.
|
|
460
|
+
await stepAuditJob(projectId, region, provider);
|
|
463
461
|
|
|
464
462
|
// === 11.4. The upgrader job: Settings → Updates → Install update (#696) ===
|
|
465
463
|
// Before operator access, which keeps the upgrader's caelo-mcp grant.
|
|
@@ -934,29 +932,6 @@ async function stepUrlMapRedirectsAccess(projectId: string): Promise<void> {
|
|
|
934
932
|
s.stop(green(`Redirects role ready (${r.role})`));
|
|
935
933
|
}
|
|
936
934
|
|
|
937
|
-
/**
|
|
938
|
-
* Give `gateway_role` (created by migration 0248) its password from the
|
|
939
|
-
* `gateway-role-password` secret (#613). public_role already has its own:
|
|
940
|
-
* the stack set it from `public-role-password`. Idempotent, so it runs on
|
|
941
|
-
* every wizard pass.
|
|
942
|
-
*/
|
|
943
|
-
async function stepGatewayRolePassword(projectId: string): Promise<void> {
|
|
944
|
-
const s = spinner();
|
|
945
|
-
s.start("Setting the gateway's database login (gateway_role)...");
|
|
946
|
-
const install = { projectId, env: GCP_STACK_ENV };
|
|
947
|
-
const sqlInstance = await findSqlInstance(install);
|
|
948
|
-
const r = sqlInstance
|
|
949
|
-
? await ensureGatewayRolePassword({ ...install, sqlInstance })
|
|
950
|
-
: { status: "failed" as const, error: "the Cloud SQL instance was not found" };
|
|
951
|
-
if (r.status === "failed") {
|
|
952
|
-
s.stop(red(`Failed: ${r.error ?? ""}`));
|
|
953
|
-
log.error("Re-run the wizard once the cause is fixed (this step is idempotent).");
|
|
954
|
-
cancel("Aborted.");
|
|
955
|
-
process.exit(1);
|
|
956
|
-
}
|
|
957
|
-
s.stop(green("gateway_role ready"));
|
|
958
|
-
}
|
|
959
|
-
|
|
960
935
|
/**
|
|
961
936
|
* Set up the upgrader job (upgrader-setup.ts, #696): what the admin's
|
|
962
937
|
* "Install update" starts. After migrations, because the job's database
|
|
@@ -1006,6 +981,38 @@ async function stepUpgraderJob(
|
|
|
1006
981
|
return true;
|
|
1007
982
|
}
|
|
1008
983
|
|
|
984
|
+
/**
|
|
985
|
+
* Set up the quality-audit job (audit-job-setup.ts) on the deployed admin's
|
|
986
|
+
* image. A failure stops the wizard: without the job every Stage audit
|
|
987
|
+
* fails. Idempotent, so it runs on every wizard pass.
|
|
988
|
+
*/
|
|
989
|
+
async function stepAuditJob(
|
|
990
|
+
projectId: string,
|
|
991
|
+
region: string,
|
|
992
|
+
provider: "gcp" | "gcp-firebase",
|
|
993
|
+
): Promise<void> {
|
|
994
|
+
const s = spinner();
|
|
995
|
+
s.start("Setting up the quality-audit job (parallel Lighthouse runs)...");
|
|
996
|
+
const image = await resolveAdminImage({ projectId, region, env: GCP_STACK_ENV });
|
|
997
|
+
const r = image.ok
|
|
998
|
+
? await ensureAuditJob({
|
|
999
|
+
provider,
|
|
1000
|
+
projectId,
|
|
1001
|
+
region,
|
|
1002
|
+
env: GCP_STACK_ENV,
|
|
1003
|
+
adminImageRef: image.imageRef,
|
|
1004
|
+
parallelism: GCP_STACK_KNOB_DEFAULTS.auditParallelism,
|
|
1005
|
+
})
|
|
1006
|
+
: { ok: false as const, done: [], error: image.error };
|
|
1007
|
+
if (!r.ok) {
|
|
1008
|
+
s.stop(red(`Failed: ${r.error}`));
|
|
1009
|
+
log.error("Re-run the wizard once the cause is fixed (this step is idempotent).");
|
|
1010
|
+
cancel("Aborted.");
|
|
1011
|
+
process.exit(1);
|
|
1012
|
+
}
|
|
1013
|
+
s.stop(green(`Quality-audit job ready (${r.done.length} steps)`));
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1009
1016
|
/**
|
|
1010
1017
|
* Whether an upgrader set up by an earlier run exists, so a failed setup
|
|
1011
1018
|
* this time still keeps its caelo-mcp signing grant (the operator-access
|
package/stacks/aws/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Pulumi stack provisioning Caelo on AWS managed services. Implements the shared `
|
|
|
10
10
|
| Blob storage | Two S3 buckets (`caelo-<env>-media` + `caelo-<env>-static`) |
|
|
11
11
|
| CDN | CloudFront distribution with two origins (S3 static + S3 media); static files are served as files, no edge compute on the request path |
|
|
12
12
|
| Container runtime | ECS Fargate cluster + four services (admin / gateway / orchestrator / runner) |
|
|
13
|
-
| Secret store | AWS Secrets Manager (postgres-password, public-role-password,
|
|
13
|
+
| Secret store | AWS Secrets Manager (postgres-password, public-role-password, csrf-secret, cookie-secret, anthropic-api-key, resend-api-key). The gateway gets `publicDatabaseUrlOut` only, never a cms_admin URL of any role (#613, #635). |
|
|
14
14
|
| DNS + cert | ACM cert (us-east-1, DNS-validated, supports `*.<domain>`) |
|
|
15
15
|
| Edge-log sink | CloudFront access logs delivered to the CloudWatch log group `/caelo/<env>/cloudfront` in us-east-1 (queryable by P12A analytics plugin) |
|
|
16
16
|
|
package/stacks/aws/index.ts
CHANGED
|
@@ -97,11 +97,8 @@ function randomHex(bytes: number): string {
|
|
|
97
97
|
const postgresPassword = pulumi.secret(randomHex(32));
|
|
98
98
|
// #613 — one password per database role, so the gateway (public_role only,
|
|
99
99
|
// #635) never holds admin_role's. bootstrap.sh creates the roles with these
|
|
100
|
-
// (
|
|
101
|
-
// any service's login; its password stays until the contract release that
|
|
102
|
-
// drops the role (#635).
|
|
100
|
+
// (ADMIN_ROLE_PASSWORD / PUBLIC_ROLE_PASSWORD).
|
|
103
101
|
const publicRolePassword = pulumi.secret(randomHex(32));
|
|
104
|
-
const gatewayRolePassword = pulumi.secret(randomHex(32));
|
|
105
102
|
const csrfSecret = pulumi.secret(randomHex(32));
|
|
106
103
|
const cookieSecret = pulumi.secret(randomHex(32));
|
|
107
104
|
const anthropicApiKey = pulumi.secret(process.env.ANTHROPIC_API_KEY ?? "");
|
|
@@ -122,7 +119,6 @@ function secret(name: string, value: pulumi.Output<string>): aws.secretsmanager.
|
|
|
122
119
|
|
|
123
120
|
const pgSecret = secret("postgres-password", postgresPassword);
|
|
124
121
|
const publicRoleSecret = secret("public-role-password", publicRolePassword);
|
|
125
|
-
const gatewayRoleSecret = secret("gateway-role-password", gatewayRolePassword);
|
|
126
122
|
const csrfSecretRes = secret("csrf-secret", csrfSecret);
|
|
127
123
|
const cookieSecretRes = secret("cookie-secret", cookieSecret);
|
|
128
124
|
const anthropicSecretRes = secret("anthropic-api-key", anthropicApiKey);
|
|
@@ -353,6 +349,14 @@ new aws.cloudwatch.LogDelivery(
|
|
|
353
349
|
//
|
|
354
350
|
// Each Caelo service runs as its own Fargate service; sharing a cluster
|
|
355
351
|
// keeps the per-month Fargate-cluster fee at zero (clusters are free).
|
|
352
|
+
//
|
|
353
|
+
// FIXME(issue #749): no quality-audit task definition yet. On GCP every
|
|
354
|
+
// Lighthouse run of a Stage audit runs in its own container (#875,
|
|
355
|
+
// src/audit-job-setup.ts); the AWS equivalent is a Fargate task per run
|
|
356
|
+
// (admin-core quality/parallel-runner.ts `AuditTaskLauncher`). Until it
|
|
357
|
+
// exists the admin records every Lighthouse audit as failed with that gap
|
|
358
|
+
// (quality/lighthouse-runner-select.ts) instead of measuring on its own
|
|
359
|
+
// shared CPU. Nothing is staged on AWS before #749 either.
|
|
356
360
|
const cluster = new aws.ecs.Cluster(`${namePrefix}-ecs`, {
|
|
357
361
|
settings: [{ name: "containerInsights", value: "enabled" }],
|
|
358
362
|
});
|
|
@@ -365,7 +369,6 @@ const cluster = new aws.ecs.Cluster(`${namePrefix}-ecs`, {
|
|
|
365
369
|
const secretArns: Record<AwsSecret, pulumi.Output<string>> = {
|
|
366
370
|
"postgres-password": pgSecret.arn,
|
|
367
371
|
"public-role-password": publicRoleSecret.arn,
|
|
368
|
-
"gateway-role-password": gatewayRoleSecret.arn,
|
|
369
372
|
"csrf-secret": csrfSecretRes.arn,
|
|
370
373
|
"cookie-secret": cookieSecretRes.arn,
|
|
371
374
|
"anthropic-api-key": anthropicSecretRes.arn,
|
package/stacks/azure/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Pulumi stack provisioning Caelo on Azure managed services. Implements the shared
|
|
|
10
10
|
| Blob storage | Storage Account with a private `media` container and static-website hosting (`$web`) |
|
|
11
11
|
| CDN / Edge | Azure Front Door Standard: the primary domain serves the static website as files, with a Front Door managed certificate; no edge compute on the request path |
|
|
12
12
|
| Container runtime | Four Container Apps (admin / gateway / orchestrator / runner) in one managed environment |
|
|
13
|
-
| Secret store | Key Vault (postgres-password, public-role-password,
|
|
13
|
+
| Secret store | Key Vault (postgres-password, public-role-password, csrf-secret, cookie-secret, anthropic-api-key, resend-api-key). The gateway container gets `PUBLIC_DATABASE_URL` only, never a cms_admin credential of any role (#613, #635). |
|
|
14
14
|
| Edge-log sink | Log Analytics workspace receives Container Apps logs (queryable by P12A analytics plugin via Azure Monitor) |
|
|
15
15
|
|
|
16
16
|
The Caelo runtime never knows it's on Azure — every Container App consumes plain `DATABASE_URL` / `MEDIA_STORAGE_URL` / `SECRETS_PROVIDER` env vars.
|