@caelo-cms/provisioning 0.11.10 → 0.11.11
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 +6 -0
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adopt-existing.d.ts +6 -2
- package/dist/adopt-existing.d.ts.map +1 -1
- package/dist/adopt-existing.js +57 -0
- package/dist/adopt-existing.js.map +1 -1
- package/dist/agent/command.d.ts +5 -2
- package/dist/agent/command.d.ts.map +1 -1
- package/dist/agent/command.js +1 -1
- package/dist/agent/command.js.map +1 -1
- package/dist/agent-db.js +25 -8
- package/dist/cli-routing.js +1 -1
- package/dist/cli-routing.js.map +1 -1
- package/dist/cli.js +59 -7
- package/dist/cli.js.map +1 -1
- package/dist/cloudsql-tls.d.ts +30 -0
- package/dist/cloudsql-tls.d.ts.map +1 -1
- package/dist/cloudsql-tls.js +7 -1
- package/dist/cloudsql-tls.js.map +1 -1
- package/dist/compose.d.ts +14 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +54 -1
- package/dist/compose.js.map +1 -1
- package/dist/converge-db-trust.d.ts +44 -0
- package/dist/converge-db-trust.d.ts.map +1 -0
- package/dist/converge-db-trust.js +158 -0
- package/dist/converge-db-trust.js.map +1 -0
- package/dist/converge-job-gcp.d.ts +5 -2
- package/dist/converge-job-gcp.d.ts.map +1 -1
- package/dist/converge-job-gcp.js +9 -2
- package/dist/converge-job-gcp.js.map +1 -1
- package/dist/converge-job-preflight.d.ts.map +1 -1
- package/dist/converge-job-preflight.js +8 -0
- package/dist/converge-job-preflight.js.map +1 -1
- package/dist/converge-job.d.ts +17 -0
- package/dist/converge-job.d.ts.map +1 -1
- package/dist/converge-job.js +12 -0
- package/dist/converge-job.js.map +1 -1
- package/dist/gcp-names.d.ts +13 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +21 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.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 +22 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job.d.ts +4 -2
- package/dist/install-job.d.ts.map +1 -1
- package/dist/install-job.js +4 -1
- package/dist/install-job.js.map +1 -1
- package/dist/install-state.d.ts +7 -0
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +3 -0
- package/dist/lifecycle.js.map +1 -1
- package/dist/migration-runner.d.ts.map +1 -1
- package/dist/migration-runner.js +8 -3
- package/dist/migration-runner.js.map +1 -1
- package/dist/migration-targets.d.ts +11 -0
- package/dist/migration-targets.d.ts.map +1 -0
- package/dist/migration-targets.js +12 -0
- package/dist/migration-targets.js.map +1 -0
- package/dist/operator-access.d.ts +10 -0
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +5 -0
- package/dist/operator-access.js.map +1 -1
- package/dist/pool-budget-stack.d.ts +13 -1
- package/dist/pool-budget-stack.d.ts.map +1 -1
- package/dist/pool-budget-stack.js +11 -1
- package/dist/pool-budget-stack.js.map +1 -1
- package/dist/public-role-isolation.d.ts +7 -3
- package/dist/public-role-isolation.d.ts.map +1 -1
- package/dist/public-role-isolation.js +25 -13
- package/dist/public-role-isolation.js.map +1 -1
- 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 +2 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +19 -15
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/scheduler-setup.d.ts +75 -0
- package/dist/scheduler-setup.d.ts.map +1 -0
- package/dist/scheduler-setup.js +331 -0
- package/dist/scheduler-setup.js.map +1 -0
- package/dist/self-hosted-roles.d.ts +16 -9
- package/dist/self-hosted-roles.d.ts.map +1 -1
- package/dist/self-hosted-roles.js +49 -15
- package/dist/self-hosted-roles.js.map +1 -1
- package/dist/self-hosted-scheduler-interval.d.ts +21 -0
- package/dist/self-hosted-scheduler-interval.d.ts.map +1 -0
- package/dist/self-hosted-scheduler-interval.js +68 -0
- package/dist/self-hosted-scheduler-interval.js.map +1 -0
- package/dist/self-hosted-upgrade.d.ts +7 -4
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +9 -5
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-contract.d.ts +67 -6
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +87 -6
- package/dist/stack-contract.js.map +1 -1
- package/dist/staging-database.d.ts +22 -0
- package/dist/staging-database.d.ts.map +1 -0
- package/dist/staging-database.js +117 -0
- package/dist/staging-database.js.map +1 -0
- package/dist/upgrade/converge.d.ts +5 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +110 -10
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/upgrade.d.ts +6 -0
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +3 -0
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-gcp.d.ts +5 -4
- package/dist/upgrader-gcp.d.ts.map +1 -1
- package/dist/upgrader-gcp.js +5 -4
- package/dist/upgrader-gcp.js.map +1 -1
- package/dist/wizard.d.ts +7 -0
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +11 -0
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp-cost.d.ts +31 -0
- package/dist/wizards/gcp-cost.d.ts.map +1 -1
- package/dist/wizards/gcp-cost.js +81 -11
- package/dist/wizards/gcp-cost.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +87 -11
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/adapter.ts +6 -0
- package/src/admin-memory.test.ts +1 -1
- package/src/adopt-existing.test.ts +46 -0
- package/src/adopt-existing.ts +58 -2
- package/src/agent/command.ts +6 -3
- package/src/caddy-redirects.test.ts +1 -1
- package/src/cli-routing.test.ts +1 -1
- package/src/cli-routing.ts +1 -1
- package/src/cli.ts +63 -8
- package/src/cloudsql-tls.test.ts +30 -0
- package/src/cloudsql-tls.ts +10 -3
- package/src/compose.ts +68 -0
- package/src/converge-db-trust.test.ts +237 -0
- package/src/converge-db-trust.ts +212 -0
- package/src/converge-job-gcp.ts +9 -1
- package/src/converge-job-preflight.test.ts +5 -0
- package/src/converge-job-preflight.ts +8 -0
- package/src/converge-job.test.ts +70 -0
- package/src/converge-job.ts +32 -0
- package/src/database-tls-builders.test.ts +3 -2
- package/src/deployed-release.test.ts +3 -0
- package/src/gateway-env-isolation.test.ts +40 -7
- package/src/gcp-names.ts +25 -0
- package/src/index.test.ts +3 -3
- package/src/index.ts +6 -0
- package/src/infra-contract.test.ts +5 -3
- package/src/infra-contract.ts +22 -1
- package/src/install-job.ts +4 -1
- package/src/install-state.ts +7 -0
- package/src/lifecycle.ts +3 -0
- package/src/migration-runner.ts +14 -9
- package/src/migration-targets.ts +12 -0
- package/src/operator-access.test.ts +15 -0
- package/src/operator-access.ts +14 -0
- package/src/pool-budget-stack.ts +15 -0
- package/src/pool-budget-stacks.test.ts +38 -13
- package/src/public-role-isolation.test.ts +18 -8
- package/src/public-role-isolation.ts +31 -17
- package/src/release-contract.ts +3 -0
- package/src/runtime-secrets.test.ts +18 -8
- package/src/runtime-secrets.ts +37 -18
- package/src/scheduler-setup.test.ts +201 -0
- package/src/scheduler-setup.ts +375 -0
- package/src/self-hosted-backup.test.ts +1 -0
- package/src/self-hosted-compose.test.ts +72 -1
- package/src/self-hosted-roles.integration.test.ts +2 -0
- package/src/self-hosted-roles.test.ts +49 -19
- package/src/self-hosted-roles.ts +60 -20
- package/src/self-hosted-scheduler-interval.test.ts +66 -0
- package/src/self-hosted-scheduler-interval.ts +79 -0
- package/src/self-hosted-start.test.ts +1 -0
- package/src/self-hosted-upgrade.test.ts +2 -1
- package/src/self-hosted-upgrade.ts +14 -8
- package/src/stack-contract.test.ts +31 -3
- package/src/stack-contract.ts +113 -6
- package/src/stack-converge-secrets.test.ts +2 -0
- package/src/stack-converge.test.ts +6 -4
- package/src/staging-database.test.ts +114 -0
- package/src/staging-database.ts +148 -0
- package/src/upgrade/converge.ts +130 -9
- package/src/upgrade/upgrade.test.ts +85 -2
- package/src/upgrade/upgrade.ts +9 -0
- package/src/upgrader-gcp.ts +5 -4
- package/src/wizard.ts +19 -0
- package/src/wizards/gcp-cost.test.ts +54 -1
- package/src/wizards/gcp-cost.ts +101 -11
- package/src/wizards/gcp.ts +109 -8
- package/stacks/aws/Pulumi.yaml +1 -1
- package/stacks/aws/index.ts +41 -1
- package/stacks/azure/index.ts +51 -3
- package/stacks/gcp/Pulumi.yaml +1 -1
- package/stacks/gcp/README.md +1 -0
- package/stacks/gcp/index.ts +50 -1
- package/stacks/gcp-firebase/Pulumi.yaml +1 -1
- package/stacks/gcp-firebase/index.ts +50 -1
- package/stacks/self-hosted/Pulumi.yaml +3 -0
- package/stacks/self-hosted/README.md +4 -0
- package/stacks/self-hosted/index.ts +29 -4
package/src/upgrade/converge.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* service accounts, secrets or other cloud resources so the install matches
|
|
6
6
|
* what the release's stacks declare — the gateway's run SA and the
|
|
7
7
|
* CLI-generated runtime secrets, the stack's IAM/CDN/firewall invariants,
|
|
8
|
-
* MCP access through IAP,
|
|
8
|
+
* MCP access through IAP, the staging database and its gateway login
|
|
9
|
+
* (#1329), the gateway logins' Cloud SQL role memberships (#633),
|
|
9
10
|
* the gateway's and public_role's database logins, the operator-access sync
|
|
10
11
|
* job, the quality-audit job (#875), the remote-MCP load-balancer rules and
|
|
11
12
|
* identity env (#806), and dropping bindings nothing reads any more.
|
|
@@ -39,7 +40,7 @@ import {
|
|
|
39
40
|
import { ensureConvergeJob } from "../converge-job-setup.js";
|
|
40
41
|
import { findSqlInstance, planPublicRoleSwitch } from "../gateway-credentials.js";
|
|
41
42
|
import type { GcloudRunner, Sleep } from "../gcloud-retry.js";
|
|
42
|
-
import { upgraderServiceAccountEmail } from "../gcp-names.js";
|
|
43
|
+
import { schedulerServiceAccountEmail, upgraderServiceAccountEmail } from "../gcp-names.js";
|
|
43
44
|
import type { InstallMetadata } from "../install-state.js";
|
|
44
45
|
import { ensureMcpIapAccess, type IapResource } from "../mcp-iap.js";
|
|
45
46
|
import { ensureOperatorAccessSync, resolveOperatorAccessTarget } from "../operator-access.js";
|
|
@@ -52,6 +53,7 @@ import {
|
|
|
52
53
|
plainGeneratedSecretSeed,
|
|
53
54
|
readSecretReplication,
|
|
54
55
|
} from "../runtime-secrets.js";
|
|
56
|
+
import { ensureSchedulerTick } from "../scheduler-setup.js";
|
|
55
57
|
import { selfCommand } from "../self-command.js";
|
|
56
58
|
import {
|
|
57
59
|
AUDIT_JOB_ENV_VAR,
|
|
@@ -64,7 +66,9 @@ import {
|
|
|
64
66
|
INTERIM_FORWARD_ORIGIN_ENV,
|
|
65
67
|
MCP_ENV_VAR,
|
|
66
68
|
OPERATOR_ACCESS_JOB_ENV_VAR,
|
|
69
|
+
parseSchedulerIntervalMinutes,
|
|
67
70
|
remoteMcpIdentityEnv,
|
|
71
|
+
SCHEDULER_SERVICE_ACCOUNT_ENV_VAR,
|
|
68
72
|
UPGRADER_JOB_ENV_VAR,
|
|
69
73
|
} from "../stack-contract.js";
|
|
70
74
|
import {
|
|
@@ -73,6 +77,7 @@ import {
|
|
|
73
77
|
liveEnvHasInlinePassword,
|
|
74
78
|
removeRetiredIamBindings,
|
|
75
79
|
} from "../stack-converge.js";
|
|
80
|
+
import { ensureStagingDatabase } from "../staging-database.js";
|
|
76
81
|
import { ensureUpgraderJob, resolveUpgraderTarget } from "../upgrader-setup.js";
|
|
77
82
|
import { ensureUrlMapRedirectsAccess } from "../url-map-redirects-access.js";
|
|
78
83
|
import type { AppRollHooks, AppRollPlan, RollPlan, RollSlug } from "./app-roll.js";
|
|
@@ -110,6 +115,11 @@ export interface ConvergeDeps {
|
|
|
110
115
|
* then fails and the converge stays incomplete.
|
|
111
116
|
*/
|
|
112
117
|
readonly opsImageRef?: string | null;
|
|
118
|
+
/**
|
|
119
|
+
* #1140 — the install's `schedulerIntervalMinutes` knob (install.json);
|
|
120
|
+
* absent: the default. The scheduler step refuses an out-of-range value.
|
|
121
|
+
*/
|
|
122
|
+
readonly schedulerIntervalMinutes?: number;
|
|
113
123
|
}
|
|
114
124
|
|
|
115
125
|
export interface ConvergeSession {
|
|
@@ -234,22 +244,48 @@ export function startConverge(
|
|
|
234
244
|
return true;
|
|
235
245
|
}
|
|
236
246
|
|
|
247
|
+
async function stagingDatabase(): Promise<boolean> {
|
|
248
|
+
// #1329 — staging's own database and its gateway login, on an install
|
|
249
|
+
// from before them. Before migrations: the cms_public_staging run and
|
|
250
|
+
// migration 0530 need both, and the membership revoke below needs the
|
|
251
|
+
// user. A failure aborts — the new admin refuses to start without the
|
|
252
|
+
// staging database.
|
|
253
|
+
const s = r.step("Ensuring the staging database (cms_public_staging) and its gateway login...");
|
|
254
|
+
const outcomes = await ensureStagingDatabase(
|
|
255
|
+
{ projectId: install.projectId, env: install.env, credentials: install.credentials },
|
|
256
|
+
{ ...runDeps, ...(deps.http ? { http: deps.http } : {}) },
|
|
257
|
+
);
|
|
258
|
+
const failed = outcomes.filter((o) => o.status === "failed");
|
|
259
|
+
if (failed.length > 0) {
|
|
260
|
+
s.failed("The staging database could not be set up. Aborting upgrade.");
|
|
261
|
+
for (const o of failed) r.error(` FAILED: ${o.id}\n ${o.error ?? ""}`);
|
|
262
|
+
r.warn("No traffic was shifted and no migrations ran. Fix the above and re-run.");
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
s.done("Staging database and its gateway login ready");
|
|
266
|
+
for (const o of outcomes.filter((o) => o.status === "applied")) {
|
|
267
|
+
r.info(` ${o.id}: ${o.detail ?? "created"}`);
|
|
268
|
+
}
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
|
|
237
272
|
async function publicRoleIsolation(): Promise<boolean> {
|
|
238
|
-
// #633 — the Admin API made public_role
|
|
239
|
-
// Revoked here, before migrations,
|
|
240
|
-
// audit is what verifies it and fails
|
|
241
|
-
|
|
273
|
+
// #633 — the Admin API made public_role (and, #1329, public_staging_role)
|
|
274
|
+
// a member of cloudsqlsuperuser. Revoked here, before migrations,
|
|
275
|
+
// because the migration job's role audit is what verifies it and fails
|
|
276
|
+
// the upgrade while it remains.
|
|
277
|
+
const s = r.step("Removing the gateway logins from the Cloud SQL superuser group...");
|
|
242
278
|
const outcome = await revokePublicRoleMemberships(
|
|
243
279
|
{ projectId: install.projectId, env: install.env, credentials: install.credentials },
|
|
244
280
|
runDeps,
|
|
245
281
|
);
|
|
246
282
|
if (outcome.status === "failed") {
|
|
247
|
-
s.failed("
|
|
283
|
+
s.failed("The gateway logins' role memberships could not be revoked. Aborting upgrade.");
|
|
248
284
|
r.error(` ${outcome.error ?? ""}`);
|
|
249
285
|
r.warn("No traffic was shifted and no migrations ran. Fix the above and re-run.");
|
|
250
286
|
return false;
|
|
251
287
|
}
|
|
252
|
-
s.done(`
|
|
288
|
+
s.done(`The gateway logins hold no database roles (${outcome.detail ?? "revoked"})`);
|
|
253
289
|
return true;
|
|
254
290
|
}
|
|
255
291
|
|
|
@@ -427,6 +463,9 @@ export function startConverge(
|
|
|
427
463
|
return [{ name: INTERIM_FORWARD_ORIGIN_ENV, value: new URL(url).origin }];
|
|
428
464
|
}
|
|
429
465
|
|
|
466
|
+
/** The admin's origin behind IAP, once {@link remoteMcpIdentity} read it. */
|
|
467
|
+
let resolvedAdminUrl: string | null = null;
|
|
468
|
+
|
|
430
469
|
async function remoteMcpIdentity(): Promise<Record<RollSlug, CloudRunEnvVar[]> | null> {
|
|
431
470
|
// #806 — CAELO_ADMIN_URL / CAELO_MCP_PUBLIC_URL / CAELO_IAP_AUDIENCE
|
|
432
471
|
// name identities the env contract cannot (stack-contract.ts
|
|
@@ -449,6 +488,7 @@ export function startConverge(
|
|
|
449
488
|
runDeps,
|
|
450
489
|
);
|
|
451
490
|
if (resolved.ok) {
|
|
491
|
+
resolvedAdminUrl = resolved.identity.adminUrl;
|
|
452
492
|
s.done(
|
|
453
493
|
`Google sign-in and remote MCP can be set up under Settings → Google sign-in (${resolved.identity.mcpPublicUrl})`,
|
|
454
494
|
);
|
|
@@ -672,6 +712,77 @@ export function startConverge(
|
|
|
672
712
|
leaveUntouched.push(CONVERGE_JOB_ENV_VAR);
|
|
673
713
|
}
|
|
674
714
|
|
|
715
|
+
let schedulerReady = false;
|
|
716
|
+
/** The scheduler's SA exists from an earlier run though this one failed. */
|
|
717
|
+
let schedulerAccountExists = false;
|
|
718
|
+
|
|
719
|
+
async function setUpSchedulerTick(): Promise<
|
|
720
|
+
| { ok: true; done: string[]; intervalMinutes: number }
|
|
721
|
+
| { ok: false; done: string[]; error: string }
|
|
722
|
+
> {
|
|
723
|
+
let intervalMinutes: number;
|
|
724
|
+
try {
|
|
725
|
+
intervalMinutes = parseSchedulerIntervalMinutes(
|
|
726
|
+
deps.schedulerIntervalMinutes,
|
|
727
|
+
"install.json schedulerIntervalMinutes",
|
|
728
|
+
);
|
|
729
|
+
} catch (e) {
|
|
730
|
+
return { ok: false, done: [], error: (e as Error).message };
|
|
731
|
+
}
|
|
732
|
+
const resource = await adminIapResource();
|
|
733
|
+
if (!resource) return { ok: false, done: [], error: "the admin's IAP resource was not found" };
|
|
734
|
+
if (!resolvedAdminUrl)
|
|
735
|
+
return { ok: false, done: [], error: "the admin's URL could not be read" };
|
|
736
|
+
const st = await ensureSchedulerTick(
|
|
737
|
+
{
|
|
738
|
+
projectId: install.projectId,
|
|
739
|
+
region: install.region,
|
|
740
|
+
env: install.env,
|
|
741
|
+
adminUrl: resolvedAdminUrl,
|
|
742
|
+
resource,
|
|
743
|
+
intervalMinutes,
|
|
744
|
+
},
|
|
745
|
+
runDeps,
|
|
746
|
+
);
|
|
747
|
+
return st.ok ? { ok: true, done: st.done, intervalMinutes } : st;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
async function schedulerTick(): Promise<void> {
|
|
751
|
+
// #1140 — the one Cloud Scheduler job that calls the admin's tick
|
|
752
|
+
// (scheduler-setup.ts). After migrations and before the operator-access
|
|
753
|
+
// step, which must keep the scheduler's account on IAP. The schedule
|
|
754
|
+
// starts right away: until the roll below finishes, a tick reaches the
|
|
755
|
+
// previous admin, which has no such endpoint (404) — that execution
|
|
756
|
+
// fails and nothing runs; the first tick after the roll does the work.
|
|
757
|
+
// A failure costs the periodic work (plugin workers, GC, release check),
|
|
758
|
+
// not the site, so it warns and the admin keeps its current env.
|
|
759
|
+
const s = r.step("Ensuring the scheduler tick (periodic work and plugin workers)...");
|
|
760
|
+
const st = await setUpSchedulerTick();
|
|
761
|
+
for (const step of st.done) r.info(` ${step}`);
|
|
762
|
+
if (st.ok) {
|
|
763
|
+
schedulerReady = true;
|
|
764
|
+
s.done(`Scheduler tick ready (every ${st.intervalMinutes} min)`);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
s.warned(`Scheduler tick not set up: ${st.error}`);
|
|
768
|
+
// An account set up by an earlier run keeps its IAP access, or the next
|
|
769
|
+
// sync would cut off a tick that still works.
|
|
770
|
+
const exists = await run([
|
|
771
|
+
"iam",
|
|
772
|
+
"service-accounts",
|
|
773
|
+
"describe",
|
|
774
|
+
schedulerServiceAccountEmail(install.projectId, install.env),
|
|
775
|
+
`--project=${install.projectId}`,
|
|
776
|
+
"--format=value(email)",
|
|
777
|
+
]);
|
|
778
|
+
schedulerAccountExists = exists.ok;
|
|
779
|
+
r.warn(
|
|
780
|
+
"The upgrade continues; plugin workers, update checks and clean-up run late or not at all until this succeeds. Re-run upgrade once the cause is fixed.",
|
|
781
|
+
);
|
|
782
|
+
incomplete = true;
|
|
783
|
+
leaveUntouched.push(SCHEDULER_SERVICE_ACCOUNT_ENV_VAR);
|
|
784
|
+
}
|
|
785
|
+
|
|
675
786
|
async function operatorAccess(): Promise<void> {
|
|
676
787
|
// The job that keeps IAP in step with the user list (operator-access.ts).
|
|
677
788
|
// After migrations: its database user gets the role migration 0239
|
|
@@ -699,6 +810,13 @@ export function startConverge(
|
|
|
699
810
|
],
|
|
700
811
|
}
|
|
701
812
|
: {}),
|
|
813
|
+
...(schedulerReady || schedulerAccountExists
|
|
814
|
+
? {
|
|
815
|
+
iapServiceAccounts: [
|
|
816
|
+
schedulerServiceAccountEmail(install.projectId, install.env),
|
|
817
|
+
],
|
|
818
|
+
}
|
|
819
|
+
: {}),
|
|
702
820
|
},
|
|
703
821
|
runDeps,
|
|
704
822
|
)
|
|
@@ -721,6 +839,7 @@ export function startConverge(
|
|
|
721
839
|
if (deps.provisionerIdentity && !(await deps.provisionerIdentity())) return false;
|
|
722
840
|
if (!(await runtimeIdentities())) return false;
|
|
723
841
|
if (!(await stackInvariants())) return false;
|
|
842
|
+
if (!(await stagingDatabase())) return false;
|
|
724
843
|
if (!(await publicRoleIsolation())) return false;
|
|
725
844
|
if (!(await databaseTls())) return false;
|
|
726
845
|
await mcpAccess();
|
|
@@ -734,10 +853,12 @@ export function startConverge(
|
|
|
734
853
|
hooks: {
|
|
735
854
|
async afterMigrations() {
|
|
736
855
|
if (!(await databaseLogins())) return { ok: false };
|
|
856
|
+
// Read-only, first: the scheduler tick needs the admin's URL.
|
|
857
|
+
const identity = await remoteMcpIdentity();
|
|
737
858
|
await upgraderJob();
|
|
738
859
|
await convergeJob();
|
|
860
|
+
await schedulerTick();
|
|
739
861
|
await operatorAccess();
|
|
740
|
-
const identity = await remoteMcpIdentity();
|
|
741
862
|
let forward: CloudRunEnvVar[];
|
|
742
863
|
try {
|
|
743
864
|
forward = await interimForwardEnv();
|
|
@@ -423,6 +423,66 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
|
|
|
423
423
|
).toBe(false);
|
|
424
424
|
});
|
|
425
425
|
|
|
426
|
+
it("#1140 — sets up the scheduler tick before operator access keeps its account on IAP, and tells the admin whom to trust", async () => {
|
|
427
|
+
const events: string[] = [];
|
|
428
|
+
const { deps, lines } = upgradeDeps(events);
|
|
429
|
+
expect(await upgradeGcpInstall({ ...TARGET, schedulerIntervalMinutes: 2 }, deps)).toBe(true);
|
|
430
|
+
const workflow = indexOf(events, /^gcloud workflows deploy caelo-production-scheduler-tick /);
|
|
431
|
+
const schedule = indexOf(
|
|
432
|
+
events,
|
|
433
|
+
/^gcloud scheduler jobs (create|update) http caelo-production-scheduler-tick /,
|
|
434
|
+
);
|
|
435
|
+
const opAccess = indexOf(
|
|
436
|
+
events,
|
|
437
|
+
/^gcloud run jobs deploy caelo-production-operator-access-sync /,
|
|
438
|
+
);
|
|
439
|
+
const adminRoll = indexOf(events, new RegExp(`^gcloud run services update ${ADMIN} `));
|
|
440
|
+
expect(workflow < schedule && schedule < opAccess && opAccess < adminRoll).toBe(true);
|
|
441
|
+
expect(events[schedule]).toContain("--schedule=*/2 * * * *");
|
|
442
|
+
expect(events[schedule]).toContain(
|
|
443
|
+
"--oauth-service-account-email=caelo-production-scheduler@acme.iam.gserviceaccount.com",
|
|
444
|
+
);
|
|
445
|
+
expect(events[opAccess]).toContain(
|
|
446
|
+
"CAELO_OPERATOR_ACCESS_IAP_SERVICE_ACCOUNTS=caelo-production-scheduler@acme.iam.gserviceaccount.com",
|
|
447
|
+
);
|
|
448
|
+
expect(events[adminRoll]).toContain(
|
|
449
|
+
"CAELO_SCHEDULER_SERVICE_ACCOUNT=caelo-production-scheduler@acme.iam.gserviceaccount.com",
|
|
450
|
+
);
|
|
451
|
+
expect(lines).toContain("done: Scheduler tick ready (every 2 min)");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("#1140 — a failed scheduler setup leaves the converge incomplete and the admin's trust env untouched", async () => {
|
|
455
|
+
const events: string[] = [];
|
|
456
|
+
const { deps, recorded, lines } = upgradeDeps(events, {
|
|
457
|
+
overrides: [
|
|
458
|
+
[/^workflows deploy caelo-production-scheduler-tick/, fail("PERMISSION_DENIED")],
|
|
459
|
+
[/^iam service-accounts describe caelo-production-scheduler@/, fail("NOT_FOUND")],
|
|
460
|
+
],
|
|
461
|
+
});
|
|
462
|
+
expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
|
|
463
|
+
expect(recorded.contract).toEqual([]);
|
|
464
|
+
expect(lines.some((l) => l.includes("Scheduler tick not set up"))).toBe(true);
|
|
465
|
+
expect(
|
|
466
|
+
events.some((e) =>
|
|
467
|
+
/services update caelo-production-admin.*CAELO_SCHEDULER_SERVICE_ACCOUNT=/.test(e),
|
|
468
|
+
),
|
|
469
|
+
).toBe(false);
|
|
470
|
+
expect(
|
|
471
|
+
events.some((e) =>
|
|
472
|
+
/^gcloud scheduler jobs (create|update) http caelo-production-scheduler-tick/.test(e),
|
|
473
|
+
),
|
|
474
|
+
).toBe(false);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it("#1140 — refuses an out-of-range interval in install.json without touching the scheduler", async () => {
|
|
478
|
+
const events: string[] = [];
|
|
479
|
+
const { deps, recorded, lines } = upgradeDeps(events);
|
|
480
|
+
expect(await upgradeGcpInstall({ ...TARGET, schedulerIntervalMinutes: 60 }, deps)).toBe(true);
|
|
481
|
+
expect(recorded.contract).toEqual([]);
|
|
482
|
+
expect(lines.some((l) => l.includes("between 1 and 15"))).toBe(true);
|
|
483
|
+
expect(events.some((e) => /^gcloud workflows deploy/.test(e))).toBe(false);
|
|
484
|
+
});
|
|
485
|
+
|
|
426
486
|
it("an unreadable audit job is reported as an error, not as a missing job, and is not moved", async () => {
|
|
427
487
|
const events: string[] = [];
|
|
428
488
|
const { deps, lines } = upgradeDeps(events, {
|
|
@@ -748,10 +808,33 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
|
|
|
748
808
|
expect(indexOf(events, /^gcloud sql backups create/)).toBeLessThan(revoke);
|
|
749
809
|
expect(revoke).toBeLessThan(indexOf(events, /^migrate /));
|
|
750
810
|
expect(lines).toContain(
|
|
751
|
-
"done:
|
|
811
|
+
"done: The gateway logins hold no database roles (public_role and public_staging_role hold no database roles on caelo-production-pg-abc)",
|
|
752
812
|
);
|
|
753
813
|
});
|
|
754
814
|
|
|
815
|
+
it("creates the staging database and its login before the revoke and the migrations (#1329)", async () => {
|
|
816
|
+
const events: string[] = [];
|
|
817
|
+
const { deps, lines } = upgradeDeps(events);
|
|
818
|
+
expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
|
|
819
|
+
const created = indexOf(events, /^gcloud sql databases (list|create)/);
|
|
820
|
+
const revoke = indexOf(events, /^gcloud sql users assign-roles public_staging_role/);
|
|
821
|
+
expect(created).toBeGreaterThan(-1);
|
|
822
|
+
expect(created).toBeLessThan(revoke);
|
|
823
|
+
expect(revoke).toBeLessThan(indexOf(events, /^migrate /));
|
|
824
|
+
expect(lines).toContain("done: Staging database and its gateway login ready");
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
it("a staging database that cannot be created stops the upgrade before migrations and any roll (#1329)", async () => {
|
|
828
|
+
const events: string[] = [];
|
|
829
|
+
const { deps, calls, lines } = upgradeDeps(events, {
|
|
830
|
+
overrides: [[/^sql databases list/, fail("PERMISSION_DENIED: no")]],
|
|
831
|
+
});
|
|
832
|
+
expect(await upgradeGcpInstall(TARGET, deps)).toBe(false);
|
|
833
|
+
expect(events.some((e) => e.startsWith("migrate"))).toBe(false);
|
|
834
|
+
expect(calls.filter((c) => APP_ROLL_WRITE.test(c))).toEqual([]);
|
|
835
|
+
expect(lines).toContain("failed: The staging database could not be set up. Aborting upgrade.");
|
|
836
|
+
});
|
|
837
|
+
|
|
755
838
|
it("a refused public_role revoke stops the upgrade before migrations and any roll (#633)", async () => {
|
|
756
839
|
const events: string[] = [];
|
|
757
840
|
const { deps, calls, lines } = upgradeDeps(events, {
|
|
@@ -761,7 +844,7 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
|
|
|
761
844
|
expect(events.some((e) => e.startsWith("migrate"))).toBe(false);
|
|
762
845
|
expect(calls.filter((c) => APP_ROLL_WRITE.test(c))).toEqual([]);
|
|
763
846
|
expect(lines).toContain(
|
|
764
|
-
"failed:
|
|
847
|
+
"failed: The gateway logins' role memberships could not be revoked. Aborting upgrade.",
|
|
765
848
|
);
|
|
766
849
|
});
|
|
767
850
|
|
package/src/upgrade/upgrade.ts
CHANGED
|
@@ -57,6 +57,12 @@ export interface GcpUpgradeTarget {
|
|
|
57
57
|
readonly domain: string;
|
|
58
58
|
readonly stagingHost: string;
|
|
59
59
|
readonly ownerEmail: string;
|
|
60
|
+
/**
|
|
61
|
+
* #1140 — install.json's `schedulerIntervalMinutes` (absent: the default).
|
|
62
|
+
* Bounded to 1–15 by the converge, so a tampered record can change only
|
|
63
|
+
* how soon due work runs.
|
|
64
|
+
*/
|
|
65
|
+
readonly schedulerIntervalMinutes?: number;
|
|
60
66
|
/** The tag rolled to: a version (`0.11.0`) or a channel tag (`latest`, `rc`). */
|
|
61
67
|
readonly targetTag: string;
|
|
62
68
|
/** The infrastructure contract version this CLI converges to (infra-contract.ts). */
|
|
@@ -276,6 +282,9 @@ export async function upgradeGcpInstallOutcome(
|
|
|
276
282
|
const opsImageRef = await verifiedOpsImage(target, deps);
|
|
277
283
|
const converge = startConverge(plan, target.ownerEmail, {
|
|
278
284
|
opsImageRef,
|
|
285
|
+
...(target.schedulerIntervalMinutes !== undefined
|
|
286
|
+
? { schedulerIntervalMinutes: target.schedulerIntervalMinutes }
|
|
287
|
+
: {}),
|
|
279
288
|
run,
|
|
280
289
|
reporter: r,
|
|
281
290
|
...(deps.sleep ? { sleep: deps.sleep } : {}),
|
package/src/upgrader-gcp.ts
CHANGED
|
@@ -73,11 +73,12 @@ export async function upgraderDatabaseHost(
|
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
75
|
* #1338 — the database host for the converge job (#819), after making sure
|
|
76
|
-
* a verify-full connection to it can work.
|
|
77
|
-
*
|
|
78
|
-
* connects with verify-full only; on an install whose services still dial
|
|
79
|
-
* the instance's IP it first runs the converge's database TLS step
|
|
76
|
+
* a verify-full connection to it can work. On an install whose services
|
|
77
|
+
* still dial the instance's IP it runs the converge's database TLS step
|
|
80
78
|
* (cloudsql-tls.ts: shared CA, private DNS name, services moved onto it).
|
|
79
|
+
* Stage 2 of the job runs it as a timeline step, over a queue opened on
|
|
80
|
+
* the IP (converge-db-trust.ts, #1373), then reconnects on the host this
|
|
81
|
+
* returns.
|
|
81
82
|
* The job runs as the install's `caelo-provisioner`, which holds
|
|
82
83
|
* `roles/cloudsql.admin` and `roles/dns.admin` (gcloud.ts PROVISIONER_ROLES).
|
|
83
84
|
* Idempotent: the converge's own step finds everything in shape.
|
package/src/wizard.ts
CHANGED
|
@@ -72,6 +72,13 @@ export interface WizardOptions {
|
|
|
72
72
|
* install a different value is refused.
|
|
73
73
|
*/
|
|
74
74
|
region?: string;
|
|
75
|
+
/**
|
|
76
|
+
* #1140 — `--scheduler-interval-minutes` (1–15, already validated): how
|
|
77
|
+
* often the scheduler tick calls the admin. Recorded in install.json, so
|
|
78
|
+
* every later `upgrade` keeps it; omitted keeps what is recorded (or the
|
|
79
|
+
* default, 5).
|
|
80
|
+
*/
|
|
81
|
+
schedulerIntervalMinutes?: number;
|
|
75
82
|
/**
|
|
76
83
|
* #696 — where `pulumi up` and the rest of a GCP install run
|
|
77
84
|
* (run-mode.ts): `job` (default in Cloud Shell) or `local`.
|
|
@@ -192,6 +199,18 @@ export async function runWizard(opts: WizardOptions = {}): Promise<void> {
|
|
|
192
199
|
}
|
|
193
200
|
}
|
|
194
201
|
|
|
202
|
+
// #1140 — the scheduler knob, on a new install and on a resumed one alike
|
|
203
|
+
// (every later `upgrade` reads it from install.json).
|
|
204
|
+
if (opts.schedulerIntervalMinutes !== undefined) {
|
|
205
|
+
const recorded = readMetadata(installId);
|
|
206
|
+
if (recorded) {
|
|
207
|
+
writeMetadata(installId, {
|
|
208
|
+
...recorded,
|
|
209
|
+
schedulerIntervalMinutes: opts.schedulerIntervalMinutes,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
195
214
|
// 3.5. #656 — a re-run of an install an earlier CLI set up: drop the
|
|
196
215
|
// secrets that CLI checkpointed (database passwords, the setup link)
|
|
197
216
|
// before any step writes progress.json again, and say what to rotate.
|
|
@@ -7,7 +7,14 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { describe, expect, it } from "bun:test";
|
|
10
|
-
import {
|
|
10
|
+
import { SERVICE_CPU_IDLE } from "../stack-contract.js";
|
|
11
|
+
import {
|
|
12
|
+
adminKeptWarmByTicks,
|
|
13
|
+
alwaysOnAdminInstanceUsd,
|
|
14
|
+
type CostEstimateInputs,
|
|
15
|
+
estimateGcpCost,
|
|
16
|
+
schedulerTickMonthlyUsd,
|
|
17
|
+
} from "./gcp-cost.js";
|
|
11
18
|
|
|
12
19
|
const inputs: CostEstimateInputs = {
|
|
13
20
|
cloudSqlTier: "db-f1-micro",
|
|
@@ -48,3 +55,49 @@ describe("cost table: MCP-Auth-Proxy (#1328)", () => {
|
|
|
48
55
|
expect(names("gcp")).toContain("Cloud Run gateway");
|
|
49
56
|
});
|
|
50
57
|
});
|
|
58
|
+
|
|
59
|
+
describe("cost table: scheduler tick (#1140)", () => {
|
|
60
|
+
it("lists the tick at the default 5 minutes: well under a dollar", () => {
|
|
61
|
+
const line = estimateGcpCost(inputs).lines.find((l) => l.name.startsWith("Scheduler tick"));
|
|
62
|
+
expect(line?.name).toBe("Scheduler tick (Cloud Scheduler + Workflows, every 5 min)");
|
|
63
|
+
// 8,640 ticks: 25,920 internal steps (20,920 billed) + 8,640 external
|
|
64
|
+
// (6,640 billed) + the scheduler job.
|
|
65
|
+
expect(line?.monthlyUsd).toBe(0.48);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("follows the knob: one minute stays a few dollars, fifteen little more than the job", () => {
|
|
69
|
+
expect(schedulerTickMonthlyUsd(1)).toBe(2.38);
|
|
70
|
+
expect(schedulerTickMonthlyUsd(15)).toBe(0.16);
|
|
71
|
+
const fast = estimateGcpCost({ ...inputs, schedulerIntervalMinutes: 1 }).lines.find((l) =>
|
|
72
|
+
l.name.startsWith("Scheduler tick"),
|
|
73
|
+
);
|
|
74
|
+
expect(fast?.name).toContain("every 1 min");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("prices the admin kept up around the clock while its CPU is always allocated (#1361)", () => {
|
|
78
|
+
// The tick (at most every 15 minutes) beats Cloud Run's 15-minute idle
|
|
79
|
+
// shutdown, so a scale-to-zero admin never stops.
|
|
80
|
+
expect(SERVICE_CPU_IDLE.admin).toBe(false);
|
|
81
|
+
expect(adminKeptWarmByTicks(0)).toBe(true);
|
|
82
|
+
// 2,592,000 s x (1 vCPU x $0.000018 + 2 GiB x $0.000002).
|
|
83
|
+
expect(alwaysOnAdminInstanceUsd(2)).toBe(57);
|
|
84
|
+
const admin = estimateGcpCost(inputs).lines.find((l) => l.name.startsWith("Cloud Run admin"));
|
|
85
|
+
expect(admin?.monthlyUsd).toBe(57);
|
|
86
|
+
expect(admin?.notes).toContain("around the clock");
|
|
87
|
+
expect(admin?.notes).toContain("#1361");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("adds nothing on the admin with a min instance, which is up anyway", () => {
|
|
91
|
+
expect(adminKeptWarmByTicks(1)).toBe(false);
|
|
92
|
+
const admin = estimateGcpCost({ ...inputs, adminMinInstances: 1 }).lines.find((l) =>
|
|
93
|
+
l.name.startsWith("Cloud Run admin"),
|
|
94
|
+
);
|
|
95
|
+
expect(admin?.notes).toContain("the scheduler tick adds nothing");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("refuses an interval outside 1-15 minutes", () => {
|
|
99
|
+
expect(() => estimateGcpCost({ ...inputs, schedulerIntervalMinutes: 20 })).toThrow(
|
|
100
|
+
"between 1 and 15",
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
});
|
package/src/wizards/gcp-cost.ts
CHANGED
|
@@ -11,7 +11,12 @@
|
|
|
11
11
|
* + AI calls + storage growth on top.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
ADMIN_MEMORY_DEFAULT,
|
|
16
|
+
memoryQuantityMiB,
|
|
17
|
+
parseSchedulerIntervalMinutes,
|
|
18
|
+
SERVICE_CPU_IDLE,
|
|
19
|
+
} from "../stack-contract.js";
|
|
15
20
|
|
|
16
21
|
export interface CostLine {
|
|
17
22
|
name: string;
|
|
@@ -41,6 +46,82 @@ export interface CostEstimateInputs {
|
|
|
41
46
|
provider?: "gcp" | "gcp-firebase";
|
|
42
47
|
/** #607 — the install region; shown in the table (prices are {@link PRICED_REGION} rates). */
|
|
43
48
|
region: string;
|
|
49
|
+
/** #1140 — the `schedulerIntervalMinutes` knob (1–15); undefined: the default. */
|
|
50
|
+
schedulerIntervalMinutes?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* #1140 — Workflows steps one scheduler tick costs (scheduler-setup.ts
|
|
55
|
+
* schedulerWorkflowSource): the claims `assign`, the signJwt call (a Google
|
|
56
|
+
* API, billed as an internal step), the `return` — and the POST to the
|
|
57
|
+
* admin, the one external HTTP step.
|
|
58
|
+
*/
|
|
59
|
+
export const SCHEDULER_TICK_STEPS = { internal: 3, external: 1 } as const;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Workflows list prices (USD, cloud.google.com/workflows/pricing, as of
|
|
63
|
+
* 2026-11) and the monthly free tier per billing account.
|
|
64
|
+
*/
|
|
65
|
+
const WORKFLOWS_PRICE = {
|
|
66
|
+
internalPer1000: 0.01,
|
|
67
|
+
externalPer1000: 0.025,
|
|
68
|
+
freeInternal: 5_000,
|
|
69
|
+
freeExternal: 2_000,
|
|
70
|
+
} as const;
|
|
71
|
+
|
|
72
|
+
/** Cloud Scheduler: $0.10 per job a month; the first 3 per billing account are free. */
|
|
73
|
+
const SCHEDULER_JOB_USD = 0.1;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* #1140 — what the scheduler tick costs a month at `minutes` between ticks:
|
|
77
|
+
* the Cloud Scheduler job (priced as paid — the 3 free jobs are shared
|
|
78
|
+
* with every other project on the billing account) plus the Workflows
|
|
79
|
+
* steps past the free tier. What the tick costs on the admin's side is
|
|
80
|
+
* priced on the admin's own line ({@link adminKeptWarmByTicks}).
|
|
81
|
+
*/
|
|
82
|
+
export function schedulerTickMonthlyUsd(minutes: number): number {
|
|
83
|
+
const ticks = Math.ceil((30 * 24 * 60) / parseSchedulerIntervalMinutes(minutes));
|
|
84
|
+
const internal = Math.max(
|
|
85
|
+
0,
|
|
86
|
+
ticks * SCHEDULER_TICK_STEPS.internal - WORKFLOWS_PRICE.freeInternal,
|
|
87
|
+
);
|
|
88
|
+
const external = Math.max(
|
|
89
|
+
0,
|
|
90
|
+
ticks * SCHEDULER_TICK_STEPS.external - WORKFLOWS_PRICE.freeExternal,
|
|
91
|
+
);
|
|
92
|
+
const usd =
|
|
93
|
+
SCHEDULER_JOB_USD +
|
|
94
|
+
(internal / 1000) * WORKFLOWS_PRICE.internalPer1000 +
|
|
95
|
+
(external / 1000) * WORKFLOWS_PRICE.externalPer1000;
|
|
96
|
+
return Math.round(usd * 100) / 100;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Cloud Run instance-based billing (CPU always allocated) list rates, USD,
|
|
101
|
+
* tier-1 region, cloud.google.com/run/pricing as of 2026-11. The free tier
|
|
102
|
+
* is left out: it is shared by every service on the billing account.
|
|
103
|
+
*/
|
|
104
|
+
const CLOUD_RUN_INSTANCE_PRICE = { vcpuSecond: 0.000018, gibSecond: 0.000002 } as const;
|
|
105
|
+
|
|
106
|
+
/** Seconds in the 30-day month the table prices. */
|
|
107
|
+
const MONTH_SECONDS = 30 * 24 * 60 * 60;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* #1140 — whether the scheduler tick keeps a scale-to-zero admin instance
|
|
111
|
+
* up around the clock. Cloud Run keeps an idle instance for up to 15
|
|
112
|
+
* minutes after its last request, and the tick comes at most every 15
|
|
113
|
+
* minutes, so with CPU always allocated (`SERVICE_CPU_IDLE.admin` false
|
|
114
|
+
* until #1361) the instance never stops and is billed every second. With
|
|
115
|
+
* request-billed CPU an idle instance costs nothing between ticks.
|
|
116
|
+
*/
|
|
117
|
+
export function adminKeptWarmByTicks(adminMinInstances: number): boolean {
|
|
118
|
+
return adminMinInstances === 0 && !SERVICE_CPU_IDLE.admin;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** One always-on admin instance (1 vCPU, `gib` memory) a month at instance-based list rates. */
|
|
122
|
+
export function alwaysOnAdminInstanceUsd(gib: number): number {
|
|
123
|
+
const perSecond = CLOUD_RUN_INSTANCE_PRICE.vcpuSecond + gib * CLOUD_RUN_INSTANCE_PRICE.gibSecond;
|
|
124
|
+
return Math.round(perSecond * MONTH_SECONDS);
|
|
44
125
|
}
|
|
45
126
|
|
|
46
127
|
/** The region the price table's list rates are for. */
|
|
@@ -71,6 +152,7 @@ export function estimateGcpCost(inputs: CostEstimateInputs): {
|
|
|
71
152
|
const sqlMonthly = Math.round(sqlBase * sqlMultiplier);
|
|
72
153
|
|
|
73
154
|
const provider = inputs.provider ?? "gcp";
|
|
155
|
+
const schedulerMinutes = parseSchedulerIntervalMinutes(inputs.schedulerIntervalMinutes);
|
|
74
156
|
const adminMemory = inputs.adminMemory ?? ADMIN_MEMORY_DEFAULT;
|
|
75
157
|
const adminMiB = memoryQuantityMiB(adminMemory);
|
|
76
158
|
if (adminMiB === null) {
|
|
@@ -91,17 +173,20 @@ export function estimateGcpCost(inputs: CostEstimateInputs): {
|
|
|
91
173
|
},
|
|
92
174
|
{
|
|
93
175
|
name: `Cloud Run admin (${adminMemory})`,
|
|
94
|
-
// FIXME(issue #
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
176
|
+
// FIXME(issue #1361): with request-billed CPU (SERVICE_CPU_IDLE) the
|
|
177
|
+
// ticks stop keeping the instance up and this line drops to ~$7 of
|
|
178
|
+
// editing sessions. Until then the scheduler tick (#1140) keeps one
|
|
179
|
+
// instance up around the clock, billed at the always-on rate.
|
|
180
|
+
monthlyUsd: adminKeptWarmByTicks(inputs.adminMinInstances)
|
|
181
|
+
? alwaysOnAdminInstanceUsd(adminGiB)
|
|
182
|
+
: inputs.adminMinInstances === 0
|
|
99
183
|
? 7
|
|
100
184
|
: Math.round(1 + inputs.adminMinInstances * perMinInstanceUsd),
|
|
101
|
-
notes:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
185
|
+
notes: adminKeptWarmByTicks(inputs.adminMinInstances)
|
|
186
|
+
? `scale-to-zero, but CPU is always allocated and the scheduler tick (every ${schedulerMinutes} min) comes before Cloud Run's 15-minute idle shutdown, so one instance (1 vCPU, ${adminMemory}) runs around the clock until request-billed CPU lands (#1361)`
|
|
187
|
+
: inputs.adminMinInstances === 0
|
|
188
|
+
? "scale-to-zero, request-billed CPU; ~$7/mo light editorial use, ticks inside the free tier"
|
|
189
|
+
: `${inputs.adminMinInstances} min-instance${inputs.adminMinInstances > 1 ? "s" : ""} (no cold start); the scheduler tick adds nothing, the instance is up anyway`,
|
|
105
190
|
},
|
|
106
191
|
{
|
|
107
192
|
name: "Quality checks (Cloud Run audit job)",
|
|
@@ -192,7 +277,7 @@ export function estimateGcpCost(inputs: CostEstimateInputs): {
|
|
|
192
277
|
name: "Secret Manager (runtime secrets)",
|
|
193
278
|
monthlyUsd: 0.2,
|
|
194
279
|
notes:
|
|
195
|
-
"6 active versions free, then ~$0.06 each; the Google sign-in client (#914)
|
|
280
|
+
"6 active versions free, then ~$0.06 each; the Google sign-in client (#914), the remote-MCP seal key (#806) and the staging database login's password (#1329; the staging database itself shares the Cloud SQL instance at no extra cost)",
|
|
196
281
|
},
|
|
197
282
|
{
|
|
198
283
|
name: "BigQuery edge log sink",
|
|
@@ -211,6 +296,11 @@ export function estimateGcpCost(inputs: CostEstimateInputs): {
|
|
|
211
296
|
notes:
|
|
212
297
|
"Hourly ~20 s job keeps Google IAP in step with the user list; within the Cloud Run free tier, 1 of 3 free scheduler jobs",
|
|
213
298
|
},
|
|
299
|
+
{
|
|
300
|
+
name: `Scheduler tick (Cloud Scheduler + Workflows, every ${schedulerMinutes} min)`,
|
|
301
|
+
monthlyUsd: schedulerTickMonthlyUsd(schedulerMinutes),
|
|
302
|
+
notes: `one job starts a ${SCHEDULER_TICK_STEPS.internal + SCHEDULER_TICK_STEPS.external}-step workflow that calls the admin's periodic work and plugin workers; ~${Math.ceil((30 * 24 * 60) / schedulerMinutes)} ticks/mo; what the ticks cost on the admin is on the admin line`,
|
|
303
|
+
},
|
|
214
304
|
];
|
|
215
305
|
|
|
216
306
|
// Cents, so a sub-dollar line (the KMS key) doesn't print float noise.
|