@caelo-cms/provisioning 0.11.10 → 0.11.12
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 +196 -23
- 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 +12 -2
- package/dist/converge-job-gcp.d.ts.map +1 -1
- package/dist/converge-job-gcp.js +42 -3
- package/dist/converge-job-gcp.js.map +1 -1
- package/dist/converge-job-preflight.d.ts +34 -2
- package/dist/converge-job-preflight.d.ts.map +1 -1
- package/dist/converge-job-preflight.js +76 -1
- package/dist/converge-job-preflight.js.map +1 -1
- package/dist/converge-job-setup.d.ts.map +1 -1
- package/dist/converge-job-setup.js +4 -1
- package/dist/converge-job-setup.js.map +1 -1
- package/dist/converge-job.d.ts +44 -3
- package/dist/converge-job.d.ts.map +1 -1
- package/dist/converge-job.js +105 -11
- 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 +10 -2
- package/dist/install-job.d.ts.map +1 -1
- package/dist/install-job.js +10 -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/mcp-auth-proxy-setup.d.ts +3 -1
- package/dist/mcp-auth-proxy-setup.d.ts.map +1 -1
- package/dist/mcp-auth-proxy-setup.js +9 -4
- package/dist/mcp-auth-proxy-setup.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/upgrader-iam-preflight.d.ts +8 -6
- package/dist/upgrader-iam-preflight.d.ts.map +1 -1
- package/dist/upgrader-iam-preflight.js +5 -1
- package/dist/upgrader-iam-preflight.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 +82 -12
- 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 +42 -2
- package/src/converge-job-preflight.test.ts +106 -0
- package/src/converge-job-preflight.ts +97 -3
- package/src/converge-job-setup.ts +4 -1
- package/src/converge-job.test.ts +213 -0
- package/src/converge-job.ts +175 -19
- 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 +10 -1
- package/src/install-state.ts +7 -0
- package/src/lifecycle.ts +3 -0
- package/src/mcp-auth-proxy-setup.test.ts +33 -1
- package/src/mcp-auth-proxy-setup.ts +11 -4
- 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/upgrader-iam-preflight.ts +15 -10
- package/src/wizard.ts +19 -0
- package/src/wizards/gcp-cost.test.ts +54 -1
- package/src/wizards/gcp-cost.ts +102 -12
- 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.
|
|
@@ -92,9 +92,9 @@ export interface AccessCheck {
|
|
|
92
92
|
readonly permissions: readonly string[];
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
/** A check with the permissions it lacks. */
|
|
96
|
-
export interface MissingAccess {
|
|
97
|
-
readonly check:
|
|
95
|
+
/** A check with the permissions it lacks (`C`: the caller's own check type, handed back as given). */
|
|
96
|
+
export interface MissingAccess<C extends AccessCheck = AccessCheck> {
|
|
97
|
+
readonly check: C;
|
|
98
98
|
readonly missing: readonly string[];
|
|
99
99
|
/**
|
|
100
100
|
* Permissions that could not be judged because another one is missing
|
|
@@ -114,9 +114,9 @@ export interface IamTester {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
/** How the self-check came out. */
|
|
117
|
-
export type PreflightOutcome =
|
|
117
|
+
export type PreflightOutcome<C extends AccessCheck = AccessCheck> =
|
|
118
118
|
| { readonly kind: "ok" }
|
|
119
|
-
| { readonly kind: "missing"; readonly missing: readonly MissingAccess[] }
|
|
119
|
+
| { readonly kind: "missing"; readonly missing: readonly MissingAccess<C>[] }
|
|
120
120
|
/** IAM could not be asked about at least one resource; nothing was judged missing. */
|
|
121
121
|
| { readonly kind: "error"; readonly errors: readonly string[] };
|
|
122
122
|
|
|
@@ -250,10 +250,10 @@ export function upgraderAccessChecks(target: {
|
|
|
250
250
|
}
|
|
251
251
|
|
|
252
252
|
/** Ask IAM about every check (one call per resource); never throws. */
|
|
253
|
-
export async function runIamPreflight(
|
|
254
|
-
checks: readonly
|
|
253
|
+
export async function runIamPreflight<C extends AccessCheck>(
|
|
254
|
+
checks: readonly C[],
|
|
255
255
|
tester: IamTester,
|
|
256
|
-
): Promise<PreflightOutcome
|
|
256
|
+
): Promise<PreflightOutcome<C>> {
|
|
257
257
|
// One question per resource: the project alone carries four checks.
|
|
258
258
|
const byResource = new Map<string, { resource: IamResource; permissions: Set<string> }>();
|
|
259
259
|
for (const c of checks) {
|
|
@@ -300,7 +300,7 @@ export async function runIamPreflight(
|
|
|
300
300
|
}),
|
|
301
301
|
);
|
|
302
302
|
if (errors.length > 0) return { kind: "error", errors: errors.sort() };
|
|
303
|
-
const missing: MissingAccess[] = [];
|
|
303
|
+
const missing: MissingAccess<C>[] = [];
|
|
304
304
|
for (const check of checks) {
|
|
305
305
|
const key = resourceName(check.resource);
|
|
306
306
|
const held = granted.get(key) ?? new Set<string>();
|
|
@@ -343,6 +343,11 @@ export function preflightMissingReason(
|
|
|
343
343
|
missing: readonly MissingAccess[],
|
|
344
344
|
voice: PreflightVoice,
|
|
345
345
|
): string {
|
|
346
|
+
return `Nothing was changed: ${voice.subject} checked its access before starting and is missing permissions this update needs — ${missingAccessText(missing)}. ${voice.repair}`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Every missing permission with what it is for, as one clause list ("it cannot …; it cannot …"). */
|
|
350
|
+
export function missingAccessText(missing: readonly MissingAccess[]): string {
|
|
346
351
|
const lines = missing.map((m) => {
|
|
347
352
|
const where = resourceName(m.check.resource);
|
|
348
353
|
const parts = [
|
|
@@ -355,7 +360,7 @@ export function preflightMissingReason(
|
|
|
355
360
|
];
|
|
356
361
|
return `it ${m.missing.length > 0 ? "cannot" : "may not be able to"} ${m.check.need} (${parts.join("; ")})`;
|
|
357
362
|
});
|
|
358
|
-
return
|
|
363
|
+
return lines.join("; ");
|
|
359
364
|
}
|
|
360
365
|
|
|
361
366
|
/** The request's end reason when IAM could not be asked. */
|
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
|
+
});
|