@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/cli.ts
CHANGED
|
@@ -86,11 +86,12 @@ import {
|
|
|
86
86
|
resolveVerifiedRelease,
|
|
87
87
|
} from "./self-hosted-release.js";
|
|
88
88
|
import {
|
|
89
|
-
|
|
89
|
+
applyPreMigrationRoles,
|
|
90
90
|
convergeSelfHostedRoles,
|
|
91
91
|
decideUp,
|
|
92
92
|
selfHostedBootstrapScript,
|
|
93
93
|
} from "./self-hosted-roles.js";
|
|
94
|
+
import { parseSchedulerIntervalMinutes } from "./stack-contract.js";
|
|
94
95
|
|
|
95
96
|
interface CaeloConfig {
|
|
96
97
|
domain: string;
|
|
@@ -153,6 +154,12 @@ interface CaeloConfig {
|
|
|
153
154
|
* carry it).
|
|
154
155
|
*/
|
|
155
156
|
composeProject?: string;
|
|
157
|
+
/**
|
|
158
|
+
* #1140 — the `schedulerIntervalMinutes` knob (`init
|
|
159
|
+
* --scheduler-interval-minutes`, 1–15): how often the scheduler container
|
|
160
|
+
* calls the admin's tick. Absent: the default (5).
|
|
161
|
+
*/
|
|
162
|
+
schedulerIntervalMinutes?: number;
|
|
156
163
|
}
|
|
157
164
|
|
|
158
165
|
const REPO_ROOT = resolve(import.meta.dir, "../../..");
|
|
@@ -255,6 +262,15 @@ function loadConfig(): CaeloConfig | null {
|
|
|
255
262
|
cfg.composeProject ??= "caelo";
|
|
256
263
|
saveConfig(cfg);
|
|
257
264
|
}
|
|
265
|
+
// #1329 — the staging gateway's login came with the staging database. An
|
|
266
|
+
// install from before gets its own password now; the next `up` or
|
|
267
|
+
// upgrade creates the role with it (self-hosted-roles.ts) before anything
|
|
268
|
+
// uses it, so the roles count as not converged until then.
|
|
269
|
+
if (cfg.rolePasswords && !cfg.rolePasswords.publicStaging) {
|
|
270
|
+
cfg.rolePasswords = { ...cfg.rolePasswords, publicStaging: randomSecret(32) };
|
|
271
|
+
cfg.rolesConverged = false;
|
|
272
|
+
saveConfig(cfg);
|
|
273
|
+
}
|
|
258
274
|
return cfg;
|
|
259
275
|
}
|
|
260
276
|
|
|
@@ -269,9 +285,9 @@ function arg(name: string): string | undefined {
|
|
|
269
285
|
return process.argv[idx + 1];
|
|
270
286
|
}
|
|
271
287
|
|
|
272
|
-
/** Fresh passwords for the
|
|
288
|
+
/** Fresh passwords for the application roles. */
|
|
273
289
|
function newRolePasswords(): RolePasswords {
|
|
274
|
-
return { admin: randomSecret(32), public: randomSecret(32) };
|
|
290
|
+
return { admin: randomSecret(32), public: randomSecret(32), publicStaging: randomSecret(32) };
|
|
275
291
|
}
|
|
276
292
|
|
|
277
293
|
function emitConfig(cfg: CaeloConfig, extraDomains: CaddyDomainSpec[] = []): void {
|
|
@@ -369,6 +385,9 @@ function renderCompose(cfg: CaeloConfig, images: ComposeImages): string {
|
|
|
369
385
|
images,
|
|
370
386
|
ports: cfg.ports,
|
|
371
387
|
project: cfg.composeProject,
|
|
388
|
+
...(cfg.schedulerIntervalMinutes !== undefined
|
|
389
|
+
? { schedulerIntervalMinutes: cfg.schedulerIntervalMinutes }
|
|
390
|
+
: {}),
|
|
372
391
|
...(activeAgent(cfg) ? { agent: activeAgent(cfg) } : {}),
|
|
373
392
|
});
|
|
374
393
|
}
|
|
@@ -405,8 +424,20 @@ function agentContext(cfg: CaeloConfig): AgentCliContext {
|
|
|
405
424
|
...(activeAgent(cfg) ? { agentPassword: activeAgent(cfg)?.dbPassword } : {}),
|
|
406
425
|
});
|
|
407
426
|
},
|
|
408
|
-
|
|
409
|
-
|
|
427
|
+
applyPreMigrationRoles: () => {
|
|
428
|
+
if (!cfg.rolePasswords) {
|
|
429
|
+
return Promise.resolve({
|
|
430
|
+
ok: false as const,
|
|
431
|
+
reachable: true,
|
|
432
|
+
error: "config.json has no role passwords — run `cms-provision up` first",
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
return applyPreMigrationRoles({
|
|
436
|
+
probe: postgresIsUp,
|
|
437
|
+
psql: superuserPsql,
|
|
438
|
+
stagingPassword: cfg.rolePasswords.publicStaging,
|
|
439
|
+
});
|
|
440
|
+
},
|
|
410
441
|
renderCompose: (images) => renderCompose(cfg, images),
|
|
411
442
|
refreshCaddy: () => refreshCaddyfile(cfg),
|
|
412
443
|
randomSecret: () => randomSecret(32),
|
|
@@ -562,7 +593,7 @@ async function init(): Promise<void> {
|
|
|
562
593
|
}
|
|
563
594
|
if (!domain || !ownerEmail) {
|
|
564
595
|
console.error(
|
|
565
|
-
"Usage: cms-provision init [--provider self-hosted] --domain example.com [--staging-domain staging.example.com] --owner-email me@example.com [--version x.y.z] [--skip-verify] [--dev] [--tls acme|internal] [--http-port 80] [--https-port 443]\n" +
|
|
596
|
+
"Usage: cms-provision init [--provider self-hosted] --domain example.com [--staging-domain staging.example.com] --owner-email me@example.com [--version x.y.z] [--skip-verify] [--dev] [--tls acme|internal] [--http-port 80] [--https-port 443] [--scheduler-interval-minutes 5]\n" +
|
|
566
597
|
"Cloud providers (gcp | gcp-firebase | aws | azure) run the wizard: cms-provision --provider <name>",
|
|
567
598
|
);
|
|
568
599
|
process.exit(2);
|
|
@@ -577,6 +608,17 @@ async function init(): Promise<void> {
|
|
|
577
608
|
console.error("--http-port and --https-port must differ");
|
|
578
609
|
process.exit(2);
|
|
579
610
|
}
|
|
611
|
+
const schedulerArg = arg("scheduler-interval-minutes");
|
|
612
|
+
let schedulerIntervalMinutes: number | undefined;
|
|
613
|
+
try {
|
|
614
|
+
schedulerIntervalMinutes =
|
|
615
|
+
schedulerArg === undefined
|
|
616
|
+
? undefined
|
|
617
|
+
: parseSchedulerIntervalMinutes(schedulerArg, "--scheduler-interval-minutes");
|
|
618
|
+
} catch (e) {
|
|
619
|
+
console.error((e as Error).message);
|
|
620
|
+
process.exit(2);
|
|
621
|
+
}
|
|
580
622
|
const stagingArg = arg("staging-domain");
|
|
581
623
|
const stagingProblem = stagingArg
|
|
582
624
|
? stagingHostProblem(stagingArg, {
|
|
@@ -645,6 +687,7 @@ async function init(): Promise<void> {
|
|
|
645
687
|
tls,
|
|
646
688
|
ports,
|
|
647
689
|
composeProject: composeProjectName(domain),
|
|
690
|
+
...(schedulerIntervalMinutes !== undefined ? { schedulerIntervalMinutes } : {}),
|
|
648
691
|
};
|
|
649
692
|
saveConfig(cfg);
|
|
650
693
|
// P15.1 — the shared HMAC secret for /api/internal/* endpoints, read by
|
|
@@ -1057,7 +1100,7 @@ async function upgradeSelfHostedCommand(opts: {
|
|
|
1057
1100
|
runner: bunHostRunner,
|
|
1058
1101
|
renderCompose: (images) => renderCompose(cfg, images),
|
|
1059
1102
|
convergeRoles: agentContext(cfg).convergeRoles,
|
|
1060
|
-
|
|
1103
|
+
applyPreMigrationRoles: agentContext(cfg).applyPreMigrationRoles,
|
|
1061
1104
|
refreshCaddy: () => refreshCaddyfile(cfg),
|
|
1062
1105
|
log: {
|
|
1063
1106
|
step: (m) => console.log(`\n==> ${m}`),
|
|
@@ -1625,13 +1668,25 @@ async function waitForDomainMappingCert(target: {
|
|
|
1625
1668
|
* §11.C — interactive wizard. Loaded lazily so the bare-CLI startup
|
|
1626
1669
|
* cost stays small (clack + kleur are pulled in only when needed).
|
|
1627
1670
|
* Reads `--provider`, `--domain`, `--owner-email`, `--project-id`, `--region`,
|
|
1628
|
-
* `--non-interactive` and the run mode (`--local` / `--install-job`, default:
|
|
1671
|
+
* `--scheduler-interval-minutes` (#1140), `--non-interactive` and the run mode (`--local` / `--install-job`, default:
|
|
1629
1672
|
* the install job in Cloud Shell, local elsewhere — run-mode.ts) from argv.
|
|
1630
1673
|
*/
|
|
1631
1674
|
async function wizardCommand(): Promise<void> {
|
|
1632
1675
|
const { runWizard } = await import("./wizard.js");
|
|
1633
1676
|
const { chooseRunMode } = await import("./run-mode.js");
|
|
1677
|
+
const schedulerArg = arg("scheduler-interval-minutes");
|
|
1678
|
+
let schedulerIntervalMinutes: number | undefined;
|
|
1679
|
+
try {
|
|
1680
|
+
schedulerIntervalMinutes =
|
|
1681
|
+
schedulerArg === undefined
|
|
1682
|
+
? undefined
|
|
1683
|
+
: parseSchedulerIntervalMinutes(schedulerArg, "--scheduler-interval-minutes");
|
|
1684
|
+
} catch (e) {
|
|
1685
|
+
console.error((e as Error).message);
|
|
1686
|
+
process.exit(2);
|
|
1687
|
+
}
|
|
1634
1688
|
await runWizard({
|
|
1689
|
+
...(schedulerIntervalMinutes !== undefined ? { schedulerIntervalMinutes } : {}),
|
|
1635
1690
|
runMode: chooseRunMode(process.argv, process.env),
|
|
1636
1691
|
nonInteractive: process.argv.includes("--non-interactive"),
|
|
1637
1692
|
provider: arg("provider") as Provider | undefined,
|
package/src/cloudsql-tls.test.ts
CHANGED
|
@@ -94,6 +94,36 @@ describe("ensureCloudSqlVerifyFull", () => {
|
|
|
94
94
|
]);
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
+
it("reads the DNS name after the move when the shared CA's certificate is active at once", async () => {
|
|
98
|
+
// searchviu-website, v0.11.10: the patch made the shared CA's certificate
|
|
99
|
+
// the active one, so no rotation re-read the instance, and the
|
|
100
|
+
// description from before the patch named no DNS host.
|
|
101
|
+
let described = 0;
|
|
102
|
+
const g = fake((a) => {
|
|
103
|
+
const line = a.join(" ");
|
|
104
|
+
if (line.startsWith("sql instances describe")) {
|
|
105
|
+
described += 1;
|
|
106
|
+
return ok(
|
|
107
|
+
instance(
|
|
108
|
+
described === 1
|
|
109
|
+
? { caMode: "GOOGLE_MANAGED_INTERNAL_CA", sslMode: "ENCRYPTED_ONLY", dns: false }
|
|
110
|
+
: { caMode: "GOOGLE_MANAGED_CAS_CA", sslMode: "ENCRYPTED_ONLY", dns: true },
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (line.startsWith("auth print-access-token")) return ok("token");
|
|
115
|
+
if (line.includes(" describe ")) return notFound();
|
|
116
|
+
return ok();
|
|
117
|
+
});
|
|
118
|
+
const r = await ensureCloudSqlVerifyFull(TARGET, {
|
|
119
|
+
run: g.run,
|
|
120
|
+
http: certs({ activeVersion: "f1", serverCerts: [{ sha1Fingerprint: "f1" }] }),
|
|
121
|
+
sleep: async () => {},
|
|
122
|
+
});
|
|
123
|
+
expect(r).toMatchObject({ ok: true, host: DNS });
|
|
124
|
+
expect(g.calls.filter((c) => c.startsWith("sql ssl"))).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
97
127
|
it("changes nothing on an instance already in shape", async () => {
|
|
98
128
|
const g = fake((a) => {
|
|
99
129
|
const line = a.join(" ");
|
package/src/cloudsql-tls.ts
CHANGED
|
@@ -72,8 +72,10 @@ export type CloudSqlTlsResult =
|
|
|
72
72
|
| { readonly ok: true; readonly host: string; readonly done: string[] }
|
|
73
73
|
| { readonly ok: false; readonly error: string; readonly done: string[] };
|
|
74
74
|
|
|
75
|
-
/** The parts of `gcloud sql instances describe --format=json` this
|
|
76
|
-
interface InstanceDescription {
|
|
75
|
+
/** The parts of `gcloud sql instances describe --format=json` this module and converge-db-trust.ts read. */
|
|
76
|
+
export interface InstanceDescription {
|
|
77
|
+
/** The CA of the instance's current server certificate (PEM in `cert`). */
|
|
78
|
+
readonly serverCaCert?: { readonly cert?: string };
|
|
77
79
|
readonly settings?: {
|
|
78
80
|
readonly ipConfiguration?: {
|
|
79
81
|
readonly serverCaMode?: string;
|
|
@@ -88,7 +90,8 @@ interface InstanceDescription {
|
|
|
88
90
|
const NOT_FOUND = /NOT_FOUND|not found|does not exist|404/i;
|
|
89
91
|
const SQL_ADMIN = "https://sqladmin.googleapis.com/v1";
|
|
90
92
|
|
|
91
|
-
|
|
93
|
+
/** `gcloud sql instances describe` of the install's instance, parsed. */
|
|
94
|
+
export async function describeInstance(
|
|
92
95
|
t: CloudSqlTlsTarget,
|
|
93
96
|
run: GcloudRunner,
|
|
94
97
|
): Promise<{ ok: true; d: InstanceDescription } | { ok: false; error: string }> {
|
|
@@ -170,6 +173,10 @@ export async function ensureCloudSqlVerifyFull(
|
|
|
170
173
|
const r = await run(["sql", "instances", "patch", t.sqlInstance, project, ...patch, "--quiet"]);
|
|
171
174
|
if (!r.ok) return fail(`patch ${t.sqlInstance} (${patch.join(" ")}): ${r.stderr.trim()}`);
|
|
172
175
|
done.push(`Cloud SQL ${t.sqlInstance}: ${patch.join(" ")}`);
|
|
176
|
+
// The instance reports its DNS name only once it uses the shared CA, so
|
|
177
|
+
// the description from before the patch cannot name the host.
|
|
178
|
+
described = await describeInstance(t, run);
|
|
179
|
+
if (!described.ok) return fail(described.error);
|
|
173
180
|
}
|
|
174
181
|
|
|
175
182
|
// The active server certificate must come from the shared CA. Before the
|
package/src/compose.ts
CHANGED
|
@@ -37,9 +37,11 @@ import {
|
|
|
37
37
|
} from "./self-hosted-db-tls.js";
|
|
38
38
|
import {
|
|
39
39
|
ADMIN_MEMORY_DEFAULT,
|
|
40
|
+
GATEWAY_ENVIRONMENT_ENV,
|
|
40
41
|
GATEWAY_TRUSTED_PROXY_HOPS,
|
|
41
42
|
MCP_AUTH_PROXY_TRUSTED_PROXY_HOPS,
|
|
42
43
|
memoryQuantityMiB,
|
|
44
|
+
parseSchedulerIntervalMinutes,
|
|
43
45
|
publicSiteUrl,
|
|
44
46
|
REMOTE_MCP_MODE,
|
|
45
47
|
REMOTE_MCP_MODE_ENV,
|
|
@@ -84,6 +86,8 @@ export const COMPOSE_POOL_BUDGET = stackPoolBudget("self-hosted", {
|
|
|
84
86
|
maxConnections: DEFAULT_MAX_CONNECTIONS["self-hosted"],
|
|
85
87
|
adminMaxInstances: 1,
|
|
86
88
|
gatewayMaxInstances: 1,
|
|
89
|
+
// #1329 — one staging gateway container (P3b-2) next to the live one.
|
|
90
|
+
stagingGatewayMaxInstances: 1,
|
|
87
91
|
});
|
|
88
92
|
|
|
89
93
|
/** The budget's env as Compose `environment:` lines for `service`. */
|
|
@@ -216,6 +220,8 @@ export function ghcrImageDigest(service: ReleaseService, ref: string): string |
|
|
|
216
220
|
export interface RolePasswords {
|
|
217
221
|
readonly admin: string;
|
|
218
222
|
readonly public: string;
|
|
223
|
+
/** #1329 — public_staging_role's, the staging gateway's login on cms_public_staging. */
|
|
224
|
+
readonly publicStaging: string;
|
|
219
225
|
}
|
|
220
226
|
|
|
221
227
|
export interface ComposeSpec {
|
|
@@ -258,6 +264,12 @@ export interface ComposeSpec {
|
|
|
258
264
|
readonly adminMemory?: string;
|
|
259
265
|
/** Which Caelo images run (see {@link ComposeImages}). */
|
|
260
266
|
readonly images: ComposeImages;
|
|
267
|
+
/**
|
|
268
|
+
* #1140 — the `schedulerIntervalMinutes` knob: how often the scheduler
|
|
269
|
+
* container calls the admin's tick (1–15, default 5; stack-contract.ts
|
|
270
|
+
* SCHEDULER_INTERVAL_MINUTES).
|
|
271
|
+
*/
|
|
272
|
+
readonly schedulerIntervalMinutes?: number;
|
|
261
273
|
/**
|
|
262
274
|
* The host ports Caddy listens on (`80` / `443` for a public install —
|
|
263
275
|
* ACME's HTTP and TLS-ALPN challenges need exactly those).
|
|
@@ -336,6 +348,7 @@ services:
|
|
|
336
348
|
POSTGRES_DB: caelo
|
|
337
349
|
ADMIN_ROLE_PASSWORD: "${escapeYaml(spec.rolePasswords.admin)}"
|
|
338
350
|
PUBLIC_ROLE_PASSWORD: "${escapeYaml(spec.rolePasswords.public)}"
|
|
351
|
+
PUBLIC_STAGING_ROLE_PASSWORD: "${escapeYaml(spec.rolePasswords.publicStaging)}"
|
|
339
352
|
# Recreates Postgres when its certificate is renewed (see the spec).
|
|
340
353
|
CAELO_DB_TLS_SERVER_CERT_SHA256: "${spec.dbTlsServerCertSha256}"
|
|
341
354
|
${postgresAgent(spec.agent)} volumes:
|
|
@@ -366,6 +379,7 @@ ${adminService(spec, adminMemoryMiB, env)}
|
|
|
366
379
|
|
|
367
380
|
${gatewayService(spec)}
|
|
368
381
|
${mcpAuthProxyService(spec.images)}
|
|
382
|
+
${schedulerService(spec)}
|
|
369
383
|
volumes:
|
|
370
384
|
caelo-pg:
|
|
371
385
|
caelo-caddy-data:
|
|
@@ -457,6 +471,8 @@ ${serviceRuntime(
|
|
|
457
471
|
environment:
|
|
458
472
|
ADMIN_DATABASE_URL: "${dbUrl("admin_role", spec.rolePasswords.admin, "cms_admin")}"
|
|
459
473
|
PUBLIC_ADMIN_DATABASE_URL: "${dbUrl("admin_role", spec.rolePasswords.admin, "cms_public")}"
|
|
474
|
+
# #1329 — the staging database's plugin schemas and projection.
|
|
475
|
+
PUBLIC_STAGING_ADMIN_DATABASE_URL: "${dbUrl("admin_role", spec.rolePasswords.admin, "cms_public_staging")}"
|
|
460
476
|
CAELO_SECRET_KEK: "${escapeYaml(spec.caeloSecretKek)}"
|
|
461
477
|
CAELO_INTERNAL_SECRET: "${escapeYaml(spec.internalSecret)}"
|
|
462
478
|
CAELO_SITE_URL: "${escapeYaml(selfHostedSiteUrl(spec.domain, spec.ports.https))}"
|
|
@@ -480,6 +496,8 @@ ${serviceRuntime(spec.images, "gateway", "bun run apps/api-gateway/src/server.ts
|
|
|
480
496
|
# public_role on cms_public is its only database login (#613, #635,
|
|
481
497
|
# CLAUDE.md §2: the public side has no path to the authoring database).
|
|
482
498
|
PUBLIC_DATABASE_URL: "${dbUrl("public_role", spec.rolePasswords.public, "cms_public")}"
|
|
499
|
+
# #1329 — the live site's gateway: it refuses anything of the staging data.
|
|
500
|
+
${GATEWAY_ENVIRONMENT_ENV}: "live"
|
|
483
501
|
GATEWAY_PORT: "${COMPOSE_GATEWAY_PORT}"
|
|
484
502
|
# Caddy is the one proxy in front (client-ip.ts in the gateway).
|
|
485
503
|
${TRUSTED_PROXY_HOPS_ENV}: "${GATEWAY_TRUSTED_PROXY_HOPS["self-hosted"]}"
|
|
@@ -523,6 +541,56 @@ ${serviceRuntime(images, "mcp-auth-proxy", "bun run apps/mcp-auth-proxy/src/serv
|
|
|
523
541
|
`;
|
|
524
542
|
}
|
|
525
543
|
|
|
544
|
+
/** #1140 — the Compose service that calls the admin's scheduler tick. */
|
|
545
|
+
export const COMPOSE_SCHEDULER_SERVICE = "caelo-scheduler";
|
|
546
|
+
|
|
547
|
+
/** Where the admin image (and the dev checkout) ships the scheduler clock. */
|
|
548
|
+
export const SCHEDULER_CLOCK_SCRIPT = "packages/admin-core/src/scheduler/clock.ts";
|
|
549
|
+
|
|
550
|
+
/** The admin's tick endpoint on the Compose network. */
|
|
551
|
+
export const COMPOSE_SCHEDULER_TICK_URL = `http://${COMPOSE_ADMIN_UPSTREAM}/api/internal/scheduler/tick`;
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* #1140 — the scheduler: the self-hosted counterpart of the Cloud Scheduler
|
|
555
|
+
* job on Google Cloud. Every `schedulerIntervalMinutes` it POSTs the admin's
|
|
556
|
+
* `/api/internal/scheduler/tick` with an internal token (scope
|
|
557
|
+
* `scheduler.tick`); the admin runs whatever its due-table says is due. The
|
|
558
|
+
* clock only calls, it does no work itself, so the admin's background work
|
|
559
|
+
* runs inside a request on every provider.
|
|
560
|
+
*
|
|
561
|
+
* It runs the admin image with another entry point (dependency-free, node
|
|
562
|
+
* built-ins only) and holds only the internal secret it signs with: no
|
|
563
|
+
* database URL, no KEK, no mount, read-only root filesystem, every
|
|
564
|
+
* capability dropped. A rollback to a release from before #1140 renders it
|
|
565
|
+
* too; that admin image has no clock script, so the container exits and
|
|
566
|
+
* Docker restarts it with back-off — that release runs its work in-process.
|
|
567
|
+
*/
|
|
568
|
+
function schedulerService(spec: ComposeSpec): string {
|
|
569
|
+
const minutes = parseSchedulerIntervalMinutes(spec.schedulerIntervalMinutes);
|
|
570
|
+
const runtime =
|
|
571
|
+
spec.images.mode === "dev"
|
|
572
|
+
? serviceRuntime(spec.images, "admin", `bun run ${SCHEDULER_CLOCK_SCRIPT}`)
|
|
573
|
+
: [
|
|
574
|
+
serviceRuntime(spec.images, "admin", ""),
|
|
575
|
+
` command: ["bun", "--bun", "/app/${SCHEDULER_CLOCK_SCRIPT}"]`,
|
|
576
|
+
].join("\n");
|
|
577
|
+
return `
|
|
578
|
+
${COMPOSE_SCHEDULER_SERVICE}:
|
|
579
|
+
${runtime}
|
|
580
|
+
restart: unless-stopped
|
|
581
|
+
read_only: true
|
|
582
|
+
tmpfs: ["/tmp"]
|
|
583
|
+
cap_drop: ["ALL"]
|
|
584
|
+
security_opt: ["no-new-privileges:true"]
|
|
585
|
+
depends_on: [caelo-admin]
|
|
586
|
+
environment:
|
|
587
|
+
CAELO_INTERNAL_SECRET: "${escapeYaml(spec.internalSecret)}"
|
|
588
|
+
CAELO_SCHEDULER_TICK_URL: "${COMPOSE_SCHEDULER_TICK_URL}"
|
|
589
|
+
CAELO_SCHEDULER_INTERVAL_MINUTES: "${minutes}"
|
|
590
|
+
NODE_ENV: production
|
|
591
|
+
`;
|
|
592
|
+
}
|
|
593
|
+
|
|
526
594
|
/**
|
|
527
595
|
* A compose-network database URL, password URL-encoded and YAML-escaped.
|
|
528
596
|
* #1338 — verify-full against the install's own CA (self-hosted-db-tls.ts),
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* converge-db-trust.ts (#1373) against a fake gcloud and a fake Cloud SQL
|
|
5
|
+
* Admin API: which host the converge job dials before the database TLS
|
|
6
|
+
* step, and whose certificate it accepts there.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
convergeDatabaseTarget,
|
|
12
|
+
normalizedSha1,
|
|
13
|
+
serverIdentityProblem,
|
|
14
|
+
} from "./converge-db-trust.js";
|
|
15
|
+
import type { GcloudResult } from "./gcloud.js";
|
|
16
|
+
import type { HttpFetch } from "./runtime-secrets.js";
|
|
17
|
+
|
|
18
|
+
const DNS = "abc123.p-label.europe-west1.sql-psa.goog";
|
|
19
|
+
const IP = "10.20.0.3";
|
|
20
|
+
const PLACEMENT = { projectId: "p", region: "europe-west1" };
|
|
21
|
+
const OWN_CA = "-----BEGIN CERTIFICATE-----\nper-instance\n-----END CERTIFICATE-----";
|
|
22
|
+
const SHARED_BUNDLE = "-----BEGIN CERTIFICATE-----\nshared\n-----END CERTIFICATE-----";
|
|
23
|
+
const ACTIVE = "a212b6cf51b3736b34a5d4a3194b200db6b3532a";
|
|
24
|
+
const OLD = "0123456789abcdef0123456789abcdef01234567";
|
|
25
|
+
|
|
26
|
+
const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
|
|
27
|
+
const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
|
|
28
|
+
|
|
29
|
+
/** The admin service as `gcloud run services describe --format=json` prints it. */
|
|
30
|
+
function adminService(host: string): string {
|
|
31
|
+
return JSON.stringify({
|
|
32
|
+
spec: {
|
|
33
|
+
template: {
|
|
34
|
+
metadata: {
|
|
35
|
+
annotations: {
|
|
36
|
+
"run.googleapis.com/network-interfaces": JSON.stringify([
|
|
37
|
+
{ network: "net", subnetwork: "sub" },
|
|
38
|
+
]),
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
spec: {
|
|
42
|
+
containers: [
|
|
43
|
+
{
|
|
44
|
+
image: "img@sha256:x",
|
|
45
|
+
env: [
|
|
46
|
+
{
|
|
47
|
+
name: "ADMIN_DATABASE_URL",
|
|
48
|
+
value: `postgresql://admin_role@${host}:5432/cms_admin?sslmode=require`,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "ADMIN_DATABASE_PASSWORD",
|
|
52
|
+
valueFrom: { secretKeyRef: { name: "pw", key: "latest" } },
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function gcloud(opts: { host: string; instance?: object; describeFails?: boolean }) {
|
|
64
|
+
const calls: string[] = [];
|
|
65
|
+
const run = async (args: string[]) => {
|
|
66
|
+
const line = args.join(" ");
|
|
67
|
+
calls.push(line);
|
|
68
|
+
if (line.startsWith("run services list")) return ok("caelo-production-admin-abc1234\n");
|
|
69
|
+
if (line.startsWith("run services describe")) return ok(adminService(opts.host));
|
|
70
|
+
if (line.startsWith("sql instances list")) return ok("caelo-production-pg-1\n");
|
|
71
|
+
if (line.startsWith("sql instances describe")) {
|
|
72
|
+
return opts.describeFails ? fail("PERMISSION_DENIED") : ok(JSON.stringify(opts.instance));
|
|
73
|
+
}
|
|
74
|
+
if (line.startsWith("auth print-access-token")) return ok("ya29.token\n");
|
|
75
|
+
return fail(`unexpected: ${line}`);
|
|
76
|
+
};
|
|
77
|
+
return { run, calls };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function api(body: object, status = 200): HttpFetch {
|
|
81
|
+
return async () => ({ ok: status < 300, status, text: async () => JSON.stringify(body) });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const perInstance = {
|
|
85
|
+
serverCaCert: { cert: OWN_CA },
|
|
86
|
+
settings: { ipConfiguration: { serverCaMode: "GOOGLE_MANAGED_INTERNAL_CA" } },
|
|
87
|
+
};
|
|
88
|
+
const shared = {
|
|
89
|
+
serverCaCert: { cert: OWN_CA },
|
|
90
|
+
settings: { ipConfiguration: { serverCaMode: "GOOGLE_MANAGED_CAS_CA" } },
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
describe("convergeDatabaseTarget", () => {
|
|
94
|
+
it("an install on the DNS name connects with verify-full and asks Cloud SQL nothing", async () => {
|
|
95
|
+
const g = gcloud({ host: DNS });
|
|
96
|
+
expect(await convergeDatabaseTarget(PLACEMENT, { run: g.run })).toEqual({
|
|
97
|
+
kind: "dns",
|
|
98
|
+
host: DNS,
|
|
99
|
+
});
|
|
100
|
+
expect(g.calls.some((c) => c.startsWith("sql "))).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("on the IP with the per-instance CA it trusts exactly that CA", async () => {
|
|
104
|
+
const g = gcloud({ host: IP, instance: perInstance });
|
|
105
|
+
const target = await convergeDatabaseTarget(PLACEMENT, {
|
|
106
|
+
run: g.run,
|
|
107
|
+
http: async () => {
|
|
108
|
+
throw new Error("the per-instance CA needs no certificate list");
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
expect(target).toEqual({ kind: "ip", host: IP, caPem: OWN_CA, pinnedSha1: null });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("on the IP with the shared CA active it trusts the shared bundle and pins the instance's certificates", async () => {
|
|
115
|
+
const g = gcloud({ host: IP, instance: shared });
|
|
116
|
+
const target = await convergeDatabaseTarget(PLACEMENT, {
|
|
117
|
+
run: g.run,
|
|
118
|
+
http: api({ activeVersion: ACTIVE, serverCerts: [{ sha1Fingerprint: ACTIVE }] }),
|
|
119
|
+
readSharedCaBundle: () => SHARED_BUNDLE,
|
|
120
|
+
});
|
|
121
|
+
expect(target).toEqual({ kind: "ip", host: IP, caPem: SHARED_BUNDLE, pinnedSha1: [ACTIVE] });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("moved to the shared CA but not rotated in: pinned to the one certificate the API names active", async () => {
|
|
125
|
+
const g = gcloud({ host: IP, instance: shared });
|
|
126
|
+
const target = await convergeDatabaseTarget(PLACEMENT, {
|
|
127
|
+
run: g.run,
|
|
128
|
+
http: api({ activeVersion: OLD.toUpperCase(), serverCerts: [] }),
|
|
129
|
+
readSharedCaBundle: () => SHARED_BUNDLE,
|
|
130
|
+
});
|
|
131
|
+
expect(target).toEqual({
|
|
132
|
+
kind: "ip",
|
|
133
|
+
host: IP,
|
|
134
|
+
caPem: `${OWN_CA}\n${SHARED_BUNDLE}`,
|
|
135
|
+
pinnedSha1: [OLD],
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("shared CA: a listing that does not positively name the active certificate is refused (fail closed)", async () => {
|
|
140
|
+
const answers: { body: unknown; why: RegExp }[] = [
|
|
141
|
+
{ body: {}, why: /does not name the active certificate/ },
|
|
142
|
+
{ body: { serverCerts: [{ sha1Fingerprint: ACTIVE }] }, why: /does not name/ },
|
|
143
|
+
{ body: { activeVersion: "", serverCerts: [] }, why: /does not name/ },
|
|
144
|
+
{ body: { activeVersion: "old-per-instance", serverCerts: [] }, why: /does not name/ },
|
|
145
|
+
{
|
|
146
|
+
body: { activeVersion: ACTIVE, serverCerts: [{ sha1Fingerprint: "nope" }] },
|
|
147
|
+
why: /does not name/,
|
|
148
|
+
},
|
|
149
|
+
{ body: { activeVersion: ACTIVE, serverCerts: [{}] }, why: /does not name/ },
|
|
150
|
+
{ body: { activeVersion: ACTIVE, serverCerts: "x" }, why: /does not name/ },
|
|
151
|
+
];
|
|
152
|
+
for (const a of answers) {
|
|
153
|
+
await expect(
|
|
154
|
+
convergeDatabaseTarget(PLACEMENT, {
|
|
155
|
+
run: gcloud({ host: IP, instance: shared }).run,
|
|
156
|
+
http: api(a.body as object),
|
|
157
|
+
readSharedCaBundle: () => SHARED_BUNDLE,
|
|
158
|
+
}),
|
|
159
|
+
).rejects.toThrow(a.why);
|
|
160
|
+
}
|
|
161
|
+
const notJson: HttpFetch = async () => ({ ok: true, status: 200, text: async () => "<html>" });
|
|
162
|
+
await expect(
|
|
163
|
+
convergeDatabaseTarget(PLACEMENT, {
|
|
164
|
+
run: gcloud({ host: IP, instance: shared }).run,
|
|
165
|
+
http: notJson,
|
|
166
|
+
readSharedCaBundle: () => SHARED_BUNDLE,
|
|
167
|
+
}),
|
|
168
|
+
).rejects.toThrow(/answered no JSON/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("refuses a CA mode it cannot reason about", async () => {
|
|
172
|
+
await expect(
|
|
173
|
+
convergeDatabaseTarget(PLACEMENT, {
|
|
174
|
+
run: gcloud({
|
|
175
|
+
host: IP,
|
|
176
|
+
instance: {
|
|
177
|
+
serverCaCert: { cert: OWN_CA },
|
|
178
|
+
settings: { ipConfiguration: { serverCaMode: "CUSTOMER_MANAGED_CAS_CA" } },
|
|
179
|
+
},
|
|
180
|
+
}).run,
|
|
181
|
+
}),
|
|
182
|
+
).rejects.toThrow(/CUSTOMER_MANAGED_CAS_CA/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("an instance that names no CA mode is on the per-instance default", async () => {
|
|
186
|
+
const g = gcloud({ host: IP, instance: { serverCaCert: { cert: OWN_CA } } });
|
|
187
|
+
expect(await convergeDatabaseTarget(PLACEMENT, { run: g.run })).toEqual({
|
|
188
|
+
kind: "ip",
|
|
189
|
+
host: IP,
|
|
190
|
+
caPem: OWN_CA,
|
|
191
|
+
pinnedSha1: null,
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("refuses to connect when it cannot tell whom to trust", async () => {
|
|
196
|
+
await expect(
|
|
197
|
+
convergeDatabaseTarget(PLACEMENT, { run: gcloud({ host: IP, describeFails: true }).run }),
|
|
198
|
+
).rejects.toThrow("PERMISSION_DENIED");
|
|
199
|
+
await expect(
|
|
200
|
+
convergeDatabaseTarget(PLACEMENT, {
|
|
201
|
+
run: gcloud({
|
|
202
|
+
host: IP,
|
|
203
|
+
instance: {
|
|
204
|
+
settings: { ipConfiguration: { serverCaMode: "GOOGLE_MANAGED_INTERNAL_CA" } },
|
|
205
|
+
},
|
|
206
|
+
}).run,
|
|
207
|
+
}),
|
|
208
|
+
).rejects.toThrow("names no server CA certificate");
|
|
209
|
+
await expect(
|
|
210
|
+
convergeDatabaseTarget(PLACEMENT, {
|
|
211
|
+
run: gcloud({ host: IP, instance: shared }).run,
|
|
212
|
+
http: api({ error: "denied" }, 403),
|
|
213
|
+
}),
|
|
214
|
+
).rejects.toThrow("HTTP 403");
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("serverIdentityProblem", () => {
|
|
219
|
+
const pinned = { kind: "ip", host: IP, caPem: SHARED_BUNDLE, pinnedSha1: [ACTIVE] } as const;
|
|
220
|
+
|
|
221
|
+
it("accepts a pinned certificate in node:tls's colon notation", () => {
|
|
222
|
+
const colons = ACTIVE.toUpperCase().replace(/(..)(?!$)/g, "$1:");
|
|
223
|
+
expect(serverIdentityProblem(pinned, colons)).toBeNull();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("refuses a certificate the instance does not list", () => {
|
|
227
|
+
expect(serverIdentityProblem(pinned, "00:11")).toContain("does not list");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("without a pin the verified chain to the instance's own CA decides", () => {
|
|
231
|
+
expect(serverIdentityProblem({ ...pinned, pinnedSha1: null }, "00:11")).toBeNull();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("normalizes fingerprints", () => {
|
|
235
|
+
expect(normalizedSha1("AB:cd:01")).toBe("abcd01");
|
|
236
|
+
});
|
|
237
|
+
});
|