@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
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The upgrader job's IAM self-check (#818): before it takes the state lock
|
|
5
|
+
* or changes anything, the job asks Google Cloud whether its own service
|
|
6
|
+
* account holds every permission the run will need, and stops with ALL the
|
|
7
|
+
* missing ones named when it does not.
|
|
8
|
+
*
|
|
9
|
+
* Why: the first live in-app update died at its first bucket write (#799)
|
|
10
|
+
* because a grant that looked right could never match the call. Unit tests
|
|
11
|
+
* against a fake gcloud cannot catch that class of bug; asking IAM can, and
|
|
12
|
+
* asking first means a missing grant ends the run before the backup, the
|
|
13
|
+
* migrations or the roll, with a reason a non-technical Owner can pass on
|
|
14
|
+
* ("Run the upgrade once with Cloud Shell" re-converges the grants).
|
|
15
|
+
*
|
|
16
|
+
* How each permission is checked, without doing the action:
|
|
17
|
+
*
|
|
18
|
+
* - `testIamPermissions` on the resource the call acts on — the project,
|
|
19
|
+
* a service account, the upgrader's own job, the ops bucket, the release
|
|
20
|
+
* image repository. Google returns the subset the CALLER holds; no
|
|
21
|
+
* permission is needed to ask.
|
|
22
|
+
* - The `install/` objects in the ops bucket are the exception. The
|
|
23
|
+
* upgrader's object rights there are an IAM grant CONDITIONED on the
|
|
24
|
+
* object name (upgrader-setup.ts `installStateCondition`), and a bucket's
|
|
25
|
+
* testIamPermissions evaluates conditions against the bucket's own
|
|
26
|
+
* resource name, never an object's — it would report a grant that works
|
|
27
|
+
* as missing (the mirror image of #799). Cloud Storage has no object
|
|
28
|
+
* testIamPermissions on uniform-access buckets, so those three are
|
|
29
|
+
* probed with requests that can never change anything: a read of an
|
|
30
|
+
* object that does not exist, and an upload + a delete preconditioned
|
|
31
|
+
* on generation 1, which no object ever has. Cloud Storage authorizes a
|
|
32
|
+
* request before it evaluates its preconditions, so 403 means the
|
|
33
|
+
* permission is missing and 404/412 means it is held — with one
|
|
34
|
+
* exception: a read of an absent object is answered 403 whenever the
|
|
35
|
+
* caller lacks storage.objects.list, so objects.get is probed only once
|
|
36
|
+
* the bucket check confirmed list, and reported "unchecked" otherwise.
|
|
37
|
+
*
|
|
38
|
+
* Direct VPC egress (#818): the migration job and both services are
|
|
39
|
+
* deployed with `--network/--subnet`. Google documents the compute
|
|
40
|
+
* permissions for that (`compute.subnetworks.use`, …) on the Cloud Run
|
|
41
|
+
* SERVICE AGENT (roles/run.serviceAgent has them), not on the principal
|
|
42
|
+
* that deploys, so they are not checked here and the upgrader gets no
|
|
43
|
+
* compute role (UPGRADER_FORBIDDEN_ROLES). The real-GCP upgrader smoke test
|
|
44
|
+
* (.github/workflows/upgrader-smoke.yml) is where that shows on real GCP:
|
|
45
|
+
* each stable release, installed on the smoke install through Settings →
|
|
46
|
+
* Updates, must serve cleanly before the next tag ships.
|
|
47
|
+
*
|
|
48
|
+
* Everything here is pure except {@link restIamTester}, which takes its
|
|
49
|
+
* HTTP client and token source as arguments.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { z } from "zod";
|
|
53
|
+
import {
|
|
54
|
+
auditServiceAccountEmail,
|
|
55
|
+
gatewayServiceAccountEmail,
|
|
56
|
+
mcpIapServiceAccountEmail,
|
|
57
|
+
runServiceAccountEmail,
|
|
58
|
+
upgraderJobName,
|
|
59
|
+
upgraderServiceAccountEmail,
|
|
60
|
+
} from "./gcp-names.js";
|
|
61
|
+
import type { HttpFetch } from "./runtime-secrets.js";
|
|
62
|
+
import { OPS_INSTALL_PREFIX, opsBucketName } from "./state-backend.js";
|
|
63
|
+
import { RELEASE_REGISTRY } from "./upgrade/release-images.js";
|
|
64
|
+
|
|
65
|
+
/** A resource IAM can be asked about. */
|
|
66
|
+
export type IamResource =
|
|
67
|
+
| { readonly kind: "project"; readonly projectId: string }
|
|
68
|
+
| { readonly kind: "service-account"; readonly projectId: string; readonly email: string }
|
|
69
|
+
| {
|
|
70
|
+
readonly kind: "run-job";
|
|
71
|
+
readonly projectId: string;
|
|
72
|
+
readonly region: string;
|
|
73
|
+
readonly name: string;
|
|
74
|
+
}
|
|
75
|
+
| { readonly kind: "bucket"; readonly bucket: string }
|
|
76
|
+
/** Objects under `prefix/` in `bucket`, behind a name-conditioned grant (probed). */
|
|
77
|
+
| { readonly kind: "bucket-objects"; readonly bucket: string; readonly prefix: string }
|
|
78
|
+
| {
|
|
79
|
+
readonly kind: "artifact-repository";
|
|
80
|
+
readonly projectId: string;
|
|
81
|
+
readonly location: string;
|
|
82
|
+
readonly repository: string;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** One thing the run will do, the permissions it takes, and where. */
|
|
86
|
+
export interface AccessCheck {
|
|
87
|
+
/** What the upgrader could not do without them, in plain words. */
|
|
88
|
+
readonly need: string;
|
|
89
|
+
readonly resource: IamResource;
|
|
90
|
+
readonly permissions: readonly string[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A check with the permissions it lacks. */
|
|
94
|
+
export interface MissingAccess {
|
|
95
|
+
readonly check: AccessCheck;
|
|
96
|
+
readonly missing: readonly string[];
|
|
97
|
+
/**
|
|
98
|
+
* Permissions that could not be judged because another one is missing
|
|
99
|
+
* first (storage.objects.get needs storage.objects.list to be testable).
|
|
100
|
+
*/
|
|
101
|
+
readonly unchecked?: readonly string[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The permissions IAM confirmed on one resource, or why it could not be asked. */
|
|
105
|
+
export type GrantedResult =
|
|
106
|
+
| { readonly ok: true; readonly granted: readonly string[] }
|
|
107
|
+
| { readonly ok: false; readonly error: string };
|
|
108
|
+
|
|
109
|
+
/** Asks IAM which of `permissions` the job's identity holds on `resource`. */
|
|
110
|
+
export interface IamTester {
|
|
111
|
+
granted(resource: IamResource, permissions: readonly string[]): Promise<GrantedResult>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** How the self-check came out. */
|
|
115
|
+
export type PreflightOutcome =
|
|
116
|
+
| { readonly kind: "ok" }
|
|
117
|
+
| { readonly kind: "missing"; readonly missing: readonly MissingAccess[] }
|
|
118
|
+
/** IAM could not be asked about at least one resource; nothing was judged missing. */
|
|
119
|
+
| { readonly kind: "error"; readonly errors: readonly string[] };
|
|
120
|
+
|
|
121
|
+
/** The object the `install/` probes name. It is never written: every probe fails its precondition. */
|
|
122
|
+
export const PREFLIGHT_PROBE_OBJECT = ".caelo-iam-preflight";
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Every permission an upgrader run needs after its decision, grouped by
|
|
126
|
+
* what it is for (upgrade/app-roll.ts, migration-runner.ts, backup.ts,
|
|
127
|
+
* health.ts, state-store.ts, the self-update in upgrader-job.ts). Keep in
|
|
128
|
+
* step with the grants in upgrader-setup.ts: a permission listed here that
|
|
129
|
+
* setup does not grant stops every update.
|
|
130
|
+
*/
|
|
131
|
+
export function upgraderAccessChecks(target: {
|
|
132
|
+
readonly projectId: string;
|
|
133
|
+
readonly region: string;
|
|
134
|
+
readonly env: string;
|
|
135
|
+
}): AccessCheck[] {
|
|
136
|
+
const { projectId, region, env } = target;
|
|
137
|
+
const project: IamResource = { kind: "project", projectId };
|
|
138
|
+
const sa = (email: string): IamResource => ({ kind: "service-account", projectId, email });
|
|
139
|
+
const bucket = opsBucketName(projectId);
|
|
140
|
+
return [
|
|
141
|
+
{
|
|
142
|
+
need: "read which release the site's admin and gateway serve",
|
|
143
|
+
resource: project,
|
|
144
|
+
permissions: [
|
|
145
|
+
"run.services.get",
|
|
146
|
+
"run.services.list",
|
|
147
|
+
"run.revisions.get",
|
|
148
|
+
"run.revisions.list",
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
need: "deploy the new release to the admin and gateway and move their traffic",
|
|
153
|
+
resource: project,
|
|
154
|
+
permissions: ["run.services.update", "run.operations.get"],
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
need: "run the database migrations as a one-off job (and check whether an earlier run still holds the install's lock)",
|
|
158
|
+
resource: project,
|
|
159
|
+
permissions: [
|
|
160
|
+
"run.jobs.create",
|
|
161
|
+
"run.jobs.delete",
|
|
162
|
+
"run.jobs.get",
|
|
163
|
+
"run.jobs.run",
|
|
164
|
+
"run.executions.get",
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
need: "back up the database before anything changes",
|
|
169
|
+
resource: project,
|
|
170
|
+
permissions: [
|
|
171
|
+
"cloudsql.backupRuns.create",
|
|
172
|
+
"cloudsql.backupRuns.get",
|
|
173
|
+
"cloudsql.backupRuns.list",
|
|
174
|
+
"cloudsql.instances.get",
|
|
175
|
+
"cloudsql.instances.list",
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
need: "run the admin and the migration job as the admin's service account",
|
|
180
|
+
resource: sa(runServiceAccountEmail(projectId, env)),
|
|
181
|
+
permissions: ["iam.serviceAccounts.actAs"],
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
need: "run the gateway as its service account",
|
|
185
|
+
resource: sa(gatewayServiceAccountEmail(projectId, env)),
|
|
186
|
+
permissions: ["iam.serviceAccounts.actAs"],
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
// #875 — every app-roll moves the audit job onto the new admin image
|
|
190
|
+
// (upgrade/audit-job-roll.ts), and upgrader-setup.ts grants actAs on
|
|
191
|
+
// the job's account for exactly that.
|
|
192
|
+
need: "move the quality-audit job onto the new admin image",
|
|
193
|
+
resource: project,
|
|
194
|
+
permissions: ["run.jobs.update"],
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
need: "run the quality-audit job as its service account after moving it",
|
|
198
|
+
resource: sa(auditServiceAccountEmail(projectId, env)),
|
|
199
|
+
permissions: ["iam.serviceAccounts.actAs"],
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
need: "keep the upgrader job running as its own service account after it updates itself",
|
|
203
|
+
resource: sa(upgraderServiceAccountEmail(projectId, env)),
|
|
204
|
+
permissions: ["iam.serviceAccounts.actAs"],
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
need: "sign in to the admin (through its identity proxy) for the health check",
|
|
208
|
+
resource: sa(mcpIapServiceAccountEmail(projectId)),
|
|
209
|
+
permissions: ["iam.serviceAccounts.signJwt"],
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
need: "move the upgrader itself onto the new release",
|
|
213
|
+
resource: { kind: "run-job", projectId, region, name: upgraderJobName(env) },
|
|
214
|
+
permissions: ["run.jobs.get", "run.jobs.update"],
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
need: "list the install's ops bucket (every write there lists it first)",
|
|
218
|
+
resource: { kind: "bucket", bucket },
|
|
219
|
+
permissions: ["storage.objects.list"],
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
need: `read and write the install's records and its state lock (${OPS_INSTALL_PREFIX}/ in the ops bucket)`,
|
|
223
|
+
resource: { kind: "bucket-objects", bucket, prefix: OPS_INSTALL_PREFIX },
|
|
224
|
+
permissions: ["storage.objects.get", "storage.objects.create", "storage.objects.delete"],
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
need: "read Caelo's release images",
|
|
228
|
+
resource: {
|
|
229
|
+
kind: "artifact-repository",
|
|
230
|
+
projectId: RELEASE_REGISTRY.project,
|
|
231
|
+
location: RELEASE_REGISTRY.region,
|
|
232
|
+
repository: RELEASE_REGISTRY.repo,
|
|
233
|
+
},
|
|
234
|
+
permissions: ["artifactregistry.repositories.downloadArtifacts"],
|
|
235
|
+
},
|
|
236
|
+
];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Ask IAM about every check (one call per resource); never throws. */
|
|
240
|
+
export async function runIamPreflight(
|
|
241
|
+
checks: readonly AccessCheck[],
|
|
242
|
+
tester: IamTester,
|
|
243
|
+
): Promise<PreflightOutcome> {
|
|
244
|
+
// One question per resource: the project alone carries four checks.
|
|
245
|
+
const byResource = new Map<string, { resource: IamResource; permissions: Set<string> }>();
|
|
246
|
+
for (const c of checks) {
|
|
247
|
+
const key = resourceName(c.resource);
|
|
248
|
+
const entry = byResource.get(key) ?? { resource: c.resource, permissions: new Set<string>() };
|
|
249
|
+
for (const p of c.permissions) entry.permissions.add(p);
|
|
250
|
+
byResource.set(key, entry);
|
|
251
|
+
}
|
|
252
|
+
const granted = new Map<string, ReadonlySet<string>>();
|
|
253
|
+
/** Permissions that could not be judged (see the object probes below). */
|
|
254
|
+
const unverified = new Map<string, ReadonlySet<string>>();
|
|
255
|
+
const errors: string[] = [];
|
|
256
|
+
const ask = async (key: string, resource: IamResource, permissions: readonly string[]) => {
|
|
257
|
+
let r: GrantedResult;
|
|
258
|
+
try {
|
|
259
|
+
r = await tester.granted(resource, [...permissions].sort());
|
|
260
|
+
} catch (e) {
|
|
261
|
+
r = { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
262
|
+
}
|
|
263
|
+
if (r.ok) granted.set(key, new Set(r.granted));
|
|
264
|
+
else errors.push(`${key}: ${r.error}`);
|
|
265
|
+
};
|
|
266
|
+
const entries = [...byResource];
|
|
267
|
+
await Promise.all(
|
|
268
|
+
entries
|
|
269
|
+
.filter(([, e]) => e.resource.kind !== "bucket-objects")
|
|
270
|
+
.map(([key, e]) => ask(key, e.resource, [...e.permissions])),
|
|
271
|
+
);
|
|
272
|
+
// The object probes go second: Cloud Storage answers a read of an absent
|
|
273
|
+
// object with 403 — not 404 — when the caller lacks storage.objects.list
|
|
274
|
+
// on the bucket, so the objects.get probe means something only once list
|
|
275
|
+
// is confirmed. Without it, get is reported as unchecked, never missing.
|
|
276
|
+
await Promise.all(
|
|
277
|
+
entries
|
|
278
|
+
.filter(([, e]) => e.resource.kind === "bucket-objects")
|
|
279
|
+
.map(([key, e]) => {
|
|
280
|
+
const r = e.resource as Extract<IamResource, { kind: "bucket-objects" }>;
|
|
281
|
+
const listHeld =
|
|
282
|
+
granted.get(resourceName({ kind: "bucket", bucket: r.bucket }))?.has(OBJECT_LIST) ??
|
|
283
|
+
false;
|
|
284
|
+
const asked = [...e.permissions].filter((p) => listHeld || p !== OBJECT_GET);
|
|
285
|
+
if (asked.length < e.permissions.size) unverified.set(key, new Set([OBJECT_GET]));
|
|
286
|
+
return ask(key, e.resource, asked);
|
|
287
|
+
}),
|
|
288
|
+
);
|
|
289
|
+
if (errors.length > 0) return { kind: "error", errors: errors.sort() };
|
|
290
|
+
const missing: MissingAccess[] = [];
|
|
291
|
+
for (const check of checks) {
|
|
292
|
+
const key = resourceName(check.resource);
|
|
293
|
+
const held = granted.get(key) ?? new Set<string>();
|
|
294
|
+
const unknown = unverified.get(key) ?? new Set<string>();
|
|
295
|
+
const lacking = check.permissions.filter((p) => !held.has(p) && !unknown.has(p));
|
|
296
|
+
const unchecked = check.permissions.filter((p) => unknown.has(p));
|
|
297
|
+
if (lacking.length > 0 || unchecked.length > 0) {
|
|
298
|
+
missing.push({ check, missing: lacking, ...(unchecked.length > 0 ? { unchecked } : {}) });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return missing.length === 0 ? { kind: "ok" } : { kind: "missing", missing };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const OBJECT_LIST = "storage.objects.list";
|
|
305
|
+
const OBJECT_GET = "storage.objects.get";
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The request's end reason for missing permissions: every one of them, each
|
|
309
|
+
* with what it is for, then the one next step.
|
|
310
|
+
*/
|
|
311
|
+
export function preflightMissingReason(missing: readonly MissingAccess[]): string {
|
|
312
|
+
const lines = missing.map((m) => {
|
|
313
|
+
const where = resourceName(m.check.resource);
|
|
314
|
+
const parts = [
|
|
315
|
+
...(m.missing.length > 0 ? [`missing ${m.missing.join(", ")} on ${where}`] : []),
|
|
316
|
+
...(m.unchecked && m.unchecked.length > 0
|
|
317
|
+
? [
|
|
318
|
+
`${m.unchecked.join(", ")} on ${where} could not be checked: without storage.objects.list Cloud Storage answers 403 for any absent object, held or not`,
|
|
319
|
+
]
|
|
320
|
+
: []),
|
|
321
|
+
];
|
|
322
|
+
return `it ${m.missing.length > 0 ? "cannot" : "may not be able to"} ${m.check.need} (${parts.join("; ")})`;
|
|
323
|
+
});
|
|
324
|
+
return `Nothing was changed: the upgrader checked its access before starting and is missing permissions this update needs — ${lines.join("; ")}. Run the upgrade once with Cloud Shell; it repairs the upgrader's access.`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The request's end reason when IAM could not be asked. */
|
|
328
|
+
export function preflightErrorReason(errors: readonly string[]): string {
|
|
329
|
+
return `Nothing was changed: the upgrader could not check its access before starting (${errors.join("; ")}). Try again in a few minutes; if it keeps failing, run the upgrade once with Cloud Shell.`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** A resource's full name, as IAM and the messages name it. */
|
|
333
|
+
export function resourceName(r: IamResource): string {
|
|
334
|
+
switch (r.kind) {
|
|
335
|
+
case "project":
|
|
336
|
+
return `projects/${r.projectId}`;
|
|
337
|
+
case "service-account":
|
|
338
|
+
return `projects/${r.projectId}/serviceAccounts/${r.email}`;
|
|
339
|
+
case "run-job":
|
|
340
|
+
return `projects/${r.projectId}/locations/${r.region}/jobs/${r.name}`;
|
|
341
|
+
case "bucket":
|
|
342
|
+
return `gs://${r.bucket}`;
|
|
343
|
+
case "bucket-objects":
|
|
344
|
+
return `gs://${r.bucket}/${r.prefix}/`;
|
|
345
|
+
case "artifact-repository":
|
|
346
|
+
return `projects/${r.projectId}/locations/${r.location}/repositories/${r.repository}`;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const testIamResponse = z.object({ permissions: z.array(z.string()).optional() });
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The real {@link IamTester}: Google's REST APIs with the job's own access
|
|
354
|
+
* token. `token` is asked once per call (gcloud caches it).
|
|
355
|
+
*
|
|
356
|
+
* @example
|
|
357
|
+
* const tester = restIamTester({ http: fetch, token: async () => "<access token>" });
|
|
358
|
+
* await tester.granted({ kind: "project", projectId: "acme" }, ["run.services.get"]);
|
|
359
|
+
*/
|
|
360
|
+
export function restIamTester(deps: {
|
|
361
|
+
readonly http: HttpFetch;
|
|
362
|
+
readonly token: () => Promise<string>;
|
|
363
|
+
}): IamTester {
|
|
364
|
+
return {
|
|
365
|
+
async granted(resource, permissions) {
|
|
366
|
+
const bearer = await deps.token();
|
|
367
|
+
if (!bearer) return { ok: false, error: "no access token for the upgrader's account" };
|
|
368
|
+
const auth = { Authorization: `Bearer ${bearer}` };
|
|
369
|
+
if (resource.kind === "bucket-objects") {
|
|
370
|
+
return probeObjects(deps.http, auth, resource, permissions);
|
|
371
|
+
}
|
|
372
|
+
const call = testIamCall(resource, permissions);
|
|
373
|
+
const res = await deps.http(call.url, {
|
|
374
|
+
method: call.method,
|
|
375
|
+
headers: { ...auth, ...(call.body ? { "Content-Type": "application/json" } : {}) },
|
|
376
|
+
...(call.body ? { body: call.body } : {}),
|
|
377
|
+
});
|
|
378
|
+
const text = await res.text();
|
|
379
|
+
if (!res.ok) return { ok: false, error: `HTTP ${res.status} ${text.trim().slice(0, 300)}` };
|
|
380
|
+
let raw: unknown;
|
|
381
|
+
try {
|
|
382
|
+
raw = JSON.parse(text);
|
|
383
|
+
} catch {
|
|
384
|
+
return { ok: false, error: "testIamPermissions answered without JSON" };
|
|
385
|
+
}
|
|
386
|
+
const parsed = testIamResponse.safeParse(raw);
|
|
387
|
+
if (!parsed.success) {
|
|
388
|
+
return { ok: false, error: "testIamPermissions answered without a permission list" };
|
|
389
|
+
}
|
|
390
|
+
// An empty answer omits `permissions`: none of them is held.
|
|
391
|
+
return { ok: true, granted: parsed.data.permissions ?? [] };
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** The testIamPermissions request for a resource that has one. */
|
|
397
|
+
export function testIamCall(
|
|
398
|
+
resource: Exclude<IamResource, { kind: "bucket-objects" }>,
|
|
399
|
+
permissions: readonly string[],
|
|
400
|
+
): { url: string; method: "GET" | "POST"; body?: string } {
|
|
401
|
+
const body = JSON.stringify({ permissions });
|
|
402
|
+
switch (resource.kind) {
|
|
403
|
+
case "project":
|
|
404
|
+
return {
|
|
405
|
+
url: `https://cloudresourcemanager.googleapis.com/v1/projects/${encodeURIComponent(resource.projectId)}:testIamPermissions`,
|
|
406
|
+
method: "POST",
|
|
407
|
+
body,
|
|
408
|
+
};
|
|
409
|
+
case "service-account":
|
|
410
|
+
return {
|
|
411
|
+
url: `https://iam.googleapis.com/v1/projects/${encodeURIComponent(resource.projectId)}/serviceAccounts/${encodeURIComponent(resource.email)}:testIamPermissions`,
|
|
412
|
+
method: "POST",
|
|
413
|
+
body,
|
|
414
|
+
};
|
|
415
|
+
case "run-job":
|
|
416
|
+
return {
|
|
417
|
+
url: `https://run.googleapis.com/v2/projects/${encodeURIComponent(resource.projectId)}/locations/${encodeURIComponent(resource.region)}/jobs/${encodeURIComponent(resource.name)}:testIamPermissions`,
|
|
418
|
+
method: "POST",
|
|
419
|
+
body,
|
|
420
|
+
};
|
|
421
|
+
case "bucket": {
|
|
422
|
+
const q = permissions.map((p) => `permissions=${encodeURIComponent(p)}`).join("&");
|
|
423
|
+
return {
|
|
424
|
+
url: `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(resource.bucket)}/iam/testPermissions?${q}`,
|
|
425
|
+
method: "GET",
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
case "artifact-repository":
|
|
429
|
+
return {
|
|
430
|
+
url: `https://artifactregistry.googleapis.com/v1/projects/${encodeURIComponent(resource.projectId)}/locations/${encodeURIComponent(resource.location)}/repositories/${encodeURIComponent(resource.repository)}:testIamPermissions`,
|
|
431
|
+
method: "POST",
|
|
432
|
+
body,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const OBJECT_PROBES: Record<
|
|
438
|
+
string,
|
|
439
|
+
{ method: "GET" | "POST" | "DELETE"; held: readonly number[] }
|
|
440
|
+
> = {
|
|
441
|
+
// A read of an object that does not exist: 404 once authorized.
|
|
442
|
+
"storage.objects.get": { method: "GET", held: [404, 200] },
|
|
443
|
+
// An upload that must match generation 1: never true, so 412 once authorized.
|
|
444
|
+
"storage.objects.create": { method: "POST", held: [412] },
|
|
445
|
+
// A delete that must match generation 1: 404 (absent) or 412 once authorized.
|
|
446
|
+
"storage.objects.delete": { method: "DELETE", held: [404, 412] },
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
/** The precondition-guarded request that tests one object permission (see the file comment). */
|
|
450
|
+
export function objectProbeCall(
|
|
451
|
+
resource: Extract<IamResource, { kind: "bucket-objects" }>,
|
|
452
|
+
permission: string,
|
|
453
|
+
): { url: string; method: "GET" | "POST" | "DELETE" } | null {
|
|
454
|
+
const probe = OBJECT_PROBES[permission];
|
|
455
|
+
if (!probe) return null;
|
|
456
|
+
const bucket = encodeURIComponent(resource.bucket);
|
|
457
|
+
const object = encodeURIComponent(`${resource.prefix}/${PREFLIGHT_PROBE_OBJECT}`);
|
|
458
|
+
switch (probe.method) {
|
|
459
|
+
case "GET":
|
|
460
|
+
return {
|
|
461
|
+
url: `https://storage.googleapis.com/storage/v1/b/${bucket}/o/${object}`,
|
|
462
|
+
method: "GET",
|
|
463
|
+
};
|
|
464
|
+
case "POST":
|
|
465
|
+
return {
|
|
466
|
+
url: `https://storage.googleapis.com/upload/storage/v1/b/${bucket}/o?uploadType=media&name=${object}&ifGenerationMatch=1`,
|
|
467
|
+
method: "POST",
|
|
468
|
+
};
|
|
469
|
+
case "DELETE":
|
|
470
|
+
return {
|
|
471
|
+
url: `https://storage.googleapis.com/storage/v1/b/${bucket}/o/${object}?ifGenerationMatch=1`,
|
|
472
|
+
method: "DELETE",
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function probeObjects(
|
|
478
|
+
http: HttpFetch,
|
|
479
|
+
auth: Record<string, string>,
|
|
480
|
+
resource: Extract<IamResource, { kind: "bucket-objects" }>,
|
|
481
|
+
permissions: readonly string[],
|
|
482
|
+
): Promise<GrantedResult> {
|
|
483
|
+
const granted: string[] = [];
|
|
484
|
+
for (const permission of permissions) {
|
|
485
|
+
const call = objectProbeCall(resource, permission);
|
|
486
|
+
const probe = OBJECT_PROBES[permission];
|
|
487
|
+
if (!call || !probe) {
|
|
488
|
+
return { ok: false, error: `no probe for ${permission} on ${resourceName(resource)}` };
|
|
489
|
+
}
|
|
490
|
+
const res = await http(call.url, {
|
|
491
|
+
method: call.method,
|
|
492
|
+
headers:
|
|
493
|
+
call.method === "POST" ? { ...auth, "Content-Type": "application/octet-stream" } : auth,
|
|
494
|
+
...(call.method === "POST" ? { body: "" } : {}),
|
|
495
|
+
});
|
|
496
|
+
const text = await res.text();
|
|
497
|
+
if (probe.held.includes(res.status)) {
|
|
498
|
+
// 200 on the upload would mean an object was written; held[] for
|
|
499
|
+
// create has no 200, so that lands below as an error, loudly.
|
|
500
|
+
granted.push(permission);
|
|
501
|
+
} else if (res.status === 403) {
|
|
502
|
+
} else {
|
|
503
|
+
return {
|
|
504
|
+
ok: false,
|
|
505
|
+
error: `probing ${permission} on ${resourceName(resource)}: HTTP ${res.status} ${text.trim().slice(0, 300)}`,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return { ok: true, granted };
|
|
510
|
+
}
|