@caelo-cms/provisioning 0.10.55 → 0.10.58
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 +97 -24
- package/dist/cli-routing.js +1 -1
- package/dist/cli-routing.js.map +1 -1
- package/dist/cli.js +4 -1
- 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 +9 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +13 -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 +33 -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 +4 -0
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +43 -7
- package/dist/operator-access.js.map +1 -1
- package/dist/release-contract.d.ts +3 -2
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +5 -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 +139 -0
- package/dist/remote-mcp.d.ts.map +1 -0
- package/dist/remote-mcp.js +434 -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 +206 -3
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +219 -4
- package/dist/stack-contract.js.map +1 -1
- package/dist/stack-converge.d.ts +53 -1
- package/dist/stack-converge.d.ts.map +1 -1
- package/dist/stack-converge.js +124 -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/upgrade/app-roll.d.ts +36 -7
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +53 -19
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/audit-job-roll.d.ts.map +1 -1
- package/dist/upgrade/audit-job-roll.js +3 -1
- package/dist/upgrade/audit-job-roll.js.map +1 -1
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +125 -12
- 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 +123 -11
- package/dist/upgrader-setup.js.map +1 -1
- package/dist/wizards/gcp-cost.d.ts.map +1 -1
- package/dist/wizards/gcp-cost.js +11 -3
- 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 +14 -2
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +106 -4
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/agent/run.test.ts +14 -1
- package/src/audit-job-setup.test.ts +7 -1
- package/src/cli-routing.ts +1 -1
- package/src/cli.ts +4 -1
- 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 +15 -0
- package/src/gcp-url-map.test.ts +9 -1
- package/src/index.ts +8 -0
- package/src/infra-contract.test.ts +8 -4
- package/src/infra-contract.ts +33 -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 +24 -1
- package/src/operator-access.ts +44 -6
- package/src/release-contract.ts +5 -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 +690 -0
- package/src/remote-mcp.ts +524 -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 +32 -2
- package/src/stack-contract.ts +363 -4
- package/src/stack-converge-secrets.test.ts +4 -0
- package/src/stack-converge.test.ts +21 -5
- package/src/stack-converge.ts +162 -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/upgrade/app-roll.ts +102 -21
- package/src/upgrade/audit-job-roll.ts +3 -1
- package/src/upgrade/converge.ts +148 -10
- 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 +203 -3
- package/src/upgrade/upgrade.ts +120 -24
- package/src/upgrader-assess.test.ts +92 -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 +586 -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 +3 -3
- package/src/upgrader-setup.ts +128 -11
- package/src/wizards/gcp-cost.ts +12 -3
- package/src/wizards/gcp-pulumi.test.ts +15 -0
- package/src/wizards/gcp-pulumi.ts +18 -2
- package/src/wizards/gcp.ts +134 -4
- package/stacks/aws/index.ts +10 -0
- package/stacks/azure/index.ts +9 -0
- package/stacks/gcp/index.ts +47 -7
- package/stacks/gcp-firebase/index.ts +38 -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 = 11;
|
|
26
26
|
|
|
27
27
|
// History (why each bump happened — an install below a number needs the
|
|
28
28
|
// converge that introduced it):
|
|
@@ -56,6 +56,35 @@ export const INFRA_CONTRACT_VERSION = 8;
|
|
|
56
56
|
// CAELO_AUDIT_RESULTS_BUCKET on the admin. The new admin fans every
|
|
57
57
|
// Stage audit out to that job, and the app-roll moves the job's image,
|
|
58
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.
|
|
78
|
+
// 11 — #914: the remote-MCP OAuth client becomes the install's Google
|
|
79
|
+
// sign-in client (IAP browser login + programmatic access + broker).
|
|
80
|
+
// Its secret is renamed (`operator-oauth-client`, CLI-generated; the
|
|
81
|
+
// old `mcp-oauth-client` is deleted after the roll), so the gateway's
|
|
82
|
+
// secret volume and env var, the admin's store env var and both
|
|
83
|
+
// services' bindings on it move; the operator-access job's SA gets
|
|
84
|
+
// `secretAccessor` on it (IAP needs the client secret) and the job a
|
|
85
|
+
// new env var; the admin gets CAELO_ADMIN_URL and the project's
|
|
86
|
+
// organisation domain. A new secret, new IAM and a job redeploy —
|
|
87
|
+
// nothing the in-cloud upgrader may do.
|
|
59
88
|
|
|
60
89
|
/**
|
|
61
90
|
* What each contract version adds, in operator terms — the upgrader's
|
|
@@ -71,6 +100,9 @@ export const INFRA_CONTRACT_STEPS: Readonly<Record<number, string>> = {
|
|
|
71
100
|
6: "the upgrader's permission to list its ops bucket (#799)",
|
|
72
101
|
7: "the gateway's trusted proxy hop count, CAELO_TRUSTED_PROXY_HOPS (#845)",
|
|
73
102
|
8: "the quality-audit job, its service account, results bucket and grants (#875)",
|
|
103
|
+
9: "the converge job that installs cloud-setup changes from Settings → Updates, and a narrower Cloud Run role for the upgrader (#819)",
|
|
104
|
+
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)",
|
|
105
|
+
11: "Google sign-in: the renamed sign-in client secret, its IAM and mounts, the operator-access job's read access to it, and the admin's URL and organisation domain (#914)",
|
|
74
106
|
};
|
|
75
107
|
|
|
76
108
|
/**
|
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>> = {};
|
|
@@ -161,10 +161,19 @@ describe("ensureOperatorAccessSync", () => {
|
|
|
161
161
|
"--args=--bun,/app/packages/admin-core/src/security/operator-access/sync-job.ts",
|
|
162
162
|
);
|
|
163
163
|
expect(deploy).toContain(
|
|
164
|
-
"--set-env-vars=^|^CAELO_OPERATOR_ACCESS_IAP_WEB=cloud_run-europe-west1/services/caelo-production-admin-abc|CAELO_MCP_IAP_SERVICE_ACCOUNT=caelo-mcp@p.iam.gserviceaccount.com|CAELO_OPERATOR_ACCESS_STATIC_MEMBERS=user:owner@x.com|CAELO_OPERATOR_ACCESS_DB_HOST=10.0.0.3",
|
|
164
|
+
"--set-env-vars=^|^CAELO_OPERATOR_ACCESS_IAP_WEB=cloud_run-europe-west1/services/caelo-production-admin-abc|CAELO_MCP_IAP_SERVICE_ACCOUNT=caelo-mcp@p.iam.gserviceaccount.com|CAELO_OPERATOR_ACCESS_STATIC_MEMBERS=user:owner@x.com|CAELO_OPERATOR_ACCESS_DB_HOST=10.0.0.3|CAELO_OPERATOR_OAUTH_CLIENT_SECRET=projects/p/secrets/caelo-production-operator-oauth-client",
|
|
165
165
|
);
|
|
166
166
|
});
|
|
167
167
|
|
|
168
|
+
it("#914 — lets the job read exactly the Google sign-in client's secret, nothing else", async () => {
|
|
169
|
+
const { run, lines } = freshInstall();
|
|
170
|
+
await ensureOperatorAccessSync(target, { run, sleep: async () => {} });
|
|
171
|
+
const secretGrants = lines().filter((l) => l.startsWith("secrets add-iam-policy-binding"));
|
|
172
|
+
expect(secretGrants).toEqual([
|
|
173
|
+
`secrets add-iam-policy-binding caelo-production-operator-oauth-client --member=serviceAccount:${JOB_SA} --role=roles/secretmanager.secretAccessor --condition=None --project=p --quiet --format=none`,
|
|
174
|
+
]);
|
|
175
|
+
});
|
|
176
|
+
|
|
168
177
|
it("schedules an hourly run as the job's own SA", async () => {
|
|
169
178
|
const { run, lines } = freshInstall();
|
|
170
179
|
await ensureOperatorAccessSync(target, { run, sleep: async () => {} });
|
|
@@ -330,3 +339,17 @@ describe("resolveOperatorAccessTarget", () => {
|
|
|
330
339
|
expect(r2.ok === false && r2.error).toContain("exactly one");
|
|
331
340
|
});
|
|
332
341
|
});
|
|
342
|
+
|
|
343
|
+
describe("caeloOperatorAccess role (#806)", () => {
|
|
344
|
+
it("holds IAM-policy and IAP-settings rights on web services and the MCP SA — nothing else", () => {
|
|
345
|
+
expect([...OPERATOR_ACCESS_ROLE.permissions].sort()).toEqual([
|
|
346
|
+
"iam.serviceAccounts.getIamPolicy",
|
|
347
|
+
"iam.serviceAccounts.setIamPolicy",
|
|
348
|
+
"iap.webServices.getIamPolicy",
|
|
349
|
+
// The admin IAP resource's oauthSettings (Google sign-in, #914).
|
|
350
|
+
"iap.webServices.getSettings",
|
|
351
|
+
"iap.webServices.setIamPolicy",
|
|
352
|
+
"iap.webServices.updateSettings",
|
|
353
|
+
]);
|
|
354
|
+
});
|
|
355
|
+
});
|
package/src/operator-access.ts
CHANGED
|
@@ -6,8 +6,11 @@
|
|
|
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
|
|
10
|
-
*
|
|
9
|
+
* follows the Caelo user list; which OAuth client IAP signs people in with
|
|
10
|
+
* and accepts programmatically (IAP `oauthSettings`: browser client, its
|
|
11
|
+
* secret and `programmaticClients`, #914) follows the Google sign-in client
|
|
12
|
+
* the Owner configured under Settings → Google sign-in. The admin does NOT
|
|
13
|
+
* write that: its runtime service account holds no IAM-policy rights. A Cloud Run job with its own
|
|
11
14
|
* service account (admin-core security/operator-access/sync-job.ts) is the
|
|
12
15
|
* only principal that may change those two bindings, and it only ever grants
|
|
13
16
|
* individual `user:` emails read from the database. The admin may only start
|
|
@@ -19,10 +22,13 @@
|
|
|
19
22
|
*
|
|
20
23
|
* What this ensures, every step idempotent:
|
|
21
24
|
* 1. the Cloud Scheduler API;
|
|
22
|
-
* 2. the custom role `caeloOperatorAccess` (get/setIamPolicy
|
|
25
|
+
* 2. the custom role `caeloOperatorAccess` (get/setIamPolicy, and
|
|
26
|
+
* get/updateSettings for `oauthSettings`);
|
|
23
27
|
* 3. the job's service account, bound to that role on exactly the admin's
|
|
24
|
-
* IAP resource and the `caelo-mcp` service account,
|
|
25
|
-
*
|
|
28
|
+
* IAP resource and the `caelo-mcp` service account, `secretAccessor`
|
|
29
|
+
* on exactly the Google sign-in client's secret (#914: IAP needs the
|
|
30
|
+
* client secret too), plus `cloudsql.instanceUser` +
|
|
31
|
+
* `logging.logWriter`;
|
|
26
32
|
* 4. removal of the admin run SA's operator-access grants an earlier
|
|
27
33
|
* revision of this feature gave it;
|
|
28
34
|
* 5. Cloud SQL IAM database authentication (flag + the job's IAM database
|
|
@@ -42,6 +48,7 @@ import { gcloud as defaultGcloud, type GcloudResult } from "./gcloud.js";
|
|
|
42
48
|
import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
|
|
43
49
|
import {
|
|
44
50
|
gcpNamePrefix,
|
|
51
|
+
gcpSecretId,
|
|
45
52
|
mcpIapServiceAccountEmail,
|
|
46
53
|
operatorAccessJobName,
|
|
47
54
|
operatorAccessScheduleName,
|
|
@@ -50,6 +57,7 @@ import {
|
|
|
50
57
|
runServiceAccountEmail,
|
|
51
58
|
} from "./gcp-names.js";
|
|
52
59
|
import { type IapResource, iapResourceArgs } from "./mcp-iap.js";
|
|
60
|
+
import { operatorOAuthClientSecretResource } from "./stack-contract.js";
|
|
53
61
|
|
|
54
62
|
type Run = (args: string[]) => Promise<GcloudResult>;
|
|
55
63
|
|
|
@@ -66,10 +74,17 @@ export const OPERATOR_ACCESS_ROLE: CustomRoleSpec = {
|
|
|
66
74
|
roleId: "caeloOperatorAccess",
|
|
67
75
|
title: "Caelo operator access",
|
|
68
76
|
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.",
|
|
77
|
+
"Lets the Caelo operator-access sync job set who passes the admin's IAP gate, which OAuth client IAP signs people in with and accepts programmatically (Google sign-in, remote MCP), and who may sign as caelo-mcp. Bound only on those two resources, only to the job's service account.",
|
|
70
78
|
permissions: [
|
|
71
79
|
"iap.webServices.getIamPolicy",
|
|
72
80
|
"iap.webServices.setIamPolicy",
|
|
81
|
+
// #914 — the admin IAP resource's `oauthSettings` (the install's Google
|
|
82
|
+
// sign-in client). IAP settings permissions per resource level:
|
|
83
|
+
// cloud.google.com/iap/docs/customizing ("Web Services": getSettings /
|
|
84
|
+
// updateSettings); the role is bound on exactly the admin's IAP web
|
|
85
|
+
// service, so it reaches no other resource's settings.
|
|
86
|
+
"iap.webServices.getSettings",
|
|
87
|
+
"iap.webServices.updateSettings",
|
|
73
88
|
"iam.serviceAccounts.getIamPolicy",
|
|
74
89
|
"iam.serviceAccounts.setIamPolicy",
|
|
75
90
|
],
|
|
@@ -186,6 +201,12 @@ export interface OperatorAccessSyncTarget {
|
|
|
186
201
|
/** Env var of the sync job listing {@link OperatorAccessSyncTarget.mcpSigners}. */
|
|
187
202
|
export const MCP_SIGNERS_ENV = "CAELO_OPERATOR_ACCESS_MCP_SIGNERS";
|
|
188
203
|
|
|
204
|
+
/** #914 — env var of the sync job naming the Google sign-in client's secret. */
|
|
205
|
+
export const OPERATOR_OAUTH_CLIENT_SECRET_ENV = "CAELO_OPERATOR_OAUTH_CLIENT_SECRET";
|
|
206
|
+
|
|
207
|
+
/** The role the job holds on exactly the Google sign-in client's secret (#914). */
|
|
208
|
+
export const OPERATOR_OAUTH_CLIENT_ACCESSOR_ROLE = "roles/secretmanager.secretAccessor";
|
|
209
|
+
|
|
189
210
|
export type OperatorAccessSyncResult =
|
|
190
211
|
| { readonly ok: true; readonly done: string[] }
|
|
191
212
|
| { readonly ok: false; readonly done: string[]; readonly error: string };
|
|
@@ -276,6 +297,22 @@ export async function ensureOperatorAccessSync(
|
|
|
276
297
|
if (!r.ok) return fail(`grant the job ${g.what}`, r);
|
|
277
298
|
done.push(`job: ${g.what}`);
|
|
278
299
|
}
|
|
300
|
+
// #914 — the job hands the Google sign-in client to IAP (`oauthSettings`
|
|
301
|
+
// needs the secret too), so it reads that one secret; no other.
|
|
302
|
+
const clientSecret = gcpSecretId(env, "operator-oauth-client");
|
|
303
|
+
const accessor = await retry([
|
|
304
|
+
"secrets",
|
|
305
|
+
"add-iam-policy-binding",
|
|
306
|
+
clientSecret,
|
|
307
|
+
`--member=${jobMember}`,
|
|
308
|
+
`--role=${OPERATOR_OAUTH_CLIENT_ACCESSOR_ROLE}`,
|
|
309
|
+
"--condition=None",
|
|
310
|
+
project,
|
|
311
|
+
"--quiet",
|
|
312
|
+
"--format=none",
|
|
313
|
+
]);
|
|
314
|
+
if (!accessor.ok) return fail(`grant the job read access to ${clientSecret}`, accessor);
|
|
315
|
+
done.push(`job: ${OPERATOR_OAUTH_CLIENT_ACCESSOR_ROLE} on ${clientSecret}`);
|
|
279
316
|
for (const role of ["roles/cloudsql.instanceUser", "roles/logging.logWriter"]) {
|
|
280
317
|
const r = await retry([
|
|
281
318
|
"projects",
|
|
@@ -354,6 +391,7 @@ export async function ensureOperatorAccessSync(
|
|
|
354
391
|
`CAELO_MCP_IAP_SERVICE_ACCOUNT=${mcpSa}`,
|
|
355
392
|
`CAELO_OPERATOR_ACCESS_STATIC_MEMBERS=${installIapAllowlist(target.ownerEmail).join(",")}`,
|
|
356
393
|
`CAELO_OPERATOR_ACCESS_DB_HOST=${target.databaseHost}`,
|
|
394
|
+
`${OPERATOR_OAUTH_CLIENT_SECRET_ENV}=${operatorOAuthClientSecretResource(projectId, env)}`,
|
|
357
395
|
...(target.mcpSigners && target.mcpSigners.length > 0
|
|
358
396
|
? [`${MCP_SIGNERS_ENV}=${target.mcpSigners.join(",")}`]
|
|
359
397
|
: []),
|
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
|
/**
|
|
@@ -52,6 +53,8 @@ export const INFRA_CONTRACT_PATHS: readonly string[] = [
|
|
|
52
53
|
// #875 — the quality-audit job, its service account, results bucket and
|
|
53
54
|
// grants.
|
|
54
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",
|
|
55
58
|
];
|
|
56
59
|
|
|
57
60
|
/**
|