@caelo-cms/provisioning 0.10.61 → 0.10.62
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/caddy/Caddyfile.production +7 -0
- package/caddy/Caddyfile.staging +7 -0
- package/dist/admin-internal-answer.d.ts +12 -0
- package/dist/admin-internal-answer.d.ts.map +1 -0
- package/dist/admin-internal-answer.js +32 -0
- package/dist/admin-internal-answer.js.map +1 -0
- package/dist/admin-internal-google.d.ts +4 -0
- package/dist/admin-internal-google.d.ts.map +1 -0
- package/dist/admin-internal-google.js +161 -0
- package/dist/admin-internal-google.js.map +1 -0
- package/dist/admin-internal-self-hosted.d.ts +30 -0
- package/dist/admin-internal-self-hosted.d.ts.map +1 -0
- package/dist/admin-internal-self-hosted.js +92 -0
- package/dist/admin-internal-self-hosted.js.map +1 -0
- package/dist/admin-internal.d.ts +33 -12
- package/dist/admin-internal.d.ts.map +1 -1
- package/dist/admin-internal.js +27 -159
- package/dist/admin-internal.js.map +1 -1
- package/dist/agent/command.d.ts +7 -0
- package/dist/agent/command.d.ts.map +1 -1
- package/dist/agent/command.js +1 -0
- package/dist/agent/command.js.map +1 -1
- package/dist/agent/lock.d.ts +13 -1
- package/dist/agent/lock.d.ts.map +1 -1
- package/dist/agent/lock.js +62 -5
- package/dist/agent/lock.js.map +1 -1
- package/dist/agent/run.d.ts +5 -1
- package/dist/agent/run.d.ts.map +1 -1
- package/dist/agent/run.js +8 -1
- package/dist/agent/run.js.map +1 -1
- package/dist/agent-db.js +192 -90
- package/dist/caddy.d.ts.map +1 -1
- package/dist/caddy.js +11 -1
- 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 +95 -38
- package/dist/cli.js.map +1 -1
- package/dist/compose.d.ts +7 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +5 -0
- package/dist/compose.js.map +1 -1
- package/dist/converge-job-gcp.d.ts.map +1 -1
- package/dist/converge-job-gcp.js +10 -7
- package/dist/converge-job-gcp.js.map +1 -1
- package/dist/converge-job-preflight.d.ts +21 -0
- package/dist/converge-job-preflight.d.ts.map +1 -0
- package/dist/converge-job-preflight.js +111 -0
- package/dist/converge-job-preflight.js.map +1 -0
- package/dist/converge-job.d.ts +8 -2
- package/dist/converge-job.d.ts.map +1 -1
- package/dist/converge-job.js +14 -0
- package/dist/converge-job.js.map +1 -1
- package/dist/gateway-credentials.d.ts +5 -3
- package/dist/gateway-credentials.d.ts.map +1 -1
- package/dist/gateway-credentials.js +5 -8
- package/dist/gateway-credentials.js.map +1 -1
- package/dist/gcloud.d.ts +29 -0
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +66 -0
- package/dist/gcloud.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 +10 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job.d.ts +3 -1
- package/dist/install-job.d.ts.map +1 -1
- package/dist/install-job.js.map +1 -1
- package/dist/install-state.d.ts +10 -0
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +12 -0
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts +12 -0
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +113 -28
- package/dist/lifecycle.js.map +1 -1
- package/dist/mcp-iap.d.ts +2 -0
- package/dist/mcp-iap.d.ts.map +1 -1
- package/dist/mcp-iap.js +50 -17
- package/dist/mcp-iap.js.map +1 -1
- package/dist/owner-reset.d.ts +16 -19
- package/dist/owner-reset.d.ts.map +1 -1
- package/dist/owner-reset.js +92 -19
- package/dist/owner-reset.js.map +1 -1
- package/dist/preview-plan.d.ts +71 -0
- package/dist/preview-plan.d.ts.map +1 -0
- package/dist/preview-plan.js +168 -0
- package/dist/preview-plan.js.map +1 -0
- package/dist/public-role-isolation.d.ts +2 -0
- package/dist/public-role-isolation.d.ts.map +1 -1
- package/dist/public-role-isolation.js +9 -3
- package/dist/public-role-isolation.js.map +1 -1
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +4 -0
- package/dist/release-contract.js.map +1 -1
- package/dist/self-hosted-internal-secret.d.ts +27 -0
- package/dist/self-hosted-internal-secret.d.ts.map +1 -0
- package/dist/self-hosted-internal-secret.js +69 -0
- package/dist/self-hosted-internal-secret.js.map +1 -0
- package/dist/self-hosted-release.d.ts +3 -1
- package/dist/self-hosted-release.d.ts.map +1 -1
- package/dist/self-hosted-release.js +6 -2
- package/dist/self-hosted-release.js.map +1 -1
- package/dist/self-hosted-upgrade.d.ts +13 -1
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +26 -2
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-converge.d.ts +5 -2
- package/dist/stack-converge.d.ts.map +1 -1
- package/dist/stack-converge.js +73 -8
- package/dist/stack-converge.js.map +1 -1
- package/dist/staging-admin.d.ts +4 -1
- package/dist/staging-admin.d.ts.map +1 -1
- package/dist/staging-admin.js.map +1 -1
- package/dist/state-backend.d.ts +3 -2
- package/dist/state-backend.d.ts.map +1 -1
- package/dist/state-backend.js +3 -2
- package/dist/state-backend.js.map +1 -1
- package/dist/state-command.d.ts +17 -0
- package/dist/state-command.d.ts.map +1 -1
- package/dist/state-command.js +34 -5
- package/dist/state-command.js.map +1 -1
- package/dist/state-migrate.d.ts +14 -3
- package/dist/state-migrate.d.ts.map +1 -1
- package/dist/state-migrate.js +31 -7
- package/dist/state-migrate.js.map +1 -1
- package/dist/state-pulumi.d.ts +0 -2
- package/dist/state-pulumi.d.ts.map +1 -1
- package/dist/state-pulumi.js +19 -20
- package/dist/state-pulumi.js.map +1 -1
- package/dist/static-security-headers.d.ts +86 -0
- package/dist/static-security-headers.d.ts.map +1 -0
- package/dist/static-security-headers.js +126 -0
- package/dist/static-security-headers.js.map +1 -0
- package/dist/static-svg-gcp.d.ts +28 -0
- package/dist/static-svg-gcp.d.ts.map +1 -0
- package/dist/static-svg-gcp.js +106 -0
- package/dist/static-svg-gcp.js.map +1 -0
- package/dist/upgrade/app-roll.d.ts +3 -0
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +12 -5
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/backup.d.ts +2 -0
- package/dist/upgrade/backup.d.ts.map +1 -1
- package/dist/upgrade/backup.js +5 -4
- package/dist/upgrade/backup.js.map +1 -1
- package/dist/upgrade/converge.d.ts +7 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +19 -7
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/gcp-lookup.d.ts +38 -3
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
- package/dist/upgrade/gcp-lookup.js +31 -9
- package/dist/upgrade/gcp-lookup.js.map +1 -1
- package/dist/upgrade/health.d.ts +31 -5
- package/dist/upgrade/health.d.ts.map +1 -1
- package/dist/upgrade/health.js +114 -25
- package/dist/upgrade/health.js.map +1 -1
- package/dist/upgrade/release-images.d.ts +17 -0
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +23 -12
- package/dist/upgrade/release-images.js.map +1 -1
- package/dist/upgrade/reporter.d.ts +2 -0
- package/dist/upgrade/reporter.d.ts.map +1 -1
- package/dist/upgrade/reporter.js +1 -0
- package/dist/upgrade/reporter.js.map +1 -1
- package/dist/upgrade/upgrade.d.ts +11 -1
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +5 -2
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-gcp.d.ts +12 -2
- package/dist/upgrader-gcp.d.ts.map +1 -1
- package/dist/upgrader-gcp.js +22 -13
- package/dist/upgrader-gcp.js.map +1 -1
- package/dist/upgrader-iam-preflight.d.ts +16 -3
- package/dist/upgrader-iam-preflight.d.ts.map +1 -1
- package/dist/upgrader-iam-preflight.js +9 -4
- package/dist/upgrader-iam-preflight.js.map +1 -1
- package/dist/upgrader-job.d.ts.map +1 -1
- package/dist/upgrader-job.js +10 -8
- package/dist/upgrader-job.js.map +1 -1
- package/dist/upgrader-reporter.d.ts.map +1 -1
- package/dist/upgrader-reporter.js +1 -0
- package/dist/upgrader-reporter.js.map +1 -1
- package/dist/wizards/gcp-pulumi.d.ts +70 -23
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +118 -39
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +67 -11
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/admin-internal-answer.ts +43 -0
- package/src/admin-internal-google.ts +187 -0
- package/src/admin-internal-self-hosted.test.ts +70 -0
- package/src/admin-internal-self-hosted.ts +128 -0
- package/src/admin-internal.ts +59 -174
- package/src/admin-memory.test.ts +1 -0
- package/src/agent/command.ts +3 -0
- package/src/agent/lock-config.test.ts +57 -2
- package/src/agent/lock.ts +68 -5
- package/src/agent/run.test.ts +18 -1
- package/src/agent/run.ts +17 -6
- package/src/caddy-redirects.test.ts +1 -0
- package/src/caddy.ts +12 -0
- package/src/cli-routing.ts +1 -1
- package/src/cli.ts +102 -44
- package/src/compose.ts +12 -0
- package/src/converge-job-gcp.ts +16 -11
- package/src/converge-job-preflight.test.ts +108 -0
- package/src/converge-job-preflight.ts +124 -0
- package/src/converge-job.test.ts +50 -0
- package/src/converge-job.ts +38 -2
- package/src/gateway-credentials.test.ts +4 -3
- package/src/gateway-credentials.ts +6 -10
- package/src/gateway-env-isolation.test.ts +1 -0
- package/src/gcloud.test.ts +93 -1
- package/src/gcloud.ts +82 -0
- package/src/index.test.ts +38 -1
- package/src/infra-contract.test.ts +4 -3
- package/src/infra-contract.ts +10 -1
- package/src/install-job.ts +5 -2
- package/src/install-state.ts +13 -0
- package/src/lifecycle.ts +134 -28
- package/src/mcp-iap.test.ts +66 -5
- package/src/mcp-iap.ts +55 -17
- package/src/owner-reset-providers.test.ts +370 -0
- package/src/owner-reset.test.ts +2 -2
- package/src/owner-reset.ts +108 -19
- package/src/preview-plan.test.ts +233 -0
- package/src/preview-plan.ts +226 -0
- package/src/public-role-isolation.test.ts +13 -2
- package/src/public-role-isolation.ts +14 -4
- package/src/release-contract.ts +4 -0
- package/src/self-hosted-compose.test.ts +25 -0
- package/src/self-hosted-internal-secret.test.ts +61 -0
- package/src/self-hosted-internal-secret.ts +80 -0
- package/src/self-hosted-release.ts +10 -3
- package/src/self-hosted-upgrade.test.ts +35 -1
- package/src/self-hosted-upgrade.ts +40 -3
- package/src/stack-contract.test.ts +1 -0
- package/src/stack-converge.test.ts +89 -5
- package/src/stack-converge.ts +81 -8
- package/src/staging-admin.test.ts +1 -1
- package/src/staging-admin.ts +2 -1
- package/src/state-backend.ts +3 -2
- package/src/state-command.test.ts +31 -0
- package/src/state-command.ts +35 -8
- package/src/state-migrate.test.ts +58 -7
- package/src/state-migrate.ts +48 -10
- package/src/state-pulumi.ts +20 -21
- package/src/static-security-headers.test.ts +124 -0
- package/src/static-security-headers.ts +154 -0
- package/src/static-svg-gcp.test.ts +133 -0
- package/src/static-svg-gcp.ts +140 -0
- package/src/upgrade/app-roll.ts +18 -7
- package/src/upgrade/backup.ts +14 -5
- package/src/upgrade/converge.ts +25 -7
- package/src/upgrade/gcp-lookup.test.ts +80 -1
- package/src/upgrade/gcp-lookup.ts +61 -11
- package/src/upgrade/health.test.ts +132 -8
- package/src/upgrade/health.ts +169 -31
- package/src/upgrade/release-images.test.ts +17 -0
- package/src/upgrade/release-images.ts +26 -14
- package/src/upgrade/reporter.ts +3 -0
- package/src/upgrade/upgrade.test.ts +137 -8
- package/src/upgrade/upgrade.ts +14 -2
- package/src/upgrader-gcp.ts +28 -13
- package/src/upgrader-iam-preflight.test.ts +6 -3
- package/src/upgrader-iam-preflight.ts +26 -5
- package/src/upgrader-job.ts +14 -6
- package/src/upgrader-reporter.ts +1 -0
- package/src/wizards/gcp-pulumi.test.ts +1 -1
- package/src/wizards/gcp-pulumi.ts +147 -38
- package/src/wizards/gcp.ts +89 -9
- package/src/wizards/stack-work-dir.test.ts +175 -0
- package/stacks/aws/index.ts +40 -0
- package/stacks/azure/index.ts +22 -1
- package/stacks/gcp/README.md +4 -0
- package/stacks/gcp/index.ts +6 -2
- package/stacks/gcp/Pulumi.production.yaml +0 -16
package/src/upgrade/health.ts
CHANGED
|
@@ -24,9 +24,18 @@
|
|
|
24
24
|
* that account). The gateway is public by design and gets no credential.
|
|
25
25
|
*
|
|
26
26
|
* Before anything changes, {@link HealthProbe.baseline} proves the probe can
|
|
27
|
-
* reach both services at all
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* reach both services at all — each answering with its own JSON, so a
|
|
28
|
+
* Google front-end 404 for a gateway whose ingress refuses the request
|
|
29
|
+
* counts as unreachable (#733). Otherwise a DNS, certificate, ingress or IAP
|
|
30
|
+
* problem would only show after migrations, as a "failed" health check that
|
|
31
|
+
* rolls a healthy release back.
|
|
32
|
+
*
|
|
33
|
+
* IAM is eventually consistent (Google: typically under 2 minutes, up to
|
|
34
|
+
* 7). When the converge step that ran just before changed the access the
|
|
35
|
+
* probe relies on — the operator's right to sign as `caelo-mcp@`, that
|
|
36
|
+
* account's IAP access, the gateway's ingress — the baseline keeps retrying
|
|
37
|
+
* a refusal for {@link IAM_PROPAGATION_WINDOW_MS} instead of failing the
|
|
38
|
+
* upgrade on a grant that has not taken effect yet (#734).
|
|
30
39
|
*/
|
|
31
40
|
|
|
32
41
|
import type { GcloudRunner, Sleep } from "../gcloud-retry.js";
|
|
@@ -38,8 +47,13 @@ export type HealthOutcome =
|
|
|
38
47
|
| { readonly ok: false; readonly error: string };
|
|
39
48
|
|
|
40
49
|
export interface HealthProbe {
|
|
41
|
-
/**
|
|
42
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Reach both services as they serve now (before any change).
|
|
52
|
+
*
|
|
53
|
+
* @param onWait told why the baseline waits, each time it waits for
|
|
54
|
+
* access the converge step just granted to take effect
|
|
55
|
+
*/
|
|
56
|
+
baseline(onWait?: (reason: string) => void): Promise<HealthOutcome>;
|
|
43
57
|
/**
|
|
44
58
|
* Deep-check both services after the roll, retrying while the new
|
|
45
59
|
* revisions warm up. `version` is the release the roll targets (null when
|
|
@@ -169,10 +183,23 @@ export function healthOrigins(target: HealthTarget): { admin: string; gateway: s
|
|
|
169
183
|
};
|
|
170
184
|
}
|
|
171
185
|
|
|
186
|
+
/**
|
|
187
|
+
* How long the baseline treats a refusal as access still propagating: IAM's
|
|
188
|
+
* documented worst case for a new binding to take effect.
|
|
189
|
+
*/
|
|
190
|
+
export const IAM_PROPAGATION_WINDOW_MS = 7 * 60_000;
|
|
191
|
+
|
|
172
192
|
export interface HealthProbeDeps {
|
|
173
193
|
readonly run: GcloudRunner;
|
|
174
194
|
readonly http: HttpFetch;
|
|
175
195
|
readonly sleep: Sleep;
|
|
196
|
+
/**
|
|
197
|
+
* Whether the converge step that ran just before changed the access the
|
|
198
|
+
* probe relies on (IAM grants, the gateway's ingress). Only then does the
|
|
199
|
+
* baseline wait for a refusal to clear: a grant that existed before this
|
|
200
|
+
* run and still refuses is missing, and waiting would only delay saying so.
|
|
201
|
+
*/
|
|
202
|
+
readonly accessJustChanged: boolean;
|
|
176
203
|
/** Deep-check attempts while the new revisions warm up (default 12). */
|
|
177
204
|
readonly attempts?: number;
|
|
178
205
|
/** Pause between attempts in ms (default 5000). */
|
|
@@ -181,7 +208,21 @@ export interface HealthProbeDeps {
|
|
|
181
208
|
readonly now?: () => number;
|
|
182
209
|
}
|
|
183
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Why the baseline could not reach the install. The refusals other than
|
|
213
|
+
* `other` are what fresh access looks like before it applies: the baseline
|
|
214
|
+
* waits them out after a converge that just changed access.
|
|
215
|
+
*/
|
|
216
|
+
type BaselineRefusal = "sign-denied" | "iap-refused" | "gateway-not-caelo" | "other";
|
|
217
|
+
|
|
218
|
+
type BaselineOutcome =
|
|
219
|
+
| { readonly ok: true; readonly detail: string }
|
|
220
|
+
| { readonly ok: false; readonly error: string; readonly refusal: BaselineRefusal };
|
|
221
|
+
|
|
184
222
|
const JWT_LIFETIME_S = 600;
|
|
223
|
+
/** First pause between baseline attempts while access propagates; doubles up to the cap. */
|
|
224
|
+
const SETTLE_FIRST_DELAY_MS = 5_000;
|
|
225
|
+
const SETTLE_MAX_DELAY_MS = 30_000;
|
|
185
226
|
const IAM_CREDENTIALS = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts";
|
|
186
227
|
|
|
187
228
|
/** The IAP-backed probe of a gcp / gcp-firebase install. */
|
|
@@ -197,24 +238,35 @@ export function createIapHealthProbe(target: HealthTarget, deps: HealthProbeDeps
|
|
|
197
238
|
* account. Never throws: a transport failure is a failed outcome, so the
|
|
198
239
|
* retries and the app-roll's rollback still run.
|
|
199
240
|
*/
|
|
200
|
-
async function iapToken(): Promise<
|
|
241
|
+
async function iapToken(): Promise<SignOutcome> {
|
|
201
242
|
try {
|
|
202
243
|
return await signIapToken();
|
|
203
244
|
} catch (e) {
|
|
204
245
|
return {
|
|
205
246
|
ok: false,
|
|
206
247
|
error: `could not sign an IAP credential as ${signer}: ${e instanceof Error ? e.message : String(e)}`,
|
|
248
|
+
denied: false,
|
|
207
249
|
};
|
|
208
250
|
}
|
|
209
251
|
}
|
|
210
252
|
|
|
211
|
-
|
|
253
|
+
type SignOutcome =
|
|
254
|
+
| { ok: true; jwt: string }
|
|
255
|
+
| {
|
|
256
|
+
ok: false;
|
|
257
|
+
error: string;
|
|
258
|
+
/** IAM refused the signing (HTTP 403): the token-creator grant is missing or not in effect yet. */
|
|
259
|
+
denied: boolean;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
async function signIapToken(): Promise<SignOutcome> {
|
|
212
263
|
const token = await deps.run(["auth", "print-access-token"]);
|
|
213
264
|
const bearer = token.ok ? token.stdout.trim() : "";
|
|
214
265
|
if (!bearer) {
|
|
215
266
|
return {
|
|
216
267
|
ok: false,
|
|
217
268
|
error: `gcloud auth print-access-token: ${token.stderr.trim() || "empty"}`,
|
|
269
|
+
denied: false,
|
|
218
270
|
};
|
|
219
271
|
}
|
|
220
272
|
const iat = now();
|
|
@@ -232,14 +284,43 @@ export function createIapHealthProbe(target: HealthTarget, deps: HealthProbeDeps
|
|
|
232
284
|
}),
|
|
233
285
|
});
|
|
234
286
|
const text = await res.text();
|
|
287
|
+
if (res.status === 403) {
|
|
288
|
+
return {
|
|
289
|
+
ok: false,
|
|
290
|
+
error: `could not sign an IAP credential as ${signer} (HTTP 403): ${text.slice(0, 300)}`,
|
|
291
|
+
denied: true,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
235
294
|
if (!res.ok) {
|
|
236
295
|
return {
|
|
237
296
|
ok: false,
|
|
238
|
-
error: `could not sign an IAP credential as ${signer} (HTTP ${res.status})
|
|
297
|
+
error: `could not sign an IAP credential as ${signer} (HTTP ${res.status}): ${text.slice(0, 300)}`,
|
|
298
|
+
denied: false,
|
|
239
299
|
};
|
|
240
300
|
}
|
|
241
301
|
const jwt = parseJson<{ signedJwt?: string }>(text)?.signedJwt;
|
|
242
|
-
return jwt
|
|
302
|
+
return jwt
|
|
303
|
+
? { ok: true, jwt }
|
|
304
|
+
: { ok: false, error: "signJwt returned no signedJwt", denied: false };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* What to do about a refused signJwt, depending on whether this run's
|
|
309
|
+
* converge just granted the access or it was there before.
|
|
310
|
+
*/
|
|
311
|
+
function signDeniedHelp(waitedMs: number): string {
|
|
312
|
+
const role = "roles/iam.serviceAccountTokenCreator";
|
|
313
|
+
if (deps.accessJustChanged) {
|
|
314
|
+
return `The upgrade's converge step changed who may sign as ${signer} just now (it grants ${role} to every user on the admin's IAP allowlist), and Google IAM had not applied it after ${Math.round(waitedMs / 60_000)} minutes of waiting. The grant exists; nothing was changed: re-run upgrade in a few minutes.`;
|
|
315
|
+
}
|
|
316
|
+
return `Your account may not sign as ${signer}: it lacks ${role} on it. Upgrade's converge step grants that role to every user on the admin's IAP allowlist, so run upgrade as an allowlisted account (gcloud auth login), or add yours to the allowlist. If an upgrade run in the last few minutes granted it, IAM may still be applying it: re-run upgrade in a few minutes.`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Why the gateway's answer is not the gateway's own, and what fixes it. */
|
|
320
|
+
function gatewayRoutingHelp(): string {
|
|
321
|
+
return target.provider === "gcp-firebase"
|
|
322
|
+
? `Requests to its run.app URL do not reach the Caelo gateway: its Cloud Run ingress likely refuses them. The gcp-firebase stack declares ingress "all"; upgrade's converge step sets it. Check it with: gcloud run services describe <gateway service> --region=<region> --project=${target.projectId} --format='value(metadata.annotations."run.googleapis.com/ingress")'`
|
|
323
|
+
: `Requests to ${origins.gateway}/api/ do not reach the Caelo gateway: the load balancer's URL map must route /api/* to the gateway backend. Check the install's load balancer, then re-run upgrade.`;
|
|
243
324
|
}
|
|
244
325
|
|
|
245
326
|
async function get(url: string, headers: Record<string, string>) {
|
|
@@ -251,30 +332,73 @@ export function createIapHealthProbe(target: HealthTarget, deps: HealthProbeDeps
|
|
|
251
332
|
}
|
|
252
333
|
}
|
|
253
334
|
|
|
335
|
+
async function baselineOnce(): Promise<BaselineOutcome> {
|
|
336
|
+
const token = await iapToken();
|
|
337
|
+
if (!token.ok) {
|
|
338
|
+
return { ok: false, error: token.error, refusal: token.denied ? "sign-denied" : "other" };
|
|
339
|
+
}
|
|
340
|
+
const auth = { Authorization: `Bearer ${token.jwt}` };
|
|
341
|
+
const admin = await get(`${origins.admin}/_caelo/health`, auth);
|
|
342
|
+
if (!admin.reached) {
|
|
343
|
+
return {
|
|
344
|
+
ok: false,
|
|
345
|
+
error: `admin unreachable at ${origins.admin}: ${admin.error}`,
|
|
346
|
+
refusal: "other",
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
// The serving (old) admin answers its shallow health JSON. Anything
|
|
350
|
+
// else — IAP's login redirect, a 401/403, an HTML error page — means
|
|
351
|
+
// the probe would not see the new revision either.
|
|
352
|
+
const body = parseJson<{ version?: string }>(admin.text);
|
|
353
|
+
if (!body?.version) {
|
|
354
|
+
return {
|
|
355
|
+
ok: false,
|
|
356
|
+
error: `admin at ${origins.admin} did not answer its health check (HTTP ${admin.status}); IAP may not admit ${signer}`,
|
|
357
|
+
// IAP refuses with 401/403 until a fresh grant to caelo-mcp applies.
|
|
358
|
+
refusal: admin.status === 401 || admin.status === 403 ? "iap-refused" : "other",
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
const gateway = await get(`${origins.gateway}/api/_caelo/health`, {});
|
|
362
|
+
if (!gateway.reached) {
|
|
363
|
+
return {
|
|
364
|
+
ok: false,
|
|
365
|
+
error: `gateway unreachable at ${origins.gateway}: ${gateway.error}`,
|
|
366
|
+
refusal: "other",
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
// Any JSON counts, whatever the status: a gateway older than its health
|
|
370
|
+
// route answers its own JSON 404. An HTML or empty answer comes from
|
|
371
|
+
// Google's front end, not from Caelo (#733).
|
|
372
|
+
if (parseJson<unknown>(gateway.text) === null) {
|
|
373
|
+
return {
|
|
374
|
+
ok: false,
|
|
375
|
+
error: `gateway at ${origins.gateway} answered HTTP ${gateway.status} without JSON. ${gatewayRoutingHelp()}`,
|
|
376
|
+
// A just-opened ingress takes a moment, like an IAM grant.
|
|
377
|
+
refusal: "gateway-not-caelo",
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
return { ok: true, detail: `admin ${body.version} and gateway reachable` };
|
|
381
|
+
}
|
|
382
|
+
|
|
254
383
|
return {
|
|
255
|
-
async baseline() {
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
384
|
+
async baseline(onWait) {
|
|
385
|
+
const windowMs = deps.accessJustChanged ? IAM_PROPAGATION_WINDOW_MS : 0;
|
|
386
|
+
let waited = 0;
|
|
387
|
+
let delay = SETTLE_FIRST_DELAY_MS;
|
|
388
|
+
for (;;) {
|
|
389
|
+
const outcome = await baselineOnce();
|
|
390
|
+
if (outcome.ok) return outcome;
|
|
391
|
+
if (outcome.refusal === "other" || waited >= windowMs) {
|
|
392
|
+
return { ok: false, error: explainRefusal(outcome, waited) };
|
|
393
|
+
}
|
|
394
|
+
onWait?.(
|
|
395
|
+
`Waiting for the access the converge step just granted to take effect (${Math.round(waited / 1000)}s of up to ${windowMs / 60_000} min): ${outcome.error.slice(0, 200)}`,
|
|
396
|
+
);
|
|
397
|
+
const pause = Math.min(delay, windowMs - waited);
|
|
398
|
+
await deps.sleep(pause);
|
|
399
|
+
waited += pause;
|
|
400
|
+
delay = Math.min(delay * 2, SETTLE_MAX_DELAY_MS);
|
|
262
401
|
}
|
|
263
|
-
// The serving (old) admin answers its shallow health JSON. Anything
|
|
264
|
-
// else — IAP's login redirect, a 401/403, an HTML error page — means
|
|
265
|
-
// the probe would not see the new revision either.
|
|
266
|
-
const body = parseJson<{ version?: string }>(admin.text);
|
|
267
|
-
if (!body?.version) {
|
|
268
|
-
return {
|
|
269
|
-
ok: false,
|
|
270
|
-
error: `admin at ${origins.admin} did not answer its health check (HTTP ${admin.status}); IAP may not admit ${signer}`,
|
|
271
|
-
};
|
|
272
|
-
}
|
|
273
|
-
const gateway = await get(`${origins.gateway}/api/_caelo/health`, {});
|
|
274
|
-
if (!gateway.reached) {
|
|
275
|
-
return { ok: false, error: `gateway unreachable at ${origins.gateway}: ${gateway.error}` };
|
|
276
|
-
}
|
|
277
|
-
return { ok: true, detail: `admin ${body.version} and gateway reachable` };
|
|
278
402
|
},
|
|
279
403
|
|
|
280
404
|
async deep(expect) {
|
|
@@ -288,6 +412,20 @@ export function createIapHealthProbe(target: HealthTarget, deps: HealthProbeDeps
|
|
|
288
412
|
},
|
|
289
413
|
};
|
|
290
414
|
|
|
415
|
+
/**
|
|
416
|
+
* The baseline's final error: a refusal that outlived the wait says the
|
|
417
|
+
* access was granted and has not applied yet (re-run later), apart from
|
|
418
|
+
* a refusal with no fresh grant behind it (missing access).
|
|
419
|
+
*/
|
|
420
|
+
function explainRefusal(
|
|
421
|
+
outcome: Extract<BaselineOutcome, { ok: false }>,
|
|
422
|
+
waitedMs: number,
|
|
423
|
+
): string {
|
|
424
|
+
if (outcome.refusal === "sign-denied") return `${outcome.error}\n${signDeniedHelp(waitedMs)}`;
|
|
425
|
+
if (outcome.refusal === "other" || !deps.accessJustChanged) return outcome.error;
|
|
426
|
+
return `${outcome.error}\nThe upgrade's converge step changed this access just now and it had not applied after ${Math.round(waitedMs / 60_000)} minutes of waiting. Nothing was changed: re-run upgrade in a few minutes.`;
|
|
427
|
+
}
|
|
428
|
+
|
|
291
429
|
async function deepOnce(expectedVersion: string | null): Promise<HealthOutcome> {
|
|
292
430
|
const token = await iapToken();
|
|
293
431
|
if (!token.ok) return token;
|
|
@@ -4,6 +4,7 @@ import { describe, expect, it } from "bun:test";
|
|
|
4
4
|
import {
|
|
5
5
|
canonicalDigest,
|
|
6
6
|
canonicalReleaseTag,
|
|
7
|
+
cosignInstallHint,
|
|
7
8
|
cosignVerifyArgv,
|
|
8
9
|
parseBearerChallenge,
|
|
9
10
|
RELEASE_SIGNER_IDENTITY_REGEXP,
|
|
@@ -117,3 +118,19 @@ describe("canonical values from the network", () => {
|
|
|
117
118
|
expect(canonicalReleaseTag("latest")).toBeNull();
|
|
118
119
|
});
|
|
119
120
|
});
|
|
121
|
+
|
|
122
|
+
describe("cosignInstallHint (#816)", () => {
|
|
123
|
+
it("never offers --skip-verify to the Owner", () => {
|
|
124
|
+
for (const platform of ["darwin", "linux", "win32"] as const) {
|
|
125
|
+
expect(cosignInstallHint(platform)).not.toContain("skip-verify");
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("is not brew-only: Cloud Shell and Linux get a verified path", () => {
|
|
130
|
+
const linux = cosignInstallHint("linux");
|
|
131
|
+
expect(linux).not.toContain("brew");
|
|
132
|
+
expect(linux).toContain("./run.sh upgrade");
|
|
133
|
+
expect(linux).toContain("docs.sigstore.dev");
|
|
134
|
+
expect(cosignInstallHint("darwin")).toContain("brew install cosign");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -300,23 +300,40 @@ function probeBinary(argv: string[]): boolean {
|
|
|
300
300
|
}
|
|
301
301
|
|
|
302
302
|
/**
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
303
|
+
* How to get cosign onto PATH on `platform`. Owner-facing, so it names
|
|
304
|
+
* only verified installs and never the unverified escape hatch: an Owner
|
|
305
|
+
* who cannot install cosign must not be steered to roll unsigned images
|
|
306
|
+
* (#816). Cloud Shell (Linux) is where the break-glass upgrade runs; its
|
|
307
|
+
* `./run.sh upgrade` installs a checksum-verified cosign itself.
|
|
308
|
+
*/
|
|
309
|
+
export function cosignInstallHint(platform: NodeJS.Platform): string {
|
|
310
|
+
const lines = ["Install cosign, then run this command again:"];
|
|
311
|
+
if (platform === "darwin") lines.push(" • brew install cosign");
|
|
312
|
+
lines.push(
|
|
313
|
+
" • Cloud Shell: run the upgrade with `./run.sh upgrade` from https://github.com/caelo-cms/install — it installs a verified cosign first",
|
|
314
|
+
" • Linux / Windows: the signed release binary, https://docs.sigstore.dev/cosign/system_config/installation/",
|
|
315
|
+
);
|
|
316
|
+
return lines.join("\n");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Ensure cosign is on PATH; the upgrade's pre-flight runs this before it
|
|
321
|
+
* plans or changes anything, so a missing cosign never stops an upgrade
|
|
322
|
+
* half-way (#816). On macOS with brew it offers `brew install cosign`;
|
|
323
|
+
* elsewhere it prints {@link cosignInstallHint}.
|
|
307
324
|
*
|
|
308
|
-
*
|
|
325
|
+
* @returns whether cosign is callable afterwards
|
|
309
326
|
*/
|
|
310
|
-
async function ensureCosignAvailable(): Promise<boolean> {
|
|
327
|
+
export async function ensureCosignAvailable(): Promise<boolean> {
|
|
311
328
|
if (probeBinary(["cosign", "version"])) return true;
|
|
312
329
|
|
|
313
|
-
log.error(red("cosign not found on PATH — required
|
|
330
|
+
log.error(red("cosign not found on PATH — required to verify the release images' signatures."));
|
|
314
331
|
|
|
315
332
|
// macOS auto-install offer. Only when `brew` is itself on PATH —
|
|
316
333
|
// otherwise the operator is on a stripped-down install where
|
|
317
334
|
// homebrew was never set up, and the manual instructions are more
|
|
318
335
|
// useful than a failing brew shell-out.
|
|
319
|
-
if (process.platform === "darwin" && probeBinary(["brew", "--version"])) {
|
|
336
|
+
if (process.platform === "darwin" && process.stdin.isTTY && probeBinary(["brew", "--version"])) {
|
|
320
337
|
const proceed = await confirm({
|
|
321
338
|
message: "Install cosign via brew now? (one-shot: brew install cosign)",
|
|
322
339
|
initialValue: true,
|
|
@@ -338,12 +355,7 @@ async function ensureCosignAvailable(): Promise<boolean> {
|
|
|
338
355
|
}
|
|
339
356
|
}
|
|
340
357
|
|
|
341
|
-
log.warn(
|
|
342
|
-
"Install cosign:\n" +
|
|
343
|
-
" • brew install cosign (macOS)\n" +
|
|
344
|
-
" • https://docs.sigstore.dev/cosign/installation/ (other)\n" +
|
|
345
|
-
"Or pass --skip-verify to roll without signature checks (NOT recommended for production).",
|
|
346
|
-
);
|
|
358
|
+
log.warn(cosignInstallHint(process.platform));
|
|
347
359
|
return false;
|
|
348
360
|
}
|
|
349
361
|
|
package/src/upgrade/reporter.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface ReportStep {
|
|
|
16
16
|
failed(message: string): void;
|
|
17
17
|
/** Ended without success, but the upgrade continues. */
|
|
18
18
|
warned(message: string): void;
|
|
19
|
+
/** Still running: say what it is waiting for now. */
|
|
20
|
+
progress(message: string): void;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export interface UpgradeReporter {
|
|
@@ -36,6 +38,7 @@ export function clackReporter(): UpgradeReporter {
|
|
|
36
38
|
done: (m) => s.stop(green(m)),
|
|
37
39
|
failed: (m) => s.stop(red(m)),
|
|
38
40
|
warned: (m) => s.stop(yellow(m)),
|
|
41
|
+
progress: (m) => s.message(m),
|
|
39
42
|
};
|
|
40
43
|
},
|
|
41
44
|
info: (m) => log.info(m),
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
} from "./app-roll.js";
|
|
22
22
|
import { restoreCommand, takeSqlBackup } from "./backup.js";
|
|
23
23
|
import { needsConverge, startConverge } from "./converge.js";
|
|
24
|
-
import type
|
|
24
|
+
import { createIapHealthProbe, type HealthOutcome, type HealthProbe } from "./health.js";
|
|
25
25
|
import type { UpgradeReporter } from "./reporter.js";
|
|
26
26
|
import {
|
|
27
27
|
type GcpUpgradeDeps,
|
|
@@ -48,9 +48,13 @@ const secretRef = (name: string, secret: string) => ({
|
|
|
48
48
|
valueFrom: { secretKeyRef: { name: `caelo-production-${secret}`, key: "latest" } },
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
51
|
+
/**
|
|
52
|
+
* A described service, as `gcloud run services describe --format=json`
|
|
53
|
+
* returns it. `ingress`: the service-level setting (#732).
|
|
54
|
+
*/
|
|
55
|
+
const serviceJson = (image: string, env: unknown[], ingress = "all") =>
|
|
53
56
|
JSON.stringify({
|
|
57
|
+
metadata: { annotations: { "run.googleapis.com/ingress": ingress } },
|
|
54
58
|
spec: {
|
|
55
59
|
template: {
|
|
56
60
|
metadata: {
|
|
@@ -150,6 +154,7 @@ function recordingReporter() {
|
|
|
150
154
|
done: (m) => lines.push(`done: ${m}`),
|
|
151
155
|
failed: (m) => lines.push(`failed: ${m}`),
|
|
152
156
|
warned: (m) => lines.push(`warned: ${m}`),
|
|
157
|
+
progress: (m) => lines.push(`progress: ${m}`),
|
|
153
158
|
};
|
|
154
159
|
},
|
|
155
160
|
info: (m) => lines.push(`info: ${m}`),
|
|
@@ -188,6 +193,7 @@ const TARGET: GcpUpgradeTarget = {
|
|
|
188
193
|
infraContractVersion: 1,
|
|
189
194
|
skipVerify: false,
|
|
190
195
|
skipHealthCheck: false,
|
|
196
|
+
credentials: "operator",
|
|
191
197
|
};
|
|
192
198
|
|
|
193
199
|
/** The same install on the `gcp` provider (load balancer + URL map). */
|
|
@@ -788,6 +794,88 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
|
|
|
788
794
|
expect(calls.some((c) => APP_ROLL_WRITE.test(c))).toBe(false);
|
|
789
795
|
});
|
|
790
796
|
|
|
797
|
+
it("#732 — opens a load-balancer-only gcp-firebase gateway in converge, before the baseline and migrations", async () => {
|
|
798
|
+
const events: string[] = [];
|
|
799
|
+
const internalGateway = serviceJson(
|
|
800
|
+
"registry/gateway@sha256:old",
|
|
801
|
+
JSON.parse(GATEWAY_JSON).spec.template.spec.containers[0].env,
|
|
802
|
+
"internal-and-cloud-load-balancing",
|
|
803
|
+
);
|
|
804
|
+
const { deps, lines } = upgradeDeps(events, {
|
|
805
|
+
overrides: [[new RegExp(`describe ${GATEWAY} .*--format=json`), ok(internalGateway)]],
|
|
806
|
+
});
|
|
807
|
+
expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
|
|
808
|
+
const ingress = indexOf(
|
|
809
|
+
events,
|
|
810
|
+
new RegExp(`^gcloud run services update ${GATEWAY} .*--ingress=all`),
|
|
811
|
+
);
|
|
812
|
+
expect(ingress).toBeLessThan(indexOf(events, /^health baseline/));
|
|
813
|
+
expect(ingress).toBeLessThan(indexOf(events, /^migrate /));
|
|
814
|
+
// Its own update: the image roll does not carry it (no new revision).
|
|
815
|
+
expect(events.filter((e) => e.includes("--ingress=all"))).toHaveLength(1);
|
|
816
|
+
expect(lines).toContain(
|
|
817
|
+
`info: applied: ingress internal-and-cloud-load-balancing → all on ${GATEWAY} (the gcp-firebase stack's gateway ingress (#650); the health check, the staging domain and remote MCP reach its run.app URL)`,
|
|
818
|
+
);
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
it("#732 — leaves a gateway that is already open alone, and never touches the gcp gateway's ingress", async () => {
|
|
822
|
+
for (const target of [TARGET, GCP_TARGET]) {
|
|
823
|
+
const events: string[] = [];
|
|
824
|
+
const { deps } = upgradeDeps(events);
|
|
825
|
+
await upgradeGcpInstall(target, deps);
|
|
826
|
+
expect(events.some((e) => e.includes("--ingress"))).toBe(false);
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it("#734 — tells the health probe when converge just changed access, so its baseline waits", async () => {
|
|
831
|
+
const seen: boolean[] = [];
|
|
832
|
+
const events: string[] = [];
|
|
833
|
+
const { deps } = upgradeDeps(events);
|
|
834
|
+
await upgradeGcpInstall(TARGET, {
|
|
835
|
+
...deps,
|
|
836
|
+
healthProbe: async (_plan, context) => {
|
|
837
|
+
seen.push(context.accessJustChanged);
|
|
838
|
+
return { ok: true, probe: fakeHealth(events) };
|
|
839
|
+
},
|
|
840
|
+
});
|
|
841
|
+
// The fake install's empty IAM policies make converge add every binding.
|
|
842
|
+
expect(seen).toEqual([true]);
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
it("#733 — a gateway answering Google's HTML 404 stops the upgrade before migrations and any roll", async () => {
|
|
846
|
+
const events: string[] = [];
|
|
847
|
+
const { deps, calls, lines } = upgradeDeps(events);
|
|
848
|
+
const htmlGateway: HttpFetch = async (url) => {
|
|
849
|
+
const [status, text] = url.includes(":signJwt")
|
|
850
|
+
? [200, JSON.stringify({ signedJwt: "signed.jwt" })]
|
|
851
|
+
: url.includes("admin")
|
|
852
|
+
? [200, JSON.stringify({ ok: true, version: "0.10.20" })]
|
|
853
|
+
: [404, "<html><title>Error 404 (Not Found)!!1</title></html>"];
|
|
854
|
+
return { ok: status < 400, status, text: async () => text };
|
|
855
|
+
};
|
|
856
|
+
expect(
|
|
857
|
+
await upgradeGcpInstall(TARGET, {
|
|
858
|
+
...deps,
|
|
859
|
+
healthProbe: async () => ({
|
|
860
|
+
ok: true,
|
|
861
|
+
probe: createIapHealthProbe(
|
|
862
|
+
{
|
|
863
|
+
provider: "gcp-firebase",
|
|
864
|
+
projectId: "acme",
|
|
865
|
+
domain: "acme.com",
|
|
866
|
+
adminServiceUrl: `https://${ADMIN}-xyz.a.run.app`,
|
|
867
|
+
gatewayServiceUrl: `https://${GATEWAY}-xyz.a.run.app`,
|
|
868
|
+
},
|
|
869
|
+
{ run: deps.run, http: htmlGateway, sleep: noSleep, accessJustChanged: false },
|
|
870
|
+
),
|
|
871
|
+
}),
|
|
872
|
+
}),
|
|
873
|
+
).toBe(false);
|
|
874
|
+
expect(events.some((e) => e.startsWith("migrate"))).toBe(false);
|
|
875
|
+
expect(calls.some((c) => APP_ROLL_WRITE.test(c))).toBe(false);
|
|
876
|
+
expect(lines.some((l) => l.includes("answered HTTP 404 without JSON"))).toBe(true);
|
|
877
|
+
});
|
|
878
|
+
|
|
791
879
|
it("--skip-health-check rolls without a probe", async () => {
|
|
792
880
|
const events: string[] = [];
|
|
793
881
|
const { deps } = upgradeDeps(events);
|
|
@@ -814,6 +902,7 @@ const INSTALL: AppRollInstall = {
|
|
|
814
902
|
env: "production",
|
|
815
903
|
domain: "acme.com",
|
|
816
904
|
stagingHost: "staging.acme.com",
|
|
905
|
+
credentials: "operator",
|
|
817
906
|
};
|
|
818
907
|
|
|
819
908
|
async function plannedRoll(run: GcloudRunner): Promise<AppRollPlan> {
|
|
@@ -942,7 +1031,7 @@ describe("takeSqlBackup", () => {
|
|
|
942
1031
|
const { run, calls } = fakeInstall(events);
|
|
943
1032
|
const r = await takeSqlBackup(
|
|
944
1033
|
run,
|
|
945
|
-
{ projectId: "acme", env: "production" },
|
|
1034
|
+
{ projectId: "acme", env: "production", credentials: "operator" },
|
|
946
1035
|
"backup",
|
|
947
1036
|
() => new Date("2026-10-13T10:00:00Z"),
|
|
948
1037
|
);
|
|
@@ -959,7 +1048,11 @@ describe("takeSqlBackup", () => {
|
|
|
959
1048
|
|
|
960
1049
|
it("fails loudly when the backup's id can't be found", async () => {
|
|
961
1050
|
const { run } = fakeInstall([], [[/^sql backups list/, ok("")]]);
|
|
962
|
-
const r = await takeSqlBackup(
|
|
1051
|
+
const r = await takeSqlBackup(
|
|
1052
|
+
run,
|
|
1053
|
+
{ projectId: "acme", env: "production", credentials: "operator" },
|
|
1054
|
+
"backup",
|
|
1055
|
+
);
|
|
963
1056
|
expect(r.ok).toBe(false);
|
|
964
1057
|
if (!r.ok)
|
|
965
1058
|
expect(r.error).toContain("gcloud sql backups list --instance=caelo-production-pg-abc");
|
|
@@ -967,11 +1060,47 @@ describe("takeSqlBackup", () => {
|
|
|
967
1060
|
|
|
968
1061
|
it("fails when the install has no Cloud SQL instance", async () => {
|
|
969
1062
|
const { run } = fakeInstall([], [[/^sql instances list/, ok("")]]);
|
|
970
|
-
const r = await takeSqlBackup(
|
|
1063
|
+
const r = await takeSqlBackup(
|
|
1064
|
+
run,
|
|
1065
|
+
{ projectId: "acme", env: "production", credentials: "operator" },
|
|
1066
|
+
"backup",
|
|
1067
|
+
);
|
|
971
1068
|
expect(r).toEqual({
|
|
972
1069
|
ok: false,
|
|
973
|
-
error:
|
|
974
|
-
"no caelo-production-pg* Cloud SQL instance in project acme (or listing it was denied)",
|
|
1070
|
+
error: "Could not find the caelo-production-pg* Cloud SQL instance in project acme",
|
|
975
1071
|
});
|
|
976
1072
|
});
|
|
1073
|
+
|
|
1074
|
+
it("reports expired credentials instead of a missing instance (#630)", async () => {
|
|
1075
|
+
const { run } = fakeInstall(
|
|
1076
|
+
[],
|
|
1077
|
+
[
|
|
1078
|
+
[
|
|
1079
|
+
/^sql instances list/,
|
|
1080
|
+
fail(
|
|
1081
|
+
"ERROR: (gcloud.sql.instances.list) Reauthentication failed. cannot prompt during non-interactive execution.",
|
|
1082
|
+
),
|
|
1083
|
+
],
|
|
1084
|
+
],
|
|
1085
|
+
);
|
|
1086
|
+
const operator = await takeSqlBackup(
|
|
1087
|
+
run,
|
|
1088
|
+
{ projectId: "acme", env: "production", credentials: "operator" },
|
|
1089
|
+
"backup",
|
|
1090
|
+
);
|
|
1091
|
+
expect(operator.ok).toBe(false);
|
|
1092
|
+
if (operator.ok) return;
|
|
1093
|
+
expect(operator.error).toContain("gcloud credentials expired");
|
|
1094
|
+
expect(operator.error).toContain("gcloud auth login");
|
|
1095
|
+
expect(operator.error).not.toContain("Could not find");
|
|
1096
|
+
const job = await takeSqlBackup(
|
|
1097
|
+
run,
|
|
1098
|
+
{ projectId: "acme", env: "production", credentials: "job" },
|
|
1099
|
+
"backup",
|
|
1100
|
+
);
|
|
1101
|
+
expect(job.ok).toBe(false);
|
|
1102
|
+
if (job.ok) return;
|
|
1103
|
+
expect(job.error).toContain("rejected this job's credentials");
|
|
1104
|
+
expect(job.error).not.toContain("gcloud auth login");
|
|
1105
|
+
});
|
|
977
1106
|
});
|
package/src/upgrade/upgrade.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
} from "./app-roll.js";
|
|
41
41
|
import { backupFailureHelp, restoreCommand, type SqlBackup, takeSqlBackup } from "./backup.js";
|
|
42
42
|
import { startConverge } from "./converge.js";
|
|
43
|
+
import type { GcloudCredentials } from "./gcp-lookup.js";
|
|
43
44
|
import type { HealthProbe } from "./health.js";
|
|
44
45
|
import type { UpgradeReporter } from "./reporter.js";
|
|
45
46
|
|
|
@@ -63,6 +64,8 @@ export interface GcpUpgradeTarget {
|
|
|
63
64
|
* release manifest (the converge job knows it; the terminal does not).
|
|
64
65
|
*/
|
|
65
66
|
readonly migrationsContract?: boolean | null;
|
|
67
|
+
/** Whose gcloud credentials run the sequence: the Owner's, or the converge job's (#630). */
|
|
68
|
+
readonly credentials: GcloudCredentials;
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
/**
|
|
@@ -91,8 +94,14 @@ export interface GcpUpgradeDeps {
|
|
|
91
94
|
readonly migrate: (
|
|
92
95
|
imageRef: string,
|
|
93
96
|
) => Promise<{ readonly ok: boolean; readonly error?: string }>;
|
|
97
|
+
/**
|
|
98
|
+
* The health probe for `plan`'s install. `accessJustChanged`: the
|
|
99
|
+
* converge before it changed access the probe relies on, so its baseline
|
|
100
|
+
* waits for that to take effect (health.ts, #734).
|
|
101
|
+
*/
|
|
94
102
|
readonly healthProbe: (
|
|
95
103
|
plan: AppRollPlan,
|
|
104
|
+
context: { readonly accessJustChanged: boolean },
|
|
96
105
|
) => Promise<
|
|
97
106
|
| { readonly ok: true; readonly probe: HealthProbe }
|
|
98
107
|
| { readonly ok: false; readonly error: string }
|
|
@@ -164,6 +173,7 @@ export async function upgradeGcpInstallOutcome(
|
|
|
164
173
|
env: target.env,
|
|
165
174
|
domain: target.domain,
|
|
166
175
|
stagingHost: target.stagingHost,
|
|
176
|
+
credentials: target.credentials,
|
|
167
177
|
},
|
|
168
178
|
images.images,
|
|
169
179
|
run,
|
|
@@ -198,7 +208,7 @@ export async function upgradeGcpInstallOutcome(
|
|
|
198
208
|
const sBak = r.step("Taking a pre-upgrade Cloud SQL backup...");
|
|
199
209
|
const taken = await takeSqlBackup(
|
|
200
210
|
run,
|
|
201
|
-
{ projectId, env: target.env },
|
|
211
|
+
{ projectId, env: target.env, credentials: target.credentials },
|
|
202
212
|
`pre-upgrade to ${target.targetTag}`,
|
|
203
213
|
deps.now,
|
|
204
214
|
);
|
|
@@ -229,7 +239,9 @@ export async function upgradeGcpInstallOutcome(
|
|
|
229
239
|
// 5. App-roll, with the converge steps that sit between its own.
|
|
230
240
|
let health: HealthProbe | null = null;
|
|
231
241
|
if (!target.skipHealthCheck) {
|
|
232
|
-
const probe = await deps.healthProbe(plan
|
|
242
|
+
const probe = await deps.healthProbe(plan, {
|
|
243
|
+
accessJustChanged: converge.accessJustChanged(),
|
|
244
|
+
});
|
|
233
245
|
if (!probe.ok) {
|
|
234
246
|
r.error(probe.error);
|
|
235
247
|
r.warn("No migrations ran and no traffic moved.");
|