@caelo-cms/provisioning 0.10.53 → 0.10.57
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-db.js +272 -128
- package/dist/audit-job-setup.d.ts +69 -0
- package/dist/audit-job-setup.d.ts.map +1 -0
- package/dist/audit-job-setup.js +317 -0
- package/dist/audit-job-setup.js.map +1 -0
- package/dist/caddy.d.ts +9 -0
- package/dist/caddy.d.ts.map +1 -1
- package/dist/caddy.js +38 -0
- package/dist/caddy.js.map +1 -1
- package/dist/cli-routing.js +1 -1
- package/dist/cli-routing.js.map +1 -1
- package/dist/cli.js +36 -9
- package/dist/cli.js.map +1 -1
- package/dist/compose.js +5 -0
- package/dist/compose.js.map +1 -1
- package/dist/converge-checkpoint.d.ts +66 -0
- package/dist/converge-checkpoint.d.ts.map +1 -0
- package/dist/converge-checkpoint.js +61 -0
- package/dist/converge-checkpoint.js.map +1 -0
- package/dist/converge-job-gcp.d.ts +46 -0
- package/dist/converge-job-gcp.d.ts.map +1 -0
- package/dist/converge-job-gcp.js +368 -0
- package/dist/converge-job-gcp.js.map +1 -0
- package/dist/converge-job-setup.d.ts +62 -0
- package/dist/converge-job-setup.d.ts.map +1 -0
- package/dist/converge-job-setup.js +228 -0
- package/dist/converge-job-setup.js.map +1 -0
- package/dist/converge-job.d.ts +141 -0
- package/dist/converge-job.d.ts.map +1 -0
- package/dist/converge-job.js +325 -0
- package/dist/converge-job.js.map +1 -0
- package/dist/gcp-names.d.ts +25 -1
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +36 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -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 +28 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job.d.ts +11 -3
- package/dist/install-job.d.ts.map +1 -1
- package/dist/install-job.js +22 -12
- package/dist/install-job.js.map +1 -1
- package/dist/lifecycle.d.ts +13 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +115 -58
- package/dist/lifecycle.js.map +1 -1
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +13 -3
- package/dist/operator-access.js.map +1 -1
- package/dist/private-file.d.ts +26 -0
- package/dist/private-file.d.ts.map +1 -0
- package/dist/private-file.js +49 -0
- package/dist/private-file.js.map +1 -0
- package/dist/release-contract.d.ts +3 -2
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +8 -2
- package/dist/release-contract.js.map +1 -1
- package/dist/release-manifest.d.ts +2 -0
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js.map +1 -1
- package/dist/remote-mcp-edge.d.ts +114 -0
- package/dist/remote-mcp-edge.d.ts.map +1 -0
- package/dist/remote-mcp-edge.js +116 -0
- package/dist/remote-mcp-edge.js.map +1 -0
- package/dist/remote-mcp.d.ts +117 -0
- package/dist/remote-mcp.d.ts.map +1 -0
- package/dist/remote-mcp.js +379 -0
- package/dist/remote-mcp.js.map +1 -0
- package/dist/runtime-secrets.d.ts +12 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +32 -4
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/seal-key-rotation.d.ts +38 -0
- package/dist/seal-key-rotation.d.ts.map +1 -0
- package/dist/seal-key-rotation.js +206 -0
- package/dist/seal-key-rotation.js.map +1 -0
- package/dist/stack-contract.d.ts +175 -3
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +183 -3
- package/dist/stack-contract.js.map +1 -1
- package/dist/stack-converge.d.ts +38 -1
- package/dist/stack-converge.d.ts.map +1 -1
- package/dist/stack-converge.js +74 -4
- package/dist/stack-converge.js.map +1 -1
- package/dist/state-lock-lease.d.ts +142 -0
- package/dist/state-lock-lease.d.ts.map +1 -0
- package/dist/state-lock-lease.js +347 -0
- package/dist/state-lock-lease.js.map +1 -0
- package/dist/state-store.d.ts +66 -9
- package/dist/state-store.d.ts.map +1 -1
- package/dist/state-store.js +249 -30
- package/dist/state-store.js.map +1 -1
- package/dist/tar-entries.d.ts.map +1 -1
- package/dist/tar-entries.js +84 -9
- package/dist/tar-entries.js.map +1 -1
- package/dist/upgrade/app-roll.d.ts +36 -7
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +88 -19
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/audit-job-roll.d.ts +38 -0
- package/dist/upgrade/audit-job-roll.d.ts.map +1 -0
- package/dist/upgrade/audit-job-roll.js +43 -0
- package/dist/upgrade/audit-job-roll.js.map +1 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +141 -8
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/gcp-lookup.d.ts +27 -10
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
- package/dist/upgrade/gcp-lookup.js +46 -7
- package/dist/upgrade/gcp-lookup.js.map +1 -1
- package/dist/upgrade/record-rolled.d.ts +40 -0
- package/dist/upgrade/record-rolled.d.ts.map +1 -0
- package/dist/upgrade/record-rolled.js +46 -0
- package/dist/upgrade/record-rolled.js.map +1 -0
- package/dist/upgrade/upgrade.d.ts +45 -1
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +60 -17
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-assess.d.ts +15 -1
- package/dist/upgrader-assess.d.ts.map +1 -1
- package/dist/upgrader-assess.js +36 -6
- package/dist/upgrader-assess.js.map +1 -1
- package/dist/upgrader-gcp.d.ts +15 -0
- package/dist/upgrader-gcp.d.ts.map +1 -1
- package/dist/upgrader-gcp.js +42 -7
- package/dist/upgrader-gcp.js.map +1 -1
- package/dist/upgrader-iam-preflight.d.ts +123 -0
- package/dist/upgrader-iam-preflight.d.ts.map +1 -0
- package/dist/upgrader-iam-preflight.js +414 -0
- package/dist/upgrader-iam-preflight.js.map +1 -0
- package/dist/upgrader-job.d.ts +33 -9
- package/dist/upgrader-job.d.ts.map +1 -1
- package/dist/upgrader-job.js +169 -27
- package/dist/upgrader-job.js.map +1 -1
- package/dist/upgrader-records.d.ts +58 -0
- package/dist/upgrader-records.d.ts.map +1 -0
- package/dist/upgrader-records.js +87 -0
- package/dist/upgrader-records.js.map +1 -0
- package/dist/upgrader-reporter.d.ts +3 -2
- package/dist/upgrader-reporter.d.ts.map +1 -1
- package/dist/upgrader-reporter.js +10 -2
- package/dist/upgrader-reporter.js.map +1 -1
- package/dist/upgrader-setup.d.ts +25 -3
- package/dist/upgrader-setup.d.ts.map +1 -1
- package/dist/upgrader-setup.js +132 -14
- package/dist/upgrader-setup.js.map +1 -1
- package/dist/wizards/gcp-cost.d.ts.map +1 -1
- package/dist/wizards/gcp-cost.js +14 -6
- package/dist/wizards/gcp-cost.js.map +1 -1
- package/dist/wizards/gcp-pulumi.d.ts +6 -0
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +7 -0
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +130 -4
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +4 -4
- package/src/agent/run.test.ts +15 -1
- package/src/audit-job-setup.test.ts +234 -0
- package/src/audit-job-setup.ts +360 -0
- package/src/caddy-redirects.docker.test.ts +19 -6
- package/src/caddy.test.ts +30 -0
- package/src/caddy.ts +43 -0
- package/src/cli-init-validation.test.ts +44 -0
- package/src/cli-routing.ts +1 -1
- package/src/cli.ts +40 -8
- package/src/compose.ts +5 -0
- package/src/converge-checkpoint.ts +75 -0
- package/src/converge-entrypoint.test.ts +213 -0
- package/src/converge-job-gcp.test.ts +222 -0
- package/src/converge-job-gcp.ts +443 -0
- package/src/converge-job-setup.test.ts +152 -0
- package/src/converge-job-setup.ts +268 -0
- package/src/converge-job.test.ts +486 -0
- package/src/converge-job.ts +542 -0
- package/src/gateway-env-isolation.test.ts +8 -1
- package/src/gcp-names.ts +43 -1
- package/src/gcp-url-map.test.ts +9 -1
- package/src/index.ts +8 -0
- package/src/infra-contract.test.ts +7 -4
- package/src/infra-contract.ts +28 -1
- package/src/install-job.test.ts +3 -1
- package/src/install-job.ts +31 -14
- package/src/lifecycle.ts +137 -71
- package/src/operator-access.test.ts +14 -0
- package/src/operator-access.ts +13 -3
- package/src/private-file.test.ts +80 -0
- package/src/private-file.ts +50 -0
- package/src/release-contract.ts +8 -2
- package/src/release-manifest.test.ts +42 -0
- package/src/release-manifest.ts +2 -0
- package/src/remote-mcp-edge.ts +137 -0
- package/src/remote-mcp.test.ts +517 -0
- package/src/remote-mcp.ts +460 -0
- package/src/runtime-secrets.test.ts +31 -6
- package/src/runtime-secrets.ts +41 -3
- package/src/seal-key-rotation.test.ts +131 -0
- package/src/seal-key-rotation.ts +258 -0
- package/src/stack-contract-parity.test.ts +20 -3
- package/src/stack-contract.test.ts +34 -2
- package/src/stack-contract.ts +305 -3
- package/src/stack-converge-secrets.test.ts +3 -0
- package/src/stack-converge.test.ts +21 -5
- package/src/stack-converge.ts +101 -7
- package/src/state-attach.test.ts +7 -1
- package/src/state-lock-lease.test.ts +302 -0
- package/src/state-lock-lease.ts +423 -0
- package/src/state-lock.test.ts +568 -0
- package/src/state-store.ts +347 -27
- package/src/tar-entries.test.ts +149 -7
- package/src/tar-entries.ts +90 -9
- package/src/upgrade/app-roll.ts +135 -21
- package/src/upgrade/audit-job-roll.ts +68 -0
- package/src/upgrade/converge.ts +170 -7
- package/src/upgrade/gcp-lookup.test.ts +80 -0
- package/src/upgrade/gcp-lookup.ts +65 -8
- package/src/upgrade/record-rolled.test.ts +105 -0
- package/src/upgrade/record-rolled.ts +91 -0
- package/src/upgrade/upgrade.test.ts +279 -5
- package/src/upgrade/upgrade.ts +120 -24
- package/src/upgrader-assess.test.ts +93 -3
- package/src/upgrader-assess.ts +58 -9
- package/src/upgrader-gcp.ts +47 -8
- package/src/upgrader-iam-preflight.test.ts +393 -0
- package/src/upgrader-iam-preflight.ts +510 -0
- package/src/upgrader-job.test.ts +587 -15
- package/src/upgrader-job.ts +237 -53
- package/src/upgrader-records.ts +151 -0
- package/src/upgrader-reporter.test.ts +6 -0
- package/src/upgrader-reporter.ts +7 -2
- package/src/upgrader-setup.test.ts +4 -3
- package/src/upgrader-setup.ts +137 -13
- package/src/wizards/gcp-cost.ts +15 -6
- package/src/wizards/gcp-pulumi.ts +7 -0
- package/src/wizards/gcp.ts +162 -4
- package/stacks/aws/index.ts +18 -0
- package/stacks/azure/index.ts +18 -0
- package/stacks/gcp/index.ts +59 -7
- package/stacks/gcp-firebase/index.ts +50 -6
package/src/infra-contract.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* whose app-roll references infrastructure an install does not have yet fails
|
|
23
23
|
* at roll time, after migrations ran.
|
|
24
24
|
*/
|
|
25
|
-
export const INFRA_CONTRACT_VERSION =
|
|
25
|
+
export const INFRA_CONTRACT_VERSION = 10;
|
|
26
26
|
|
|
27
27
|
// History (why each bump happened — an install below a number needs the
|
|
28
28
|
// converge that introduced it):
|
|
@@ -51,6 +51,30 @@ export const INFRA_CONTRACT_VERSION = 7;
|
|
|
51
51
|
// runs from (the installed one), which does not set it — so the new
|
|
52
52
|
// gateway would not start. The operator-identity upgrade runs the new
|
|
53
53
|
// release's contract and sets it.
|
|
54
|
+
// 8 — #875: the quality-audit job, its service account, the audit results
|
|
55
|
+
// bucket and their grants, plus CAELO_AUDIT_JOB /
|
|
56
|
+
// CAELO_AUDIT_RESULTS_BUCKET on the admin. The new admin fans every
|
|
57
|
+
// Stage audit out to that job, and the app-roll moves the job's image,
|
|
58
|
+
// so it must exist before an in-cloud upgrader rolls onto it.
|
|
59
|
+
// 9 — #819: the converge job (`caelo-<env>-converge`, converge-job-setup.ts)
|
|
60
|
+
// that installs contract-bumping releases from Settings → Updates as
|
|
61
|
+
// the install's caelo-provisioner, its Cloud SQL IAM database login
|
|
62
|
+
// (platform_updater), the admin's start-only grant on it and
|
|
63
|
+
// CAELO_CONVERGE_JOB on the admin. Installed by the last converge an
|
|
64
|
+
// Owner runs by hand; every later one runs from Caelo. The same bump
|
|
65
|
+
// replaces the upgrader's project-wide roles/run.developer (which
|
|
66
|
+
// holds run.jobs.runWithOverrides on the provisioner's jobs) with the
|
|
67
|
+
// custom role caeloUpgraderRun (#883 review). An install without a
|
|
68
|
+
// caelo-provisioner reaches 9 without the job: there is nothing it
|
|
69
|
+
// could run as, so its admin keeps routing cloud-setup releases to
|
|
70
|
+
// the terminal (upgrade/converge.ts, update-route.ts).
|
|
71
|
+
// 10 — #806: remote MCP. Two CLI-generated secrets (the Google OAuth client
|
|
72
|
+
// placeholder, the broker's seal key) the gateway's new revision
|
|
73
|
+
// references — a secret volume and a secret env var Cloud Run refuses
|
|
74
|
+
// to deploy without — plus the gateway SA's accessor and the admin
|
|
75
|
+
// SA's version-adder bindings on them, the operator-access role's
|
|
76
|
+
// IAP-settings permissions, and on `gcp` the URL-map routes and the
|
|
77
|
+
// Cloud Armor rule. The in-cloud upgrader can create none of these.
|
|
54
78
|
|
|
55
79
|
/**
|
|
56
80
|
* What each contract version adds, in operator terms — the upgrader's
|
|
@@ -65,6 +89,9 @@ export const INFRA_CONTRACT_STEPS: Readonly<Record<number, string>> = {
|
|
|
65
89
|
5: "the gateway's cms_admin login removed from its env and IAM (#635)",
|
|
66
90
|
6: "the upgrader's permission to list its ops bucket (#799)",
|
|
67
91
|
7: "the gateway's trusted proxy hop count, CAELO_TRUSTED_PROXY_HOPS (#845)",
|
|
92
|
+
8: "the quality-audit job, its service account, results bucket and grants (#875)",
|
|
93
|
+
9: "the converge job that installs cloud-setup changes from Settings → Updates, and a narrower Cloud Run role for the upgrader (#819)",
|
|
94
|
+
10: "remote MCP: the OAuth client and seal-key secrets, their IAM, the gateway's secret file and timeout, IAP programmatic-client sync and the load-balancer rules (#806)",
|
|
68
95
|
};
|
|
69
96
|
|
|
70
97
|
/**
|
package/src/install-job.test.ts
CHANGED
|
@@ -162,7 +162,9 @@ describe("the status document", () => {
|
|
|
162
162
|
|
|
163
163
|
it("fails loudly on a malformed document and on an unreadable bucket", () => {
|
|
164
164
|
const objects = new Map([[installJobStatusUrl(BUCKET), '{"statusVersion":9}']]);
|
|
165
|
-
expect(() => readInstallJobStatus(BUCKET, fakeBucket(objects))).toThrow(
|
|
165
|
+
expect(() => readInstallJobStatus(BUCKET, fakeBucket(objects))).toThrow(
|
|
166
|
+
/not a job status this CLI understands/,
|
|
167
|
+
);
|
|
166
168
|
expect(() => readInstallJobStatus(BUCKET, () => fail("403 Forbidden"))).toThrow(/403/);
|
|
167
169
|
});
|
|
168
170
|
});
|
package/src/install-job.ts
CHANGED
|
@@ -113,12 +113,12 @@ export function installJobStatusUrl(bucket: string): string {
|
|
|
113
113
|
const NOT_FOUND = /matched no objects|No URLs matched|NotFound|NOT_FOUND|404|does not exist/i;
|
|
114
114
|
|
|
115
115
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
116
|
+
* A job status document (`install-job.json`, and the converge job's
|
|
117
|
+
* `converge-job.json`, #819) as the ops bucket holds it now — never cached:
|
|
118
|
+
* another process (the job) writes it — or null before the first start.
|
|
118
119
|
* Throws when the bucket can't be read or the document is malformed.
|
|
119
120
|
*/
|
|
120
|
-
export function
|
|
121
|
-
const url = installJobStatusUrl(bucket);
|
|
121
|
+
export function readJobStatusDoc<T>(url: string, schema: z.ZodType<T>, run: SyncGcloud): T | null {
|
|
122
122
|
const r = run(["storage", "cat", url]);
|
|
123
123
|
if (!r.ok) {
|
|
124
124
|
if (NOT_FOUND.test(r.stderr)) return null;
|
|
@@ -128,26 +128,26 @@ export function readInstallJobStatus(bucket: string, run: SyncGcloud): InstallJo
|
|
|
128
128
|
try {
|
|
129
129
|
json = JSON.parse(r.stdout);
|
|
130
130
|
} catch {
|
|
131
|
-
throw new Error(`${url} is not valid JSON; remove it and re-run to start
|
|
131
|
+
throw new Error(`${url} is not valid JSON; remove it and re-run to start again.`);
|
|
132
132
|
}
|
|
133
|
-
const parsed =
|
|
133
|
+
const parsed = schema.safeParse(json);
|
|
134
134
|
if (!parsed.success) {
|
|
135
135
|
throw new Error(
|
|
136
|
-
`${url} is not
|
|
136
|
+
`${url} is not a job status this CLI understands (${parsed.error.message}); remove it and re-run.`,
|
|
137
137
|
);
|
|
138
138
|
}
|
|
139
139
|
return parsed.data;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
/** Replace
|
|
143
|
-
export function
|
|
144
|
-
|
|
145
|
-
|
|
142
|
+
/** Replace a job status document. Refuses secret material (#656). */
|
|
143
|
+
export function writeJobStatusDoc<T>(
|
|
144
|
+
url: string,
|
|
145
|
+
schema: z.ZodType<T>,
|
|
146
|
+
status: T,
|
|
146
147
|
run: SyncGcloud,
|
|
147
148
|
): void {
|
|
148
|
-
const parsed =
|
|
149
|
-
assertNoSecrets(parsed,
|
|
150
|
-
const url = installJobStatusUrl(bucket);
|
|
149
|
+
const parsed = schema.parse(status);
|
|
150
|
+
assertNoSecrets(parsed, url.split("/").pop() ?? url);
|
|
151
151
|
const r = run(
|
|
152
152
|
["storage", "cp", "-", url, "--content-type=application/json"],
|
|
153
153
|
`${JSON.stringify(parsed, null, 2)}\n`,
|
|
@@ -157,6 +157,23 @@ export function writeInstallJobStatus(
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* The install job's status as the ops bucket holds it now, or null before
|
|
162
|
+
* the first start ({@link readJobStatusDoc}).
|
|
163
|
+
*/
|
|
164
|
+
export function readInstallJobStatus(bucket: string, run: SyncGcloud): InstallJobStatus | null {
|
|
165
|
+
return readJobStatusDoc(installJobStatusUrl(bucket), InstallJobStatusSchema, run);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Replace the status document. Refuses secret material (#656). */
|
|
169
|
+
export function writeInstallJobStatus(
|
|
170
|
+
bucket: string,
|
|
171
|
+
status: InstallJobStatus,
|
|
172
|
+
run: SyncGcloud,
|
|
173
|
+
): void {
|
|
174
|
+
writeJobStatusDoc(installJobStatusUrl(bucket), InstallJobStatusSchema, status, run);
|
|
175
|
+
}
|
|
176
|
+
|
|
160
177
|
/**
|
|
161
178
|
* Write `status` only if the document doesn't name its execution yet and
|
|
162
179
|
* nobody wrote it since this call read it (`--if-generation-match`). The
|
package/src/lifecycle.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
installRoot,
|
|
33
33
|
installStagingHost,
|
|
34
34
|
listInstalls,
|
|
35
|
+
readLocalMetadata,
|
|
35
36
|
readMetadata,
|
|
36
37
|
readSecret,
|
|
37
38
|
recordImageDigests,
|
|
@@ -53,14 +54,18 @@ import {
|
|
|
53
54
|
rolesOfSecret,
|
|
54
55
|
rotateRuntimeSecret,
|
|
55
56
|
rotationRefusal,
|
|
57
|
+
SEAL_KEY_SECRET,
|
|
56
58
|
} from "./runtime-secrets.js";
|
|
59
|
+
import { finishSealKeyRotation, startSealKeyRotation } from "./seal-key-rotation.js";
|
|
57
60
|
import { selfCommand } from "./self-command.js";
|
|
58
61
|
import {
|
|
59
62
|
backendShellEnv,
|
|
60
63
|
localStateNotice,
|
|
64
|
+
OPS_INSTALL_PREFIX,
|
|
61
65
|
type ResolvedBackend,
|
|
62
66
|
resolveBackend,
|
|
63
67
|
} from "./state-backend.js";
|
|
68
|
+
import { acquireStateLock, realSyncGcloud, releaseOnSignal } from "./state-store.js";
|
|
64
69
|
import {
|
|
65
70
|
type AppRollPlan,
|
|
66
71
|
type ReleaseImage,
|
|
@@ -70,6 +75,7 @@ import {
|
|
|
70
75
|
import { restoreCommand, takeSqlBackup } from "./upgrade/backup.js";
|
|
71
76
|
import { findServiceUrl, resolveGcpResourceName } from "./upgrade/gcp-lookup.js";
|
|
72
77
|
import { createIapHealthProbe, type HealthProbe } from "./upgrade/health.js";
|
|
78
|
+
import { recordRolledDigests } from "./upgrade/record-rolled.js";
|
|
73
79
|
import {
|
|
74
80
|
canonicalReleaseTag,
|
|
75
81
|
fetchLatestReleaseTag,
|
|
@@ -488,6 +494,53 @@ async function upgradeInstall(
|
|
|
488
494
|
const provider = meta.provider;
|
|
489
495
|
const projectId = meta.projectId;
|
|
490
496
|
|
|
497
|
+
// #815 — the install's state lock, the one the in-app upgrader job,
|
|
498
|
+
// `pulumi up` and `state migrate` take: a Cloud Shell upgrade must not
|
|
499
|
+
// race an Owner-started or automatic job run. The local copy always
|
|
500
|
+
// names where the state lives (install-state.ts stateStoreFor). A lock
|
|
501
|
+
// left by a job whose run has ended, by a CLI whose process is gone on
|
|
502
|
+
// this machine, or by one that stopped renewing its lease is broken
|
|
503
|
+
// automatically. This run's own lock carries host + pid and a lease its
|
|
504
|
+
// heartbeat renews, and is given back on Ctrl-C / SIGTERM — a killed CLI
|
|
505
|
+
// or a closed Cloud Shell never blocks in-app updates for good.
|
|
506
|
+
const backend = readLocalMetadata(installId)?.stateBackend;
|
|
507
|
+
let releaseLock: () => void;
|
|
508
|
+
try {
|
|
509
|
+
releaseLock = acquireStateLock(
|
|
510
|
+
backend?.kind === "gcs" ? { kind: "gcs", bucket: backend.bucket } : { kind: "local" },
|
|
511
|
+
installRoot(installId),
|
|
512
|
+
"cms-provision upgrade",
|
|
513
|
+
realSyncGcloud,
|
|
514
|
+
{
|
|
515
|
+
onBreak: (message) => log.info(message),
|
|
516
|
+
onLost: (message) => log.error(red(message)),
|
|
517
|
+
},
|
|
518
|
+
);
|
|
519
|
+
} catch (e) {
|
|
520
|
+
log.error(red(`Nothing was changed: ${e instanceof Error ? e.message : String(e)}`));
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
const disposeSignals = releaseOnSignal(releaseLock, (message) => log.warn(yellow(message)));
|
|
524
|
+
try {
|
|
525
|
+
return await upgradeLockedInstall(installId, meta, opts, provider, projectId);
|
|
526
|
+
} finally {
|
|
527
|
+
disposeSignals();
|
|
528
|
+
try {
|
|
529
|
+
releaseLock();
|
|
530
|
+
} catch (e) {
|
|
531
|
+
log.warn(yellow(e instanceof Error ? e.message : String(e)));
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** {@link upgradeInstall} once it holds the install's state lock. */
|
|
537
|
+
async function upgradeLockedInstall(
|
|
538
|
+
installId: string,
|
|
539
|
+
meta: InstallMetadata,
|
|
540
|
+
opts: UpgradeOpts,
|
|
541
|
+
provider: "gcp" | "gcp-firebase",
|
|
542
|
+
projectId: string,
|
|
543
|
+
): Promise<boolean> {
|
|
491
544
|
// #607 — upgrade never moves an install: a `--region` other than the
|
|
492
545
|
// recorded one is refused before anything rolls.
|
|
493
546
|
const region = installRegion(meta, opts.region);
|
|
@@ -501,7 +554,9 @@ async function upgradeInstall(
|
|
|
501
554
|
|
|
502
555
|
const run: GcloudRunner = gcloud;
|
|
503
556
|
const http = fetch as unknown as HttpFetch;
|
|
504
|
-
|
|
557
|
+
// #860 — a roll whose records could not be written is a failed upgrade.
|
|
558
|
+
let recorded = true;
|
|
559
|
+
const rolled = await upgradeGcpInstall(
|
|
505
560
|
{
|
|
506
561
|
provider,
|
|
507
562
|
projectId,
|
|
@@ -524,20 +579,29 @@ async function upgradeInstall(
|
|
|
524
579
|
migrate: (imageRef) =>
|
|
525
580
|
runMigrationsViaCloudRunJob({ projectId, region, imageOverride: imageRef }, { run }),
|
|
526
581
|
healthProbe: (plan) => gcpHealthProbe(run, http, plan),
|
|
527
|
-
recordDigests: async (digests) =>
|
|
528
|
-
|
|
582
|
+
recordDigests: async (digests) => {
|
|
583
|
+
recorded = await recordRolledRelease(
|
|
529
584
|
installId,
|
|
530
585
|
provider,
|
|
531
586
|
digests,
|
|
532
587
|
// #696 — the version the upgrader job judges later updates by.
|
|
533
588
|
canonicalReleaseTag(targetTag)?.replace(/^v/, "") ??
|
|
534
589
|
(await releaseVersionForDigest(run, "admin", digests.admin)),
|
|
535
|
-
)
|
|
590
|
+
);
|
|
591
|
+
},
|
|
536
592
|
resolveOpsImage: resolveOpsImageRef,
|
|
537
593
|
recordInfraContractVersion: (version) => recordInfraContractVersion(installId, version),
|
|
538
594
|
provisionerIdentity: () => upgradeKeylessProvisioner(installId, projectId),
|
|
539
595
|
},
|
|
540
596
|
);
|
|
597
|
+
if (rolled && !recorded) {
|
|
598
|
+
log.error(
|
|
599
|
+
red(
|
|
600
|
+
"The new release serves, but the upgrade could not record it (see above). Apply the fixes above; until then this upgrade counts as failed.",
|
|
601
|
+
),
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
const ok = rolled && recorded;
|
|
541
605
|
// #696 — upgrade never moves Pulumi state; it says how to.
|
|
542
606
|
const stateNotice = localStateNotice(meta);
|
|
543
607
|
if (stateNotice) note(stateNotice, "Pulumi state is on this machine");
|
|
@@ -586,74 +650,44 @@ async function gcpHealthProbe(
|
|
|
586
650
|
}
|
|
587
651
|
|
|
588
652
|
/**
|
|
589
|
-
* Record the digests just rolled
|
|
590
|
-
* install.json
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
653
|
+
* Record the digests just rolled (upgrade/record-rolled.ts), wired to this
|
|
654
|
+
* install's install.json and Pulumi stack. The converge job (#819,
|
|
655
|
+
* converge-job-gcp.ts) records through the same function as the terminal.
|
|
656
|
+
*
|
|
657
|
+
* @returns whether every record was written; false fails the upgrade.
|
|
594
658
|
*/
|
|
595
|
-
|
|
659
|
+
export function recordRolledRelease(
|
|
596
660
|
installId: string,
|
|
597
661
|
provider: "gcp" | "gcp-firebase",
|
|
598
662
|
digests: ImageDigests,
|
|
599
663
|
releaseVersion: string | null,
|
|
600
|
-
): Promise<
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
)
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
)
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
} catch (e) {
|
|
628
|
-
log.warn(
|
|
629
|
-
yellow(
|
|
630
|
-
`Stack config not updated (${reason(e)}). Before any manual \`pulumi up\`, run:\n${manual}`,
|
|
631
|
-
),
|
|
632
|
-
);
|
|
633
|
-
return;
|
|
634
|
-
}
|
|
635
|
-
try {
|
|
636
|
-
const { writeImageDigestsToStack } = await import("./wizards/gcp-pulumi.js");
|
|
637
|
-
const { removedOverrides } = await writeImageDigestsToStack({
|
|
638
|
-
backend,
|
|
639
|
-
provider,
|
|
640
|
-
digests,
|
|
641
|
-
});
|
|
642
|
-
log.info(dim("Pinned the rolled image digests in the Pulumi stack config."));
|
|
643
|
-
if (removedOverrides.length > 0) {
|
|
644
|
-
log.warn(
|
|
645
|
-
yellow(
|
|
646
|
-
`Removed ${removedOverrides.join(", ")} from the stack config: those image overrides win over the digest pins, so a later \`pulumi up\` would have rolled back to them.`,
|
|
647
|
-
),
|
|
648
|
-
);
|
|
649
|
-
}
|
|
650
|
-
} catch (e) {
|
|
651
|
-
log.warn(
|
|
652
|
-
yellow(
|
|
653
|
-
`Could not pin the rolled digests in the Pulumi stack config (${reason(e)}). Before any manual \`pulumi up\`, run:\n${manual}`,
|
|
654
|
-
),
|
|
655
|
-
);
|
|
656
|
-
}
|
|
664
|
+
): Promise<boolean> {
|
|
665
|
+
// The local copy always names where the state lives (install-state.ts
|
|
666
|
+
// stateStoreFor), and reading it cannot fail on bucket access.
|
|
667
|
+
const local = readLocalMetadata(installId);
|
|
668
|
+
const bucket = local?.stateBackend?.kind === "gcs" ? local.stateBackend.bucket : null;
|
|
669
|
+
return recordRolledDigests(digests, releaseVersion, {
|
|
670
|
+
recordInstall: (d, v) => recordImageDigests(installId, d, v),
|
|
671
|
+
pinStack: async (d) => {
|
|
672
|
+
const meta = readMetadata(installId);
|
|
673
|
+
if (!meta) throw new Error(`no install.json for '${installId}'`);
|
|
674
|
+
const backend: ResolvedBackend = resolveBackend(meta, {
|
|
675
|
+
installRoot: installRoot(installId),
|
|
676
|
+
readPassphrase: () => readSecret(installId, "pulumi-passphrase"),
|
|
677
|
+
});
|
|
678
|
+
const { writeImageDigestsToStack } = await import("./wizards/gcp-pulumi.js");
|
|
679
|
+
return writeImageDigestsToStack({ backend, provider, digests: d });
|
|
680
|
+
},
|
|
681
|
+
stackRecovery: (d) => stackConfigRecovery(installId, local ?? {}, provider, d),
|
|
682
|
+
installJsonLocation: bucket
|
|
683
|
+
? `gs://${bucket}/${OPS_INSTALL_PREFIX}/install.json (and ${join(installRoot(installId), "install.json")})`
|
|
684
|
+
: join(installRoot(installId), "install.json"),
|
|
685
|
+
log: {
|
|
686
|
+
info: (m) => log.info(dim(m)),
|
|
687
|
+
warn: (m) => log.warn(yellow(m)),
|
|
688
|
+
error: (m) => log.error(red(m)),
|
|
689
|
+
},
|
|
690
|
+
});
|
|
657
691
|
}
|
|
658
692
|
|
|
659
693
|
/**
|
|
@@ -717,10 +751,13 @@ async function backupOf(meta: InstallMetadata): Promise<boolean> {
|
|
|
717
751
|
// rotate-secret <name>
|
|
718
752
|
// =========================================================================
|
|
719
753
|
|
|
720
|
-
export async function rotateSecretCommand(
|
|
754
|
+
export async function rotateSecretCommand(
|
|
755
|
+
name: string | undefined,
|
|
756
|
+
opts: { readonly finish?: boolean; readonly now?: boolean } = {},
|
|
757
|
+
): Promise<void> {
|
|
721
758
|
if (!name) {
|
|
722
|
-
log.error(red("Usage: caelo-cms rotate-secret <name>"));
|
|
723
|
-
log.warn(`Names: ${ROTATABLE_SECRETS.join(", ")}`);
|
|
759
|
+
log.error(red("Usage: caelo-cms rotate-secret <name> [--finish]"));
|
|
760
|
+
log.warn(`Names: ${[...ROTATABLE_SECRETS, SEAL_KEY_SECRET].join(", ")}`);
|
|
724
761
|
process.exit(2);
|
|
725
762
|
}
|
|
726
763
|
const refusal = rotationRefusal(name);
|
|
@@ -728,7 +765,10 @@ export async function rotateSecretCommand(name: string | undefined): Promise<voi
|
|
|
728
765
|
log.error(red(refusal));
|
|
729
766
|
process.exit(2);
|
|
730
767
|
}
|
|
731
|
-
|
|
768
|
+
if (opts.finish && name !== SEAL_KEY_SECRET) {
|
|
769
|
+
log.error(red(`--finish applies to ${SEAL_KEY_SECRET} only; ${name} rotates in one step.`));
|
|
770
|
+
process.exit(2);
|
|
771
|
+
}
|
|
732
772
|
const { meta } = await requireInstall("rotate-secret");
|
|
733
773
|
if (meta.provider !== "gcp" && meta.provider !== "gcp-firebase") {
|
|
734
774
|
log.warn(`rotate-secret for provider ${meta.provider} not yet implemented.`);
|
|
@@ -737,6 +777,32 @@ export async function rotateSecretCommand(name: string | undefined): Promise<voi
|
|
|
737
777
|
if (!meta.projectId) return;
|
|
738
778
|
const region = installRegion(meta);
|
|
739
779
|
|
|
780
|
+
if (name === SEAL_KEY_SECRET) {
|
|
781
|
+
const gatewayService = await resolveGcpResourceName(
|
|
782
|
+
gcloud,
|
|
783
|
+
"run-service",
|
|
784
|
+
"caelo-production-gateway",
|
|
785
|
+
meta.projectId,
|
|
786
|
+
region,
|
|
787
|
+
);
|
|
788
|
+
if (!gatewayService) {
|
|
789
|
+
log.error(
|
|
790
|
+
red("Could not find the caelo-production-gateway* Cloud Run service — nothing rotated"),
|
|
791
|
+
);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
const target = { projectId: meta.projectId, region, env: GCP_STACK_ENV, gatewayService };
|
|
795
|
+
const report = opts.finish
|
|
796
|
+
? await finishSealKeyRotation(target, {}, { now: opts.now === true })
|
|
797
|
+
: await startSealKeyRotation(target);
|
|
798
|
+
for (const step of report.steps) log.info(` ${step}`);
|
|
799
|
+
if (report.ok)
|
|
800
|
+
log.success(green(opts.finish ? "seal-key rotation finished" : "seal-key rotation started"));
|
|
801
|
+
else log.error(red(report.error ?? "seal-key rotation failed"));
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
const secret = name as RotatableSecret;
|
|
805
|
+
|
|
740
806
|
const s = spinner();
|
|
741
807
|
s.start(`Rotating ${secret}...`);
|
|
742
808
|
const services: Partial<Record<"admin" | "gateway", string>> = {};
|
|
@@ -330,3 +330,17 @@ describe("resolveOperatorAccessTarget", () => {
|
|
|
330
330
|
expect(r2.ok === false && r2.error).toContain("exactly one");
|
|
331
331
|
});
|
|
332
332
|
});
|
|
333
|
+
|
|
334
|
+
describe("caeloOperatorAccess role (#806)", () => {
|
|
335
|
+
it("holds IAM-policy and IAP-settings rights on web services and the MCP SA — nothing else", () => {
|
|
336
|
+
expect([...OPERATOR_ACCESS_ROLE.permissions].sort()).toEqual([
|
|
337
|
+
"iam.serviceAccounts.getIamPolicy",
|
|
338
|
+
"iam.serviceAccounts.setIamPolicy",
|
|
339
|
+
"iap.webServices.getIamPolicy",
|
|
340
|
+
// The admin IAP resource's programmatic_clients (remote MCP).
|
|
341
|
+
"iap.webServices.getSettings",
|
|
342
|
+
"iap.webServices.setIamPolicy",
|
|
343
|
+
"iap.webServices.updateSettings",
|
|
344
|
+
]);
|
|
345
|
+
});
|
|
346
|
+
});
|
package/src/operator-access.ts
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* `cms-provision upgrade`, both as the operator's own gcloud identity.
|
|
7
7
|
*
|
|
8
8
|
* Trust boundary. Who may pass the admin's IAP gate and sign as `caelo-mcp`
|
|
9
|
-
* follows the Caelo user list
|
|
9
|
+
* follows the Caelo user list; which OAuth client may call the admin
|
|
10
|
+
* programmatically (IAP `programmatic_clients`, remote MCP #806) follows the
|
|
11
|
+
* client the Owner configured under Settings → MCP → Remote. The admin does NOT write that: its runtime
|
|
10
12
|
* service account holds no IAM-policy rights. A Cloud Run job with its own
|
|
11
13
|
* service account (admin-core security/operator-access/sync-job.ts) is the
|
|
12
14
|
* only principal that may change those two bindings, and it only ever grants
|
|
@@ -19,7 +21,8 @@
|
|
|
19
21
|
*
|
|
20
22
|
* What this ensures, every step idempotent:
|
|
21
23
|
* 1. the Cloud Scheduler API;
|
|
22
|
-
* 2. the custom role `caeloOperatorAccess` (get/setIamPolicy
|
|
24
|
+
* 2. the custom role `caeloOperatorAccess` (get/setIamPolicy, and
|
|
25
|
+
* get/updateSettings for `programmatic_clients`);
|
|
23
26
|
* 3. the job's service account, bound to that role on exactly the admin's
|
|
24
27
|
* IAP resource and the `caelo-mcp` service account, plus
|
|
25
28
|
* `cloudsql.instanceUser` + `logging.logWriter`;
|
|
@@ -66,10 +69,17 @@ export const OPERATOR_ACCESS_ROLE: CustomRoleSpec = {
|
|
|
66
69
|
roleId: "caeloOperatorAccess",
|
|
67
70
|
title: "Caelo operator access",
|
|
68
71
|
description:
|
|
69
|
-
"Lets the Caelo operator-access sync job set who passes the admin's IAP gate and who may sign as caelo-mcp. Bound only on those two resources, only to the job's service account.",
|
|
72
|
+
"Lets the Caelo operator-access sync job set who passes the admin's IAP gate, which OAuth client may call it programmatically (remote MCP), and who may sign as caelo-mcp. Bound only on those two resources, only to the job's service account.",
|
|
70
73
|
permissions: [
|
|
71
74
|
"iap.webServices.getIamPolicy",
|
|
72
75
|
"iap.webServices.setIamPolicy",
|
|
76
|
+
// #806 — the admin IAP resource's `programmatic_clients` (the remote-MCP
|
|
77
|
+
// OAuth client). IAP settings permissions per resource level:
|
|
78
|
+
// cloud.google.com/iap/docs/customizing ("Web Services": getSettings /
|
|
79
|
+
// updateSettings); the role is bound on exactly the admin's IAP web
|
|
80
|
+
// service, so it reaches no other resource's settings.
|
|
81
|
+
"iap.webServices.getSettings",
|
|
82
|
+
"iap.webServices.updateSettings",
|
|
73
83
|
"iam.serviceAccounts.getIamPolicy",
|
|
74
84
|
"iam.serviceAccounts.setIamPolicy",
|
|
75
85
|
],
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
3
|
+
import {
|
|
4
|
+
chmodSync,
|
|
5
|
+
closeSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
openSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
readSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
statSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { tightenPrivateTree, writePrivateFile } from "./private-file.js";
|
|
19
|
+
|
|
20
|
+
let dir: string;
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
dir = mkdtempSync(join(tmpdir(), "caelo-private-file-"));
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
25
|
+
|
|
26
|
+
const mode = (path: string) => statSync(path).mode & 0o777;
|
|
27
|
+
|
|
28
|
+
describe("writePrivateFile", () => {
|
|
29
|
+
it("creates the file owner-only and replaces a world-readable file with an owner-only one", () => {
|
|
30
|
+
const fresh = join(dir, "fresh.json");
|
|
31
|
+
writePrivateFile(fresh, "{}");
|
|
32
|
+
expect(mode(fresh)).toBe(0o600);
|
|
33
|
+
|
|
34
|
+
const legacy = join(dir, "legacy.json");
|
|
35
|
+
writeFileSync(legacy, "{}", { mode: 0o644 });
|
|
36
|
+
chmodSync(legacy, 0o644);
|
|
37
|
+
writePrivateFile(legacy, '{"secret":"x"}');
|
|
38
|
+
expect(mode(legacy)).toBe(0o600);
|
|
39
|
+
expect(readFileSync(legacy, "utf8")).toBe('{"secret":"x"}');
|
|
40
|
+
expect(readdirSync(dir).sort()).toEqual(["fresh.json", "legacy.json"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("never writes the new secret into the old world-readable inode", () => {
|
|
44
|
+
// A local user who opened the 0644 file before the rewrite keeps that
|
|
45
|
+
// descriptor; a write-then-chmod would let it read the new secret.
|
|
46
|
+
const legacy = join(dir, "internal-secret.json");
|
|
47
|
+
writeFileSync(legacy, "old");
|
|
48
|
+
chmodSync(legacy, 0o644);
|
|
49
|
+
const fd = openSync(legacy, "r");
|
|
50
|
+
try {
|
|
51
|
+
writePrivateFile(legacy, "new-secret");
|
|
52
|
+
const buf = Buffer.alloc(64);
|
|
53
|
+
const n = readSync(fd, buf, 0, buf.length, 0);
|
|
54
|
+
expect(buf.subarray(0, n).toString()).toBe("old");
|
|
55
|
+
} finally {
|
|
56
|
+
closeSync(fd);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("tightenPrivateTree", () => {
|
|
62
|
+
it("takes group/other access off an older install's directory and secrets, skipping missing ones", () => {
|
|
63
|
+
const install = join(dir, ".caelo");
|
|
64
|
+
mkdirSync(join(install, "bootstrap"), { recursive: true });
|
|
65
|
+
chmodSync(install, 0o755);
|
|
66
|
+
chmodSync(join(install, "bootstrap"), 0o755);
|
|
67
|
+
writeFileSync(join(install, "config.json"), "{}");
|
|
68
|
+
chmodSync(join(install, "config.json"), 0o644);
|
|
69
|
+
writeFileSync(join(install, "Caddyfile"), "");
|
|
70
|
+
chmodSync(join(install, "Caddyfile"), 0o644);
|
|
71
|
+
|
|
72
|
+
tightenPrivateTree(install, ["bootstrap", "backups"], ["config.json", "internal-secret.json"]);
|
|
73
|
+
|
|
74
|
+
expect(mode(install)).toBe(0o700);
|
|
75
|
+
expect(mode(join(install, "bootstrap"))).toBe(0o700);
|
|
76
|
+
expect(mode(join(install, "config.json"))).toBe(0o600);
|
|
77
|
+
// Not a secret: the Caddy container reads it through its bind mount.
|
|
78
|
+
expect(mode(join(install, "Caddyfile"))).toBe(0o644);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { chmodSync, existsSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Write a file only its owner may read (0600), replacing any file at `path`.
|
|
7
|
+
*
|
|
8
|
+
* The data goes into a fresh temp file created 0600 (`wx`, so never an
|
|
9
|
+
* existing file or a planted symlink) and is renamed over `path`. Writing in
|
|
10
|
+
* place would not do: `writeFileSync`'s `mode` applies only when the call
|
|
11
|
+
* creates the file, so a secret file an older CLI wrote world-readable would
|
|
12
|
+
* hold the new secret world-readable until a chmod after the write — and a
|
|
13
|
+
* reader that opened it before any chmod keeps its descriptor. The rename
|
|
14
|
+
* gives the new contents a new inode that was never readable by anyone else.
|
|
15
|
+
*/
|
|
16
|
+
export function writePrivateFile(path: string, data: string): void {
|
|
17
|
+
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
18
|
+
writeFileSync(tmp, data, { mode: 0o600, flag: "wx" });
|
|
19
|
+
try {
|
|
20
|
+
renameSync(tmp, path);
|
|
21
|
+
} catch (e) {
|
|
22
|
+
rmSync(tmp, { force: true });
|
|
23
|
+
throw e;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Take group/other access off an install's secrets directory and the secret
|
|
29
|
+
* files in it, for installs an older CLI created with the default umask
|
|
30
|
+
* (`.caelo/` 0755, `config.json` / `internal-secret.json` 0644). Those files
|
|
31
|
+
* are written once and rarely rewritten, so tightening only on the next write
|
|
32
|
+
* would leave them readable to every local user. Missing paths are skipped:
|
|
33
|
+
* which secrets exist depends on the provider and how far `init` got.
|
|
34
|
+
*
|
|
35
|
+
* @param dir the install's directory, tightened to 0700
|
|
36
|
+
* @param privateDirs subdirectories (relative to `dir`) tightened to 0700
|
|
37
|
+
* @param privateFiles files (relative to `dir`) tightened to 0600
|
|
38
|
+
*/
|
|
39
|
+
export function tightenPrivateTree(
|
|
40
|
+
dir: string,
|
|
41
|
+
privateDirs: readonly string[],
|
|
42
|
+
privateFiles: readonly string[],
|
|
43
|
+
): void {
|
|
44
|
+
const tighten = (path: string, mode: number) => {
|
|
45
|
+
if (existsSync(path) && (statSync(path).mode & 0o077) !== 0) chmodSync(path, mode);
|
|
46
|
+
};
|
|
47
|
+
tighten(dir, 0o700);
|
|
48
|
+
for (const sub of privateDirs) tighten(`${dir}/${sub}`, 0o700);
|
|
49
|
+
for (const file of privateFiles) tighten(`${dir}/${file}`, 0o600);
|
|
50
|
+
}
|
package/src/release-contract.ts
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* The admin's upgrader job (Phase 2) only rolls images: it holds no IAM
|
|
12
12
|
* write, no secret create, no resource create. Anything a release needs
|
|
13
13
|
* beyond "new image digests + expand-only migrations" must therefore be
|
|
14
|
-
* declared here, so the upgrader refuses it and
|
|
15
|
-
*
|
|
14
|
+
* declared here, so the upgrader refuses it and the admin routes the
|
|
15
|
+
* release to the converge job (#819; the provisioner's identity) instead
|
|
16
|
+
* of half-applying it.
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -49,6 +50,11 @@ export const INFRA_CONTRACT_PATHS: readonly string[] = [
|
|
|
49
50
|
// The in-cloud upgrader job's service account, custom roles and bucket
|
|
50
51
|
// grants (#799 added one without the checks noticing).
|
|
51
52
|
"packages/provisioning/src/upgrader-setup.ts",
|
|
53
|
+
// #875 — the quality-audit job, its service account, results bucket and
|
|
54
|
+
// grants.
|
|
55
|
+
"packages/provisioning/src/audit-job-setup.ts",
|
|
56
|
+
// #819 — the converge job, its database login and the admin's grant on it.
|
|
57
|
+
"packages/provisioning/src/converge-job-setup.ts",
|
|
52
58
|
];
|
|
53
59
|
|
|
54
60
|
/**
|