@caelo-cms/provisioning 0.10.71 → 0.11.0

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.
@@ -0,0 +1,54 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import { adcProbeFromResult } from "./adc-check.js";
5
+ import type { GcloudResult } from "./gcloud.js";
6
+
7
+ const failed = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
8
+
9
+ describe("adcProbeFromResult (#1078)", () => {
10
+ it("a printed token is working ADC", () => {
11
+ expect(adcProbeFromResult({ ok: true, stdout: "ya29.x\n", stderr: "", exitCode: 0 })).toEqual({
12
+ ok: true,
13
+ });
14
+ });
15
+
16
+ it("no ADC file reads as missing and names the login", () => {
17
+ const r = adcProbeFromResult(
18
+ failed(
19
+ "ERROR: (gcloud.auth.application-default.print-access-token) Your default credentials were not found. To set up Application Default Credentials, see https://cloud.google.com/docs/authentication/external/set-up-adc for more information.",
20
+ ),
21
+ );
22
+ expect(r.ok).toBe(false);
23
+ if (r.ok) return;
24
+ expect(r.reason).toBe("missing");
25
+ expect(r.error).toContain("No Application Default Credentials");
26
+ expect(r.error).toContain("`gcloud auth application-default login`");
27
+ expect(r.error).not.toContain("\n");
28
+ });
29
+
30
+ for (const stderr of [
31
+ "ERROR: (gcloud.auth.application-default.print-access-token) There was a problem refreshing your current auth tokens: ('invalid_grant: reauth related error (invalid_rapt)', {'error': 'invalid_grant', 'error_subtype': 'invalid_rapt'})",
32
+ "ERROR: (gcloud.auth.application-default.print-access-token) Reauthentication failed. cannot prompt during non-interactive execution.",
33
+ "ERROR: (gcloud.auth.application-default.print-access-token) ('invalid_grant: Bad Request', {'error': 'invalid_grant', 'error_description': 'Bad Request'})",
34
+ ]) {
35
+ it(`reads as expired: ${stderr.slice(62, 110)}`, () => {
36
+ const r = adcProbeFromResult(failed(stderr));
37
+ expect(r.ok).toBe(false);
38
+ if (r.ok) return;
39
+ expect(r.reason).toBe("expired");
40
+ expect(r.error).toContain("have expired");
41
+ expect(r.error).toContain("`gcloud auth application-default login`");
42
+ expect(r.error).not.toContain("\n");
43
+ });
44
+ }
45
+
46
+ it("any other failure still names the login, with gcloud's line", () => {
47
+ const r = adcProbeFromResult(failed("ERROR: network unreachable"));
48
+ expect(r.ok).toBe(false);
49
+ if (r.ok) return;
50
+ expect(r.reason).toBe("error");
51
+ expect(r.error).toContain("network unreachable");
52
+ expect(r.error).toContain("gcloud auth application-default login");
53
+ });
54
+ });
@@ -0,0 +1,70 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Probe this machine's Application Default Credentials (#1078) — the
5
+ * credentials every local Pulumi run uses (the installer in local mode,
6
+ * `state migrate`). Without the probe, Pulumi starts with missing or
7
+ * expired ADC and prints one provider error per resource (`invalid_grant`
8
+ * … `invalid_rapt`) before failing, and the one line the Owner needs —
9
+ * `gcloud auth application-default login` — never appears.
10
+ *
11
+ * The probe runs `gcloud auth application-default print-access-token`,
12
+ * which refreshes the token exactly as Pulumi's Google provider would, so
13
+ * expired or reauth-blocked credentials fail here. The token is discarded.
14
+ */
15
+
16
+ import { type GcloudResult, gcloudErrorLine, isGcloudAuthError } from "./gcloud.js";
17
+
18
+ /** The command that fixes both a missing and an expired login. */
19
+ export const ADC_LOGIN_COMMAND = "gcloud auth application-default login";
20
+
21
+ export type AdcProbe =
22
+ | { readonly ok: true }
23
+ | {
24
+ readonly ok: false;
25
+ /**
26
+ * `missing`: no ADC on this machine; `expired`: they exist but can no
27
+ * longer mint a token (expired/revoked refresh token, reauth policy);
28
+ * `error`: gcloud failed for another reason (its line is in `error`).
29
+ */
30
+ readonly reason: "missing" | "expired" | "error";
31
+ /** One line naming {@link ADC_LOGIN_COMMAND}. */
32
+ readonly error: string;
33
+ };
34
+
35
+ /** gcloud's wording when no ADC file exists (and google-auth's, for older SDKs). */
36
+ const ADC_MISSING = /credentials were not found|could not automatically determine credentials/i;
37
+
38
+ /**
39
+ * Classify the result of `gcloud auth application-default print-access-token`.
40
+ * Pure, so the async installer path and the sync attach path word it alike.
41
+ */
42
+ export function adcProbeFromResult(r: GcloudResult): AdcProbe {
43
+ if (r.ok && r.stdout.trim().length > 0) return { ok: true };
44
+ if (ADC_MISSING.test(r.stderr) || (r.ok && r.stdout.trim().length === 0)) {
45
+ return {
46
+ ok: false,
47
+ reason: "missing",
48
+ error: `No Application Default Credentials on this machine; Pulumi needs them. Run \`${ADC_LOGIN_COMMAND}\` and re-run this command.`,
49
+ };
50
+ }
51
+ if (isGcloudAuthError(r.stderr)) {
52
+ return {
53
+ ok: false,
54
+ reason: "expired",
55
+ error: `The Application Default Credentials on this machine have expired (gcloud: ${gcloudErrorLine(r.stderr)}). Run \`${ADC_LOGIN_COMMAND}\` and re-run this command.`,
56
+ };
57
+ }
58
+ return {
59
+ ok: false,
60
+ reason: "error",
61
+ error: `Could not check the Application Default Credentials on this machine (gcloud: ${gcloudErrorLine(r.stderr)}). Run \`${ADC_LOGIN_COMMAND}\` if they are missing or expired, then re-run this command.`,
62
+ };
63
+ }
64
+
65
+ /** The `print-access-token` arguments the probe runs. */
66
+ export const ADC_PROBE_ARGS: readonly string[] = [
67
+ "auth",
68
+ "application-default",
69
+ "print-access-token",
70
+ ];
@@ -12,6 +12,7 @@
12
12
  * stay in local variables: never printed, logged, returned or written.
13
13
  */
14
14
 
15
+ import { ADC_PROBE_ARGS, adcProbeFromResult } from "./adc-check.js";
15
16
  import { gcloud as defaultGcloud } from "./gcloud.js";
16
17
  import { type GcloudRunner, realSleep, type Sleep } from "./gcloud-retry.js";
17
18
  import {
@@ -30,8 +31,12 @@ export type AdcCheck =
30
31
  | { readonly ok: true }
31
32
  | {
32
33
  readonly ok: false;
33
- /** `no-adc`: no Application Default Credentials; `denied`: they can't impersonate. */
34
- readonly reason: "no-adc" | "denied" | "error";
34
+ /**
35
+ * `no-adc`: no Application Default Credentials; `expired-adc`: they
36
+ * exist but can no longer mint a token (#1078); `denied`: they can't
37
+ * impersonate.
38
+ */
39
+ readonly reason: "no-adc" | "expired-adc" | "denied" | "error";
35
40
  readonly error: string;
36
41
  };
37
42
 
@@ -53,19 +58,17 @@ export async function checkAdcImpersonation(
53
58
  const sa = provisionerServiceAccountEmail(target.projectId);
54
59
  const env = withoutAmbientGoogleCredentials(deps.env ?? process.env);
55
60
 
56
- const adc = await run(["auth", "application-default", "print-access-token"], { env });
57
- const sourceToken = adc.ok ? adc.stdout.trim() : "";
58
- if (!sourceToken) {
61
+ const adc = await run([...ADC_PROBE_ARGS], { env });
62
+ const probe = adcProbeFromResult(adc);
63
+ if (!probe.ok) {
59
64
  return {
60
65
  ok: false,
61
- reason: "no-adc",
62
- error: [
63
- "No Application Default Credentials on this machine.",
64
- `Infrastructure runs authenticate with your own Google login and impersonate ${sa}; no key file is used.`,
65
- `Run \`gcloud auth application-default login\` (sign in as ${target.operatorAccount}), then re-run.`,
66
- ].join("\n"),
66
+ reason: probe.reason === "expired" ? "expired-adc" : "no-adc",
67
+ // One line (#1078): the login command must not drown in context.
68
+ error: `${probe.error} Sign in as ${target.operatorAccount}: Pulumi uses these credentials to impersonate ${sa}, no key file.`,
67
69
  };
68
70
  }
71
+ const sourceToken = adc.stdout.trim();
69
72
 
70
73
  // The account to impersonate is the one IAM reports, not a string
71
74
  // assembled from install.json: what goes into the request is an email
@@ -369,6 +369,31 @@ describe("checkAdcImpersonation", () => {
369
369
  expect(env.GOOGLE_OAUTH_ACCESS_TOKEN).toBeUndefined();
370
370
  });
371
371
 
372
+ it("expired ADC stop before IAM or the Credentials API is asked (#1078)", async () => {
373
+ const g = fakeGcloud({
374
+ "auth application-default print-access-token": [
375
+ fail(
376
+ "ERROR: (gcloud.auth.application-default.print-access-token) There was a problem refreshing your current auth tokens: ('invalid_grant: reauth related error (invalid_rapt)', {'error': 'invalid_grant'})",
377
+ ),
378
+ ],
379
+ });
380
+ const f = fakeFetch([]);
381
+ const r = await checkAdcImpersonation(
382
+ { projectId: PROJECT, operatorAccount: OPERATOR, freshGrant: false },
383
+ { run: g.run, sleep: noSleep, fetch: f.impl },
384
+ );
385
+ expect(r.ok).toBe(false);
386
+ if (r.ok) return;
387
+ expect(r.reason).toBe("expired-adc");
388
+ expect(r.error).toContain("have expired");
389
+ expect(r.error).toContain("`gcloud auth application-default login`");
390
+ expect(r.error).not.toContain("\n");
391
+ expect(g.calls.map((c) => c.args.join(" "))).toEqual([
392
+ "auth application-default print-access-token",
393
+ ]);
394
+ expect(f.urls).toEqual([]);
395
+ });
396
+
372
397
  it("succeeds when ADC may mint a token for the provisioner, and keeps no token", async () => {
373
398
  const g = fakeGcloud({
374
399
  "auth application-default print-access-token": [ok(`${SOURCE}\n`)],
@@ -28,4 +28,16 @@ describe("adcNote (#816)", () => {
28
28
  expect(note).toContain("gcloud auth application-default login");
29
29
  expect(note).toContain("upgrade, status and backup don't need them");
30
30
  });
31
+
32
+ it("expired ADC say so instead of 'no ADC' (#1078)", () => {
33
+ const note = adcNote(() => ({
34
+ ok: false,
35
+ stdout: "",
36
+ stderr:
37
+ "ERROR: (gcloud.auth.application-default.print-access-token) Reauthentication failed. cannot prompt during non-interactive execution.",
38
+ exitCode: 1,
39
+ }));
40
+ expect(note).toContain("have expired");
41
+ expect(note).toContain("gcloud auth application-default login");
42
+ });
31
43
  });
@@ -14,6 +14,8 @@ import { homedir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import { confirm, isCancel, log, note } from "@clack/prompts";
16
16
  import { bold, red } from "kleur/colors";
17
+ import { ADC_LOGIN_COMMAND, ADC_PROBE_ARGS, adcProbeFromResult } from "./adc-check.js";
18
+ import { checkAdcImpersonation } from "./adc-impersonation.js";
17
19
  import { adoptionCandidates, existingCandidates } from "./adopt-existing.js";
18
20
  import { checkGcloudAuth, gcloud } from "./gcloud.js";
19
21
  import { GCP_STACK_ENV } from "./gcp-names.js";
@@ -209,6 +211,8 @@ export async function stateCommand(argv: readonly string[]): Promise<void> {
209
211
  }),
210
212
  }),
211
213
  report: clackReporter(),
214
+ checkAdc: () =>
215
+ checkAdcImpersonation({ projectId, operatorAccount: operator, freshGrant: false }),
212
216
  resolve: (record) =>
213
217
  resolveBackend(
214
218
  { installId, stateBackend: record },
@@ -334,9 +338,13 @@ export function attachProject(projectId: string): string {
334
338
  * do not, so a note on every attach sent Owners on a detour (#816).
335
339
  */
336
340
  export function adcNote(run: SyncGcloud): string | null {
337
- const r = run(["auth", "application-default", "print-access-token"]);
338
- if (r.ok) return null;
339
- return "No Application Default Credentials on this machine. upgrade, status and backup don't need them; re-running the installer or `state migrate` does: gcloud auth application-default login";
341
+ const probe = adcProbeFromResult(run([...ADC_PROBE_ARGS]));
342
+ if (probe.ok) return null;
343
+ const state =
344
+ probe.reason === "expired"
345
+ ? "The Application Default Credentials on this machine have expired."
346
+ : "No Application Default Credentials on this machine.";
347
+ return `${state} upgrade, status and backup don't need them; re-running the installer or \`state migrate\` does: ${ADC_LOGIN_COMMAND}`;
340
348
  }
341
349
 
342
350
  /**
@@ -156,6 +156,9 @@ function machine(initial: StateBackendRecord = { kind: "local" }) {
156
156
  planFiles: [] as { label: string; steps: number }[],
157
157
  /** What existingInProject answers (#1079); an Error is thrown. */
158
158
  existing: [] as readonly string[] | Error,
159
+ /** What checkAdc answers (#1078). */
160
+ adc: { ok: true } as { ok: true } | { ok: false; error: string },
161
+ adcChecks: 0,
159
162
  };
160
163
  const meta = (): MigrateInstall => ({
161
164
  installId: "gcp-firebase-acme",
@@ -169,6 +172,10 @@ function machine(initial: StateBackendRecord = { kind: "local" }) {
169
172
  const deps: MigrateDeps = {
170
173
  pulumi: p.ops,
171
174
  report,
175
+ checkAdc: async () => {
176
+ m.adcChecks++;
177
+ return m.adc;
178
+ },
172
179
  resolve: (record) =>
173
180
  resolveBackend(
174
181
  { installId: "gcp-firebase-acme", stateBackend: record },
@@ -566,3 +573,49 @@ describe("helpers", () => {
566
573
  expect(localStateNotice({ installId: "x", provider: "self-hosted" })).toBeNull();
567
574
  });
568
575
  });
576
+
577
+ describe("migrateState — Application Default Credentials (#1078)", () => {
578
+ /** A Pulumi workspace that fails the test on any call. */
579
+ const untouchable = (): StatePulumiOps =>
580
+ new Proxy({} as StatePulumiOps, {
581
+ get: (_t, name) => () => {
582
+ throw new Error(`Pulumi ${String(name)} must not run without working ADC`);
583
+ },
584
+ });
585
+
586
+ for (const [what, error] of [
587
+ [
588
+ "missing",
589
+ "No Application Default Credentials on this machine; Pulumi needs them. Run `gcloud auth application-default login` and re-run this command.",
590
+ ],
591
+ [
592
+ "expired",
593
+ "The Application Default Credentials on this machine have expired (gcloud: invalid_grant: reauth related error (invalid_rapt)). Run `gcloud auth application-default login` and re-run this command.",
594
+ ],
595
+ ] as const) {
596
+ for (const dryRun of [false, true]) {
597
+ it(`${what} ADC stop the run before any Pulumi call${dryRun ? " (dry run)" : ""}`, async () => {
598
+ const t = machine();
599
+ t.m.adc = { ok: false, error };
600
+ const r = await migrateState(
601
+ t.meta(),
602
+ { to: "remote", dryRun },
603
+ { ...t.deps, pulumi: untouchable() },
604
+ );
605
+ expect(r).toEqual({ ok: false, error: `${error} Nothing was changed.` });
606
+ expect(t.m.adcChecks).toBe(1);
607
+ expect(t.m.backend).toEqual({ kind: "local" });
608
+ expect(t.m.backups).toBe(0);
609
+ expect(t.m.ensureRemoteCalls).toEqual([]);
610
+ expect(t.m.locked).toBe(false);
611
+ });
612
+ }
613
+ }
614
+
615
+ it("working ADC let the migration run", async () => {
616
+ const t = machine();
617
+ const r = await migrateState(t.meta(), { to: "remote", dryRun: false }, t.deps);
618
+ expect(r).toEqual({ ok: true, changed: true });
619
+ expect(t.m.adcChecks).toBe(1);
620
+ });
621
+ });
@@ -82,6 +82,13 @@ export interface StatePulumiOps {
82
82
  export interface MigrateDeps {
83
83
  readonly pulumi: StatePulumiOps;
84
84
  readonly report: UpgradeReporter;
85
+ /**
86
+ * #1078 — whether this machine's Application Default Credentials (which
87
+ * every Pulumi call here uses) work now. Checked before the first Pulumi
88
+ * call, so missing or expired ones stop the run with the login command
89
+ * instead of a page of per-resource provider errors.
90
+ */
91
+ checkAdc(): Promise<{ readonly ok: true } | { readonly ok: false; readonly error: string }>;
85
92
  /** The install's backend resolved for Pulumi. */
86
93
  resolve(record: StateBackendRecord): ResolvedBackend;
87
94
  /** Ask the operator to accept the ops backend's cost; false = cancel. */
@@ -241,6 +248,10 @@ export async function migrateState(
241
248
  };
242
249
  }
243
250
  }
251
+ const adc = await deps.checkAdc();
252
+ if (!adc.ok) {
253
+ return { ok: false, error: `${adc.error} Nothing was changed.` };
254
+ }
244
255
  const pre = report.step("Preflight: Pulumi CLI, source stack, pending operations, drift");
245
256
  let from: ResolvedBackend;
246
257
  let mode: "stack-migrate" | "export-import";
@@ -51,6 +51,7 @@ import type { DnsRecord } from "../dns/types.js";
51
51
  import {
52
52
  activeAccount,
53
53
  type BillingAccount,
54
+ checkGcloudAuth,
54
55
  createProject,
55
56
  createServiceAccount,
56
57
  enableApis,
@@ -639,19 +640,20 @@ async function resolveImageDigests(): Promise<ImageDigests> {
639
640
  // Per-step helpers
640
641
  // =========================================================================
641
642
 
643
+ /**
644
+ * An active gcloud account that can mint a token now. `activeAccount` alone
645
+ * passes with an expired login (`auth list` reads local config), and the
646
+ * first real gcloud call of the run then failed with a reauth error deep in
647
+ * a later step (#630, #1078).
648
+ */
642
649
  async function stepActiveGcloud(): Promise<void> {
643
- const stepName = "gcloud-active";
644
- const account = await activeAccount();
645
- if (!account) {
646
- log.error(red("No active gcloud account."));
647
- log.warn(
648
- `Run ${bold("gcloud auth login")} in another terminal, then re-run ${bold(selfCommand())}.`,
649
- );
650
- cancel("Aborted — no gcloud auth.");
650
+ const auth = await checkGcloudAuth();
651
+ if (!auth.ok) {
652
+ log.error(red(auth.error));
653
+ cancel("Aborted — nothing was changed.");
651
654
  process.exit(2);
652
655
  }
653
- log.success(`gcloud auth: ${bold(account)}`);
654
- void stepName;
656
+ log.success(`gcloud auth: ${bold(auth.account)}`);
655
657
  }
656
658
 
657
659
  async function stepProjectId(opts: GcpWizardOpts): Promise<string> {
@@ -1385,8 +1387,18 @@ async function stepKeylessProvisioner(
1385
1387
  const sAdc = spinner();
1386
1388
  sAdc.start("Checking that your Application Default Credentials can impersonate it...");
1387
1389
  let adc = await check();
1388
- if (!adc.ok && adc.reason === "no-adc" && !opts.nonInteractive) {
1389
- sAdc.stop(yellow("No Application Default Credentials yet"));
1390
+ if (
1391
+ !adc.ok &&
1392
+ (adc.reason === "no-adc" || adc.reason === "expired-adc") &&
1393
+ !opts.nonInteractive
1394
+ ) {
1395
+ sAdc.stop(
1396
+ yellow(
1397
+ adc.reason === "expired-adc"
1398
+ ? "Your Application Default Credentials have expired"
1399
+ : "No Application Default Credentials yet",
1400
+ ),
1401
+ );
1390
1402
  const login = await confirm({
1391
1403
  message: `Run ${bold("gcloud auth application-default login")} now (opens your browser; sign in as ${account})?`,
1392
1404
  initialValue: true,
@@ -1403,7 +1415,7 @@ async function stepKeylessProvisioner(
1403
1415
  }
1404
1416
  if (!adc.ok) {
1405
1417
  sAdc.stop(red("Infrastructure credentials not ready"));
1406
- log.error(adc.error);
1418
+ log.error(`${adc.error}\nPulumi was not started.`);
1407
1419
  cancel("Aborted.");
1408
1420
  process.exit(1);
1409
1421
  }