@caelo-cms/provisioning 0.10.47 → 0.10.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/apply.d.ts +45 -0
- package/dist/agent/apply.d.ts.map +1 -0
- package/dist/agent/apply.js +83 -0
- package/dist/agent/apply.js.map +1 -0
- package/dist/agent/cli-provenance.d.ts +42 -0
- package/dist/agent/cli-provenance.d.ts.map +1 -0
- package/dist/agent/cli-provenance.js +138 -0
- package/dist/agent/cli-provenance.js.map +1 -0
- package/dist/agent/command.d.ts +79 -0
- package/dist/agent/command.d.ts.map +1 -0
- package/dist/agent/command.js +454 -0
- package/dist/agent/command.js.map +1 -0
- package/dist/agent/config.d.ts +30 -0
- package/dist/agent/config.d.ts.map +1 -0
- package/dist/agent/config.js +60 -0
- package/dist/agent/config.js.map +1 -0
- package/dist/agent/lock.d.ts +13 -0
- package/dist/agent/lock.d.ts.map +1 -0
- package/dist/agent/lock.js +100 -0
- package/dist/agent/lock.js.map +1 -0
- package/dist/agent/queue.d.ts +35 -0
- package/dist/agent/queue.d.ts.map +1 -0
- package/dist/agent/queue.js +50 -0
- package/dist/agent/queue.js.map +1 -0
- package/dist/agent/run.d.ts +97 -0
- package/dist/agent/run.d.ts.map +1 -0
- package/dist/agent/run.js +198 -0
- package/dist/agent/run.js.map +1 -0
- package/dist/agent/systemd.d.ts +55 -0
- package/dist/agent/systemd.d.ts.map +1 -0
- package/dist/agent/systemd.js +171 -0
- package/dist/agent/systemd.js.map +1 -0
- package/dist/agent/units.d.ts +44 -0
- package/dist/agent/units.d.ts.map +1 -0
- package/dist/agent/units.js +127 -0
- package/dist/agent/units.js.map +1 -0
- package/dist/agent-db.js +18362 -0
- package/dist/cli.js +119 -16
- package/dist/cli.js.map +1 -1
- package/dist/cloudsql-iam-user.d.ts +39 -0
- package/dist/cloudsql-iam-user.d.ts.map +1 -0
- package/dist/cloudsql-iam-user.js +119 -0
- package/dist/cloudsql-iam-user.js.map +1 -0
- package/dist/compose.d.ts +16 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +26 -1
- package/dist/compose.js.map +1 -1
- package/dist/dns/index.d.ts +2 -0
- package/dist/dns/index.d.ts.map +1 -1
- package/dist/dns/index.js +3 -2
- package/dist/dns/index.js.map +1 -1
- package/dist/dns/manual.d.ts +5 -0
- package/dist/dns/manual.d.ts.map +1 -1
- package/dist/dns/manual.js +28 -12
- package/dist/dns/manual.js.map +1 -1
- package/dist/gcloud.d.ts +2 -0
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +1 -1
- package/dist/gcloud.js.map +1 -1
- package/dist/gcp-names.d.ts +15 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +23 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.js +7 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job-runner.d.ts +13 -0
- package/dist/install-job-runner.d.ts.map +1 -0
- package/dist/install-job-runner.js +155 -0
- package/dist/install-job-runner.js.map +1 -0
- package/dist/install-job.d.ts +210 -0
- package/dist/install-job.d.ts.map +1 -0
- package/dist/install-job.js +449 -0
- package/dist/install-job.js.map +1 -0
- package/dist/install-state.d.ts +10 -1
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +10 -2
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +19 -4
- package/dist/lifecycle.js.map +1 -1
- package/dist/migration-runner.d.ts +5 -0
- package/dist/migration-runner.d.ts.map +1 -1
- package/dist/migration-runner.js +1 -1
- package/dist/migration-runner.js.map +1 -1
- package/dist/operator-access.d.ts +14 -8
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +16 -102
- package/dist/operator-access.js.map +1 -1
- package/dist/run-mode.d.ts +27 -0
- package/dist/run-mode.d.ts.map +1 -0
- package/dist/run-mode.js +22 -0
- package/dist/run-mode.js.map +1 -0
- package/dist/runtime-secrets.d.ts +19 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +44 -0
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/self-hosted-roles.d.ts +22 -1
- package/dist/self-hosted-roles.d.ts.map +1 -1
- package/dist/self-hosted-roles.js +81 -12
- package/dist/self-hosted-roles.js.map +1 -1
- package/dist/self-hosted-upgrade.d.ts +17 -0
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +16 -1
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-contract.d.ts +6 -0
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +10 -1
- package/dist/stack-contract.js.map +1 -1
- package/dist/upgrade/converge.d.ts +6 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +59 -2
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/gcp-lookup.d.ts +6 -0
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
- package/dist/upgrade/gcp-lookup.js +25 -0
- package/dist/upgrade/gcp-lookup.js.map +1 -1
- package/dist/upgrade/release-images.d.ts +12 -0
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +31 -0
- package/dist/upgrade/release-images.js.map +1 -1
- package/dist/upgrade/upgrade.d.ts +5 -0
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +28 -1
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-assess.d.ts +82 -0
- package/dist/upgrader-assess.d.ts.map +1 -0
- package/dist/upgrader-assess.js +116 -0
- package/dist/upgrader-assess.js.map +1 -0
- package/dist/upgrader-gcp.d.ts +17 -0
- package/dist/upgrader-gcp.d.ts.map +1 -0
- package/dist/upgrader-gcp.js +87 -0
- package/dist/upgrader-gcp.js.map +1 -0
- package/dist/upgrader-job.d.ts +131 -0
- package/dist/upgrader-job.d.ts.map +1 -0
- package/dist/upgrader-job.js +259 -0
- package/dist/upgrader-job.js.map +1 -0
- package/dist/upgrader-reporter.d.ts +35 -0
- package/dist/upgrader-reporter.d.ts.map +1 -0
- package/dist/upgrader-reporter.js +79 -0
- package/dist/upgrader-reporter.js.map +1 -0
- package/dist/upgrader-setup.d.ts +59 -0
- package/dist/upgrader-setup.d.ts.map +1 -0
- package/dist/upgrader-setup.js +308 -0
- package/dist/upgrader-setup.js.map +1 -0
- package/dist/wizard.d.ts +6 -0
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +32 -4
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp-firebase.d.ts +3 -0
- package/dist/wizards/gcp-firebase.d.ts.map +1 -1
- package/dist/wizards/gcp-firebase.js +1 -0
- package/dist/wizards/gcp-firebase.js.map +1 -1
- package/dist/wizards/gcp-install-job.d.ts +74 -0
- package/dist/wizards/gcp-install-job.d.ts.map +1 -0
- package/dist/wizards/gcp-install-job.js +398 -0
- package/dist/wizards/gcp-install-job.js.map +1 -0
- package/dist/wizards/gcp-prompts.d.ts +3 -0
- package/dist/wizards/gcp-prompts.d.ts.map +1 -0
- package/dist/wizards/gcp-prompts.js +25 -0
- package/dist/wizards/gcp-prompts.js.map +1 -0
- package/dist/wizards/gcp-pulumi.d.ts +4 -2
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +1 -1
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts +40 -0
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +223 -59
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +6 -4
- package/src/adapter.ts +97 -0
- package/src/adc-impersonation.ts +176 -0
- package/src/admin-domain.test.ts +254 -0
- package/src/admin-domain.ts +245 -0
- package/src/admin-memory.test.ts +151 -0
- package/src/agent/apply.test.ts +185 -0
- package/src/agent/apply.ts +124 -0
- package/src/agent/cli-provenance.test.ts +159 -0
- package/src/agent/cli-provenance.ts +191 -0
- package/src/agent/command.test.ts +77 -0
- package/src/agent/command.ts +564 -0
- package/src/agent/config.ts +86 -0
- package/src/agent/lock-config.test.ts +97 -0
- package/src/agent/lock.ts +110 -0
- package/src/agent/queue.test.ts +10 -0
- package/src/agent/queue.ts +91 -0
- package/src/agent/run.test.ts +382 -0
- package/src/agent/run.ts +295 -0
- package/src/agent/systemd.ts +209 -0
- package/src/agent/units.test.ts +223 -0
- package/src/agent/units.ts +163 -0
- package/src/azure-front-door.test.ts +119 -0
- package/src/azure-front-door.ts +56 -0
- package/src/bootstrap-token.ts +20 -0
- package/src/caddy.ts +112 -0
- package/src/cdn-copy.ts +91 -0
- package/src/cert-wait.test.ts +194 -0
- package/src/cert-wait.ts +163 -0
- package/src/checkpoint-secrets.test.ts +380 -0
- package/src/checkpoint-secrets.ts +283 -0
- package/src/cli-routing.test.ts +45 -0
- package/src/cli-routing.ts +43 -0
- package/src/cli.ts +1491 -0
- package/src/cloudsql-iam-user.ts +147 -0
- package/src/compose.ts +334 -0
- package/src/default-firewall.test.ts +411 -0
- package/src/default-firewall.ts +342 -0
- package/src/deployed-release.test.ts +231 -0
- package/src/deployed-release.ts +220 -0
- package/src/dns/cloudflare.ts +182 -0
- package/src/dns/groups.test.ts +76 -0
- package/src/dns/groups.ts +86 -0
- package/src/dns/index.ts +56 -0
- package/src/dns/manual.ts +130 -0
- package/src/dns/types.ts +27 -0
- package/src/gateway-credentials.test.ts +180 -0
- package/src/gateway-credentials.ts +144 -0
- package/src/gcloud-components.test.ts +76 -0
- package/src/gcloud-components.ts +85 -0
- package/src/gcloud-retry.ts +41 -0
- package/src/gcloud-runner.test.ts +61 -0
- package/src/gcloud.test.ts +99 -0
- package/src/gcloud.ts +322 -0
- package/src/gcp-names.ts +135 -0
- package/src/index.test.ts +332 -0
- package/src/index.ts +114 -0
- package/src/infra-contract.ts +32 -0
- package/src/install-job-runner.ts +166 -0
- package/src/install-job.test.ts +566 -0
- package/src/install-job.ts +542 -0
- package/src/install-state.test.ts +239 -0
- package/src/install-state.ts +583 -0
- package/src/internal-token.ts +35 -0
- package/src/lifecycle.ts +900 -0
- package/src/mcp-iap-stacks.test.ts +58 -0
- package/src/mcp-iap.test.ts +149 -0
- package/src/mcp-iap.ts +131 -0
- package/src/media-volume.test.ts +296 -0
- package/src/migration-runner.ts +365 -0
- package/src/operator-access-stacks.test.ts +55 -0
- package/src/operator-access.test.ts +332 -0
- package/src/operator-access.ts +554 -0
- package/src/ops-backend.test.ts +320 -0
- package/src/ops-backend.ts +414 -0
- package/src/provisioner-identity.test.ts +482 -0
- package/src/provisioner-identity.ts +407 -0
- package/src/redirects-emit.ts +175 -0
- package/src/region-data.ts +428 -0
- package/src/regions.test.ts +277 -0
- package/src/regions.ts +370 -0
- package/src/release-contract.ts +57 -0
- package/src/release-manifest.test.ts +293 -0
- package/src/release-manifest.ts +430 -0
- package/src/run-mode.ts +40 -0
- package/src/runtime-secrets.test.ts +627 -0
- package/src/runtime-secrets.ts +754 -0
- package/src/self-command.test.ts +14 -0
- package/src/self-command.ts +21 -0
- package/src/self-hosted-compose.test.ts +177 -0
- package/src/self-hosted-release.ts +240 -0
- package/src/self-hosted-roles.integration.test.ts +161 -0
- package/src/self-hosted-roles.test.ts +124 -0
- package/src/self-hosted-roles.ts +203 -0
- package/src/self-hosted-upgrade.test.ts +394 -0
- package/src/self-hosted-upgrade.ts +548 -0
- package/src/stack-contract-parity.test.ts +199 -0
- package/src/stack-contract.test.ts +391 -0
- package/src/stack-contract.ts +888 -0
- package/src/stack-converge-secrets.test.ts +421 -0
- package/src/stack-converge.test.ts +833 -0
- package/src/stack-converge.ts +909 -0
- package/src/staging-admin.test.ts +246 -0
- package/src/staging-admin.ts +324 -0
- package/src/state-attach.test.ts +211 -0
- package/src/state-attach.ts +132 -0
- package/src/state-backend.test.ts +145 -0
- package/src/state-backend.ts +262 -0
- package/src/state-command.ts +293 -0
- package/src/state-migrate.test.ts +479 -0
- package/src/state-migrate.ts +434 -0
- package/src/state-pulumi.ts +289 -0
- package/src/state-store.test.ts +212 -0
- package/src/state-store.ts +243 -0
- package/src/upgrade/app-roll.ts +486 -0
- package/src/upgrade/backup.ts +109 -0
- package/src/upgrade/converge.ts +437 -0
- package/src/upgrade/gcp-lookup.ts +167 -0
- package/src/upgrade/health.test.ts +279 -0
- package/src/upgrade/health.ts +305 -0
- package/src/upgrade/release-images.test.ts +119 -0
- package/src/upgrade/release-images.ts +391 -0
- package/src/upgrade/reporter.ts +46 -0
- package/src/upgrade/upgrade.test.ts +626 -0
- package/src/upgrade/upgrade.ts +249 -0
- package/src/upgrader-assess.test.ts +265 -0
- package/src/upgrader-assess.ts +233 -0
- package/src/upgrader-gcp.ts +123 -0
- package/src/upgrader-job.test.ts +408 -0
- package/src/upgrader-job.ts +499 -0
- package/src/upgrader-reporter.test.ts +46 -0
- package/src/upgrader-reporter.ts +105 -0
- package/src/upgrader-setup.test.ts +169 -0
- package/src/upgrader-setup.ts +355 -0
- package/src/wizard.ts +425 -0
- package/src/wizards/gcp-cost.ts +188 -0
- package/src/wizards/gcp-firebase.ts +72 -0
- package/src/wizards/gcp-install-job.ts +506 -0
- package/src/wizards/gcp-prompts.ts +26 -0
- package/src/wizards/gcp-pulumi.test.ts +246 -0
- package/src/wizards/gcp-pulumi.ts +516 -0
- package/src/wizards/gcp.ts +1935 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { CONTENT_HASHED_PATH_PATTERN, IMMUTABLE_CACHE_CONTROL } from "@caelo-cms/shared";
|
|
7
|
+
import { generateBootstrapToken } from "./bootstrap-token.js";
|
|
8
|
+
import { generateCaddyfile } from "./caddy.js";
|
|
9
|
+
import { loadCdnCopyAdapter, selfHostedCdnCopy } from "./cdn-copy.js";
|
|
10
|
+
import { generateDockerCompose } from "./compose.js";
|
|
11
|
+
import {
|
|
12
|
+
emitRedirectsAzureFrontDoor,
|
|
13
|
+
emitRedirectsCloudFront,
|
|
14
|
+
emitRedirectsCloudflare,
|
|
15
|
+
type RedirectRow,
|
|
16
|
+
} from "./redirects-emit.js";
|
|
17
|
+
|
|
18
|
+
describe("Caddyfile generator", () => {
|
|
19
|
+
it("emits a vhost per domain + email block", () => {
|
|
20
|
+
const out = generateCaddyfile({
|
|
21
|
+
ownerEmail: "owner@example.com",
|
|
22
|
+
publicSiteRoot: "/srv/caelo/output/production/current",
|
|
23
|
+
stagingSiteRoot: "/srv/caelo/output/staging/current",
|
|
24
|
+
adminPort: 5173,
|
|
25
|
+
gatewayPort: 8090,
|
|
26
|
+
domains: [
|
|
27
|
+
{ hostname: "example.com", kind: "public", env: "production" },
|
|
28
|
+
{ hostname: "staging.example.com", kind: "public", env: "staging" },
|
|
29
|
+
{ hostname: "admin.example.com", kind: "admin", env: "production" },
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
expect(out).toContain("email owner@example.com");
|
|
33
|
+
expect(out).toContain("example.com {");
|
|
34
|
+
expect(out).toContain("staging.example.com {");
|
|
35
|
+
expect(out).toContain("admin.example.com {");
|
|
36
|
+
// Staging gets noindex.
|
|
37
|
+
const stagingBlock = out.match(/staging\.example\.com \{[\s\S]*?\n\}/)?.[0] ?? "";
|
|
38
|
+
expect(stagingBlock).toContain('X-Robots-Tag "noindex"');
|
|
39
|
+
// Production does NOT get noindex.
|
|
40
|
+
const productionBlock =
|
|
41
|
+
out.match(/(?<!staging\.|admin\.)example\.com \{[\s\S]*?\n\}/)?.[0] ?? "";
|
|
42
|
+
expect(productionBlock).not.toContain('X-Robots-Tag "noindex"');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("falls back to a localhost dev block when no domains configured", () => {
|
|
46
|
+
const out = generateCaddyfile({
|
|
47
|
+
ownerEmail: "x@y.z",
|
|
48
|
+
publicSiteRoot: "/srv/caelo/output/production/current",
|
|
49
|
+
stagingSiteRoot: "/srv/caelo/output/staging/current",
|
|
50
|
+
adminPort: 5173,
|
|
51
|
+
gatewayPort: 8090,
|
|
52
|
+
domains: [],
|
|
53
|
+
});
|
|
54
|
+
expect(out).toContain(":8081 {");
|
|
55
|
+
expect(out).toContain("reverse_proxy localhost:5173");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("Caddy cache policy for content-hashed assets", () => {
|
|
60
|
+
const spec = {
|
|
61
|
+
ownerEmail: "owner@example.com",
|
|
62
|
+
publicSiteRoot: "/srv/caelo/output/production/current",
|
|
63
|
+
stagingSiteRoot: "/srv/caelo/output/staging/current",
|
|
64
|
+
adminPort: 5173,
|
|
65
|
+
gatewayPort: 8090,
|
|
66
|
+
domains: [
|
|
67
|
+
{ hostname: "example.com", kind: "public", env: "production" },
|
|
68
|
+
{ hostname: "admin.example.com", kind: "admin", env: "production" },
|
|
69
|
+
],
|
|
70
|
+
} as const;
|
|
71
|
+
|
|
72
|
+
it("public vhosts mark only CONTENT_HASHED_PATH_PATTERN paths immutable", () => {
|
|
73
|
+
const out = generateCaddyfile(spec);
|
|
74
|
+
const publicBlock = out.match(/\nexample\.com \{[\s\S]*?\n\}/)?.[0] ?? "";
|
|
75
|
+
// `file` ANDs an existence check onto the path match, so a 404 for
|
|
76
|
+
// a hashed URL is never served with a year-long Cache-Control.
|
|
77
|
+
expect(publicBlock).toContain(
|
|
78
|
+
`@content_hashed {\n path_regexp content_hashed ${CONTENT_HASHED_PATH_PATTERN}\n file\n }\n`,
|
|
79
|
+
);
|
|
80
|
+
expect(publicBlock).toContain(
|
|
81
|
+
`header @content_hashed Cache-Control "${IMMUTABLE_CACHE_CONTROL}"`,
|
|
82
|
+
);
|
|
83
|
+
// No blanket Cache-Control: pages + slug media keep file_server's
|
|
84
|
+
// ETag/Last-Modified revalidation.
|
|
85
|
+
expect(publicBlock.match(/Cache-Control/g)?.length).toBe(1);
|
|
86
|
+
const adminBlock = out.match(/admin\.example\.com \{[\s\S]*?\n\}/)?.[0] ?? "";
|
|
87
|
+
expect(adminBlock).not.toContain("Cache-Control");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("the checked-in compose Caddyfiles carry the same pattern as @caelo-cms/shared", () => {
|
|
91
|
+
for (const name of ["Caddyfile.production", "Caddyfile.staging"]) {
|
|
92
|
+
const body = readFileSync(join(import.meta.dir, "..", "caddy", name), "utf8");
|
|
93
|
+
expect(body).toContain(
|
|
94
|
+
`@content_hashed {\n\t\tpath_regexp content_hashed ${CONTENT_HASHED_PATH_PATTERN}\n\t\tfile\n\t}\n`,
|
|
95
|
+
);
|
|
96
|
+
expect(body).toContain(`header @content_hashed Cache-Control "${IMMUTABLE_CACHE_CONTROL}"`);
|
|
97
|
+
expect(body).toContain('\t@ico path *.ico\n\theader @ico Content-Type "image/x-icon"\n');
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("public vhosts serve .ico favicons as image/x-icon; admin vhosts are untouched", () => {
|
|
102
|
+
const out = generateCaddyfile(spec);
|
|
103
|
+
const publicBlock = out.match(/\nexample\.com \{[\s\S]*?\n\}/)?.[0] ?? "";
|
|
104
|
+
expect(publicBlock).toContain(' @ico path *.ico\n header @ico Content-Type "image/x-icon"\n');
|
|
105
|
+
const adminBlock = out.match(/admin\.example\.com \{[\s\S]*?\n\}/)?.[0] ?? "";
|
|
106
|
+
expect(adminBlock).not.toContain("@ico");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const RELEASE_IMAGES = {
|
|
111
|
+
mode: "release",
|
|
112
|
+
admin: `ghcr.io/caelo-cms/admin@sha256:${"a".repeat(64)}`,
|
|
113
|
+
gateway: `ghcr.io/caelo-cms/gateway@sha256:${"b".repeat(64)}`,
|
|
114
|
+
} as const;
|
|
115
|
+
|
|
116
|
+
describe("docker-compose generator", () => {
|
|
117
|
+
it("emits postgres + caddy + caelo services with the supplied secrets", () => {
|
|
118
|
+
const out = generateDockerCompose({
|
|
119
|
+
domain: "example.com",
|
|
120
|
+
postgresPassword: "supersecret",
|
|
121
|
+
rolePasswords: { admin: "adminpw", public: "publicpw", gateway: "gatewaypw" },
|
|
122
|
+
minioRootUser: "caelo",
|
|
123
|
+
minioRootPassword: "miniopass",
|
|
124
|
+
caeloSecretKek: "0".repeat(64),
|
|
125
|
+
anthropicApiKey: "sk-ant-…",
|
|
126
|
+
diskSize: "20Gi",
|
|
127
|
+
images: RELEASE_IMAGES,
|
|
128
|
+
});
|
|
129
|
+
expect(out).toContain("caelo-postgres");
|
|
130
|
+
expect(out).toContain("caelo-caddy");
|
|
131
|
+
expect(out).toContain("caelo-admin");
|
|
132
|
+
expect(out).toContain("caelo-gateway");
|
|
133
|
+
expect(out).toContain("supersecret");
|
|
134
|
+
expect(out).toContain("miniopass");
|
|
135
|
+
expect(out).toContain("ANTHROPIC_API_KEY");
|
|
136
|
+
expect(out).toContain("CAELO_SECRET_KEK");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("gives the gateway gateway_role + public_role and no admin credential (#613)", () => {
|
|
140
|
+
const out = generateDockerCompose({
|
|
141
|
+
domain: "example.com",
|
|
142
|
+
postgresPassword: "supersecret",
|
|
143
|
+
rolePasswords: { admin: "adminpw", public: "publicpw", gateway: "gatewaypw" },
|
|
144
|
+
minioRootUser: "caelo",
|
|
145
|
+
minioRootPassword: "miniopass",
|
|
146
|
+
caeloSecretKek: "0".repeat(64),
|
|
147
|
+
diskSize: "20Gi",
|
|
148
|
+
images: RELEASE_IMAGES,
|
|
149
|
+
});
|
|
150
|
+
const gateway = out.slice(out.indexOf("caelo-gateway:"), out.indexOf("\nvolumes:"));
|
|
151
|
+
expect(gateway).toContain(
|
|
152
|
+
'GATEWAY_DATABASE_URL: "postgres://gateway_role:gatewaypw@postgres:5432/cms_admin"',
|
|
153
|
+
);
|
|
154
|
+
expect(gateway).toContain(
|
|
155
|
+
'PUBLIC_DATABASE_URL: "postgres://public_role:publicpw@postgres:5432/cms_public"',
|
|
156
|
+
);
|
|
157
|
+
expect(gateway).not.toMatch(/ADMIN_DATABASE|admin_role|adminpw|supersecret|CAELO_SECRET_KEK/);
|
|
158
|
+
// bootstrap.sh creates the three roles from these.
|
|
159
|
+
expect(out).toContain('GATEWAY_ROLE_PASSWORD: "gatewaypw"');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("escapes secrets containing quotes safely", () => {
|
|
163
|
+
const out = generateDockerCompose({
|
|
164
|
+
domain: "example.com",
|
|
165
|
+
postgresPassword: 'pa"ss',
|
|
166
|
+
rolePasswords: { admin: "a", public: "b", gateway: "g" },
|
|
167
|
+
minioRootUser: "caelo",
|
|
168
|
+
minioRootPassword: "x",
|
|
169
|
+
caeloSecretKek: "0".repeat(64),
|
|
170
|
+
diskSize: "1Gi",
|
|
171
|
+
images: RELEASE_IMAGES,
|
|
172
|
+
});
|
|
173
|
+
expect(out).toContain('pa\\"ss');
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("bootstrap token generator", () => {
|
|
178
|
+
it("emits 64 hex chars + ~24h expiry", () => {
|
|
179
|
+
const t = generateBootstrapToken();
|
|
180
|
+
expect(t.token).toMatch(/^[0-9a-f]{64}$/);
|
|
181
|
+
const ttlHours = (Date.parse(t.expiresAt) - Date.now()) / 3600_000;
|
|
182
|
+
expect(ttlHours).toBeGreaterThan(23);
|
|
183
|
+
expect(ttlHours).toBeLessThan(25);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("each call returns a unique token", () => {
|
|
187
|
+
const a = generateBootstrapToken().token;
|
|
188
|
+
const b = generateBootstrapToken().token;
|
|
189
|
+
expect(a).not.toBe(b);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("redirects-emit", () => {
|
|
194
|
+
const rows: RedirectRow[] = [
|
|
195
|
+
{ fromPath: "/old", toPath: "/new", statusCode: 301 },
|
|
196
|
+
{ fromPath: "/blog/2024", toPath: "/blog", statusCode: 302 },
|
|
197
|
+
{ fromPath: "/legacy", toPath: "/modern", statusCode: 308 },
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
describe("emitRedirectsCloudflare", () => {
|
|
201
|
+
it("emits one line per row in `<from> <to> <status>` format", () => {
|
|
202
|
+
const out = emitRedirectsCloudflare(rows);
|
|
203
|
+
expect(out).toContain("/old /new 301\n");
|
|
204
|
+
expect(out).toContain("/blog/2024 /blog 302\n");
|
|
205
|
+
expect(out).toContain("/legacy /modern 308\n");
|
|
206
|
+
expect(out.startsWith("# Generated by @caelo-cms/provisioning")).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("empty rows → header only, no trailing newline noise", () => {
|
|
210
|
+
const out = emitRedirectsCloudflare([]);
|
|
211
|
+
expect(out).toBe("# Generated by @caelo-cms/provisioning — do not edit by hand.\n");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("is byte-identical for the same input (deterministic)", () => {
|
|
215
|
+
expect(emitRedirectsCloudflare(rows)).toBe(emitRedirectsCloudflare([...rows]));
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("emitRedirectsCloudFront", () => {
|
|
220
|
+
it("returns parseable JSON config + a Lambda source that consumes it", () => {
|
|
221
|
+
const out = emitRedirectsCloudFront(rows);
|
|
222
|
+
const parsed = JSON.parse(out.jsonConfig) as {
|
|
223
|
+
redirects: Array<{ from: string; to: string; status: number }>;
|
|
224
|
+
};
|
|
225
|
+
expect(parsed.redirects).toHaveLength(3);
|
|
226
|
+
expect(parsed.redirects[0]).toEqual({ from: "/old", to: "/new", status: 301 });
|
|
227
|
+
expect(out.lambdaSource).toContain("exports.handler");
|
|
228
|
+
expect(out.lambdaSource).toContain('require("./redirects.json")');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("preserves status codes verbatim (301/302/307/308)", () => {
|
|
232
|
+
const allStatuses: RedirectRow[] = [
|
|
233
|
+
{ fromPath: "/a", toPath: "/x", statusCode: 301 },
|
|
234
|
+
{ fromPath: "/b", toPath: "/x", statusCode: 302 },
|
|
235
|
+
{ fromPath: "/c", toPath: "/x", statusCode: 307 },
|
|
236
|
+
{ fromPath: "/d", toPath: "/x", statusCode: 308 },
|
|
237
|
+
];
|
|
238
|
+
const out = emitRedirectsCloudFront(allStatuses);
|
|
239
|
+
const parsed = JSON.parse(out.jsonConfig) as {
|
|
240
|
+
redirects: Array<{ status: number }>;
|
|
241
|
+
};
|
|
242
|
+
expect(parsed.redirects.map((r) => r.status)).toEqual([301, 302, 307, 308]);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe("emitRedirectsAzureFrontDoor", () => {
|
|
247
|
+
it("emits one rule per row with monotonically-increasing order", () => {
|
|
248
|
+
const out = emitRedirectsAzureFrontDoor(rows);
|
|
249
|
+
expect(out).toHaveLength(3);
|
|
250
|
+
expect(out[0]?.name).toBe("redirect-1");
|
|
251
|
+
expect(out[0]?.order).toBe(1);
|
|
252
|
+
expect(out[2]?.order).toBe(3);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("maps status codes to Front Door redirectType vocabulary", () => {
|
|
256
|
+
const out = emitRedirectsAzureFrontDoor([
|
|
257
|
+
{ fromPath: "/a", toPath: "/x", statusCode: 301 },
|
|
258
|
+
{ fromPath: "/b", toPath: "/x", statusCode: 302 },
|
|
259
|
+
{ fromPath: "/c", toPath: "/x", statusCode: 307 },
|
|
260
|
+
{ fromPath: "/d", toPath: "/x", statusCode: 308 },
|
|
261
|
+
]);
|
|
262
|
+
expect(out.map((r) => r.actions[0]?.parameters.redirectType)).toEqual([
|
|
263
|
+
"Moved",
|
|
264
|
+
"Found",
|
|
265
|
+
"TemporaryRedirect",
|
|
266
|
+
"PermanentRedirect",
|
|
267
|
+
]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("RequestUri condition matches the from path exactly", () => {
|
|
271
|
+
const out = emitRedirectsAzureFrontDoor(rows);
|
|
272
|
+
expect(out[1]?.conditions[0]?.parameters.matchValues).toEqual(["/blog/2024"]);
|
|
273
|
+
expect(out[1]?.actions[0]?.parameters.destinationPath).toBe("/blog");
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe("path-validation guard (review-pass #9)", () => {
|
|
278
|
+
it("Cloudflare emitter rejects fromPath with whitespace", () => {
|
|
279
|
+
expect(() =>
|
|
280
|
+
emitRedirectsCloudflare([{ fromPath: "/old path", toPath: "/new", statusCode: 301 }]),
|
|
281
|
+
).toThrow(/contains whitespace/);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("CloudFront emitter rejects toPath with newline", () => {
|
|
285
|
+
expect(() =>
|
|
286
|
+
emitRedirectsCloudFront([{ fromPath: "/old", toPath: "/new\nlies", statusCode: 301 }]),
|
|
287
|
+
).toThrow(/contains whitespace/);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("every emitter rejects an off-site or URL-embedding toPath (#657)", () => {
|
|
291
|
+
for (const toPath of ["//evil.example/x", "/https://example.com/x"]) {
|
|
292
|
+
const rows = [{ fromPath: "/old", toPath, statusCode: 301 as const }];
|
|
293
|
+
expect(() => emitRedirectsCloudflare(rows)).toThrow(/redirects-emit: toPath/);
|
|
294
|
+
expect(() => emitRedirectsCloudFront(rows)).toThrow(/redirects-emit: toPath/);
|
|
295
|
+
expect(() => emitRedirectsAzureFrontDoor(rows)).toThrow(/redirects-emit: toPath/);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("Azure emitter rejects tab in path", () => {
|
|
300
|
+
expect(() =>
|
|
301
|
+
emitRedirectsAzureFrontDoor([{ fromPath: "/old\there", toPath: "/x", statusCode: 301 }]),
|
|
302
|
+
).toThrow(/contains whitespace/);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("hyphens, dots, slashes, unicode in path segments are accepted", () => {
|
|
306
|
+
// Regression guard for the original buggy regex `[\s -]` which
|
|
307
|
+
// rejected hyphens.
|
|
308
|
+
expect(() =>
|
|
309
|
+
emitRedirectsCloudflare([
|
|
310
|
+
{ fromPath: "/blog/spring-launch", toPath: "/blog/sommer-2026", statusCode: 301 },
|
|
311
|
+
]),
|
|
312
|
+
).not.toThrow();
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe("cdn-copy adapter loader", () => {
|
|
318
|
+
it("returns the no-op self-hosted adapter when provider is unset/empty/self-hosted", async () => {
|
|
319
|
+
expect(await loadCdnCopyAdapter(undefined)).toBe(selfHostedCdnCopy);
|
|
320
|
+
expect(await loadCdnCopyAdapter("")).toBe(selfHostedCdnCopy);
|
|
321
|
+
expect(await loadCdnCopyAdapter("self-hosted")).toBe(selfHostedCdnCopy);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("self-hosted adapter pin returns a /media/<key> URL", async () => {
|
|
325
|
+
const url = await selfHostedCdnCopy.pin("photos/2026/april/sunset.webp");
|
|
326
|
+
expect(url).toBe("/media/photos/2026/april/sunset.webp");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("rejects unknown provider names loudly (no silent fallback per CLAUDE.md §2)", async () => {
|
|
330
|
+
await expect(loadCdnCopyAdapter("digitalocean")).rejects.toThrow(/unknown CAELO_PROVIDER/);
|
|
331
|
+
});
|
|
332
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @caelo-cms/provisioning — P14.
|
|
5
|
+
*
|
|
6
|
+
* Pulumi-driven self-hosted stack + cms-provision CLI helpers.
|
|
7
|
+
*
|
|
8
|
+
* Public surface:
|
|
9
|
+
* - generateCaddyfile(spec) → string
|
|
10
|
+
* - generateDockerCompose(spec) → string
|
|
11
|
+
* - generateBootstrapToken() → { token, expiresAt }
|
|
12
|
+
*
|
|
13
|
+
* The CLI (cli.ts) wires these into init / up / regenerate-caddy /
|
|
14
|
+
* backup / restore / status sub-commands. The Pulumi stack files
|
|
15
|
+
* (stacks/self-hosted/*) are imported by the CLI's `up` path and
|
|
16
|
+
* declare the actual Docker resources.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type {
|
|
20
|
+
CloudAdapterInputs,
|
|
21
|
+
CloudAdapterOutputs,
|
|
22
|
+
DnsRecord,
|
|
23
|
+
Environment,
|
|
24
|
+
ProvisioningOutputsJson,
|
|
25
|
+
SupportedProvider,
|
|
26
|
+
} from "./adapter.js";
|
|
27
|
+
export type {
|
|
28
|
+
AgentDbConnection,
|
|
29
|
+
AgentHeartbeat,
|
|
30
|
+
AgentQueue,
|
|
31
|
+
OpenAgentQueue,
|
|
32
|
+
OpenedAgentQueue,
|
|
33
|
+
} from "./agent/queue.js";
|
|
34
|
+
export {
|
|
35
|
+
type BootstrapToken,
|
|
36
|
+
generateBootstrapToken,
|
|
37
|
+
} from "./bootstrap-token.js";
|
|
38
|
+
export {
|
|
39
|
+
type CaddyDomainSpec,
|
|
40
|
+
type CaddyfileSpec,
|
|
41
|
+
generateCaddyfile,
|
|
42
|
+
} from "./caddy.js";
|
|
43
|
+
export {
|
|
44
|
+
type CdnCopyAdapter,
|
|
45
|
+
loadCdnCopyAdapter,
|
|
46
|
+
selfHostedCdnCopy,
|
|
47
|
+
} from "./cdn-copy.js";
|
|
48
|
+
export { type ComposeSpec, generateDockerCompose, type RolePasswords } from "./compose.js";
|
|
49
|
+
export { INFRA_CONTRACT_VERSION } from "./infra-contract.js";
|
|
50
|
+
export {
|
|
51
|
+
type CloudFrontRedirectArtifact,
|
|
52
|
+
emitRedirectsAzureFrontDoor,
|
|
53
|
+
emitRedirectsCloudFront,
|
|
54
|
+
emitRedirectsCloudflare,
|
|
55
|
+
type FrontDoorRule,
|
|
56
|
+
type RedirectRow,
|
|
57
|
+
type RedirectStatusCode,
|
|
58
|
+
} from "./redirects-emit.js";
|
|
59
|
+
export {
|
|
60
|
+
INFRA_CONTRACT_PATHS,
|
|
61
|
+
isInfraContractPath,
|
|
62
|
+
RELEASE_MIN_FROM_VERSION,
|
|
63
|
+
} from "./release-contract.js";
|
|
64
|
+
export {
|
|
65
|
+
assessAutoInstall,
|
|
66
|
+
channelForVersion,
|
|
67
|
+
compareImageDigests,
|
|
68
|
+
compareSemver,
|
|
69
|
+
type DigestMismatch,
|
|
70
|
+
fetchVerifiedReleaseManifest,
|
|
71
|
+
type InstallAssessment,
|
|
72
|
+
type InstalledRelease,
|
|
73
|
+
type InstallMode,
|
|
74
|
+
RELEASE_CHANNELS,
|
|
75
|
+
RELEASE_IMAGES,
|
|
76
|
+
RELEASE_MANIFEST_ARTIFACT_TYPE,
|
|
77
|
+
RELEASE_MANIFEST_MEDIA_TYPE,
|
|
78
|
+
RELEASE_MANIFEST_REGISTRY,
|
|
79
|
+
RELEASE_MANIFEST_REPOSITORY,
|
|
80
|
+
RELEASE_OIDC_ISSUER,
|
|
81
|
+
type ReleaseChannel,
|
|
82
|
+
type ReleaseImage,
|
|
83
|
+
type ReleaseManifest,
|
|
84
|
+
type ReleaseManifestDeps,
|
|
85
|
+
type ReleaseManifestError,
|
|
86
|
+
type ReleaseManifestErrorCode,
|
|
87
|
+
type ReleaseManifestResult,
|
|
88
|
+
ReleaseManifestSchema,
|
|
89
|
+
releaseManifestSignerIdentity,
|
|
90
|
+
SIGSTORE_BUNDLE_MEDIA_TYPE,
|
|
91
|
+
} from "./release-manifest.js";
|
|
92
|
+
export { AGENT_DB_ROLE, roleConvergenceScript } from "./self-hosted-roles.js";
|
|
93
|
+
export {
|
|
94
|
+
assessUpgradeRequest,
|
|
95
|
+
type InstalledState,
|
|
96
|
+
type RefusalCode,
|
|
97
|
+
type RequestDecision,
|
|
98
|
+
} from "./upgrader-assess.js";
|
|
99
|
+
export {
|
|
100
|
+
gcpUpgraderDeps,
|
|
101
|
+
type UpgraderPlacement,
|
|
102
|
+
upgraderDatabaseHost,
|
|
103
|
+
} from "./upgrader-gcp.js";
|
|
104
|
+
export {
|
|
105
|
+
type ClaimedUpdate,
|
|
106
|
+
runUpgraderJob,
|
|
107
|
+
type UpdateEnd,
|
|
108
|
+
type UpdateEndStatus,
|
|
109
|
+
type UpdateFacts,
|
|
110
|
+
type UpdateQueue,
|
|
111
|
+
type UpgraderJobDeps,
|
|
112
|
+
type UpgraderRunResult,
|
|
113
|
+
} from "./upgrader-job.js";
|
|
114
|
+
export type { JobLog } from "./upgrader-reporter.js";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The infrastructure contract version (epic #696).
|
|
5
|
+
*
|
|
6
|
+
* An upgrade has two halves (packages/provisioning/src/upgrade/): the
|
|
7
|
+
* *converge* half changes IAM, service accounts, secrets and other cloud
|
|
8
|
+
* resources the stacks declare; the *app-roll* half only runs migrations and
|
|
9
|
+
* moves the two Cloud Run services onto new images. The app-roll half can run
|
|
10
|
+
* under an identity without IAM-write or secret-create rights (the in-cloud
|
|
11
|
+
* upgrader job), but only while the install's infrastructure already is what
|
|
12
|
+
* the release expects.
|
|
13
|
+
*
|
|
14
|
+
* This number names that expectation. A release that needs a converge step
|
|
15
|
+
* the installs out there have not had yet (a new secret, a new IAM binding, a
|
|
16
|
+
* new service account) bumps it; `cms-provision upgrade` records the version
|
|
17
|
+
* it converged to in install.json, and the upgrader job refuses an app-roll
|
|
18
|
+
* while the recorded version is lower (`needsConverge` in upgrade/converge.ts),
|
|
19
|
+
* sending the operator to the converge path instead.
|
|
20
|
+
*
|
|
21
|
+
* Bump it in the same PR that adds the converge step, never later: a release
|
|
22
|
+
* whose app-roll references infrastructure an install does not have yet fails
|
|
23
|
+
* at roll time, after migrations ran.
|
|
24
|
+
*/
|
|
25
|
+
export const INFRA_CONTRACT_VERSION = 2;
|
|
26
|
+
|
|
27
|
+
// History (why each bump happened — an install below a number needs the
|
|
28
|
+
// converge that introduced it):
|
|
29
|
+
// 1 — epic #696 split: converge vs app-roll.
|
|
30
|
+
// 2 — the upgrader job (#696 Phase 2): its service account + scoped
|
|
31
|
+
// grants, the Cloud SQL backup role, its IAM database user, the job,
|
|
32
|
+
// the admin's run-only grant and CAELO_UPGRADER_JOB on the admin.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `caelo-ops install-job --project <id>` — the install job's entry point
|
|
5
|
+
* (epic #696, Phase 3; install-job.ts has the design). Runs inside the Cloud
|
|
6
|
+
* Run job, as `caelo-provisioner`, from the signed ops image:
|
|
7
|
+
*
|
|
8
|
+
* 1. attach the install from its ops bucket (the container starts empty);
|
|
9
|
+
* 2. record `running` in `install/install-job.json`;
|
|
10
|
+
* 3. run the converge half of the GCP install (wizards/gcp.ts
|
|
11
|
+
* runGcpConverge) non-interactively, recording each phase;
|
|
12
|
+
* 4. record `succeeded` — or, from an exit hook, `failed` with the last
|
|
13
|
+
* lines of output, whatever made the process stop.
|
|
14
|
+
*
|
|
15
|
+
* It takes the project and nothing else: every input is the install's own
|
|
16
|
+
* record in its bucket, which only the operator and the provisioner can write.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { CAELO_VERSION } from "@caelo-cms/shared";
|
|
20
|
+
import { assertNoSecrets } from "./checkpoint-secrets.js";
|
|
21
|
+
import {
|
|
22
|
+
type InstallJobStatus,
|
|
23
|
+
readInstallJobStatus,
|
|
24
|
+
writeInstallJobStatus,
|
|
25
|
+
} from "./install-job.js";
|
|
26
|
+
import { currentMetadata, installRoot, readLocalMetadata } from "./install-state.js";
|
|
27
|
+
import { attachInstall } from "./state-attach.js";
|
|
28
|
+
import { hasLocalPulumiState, opsBucketName, resolveBackend } from "./state-backend.js";
|
|
29
|
+
import { realSyncGcloud, type SyncGcloud } from "./state-store.js";
|
|
30
|
+
|
|
31
|
+
const PROJECT_ID = /^[a-z][a-z0-9-]{4,28}[a-z0-9]$/;
|
|
32
|
+
const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[A-Za-z]`, "g");
|
|
33
|
+
|
|
34
|
+
/** The `--project` value of `argv`, validated. */
|
|
35
|
+
export function installJobProject(argv: readonly string[]): string {
|
|
36
|
+
const i = argv.indexOf("--project");
|
|
37
|
+
const value = i === -1 ? undefined : argv[i + 1];
|
|
38
|
+
if (!value || !PROJECT_ID.test(value)) {
|
|
39
|
+
throw new Error("install-job needs --project <gcp-project-id>");
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The failure detail recorded for the operator's terminal: the last
|
|
46
|
+
* non-empty lines of output, colour codes stripped, credentials removed.
|
|
47
|
+
* Anything the secrets guard still objects to is replaced by a pointer to
|
|
48
|
+
* the logs rather than written to the bucket.
|
|
49
|
+
*/
|
|
50
|
+
export function failureDetail(lines: readonly string[], code: number): string {
|
|
51
|
+
const tail = lines
|
|
52
|
+
.map((l) => l.replace(ANSI, "").trim())
|
|
53
|
+
.filter((l) => l !== "" && !/^[│┌└◇◆●■▲]+$/.test(l))
|
|
54
|
+
.slice(-6)
|
|
55
|
+
.join(" | ");
|
|
56
|
+
// Stricter than redactCredentials' `user:***@`, which the bucket's guard
|
|
57
|
+
// still (rightly) reads as a credential URL: drop the password and any
|
|
58
|
+
// token-like query value entirely.
|
|
59
|
+
const detail = `exit ${code}: ${tail}`
|
|
60
|
+
.replace(/([a-z][a-z0-9+.-]{0,31}:\/\/[^\s:@/]*):[^\s@/]+@/gi, "$1@")
|
|
61
|
+
.replace(
|
|
62
|
+
/([?&](?:token|secret|password|passwd|pwd|api[_-]?key|access[_-]?key|sig|signature)=)[^&\s#]+/gi,
|
|
63
|
+
"$1",
|
|
64
|
+
)
|
|
65
|
+
.slice(0, 1500);
|
|
66
|
+
try {
|
|
67
|
+
assertNoSecrets({ detail }, "install-job.json detail");
|
|
68
|
+
return detail;
|
|
69
|
+
} catch {
|
|
70
|
+
return `exit ${code} — see the job's logs`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Keep the last `max` lines written to stdout + stderr. */
|
|
75
|
+
function captureOutputTail(max: number): () => string[] {
|
|
76
|
+
const lines: string[] = [];
|
|
77
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
78
|
+
const write = stream.write.bind(stream) as (chunk: unknown, ...rest: unknown[]) => boolean;
|
|
79
|
+
stream.write = ((chunk: unknown, ...rest: unknown[]) => {
|
|
80
|
+
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk as Uint8Array).toString();
|
|
81
|
+
for (const l of text.split(/\r?\n/)) if (l.trim() !== "") lines.push(l);
|
|
82
|
+
if (lines.length > max) lines.splice(0, lines.length - max);
|
|
83
|
+
return write(chunk, ...rest);
|
|
84
|
+
}) as typeof stream.write;
|
|
85
|
+
}
|
|
86
|
+
return () => [...lines];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Run the install job for `--project` in `argv`. */
|
|
90
|
+
export async function installJobCommand(
|
|
91
|
+
argv: readonly string[],
|
|
92
|
+
run: SyncGcloud = realSyncGcloud,
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
const projectId = installJobProject(argv);
|
|
95
|
+
const attached = attachInstall(projectId, {
|
|
96
|
+
run,
|
|
97
|
+
rootFor: installRoot,
|
|
98
|
+
hasLocalState: hasLocalPulumiState,
|
|
99
|
+
});
|
|
100
|
+
if (!attached.ok) throw new Error(attached.error);
|
|
101
|
+
const local = readLocalMetadata(attached.installId);
|
|
102
|
+
if (!local) throw new Error(`attach wrote no install.json for ${attached.installId}`);
|
|
103
|
+
const meta = currentMetadata(local);
|
|
104
|
+
if (meta.provider !== "gcp" && meta.provider !== "gcp-firebase") {
|
|
105
|
+
throw new Error(`install-job runs gcp / gcp-firebase installs; this one is ${meta.provider}.`);
|
|
106
|
+
}
|
|
107
|
+
if (!meta.region) throw new Error(`install.json of ${meta.installId} records no region.`);
|
|
108
|
+
|
|
109
|
+
const bucket = opsBucketName(projectId);
|
|
110
|
+
// Cloud Run sets it; the launcher recorded the same name when it started us.
|
|
111
|
+
const execution = process.env.CLOUD_RUN_EXECUTION ?? "local-run";
|
|
112
|
+
const previous = readInstallJobStatus(bucket, run);
|
|
113
|
+
const now = new Date().toISOString();
|
|
114
|
+
let status: InstallJobStatus = {
|
|
115
|
+
statusVersion: 1,
|
|
116
|
+
installId: meta.installId,
|
|
117
|
+
execution,
|
|
118
|
+
state: "running",
|
|
119
|
+
phase: "Starting",
|
|
120
|
+
caeloVersion: CAELO_VERSION,
|
|
121
|
+
startedAt: previous?.execution === execution ? previous.startedAt : now,
|
|
122
|
+
updatedAt: now,
|
|
123
|
+
};
|
|
124
|
+
const record = (patch: Partial<InstallJobStatus>): void => {
|
|
125
|
+
status = { ...status, ...patch, updatedAt: new Date().toISOString() };
|
|
126
|
+
if (patch.detail === undefined) delete status.detail;
|
|
127
|
+
try {
|
|
128
|
+
writeInstallJobStatus(bucket, status, run);
|
|
129
|
+
} catch (e) {
|
|
130
|
+
// The install goes on; the terminal falls back to the execution state.
|
|
131
|
+
console.error(`could not record progress: ${e instanceof Error ? e.message : String(e)}`);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
record({});
|
|
135
|
+
|
|
136
|
+
const outputTail = captureOutputTail(40);
|
|
137
|
+
// The wizard's steps end the process with process.exit on failure; the
|
|
138
|
+
// hook runs synchronously (the bucket write is a spawnSync) either way.
|
|
139
|
+
process.on("exit", (code) => {
|
|
140
|
+
if (status.state !== "running") return;
|
|
141
|
+
record({ state: "failed", detail: failureDetail(outputTail(), code) });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const backend = resolveBackend(meta, {
|
|
145
|
+
installRoot: installRoot(meta.installId),
|
|
146
|
+
readPassphrase: () => null,
|
|
147
|
+
});
|
|
148
|
+
if (backend.kind !== "gcs") {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`${meta.installId}'s state is not in its ops bucket; run the installer with --local.`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const { runGcpConverge } = await import("./wizards/gcp.js");
|
|
154
|
+
await runGcpConverge({
|
|
155
|
+
installId: meta.installId,
|
|
156
|
+
projectId,
|
|
157
|
+
region: meta.region,
|
|
158
|
+
provider: meta.provider,
|
|
159
|
+
impersonateServiceAccount: null,
|
|
160
|
+
backend,
|
|
161
|
+
nonInteractive: true,
|
|
162
|
+
setupLink: "secret",
|
|
163
|
+
onPhase: (phase, detail) => record({ phase, detail }),
|
|
164
|
+
});
|
|
165
|
+
record({ state: "succeeded", phase: "Done" });
|
|
166
|
+
}
|