@caelo-cms/provisioning 0.10.68 → 0.10.70
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/admin-domain.d.ts +28 -0
- package/dist/admin-domain.d.ts.map +1 -1
- package/dist/admin-domain.js +65 -0
- package/dist/admin-domain.js.map +1 -1
- package/dist/adopt-existing.d.ts +119 -0
- package/dist/adopt-existing.d.ts.map +1 -0
- package/dist/adopt-existing.js +118 -0
- package/dist/adopt-existing.js.map +1 -0
- package/dist/cli.js +18 -0
- package/dist/cli.js.map +1 -1
- package/dist/default-firewall.d.ts +1 -1
- package/dist/default-firewall.d.ts.map +1 -1
- package/dist/default-firewall.js +2 -1
- package/dist/default-firewall.js.map +1 -1
- package/dist/gcloud-retry.d.ts +21 -0
- package/dist/gcloud-retry.d.ts.map +1 -1
- package/dist/gcloud-retry.js +34 -0
- package/dist/gcloud-retry.js.map +1 -1
- package/dist/ops-backend.d.ts.map +1 -1
- package/dist/ops-backend.js +13 -3
- package/dist/ops-backend.js.map +1 -1
- package/dist/preview-plan.d.ts +7 -0
- package/dist/preview-plan.d.ts.map +1 -1
- package/dist/preview-plan.js +1 -0
- package/dist/preview-plan.js.map +1 -1
- package/dist/stack-contract.d.ts +9 -2
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +4 -2
- package/dist/stack-contract.js.map +1 -1
- package/dist/stack-converge.d.ts +14 -0
- package/dist/stack-converge.d.ts.map +1 -1
- package/dist/stack-converge.js +83 -8
- package/dist/stack-converge.js.map +1 -1
- package/dist/state-command.d.ts.map +1 -1
- package/dist/state-command.js +3 -1
- package/dist/state-command.js.map +1 -1
- package/dist/state-migrate.d.ts +7 -0
- package/dist/state-migrate.d.ts.map +1 -1
- package/dist/state-migrate.js +18 -1
- package/dist/state-migrate.js.map +1 -1
- package/dist/upgrade/app-roll.d.ts +9 -1
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +14 -2
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +1 -1
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-job.d.ts.map +1 -1
- package/dist/upgrader-job.js +4 -1
- package/dist/upgrader-job.js.map +1 -1
- package/dist/wizards/gcp-pulumi.d.ts +19 -4
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +62 -7
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +14 -5
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/fixtures/viu-one-plan-state-migrate.txt +98 -0
- package/src/admin-domain.test.ts +87 -0
- package/src/admin-domain.ts +71 -0
- package/src/adopt-existing.test.ts +397 -0
- package/src/adopt-existing.ts +228 -0
- package/src/cli.ts +22 -0
- package/src/default-firewall.ts +5 -3
- package/src/gcloud-retry.ts +37 -0
- package/src/ops-backend.test.ts +56 -0
- package/src/ops-backend.ts +21 -3
- package/src/preview-plan.ts +8 -0
- package/src/remote-mcp.test.ts +2 -0
- package/src/stack-contract.ts +10 -2
- package/src/stack-converge-secrets.test.ts +137 -1
- package/src/stack-converge.ts +88 -7
- package/src/state-command.ts +6 -1
- package/src/state-migrate.test.ts +38 -0
- package/src/state-migrate.ts +24 -1
- package/src/upgrade/app-roll.ts +29 -0
- package/src/upgrade/upgrade.test.ts +1 -1
- package/src/upgrade/upgrade.ts +1 -0
- package/src/upgrader-job.ts +3 -0
- package/src/wizards/gcp-pulumi.ts +84 -8
- package/src/wizards/gcp.ts +11 -4
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Adopt cloud resources the stack declares but its Pulumi state lacks
|
|
5
|
+
* (#1079), before the installer's `pulumi up`.
|
|
6
|
+
*
|
|
7
|
+
* `cms-provision upgrade` converges an install through gcloud and never runs
|
|
8
|
+
* Pulumi (stack-converge.ts), so it creates some resources the stack also
|
|
9
|
+
* declares — the gateway's service account (runtime-secrets.ts), the MCP
|
|
10
|
+
* service account (mcp-iap.ts) — without them entering the state. The next
|
|
11
|
+
* installer run then plans to CREATE them, and `state migrate` refuses an
|
|
12
|
+
* install whose preview plans anything. So, after the preview and before
|
|
13
|
+
* the review:
|
|
14
|
+
*
|
|
15
|
+
* 1. {@link adoptionCandidates}: the planned creates of a kind in
|
|
16
|
+
* {@link ADOPTABLE_KINDS} whose inputs name the cloud resource. Only
|
|
17
|
+
* planned steps are looked at, so nothing the stack does not declare is
|
|
18
|
+
* ever adopted; every other kind is left to Pulumi.
|
|
19
|
+
* 2. {@link existingCandidates}: a gcloud describe per candidate. Exists →
|
|
20
|
+
* adopt; NOT_FOUND → a plain create; anything else stops the run (we
|
|
21
|
+
* can't tell, and guessing either way is wrong).
|
|
22
|
+
* 3. `pulumi import` of the existing ones into the stack, under the
|
|
23
|
+
* stack's explicit GCP provider (wizards/gcp-pulumi.ts). State only: the
|
|
24
|
+
* cloud resources are not touched.
|
|
25
|
+
* 4. A second preview: what the review shows and `up` applies. An adopted
|
|
26
|
+
* resource that is still planned as a create stops the run.
|
|
27
|
+
*
|
|
28
|
+
* Why only service accounts ({@link ADOPTABLE_KINDS}). A kind is adopted
|
|
29
|
+
* when the stack declares it AND the CLI also creates it through gcloud, and
|
|
30
|
+
* its name is unique in the project. Checked per kind against both stacks:
|
|
31
|
+
*
|
|
32
|
+
* - Secret Manager secrets: the stack declares only `postgres-password` and
|
|
33
|
+
* `secret-kek`, which the CLI never creates; the secrets the CLI makes
|
|
34
|
+
* (stack-contract.ts CLI_GENERATED_SECRETS) the stack only references by
|
|
35
|
+
* id. Adopting a secret without its value resource would also make `up`
|
|
36
|
+
* add a version with a freshly generated value — a new KEK.
|
|
37
|
+
* - Buckets, Cloud Run jobs, custom roles: the stack's buckets are made by
|
|
38
|
+
* Pulumi alone; the CLI's own (ops, audit results, the upgrader, converge,
|
|
39
|
+
* audit and operator-access jobs, their custom roles) are not declared.
|
|
40
|
+
* - The admin's Cloud Run domain mapping: owned by `admin-domain enable`
|
|
41
|
+
* (admin-domain.ts), the stack's copy stays off; Pulumi, running as the
|
|
42
|
+
* provisioner, could not manage it.
|
|
43
|
+
* - IAM member resources (`*IamMember`, `IAMMember`): non-authoritative.
|
|
44
|
+
* Their create is a read-modify-write that merges the member into the
|
|
45
|
+
* policy and succeeds when it is there already, so a planned create of
|
|
46
|
+
* one is an adoption already.
|
|
47
|
+
* - Cloud SQL users: the CLI's are IAM users named after service accounts,
|
|
48
|
+
* the stack's are the built-in `admin_role` / `public_role`.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import type { GcloudRunner } from "./gcloud-retry.js";
|
|
52
|
+
import type { PlannedStep } from "./preview-plan.js";
|
|
53
|
+
|
|
54
|
+
/** The cloud identity of a resource a planned create would make. */
|
|
55
|
+
export interface AdoptionTarget {
|
|
56
|
+
/** How the operator knows it, e.g. the service account's email. */
|
|
57
|
+
readonly display: string;
|
|
58
|
+
/** gcloud args that succeed exactly when it exists. */
|
|
59
|
+
readonly describeArgs: readonly string[];
|
|
60
|
+
/** The id `pulumi import` takes for the kind. */
|
|
61
|
+
readonly importId: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A resource kind the installer adopts when it exists already. */
|
|
65
|
+
export interface AdoptableKind {
|
|
66
|
+
/** What it is, for messages (`service account`). */
|
|
67
|
+
readonly label: string;
|
|
68
|
+
/**
|
|
69
|
+
* The resource a create with these inputs would make, or null when the
|
|
70
|
+
* inputs don't pin it down (then the create is left to Pulumi).
|
|
71
|
+
*/
|
|
72
|
+
target(inputs: Readonly<Record<string, unknown>>, projectId: string): AdoptionTarget | null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Kinds adopted before `up`, by Pulumi type token. See the file comment for the rest. */
|
|
76
|
+
export const ADOPTABLE_KINDS: Readonly<Record<string, AdoptableKind>> = {
|
|
77
|
+
"gcp:serviceaccount/account:Account": {
|
|
78
|
+
label: "service account",
|
|
79
|
+
target(inputs, projectId) {
|
|
80
|
+
// GCP's account-id format; also keeps out the engine's placeholder
|
|
81
|
+
// for a value only known after apply.
|
|
82
|
+
const accountId = inputs.accountId;
|
|
83
|
+
if (typeof accountId !== "string" || !/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(accountId)) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
// Unset means the provider's project, which is the install's
|
|
87
|
+
// (assertStackProject). Another project is never adopted.
|
|
88
|
+
const project = inputs.project ?? projectId;
|
|
89
|
+
if (project !== projectId) return null;
|
|
90
|
+
const email = `${accountId}@${projectId}.iam.gserviceaccount.com`;
|
|
91
|
+
return {
|
|
92
|
+
display: email,
|
|
93
|
+
describeArgs: [
|
|
94
|
+
"iam",
|
|
95
|
+
"service-accounts",
|
|
96
|
+
"describe",
|
|
97
|
+
email,
|
|
98
|
+
`--project=${projectId}`,
|
|
99
|
+
"--format=value(email)",
|
|
100
|
+
],
|
|
101
|
+
importId: `projects/${projectId}/serviceAccounts/${email}`,
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** A planned create the installer may adopt instead. */
|
|
108
|
+
export interface AdoptionCandidate extends AdoptionTarget {
|
|
109
|
+
readonly step: PlannedStep;
|
|
110
|
+
readonly label: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Thrown when adoption cannot finish; nothing was applied. */
|
|
114
|
+
export class AdoptionError extends Error {
|
|
115
|
+
override readonly name = "AdoptionError";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The planned creates of an adoptable kind, with what each would create. */
|
|
119
|
+
export function adoptionCandidates(
|
|
120
|
+
steps: readonly PlannedStep[],
|
|
121
|
+
projectId: string,
|
|
122
|
+
): AdoptionCandidate[] {
|
|
123
|
+
const out: AdoptionCandidate[] = [];
|
|
124
|
+
for (const step of steps) {
|
|
125
|
+
if (step.op !== "create") continue;
|
|
126
|
+
const kind = ADOPTABLE_KINDS[step.type];
|
|
127
|
+
if (!kind || !step.inputs) continue;
|
|
128
|
+
const target = kind.target(step.inputs, projectId);
|
|
129
|
+
if (target) out.push({ ...target, step, label: kind.label });
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const NOT_FOUND = /NOT_FOUND|not found|does not exist/i;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The candidates that exist in the project.
|
|
138
|
+
*
|
|
139
|
+
* @throws AdoptionError when a describe fails for another reason than NOT_FOUND
|
|
140
|
+
*/
|
|
141
|
+
export async function existingCandidates(
|
|
142
|
+
candidates: readonly AdoptionCandidate[],
|
|
143
|
+
run: GcloudRunner,
|
|
144
|
+
): Promise<AdoptionCandidate[]> {
|
|
145
|
+
const existing: AdoptionCandidate[] = [];
|
|
146
|
+
for (const c of candidates) {
|
|
147
|
+
const r = await run([...c.describeArgs]);
|
|
148
|
+
if (r.ok) existing.push(c);
|
|
149
|
+
else if (!NOT_FOUND.test(r.stderr)) {
|
|
150
|
+
throw new AdoptionError(
|
|
151
|
+
`could not tell whether the ${c.label} ${c.display} exists (gcloud: ${r.stderr.trim()}). Nothing was imported or applied. Check that gcloud is signed in as the Owner (gcloud auth login) and re-run the installer.`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return existing;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** What adoption needs from Pulumi and gcloud; fakes in tests. */
|
|
159
|
+
export interface AdoptionDeps {
|
|
160
|
+
readonly projectId: string;
|
|
161
|
+
readonly gcloud: GcloudRunner;
|
|
162
|
+
/** A fresh preview of the stack. */
|
|
163
|
+
preview(): Promise<readonly PlannedStep[]>;
|
|
164
|
+
/** `pulumi import` the candidates into the stack (state only). */
|
|
165
|
+
importResources(candidates: readonly AdoptionCandidate[]): Promise<void>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The plan to review after adoption, and what was adopted into it. */
|
|
169
|
+
export interface AdoptionResult {
|
|
170
|
+
readonly adopted: readonly AdoptionCandidate[];
|
|
171
|
+
readonly steps: readonly PlannedStep[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The line of a failed Pulumi call that says what went wrong. The
|
|
176
|
+
* Automation API's error starts with `code: 255` and carries the CLI's
|
|
177
|
+
* stderr below; its first `error:` line is the reason.
|
|
178
|
+
*/
|
|
179
|
+
export function pulumiErrorReason(e: unknown): string {
|
|
180
|
+
const lines = (e instanceof Error ? e.message : String(e))
|
|
181
|
+
.split("\n")
|
|
182
|
+
.map((l) => l.trim().replace(/^stderr:\s*/, ""))
|
|
183
|
+
.filter((l) => l !== "");
|
|
184
|
+
return lines.find((l) => /^error:/i.test(l)) ?? lines[0] ?? "no output";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Adopt the planned creates of `steps` that exist in the project already,
|
|
189
|
+
* and return the plan after it. With nothing to adopt, `steps` comes back
|
|
190
|
+
* unchanged and nothing else runs.
|
|
191
|
+
*
|
|
192
|
+
* @throws AdoptionError when a probe or the import fails, or a re-plan still
|
|
193
|
+
* creates an adopted resource; `up` must not run then
|
|
194
|
+
*/
|
|
195
|
+
export async function adoptExistingResources(
|
|
196
|
+
steps: readonly PlannedStep[],
|
|
197
|
+
deps: AdoptionDeps,
|
|
198
|
+
): Promise<AdoptionResult> {
|
|
199
|
+
const existing = await existingCandidates(adoptionCandidates(steps, deps.projectId), deps.gcloud);
|
|
200
|
+
if (existing.length === 0) return { adopted: [], steps };
|
|
201
|
+
const names = existing.map((c) => `${c.label} ${c.display}`).join(", ");
|
|
202
|
+
try {
|
|
203
|
+
await deps.importResources(existing);
|
|
204
|
+
} catch (e) {
|
|
205
|
+
throw new AdoptionError(
|
|
206
|
+
`could not adopt ${names} (already in the project) into the stack: pulumi import says "${pulumiErrorReason(e)}". Nothing was applied. Re-run the installer: what was adopted stays adopted. If it fails the same way, report it with this message.`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
const after = await deps.preview();
|
|
210
|
+
const stillCreated = after.filter(
|
|
211
|
+
(s) => s.op === "create" && existing.some((c) => c.step.urn === s.urn),
|
|
212
|
+
);
|
|
213
|
+
if (stillCreated.length > 0) {
|
|
214
|
+
throw new AdoptionError(
|
|
215
|
+
`imported ${names}, but the next preview still creates ${stillCreated.map((s) => s.name).join(", ")}. Nothing was applied. Report this with the message; re-running the installer does not fix it.`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
return { adopted: existing, steps: after };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The adopted resources as lines for the review, or none. */
|
|
222
|
+
export function formatAdopted(adopted: readonly AdoptionCandidate[]): string[] {
|
|
223
|
+
if (adopted.length === 0) return [];
|
|
224
|
+
return [
|
|
225
|
+
`Adopted (${adopted.length}) — already in the project, now in the stack instead of created again:`,
|
|
226
|
+
...adopted.map((c) => ` ${c.label} ${c.display} (${c.step.name})`),
|
|
227
|
+
];
|
|
228
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -1500,6 +1500,28 @@ async function domainMappingCommand(kind: "admin" | "staging"): Promise<void> {
|
|
|
1500
1500
|
// Prints the staging basic-auth password ON PURPOSE (#678): the operator
|
|
1501
1501
|
// asked for it to be shown once; it is never written to disk or logs.
|
|
1502
1502
|
console.log(describeStagingApply(applied.value).join("\n"));
|
|
1503
|
+
// #1058 — and the admin's CAELO_STAGING_HOST, so the provisioned value
|
|
1504
|
+
// agrees with install.json and the stored host (the converge job
|
|
1505
|
+
// refuses an install whose records and admin disagree). After the
|
|
1506
|
+
// provision call: the update starts a new admin revision.
|
|
1507
|
+
const { setAdminStagingHostEnv } = await import("./admin-domain.js");
|
|
1508
|
+
const env = await setAdminStagingHostEnv({
|
|
1509
|
+
projectId: meta.projectId,
|
|
1510
|
+
region: meta.region,
|
|
1511
|
+
stagingHost: r.hostname,
|
|
1512
|
+
});
|
|
1513
|
+
if (env.status === "failed") {
|
|
1514
|
+
console.error(
|
|
1515
|
+
`${command}: ${r.hostname} is mapped and stored in the admin, but its CAELO_STAGING_HOST could not be updated: ${env.error}\n` +
|
|
1516
|
+
`Run \`${retry}\` again once that is fixed (it is idempotent).`,
|
|
1517
|
+
);
|
|
1518
|
+
process.exit(5);
|
|
1519
|
+
}
|
|
1520
|
+
if (env.status === "set") {
|
|
1521
|
+
console.log(
|
|
1522
|
+
`The admin (${env.service}) now runs with CAELO_STAGING_HOST=${r.hostname}${env.previous ? ` (was ${env.previous})` : ""}.`,
|
|
1523
|
+
);
|
|
1524
|
+
}
|
|
1503
1525
|
}
|
|
1504
1526
|
if (r.records.length === 0) {
|
|
1505
1527
|
console.log(
|
package/src/default-firewall.ts
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import { gcloud as defaultGcloud } from "./gcloud.js";
|
|
33
|
-
import type
|
|
33
|
+
import { type GcloudRunner, SERVICE_NOT_ENABLED } from "./gcloud-retry.js";
|
|
34
34
|
import {
|
|
35
35
|
ABSENT_DEFAULT_FIREWALL_RULES,
|
|
36
36
|
type AbsentDefaultFirewallRule,
|
|
@@ -65,8 +65,10 @@ interface LiveInstance {
|
|
|
65
65
|
* gcloud's error when Compute Engine is not enabled on the project. Without
|
|
66
66
|
* Compute Engine there is no `default` network, so there is nothing to remove.
|
|
67
67
|
*/
|
|
68
|
-
const COMPUTE_DISABLED =
|
|
69
|
-
|
|
68
|
+
const COMPUTE_DISABLED = new RegExp(
|
|
69
|
+
`${SERVICE_NOT_ENABLED.source}|API \\[compute\\.googleapis\\.com\\] not enabled|compute\\.googleapis\\.com.*(?:disabled|not enabled)`,
|
|
70
|
+
"i",
|
|
71
|
+
);
|
|
70
72
|
|
|
71
73
|
/** gcloud's stderr when an aggregated list succeeded for only some zones. */
|
|
72
74
|
const PARTIAL_LIST = /did not succeed|unreachable|did not respond|could not fetch/i;
|
package/src/gcloud-retry.ts
CHANGED
|
@@ -39,3 +39,40 @@ export async function runWithRetry(
|
|
|
39
39
|
}
|
|
40
40
|
return r;
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* gcloud's wording for a call against an API that is not enabled on the
|
|
45
|
+
* project — also for the few minutes after `services enable` returned,
|
|
46
|
+
* until the enablement has propagated ("Cloud KMS API has not been used in
|
|
47
|
+
* project N before or it is disabled … (reason: SERVICE_DISABLED)", #1060).
|
|
48
|
+
* Shared with default-firewall.ts, which reads it as "Compute Engine is off".
|
|
49
|
+
*/
|
|
50
|
+
export const SERVICE_NOT_ENABLED =
|
|
51
|
+
/SERVICE_DISABLED|accessNotConfigured|has not been used in project|it is disabled/i;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Backoff while a just-enabled API propagates: ~3.5 minutes in total, the
|
|
55
|
+
* window Google documents for enablement to reach every endpoint.
|
|
56
|
+
*/
|
|
57
|
+
export const SERVICE_ENABLE_DELAYS_MS: readonly number[] = [
|
|
58
|
+
5_000, 10_000, 20_000, 30_000, 30_000, 30_000, 30_000, 30_000, 30_000,
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Wrap `run` so a call that fails with {@link SERVICE_NOT_ENABLED} is
|
|
63
|
+
* retried with {@link SERVICE_ENABLE_DELAYS_MS} backoff. Every other result
|
|
64
|
+
* (success or a different failure) returns at once; when the backoff is
|
|
65
|
+
* exhausted the last failure is returned unchanged, so the caller can still
|
|
66
|
+
* tell the operator that the API never came up.
|
|
67
|
+
*/
|
|
68
|
+
export function retryWhileServiceEnabling(run: GcloudRunner, sleep: Sleep): GcloudRunner {
|
|
69
|
+
return async (args, opts) => {
|
|
70
|
+
let r = await run(args, opts);
|
|
71
|
+
for (const delay of SERVICE_ENABLE_DELAYS_MS) {
|
|
72
|
+
if (r.ok || !SERVICE_NOT_ENABLED.test(r.stderr)) break;
|
|
73
|
+
await sleep(delay);
|
|
74
|
+
r = await run(args, opts);
|
|
75
|
+
}
|
|
76
|
+
return r;
|
|
77
|
+
};
|
|
78
|
+
}
|
package/src/ops-backend.test.ts
CHANGED
|
@@ -301,6 +301,62 @@ describe("ensureOpsBackend — new install", () => {
|
|
|
301
301
|
});
|
|
302
302
|
});
|
|
303
303
|
|
|
304
|
+
describe("ensureOpsBackend — a just-enabled KMS API (#1060)", () => {
|
|
305
|
+
const DISABLED =
|
|
306
|
+
"ERROR: (gcloud.kms.keyrings.describe) PERMISSION_DENIED: Cloud Key Management Service (KMS) API has not been used in project 42 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/cloudkms.googleapis.com/overview?project=42 then retry. (reason: SERVICE_DISABLED)";
|
|
307
|
+
|
|
308
|
+
/** KMS calls answer SERVICE_DISABLED for the first `failures` calls after enabling. */
|
|
309
|
+
function propagating(failures: number) {
|
|
310
|
+
const g = fakeGcp();
|
|
311
|
+
let left = failures;
|
|
312
|
+
const sleeps: number[] = [];
|
|
313
|
+
const run = async (args: string[]) => {
|
|
314
|
+
if (args[0] === "kms" && left > 0) {
|
|
315
|
+
left--;
|
|
316
|
+
g.calls.push(args.join(" "));
|
|
317
|
+
return fail(DISABLED);
|
|
318
|
+
}
|
|
319
|
+
return g.run(args);
|
|
320
|
+
};
|
|
321
|
+
const sleep = async (ms: number) => {
|
|
322
|
+
sleeps.push(ms);
|
|
323
|
+
};
|
|
324
|
+
return { g, run, sleep, sleeps };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
it("waits for the enablement to propagate, then creates the key ring and key", async () => {
|
|
328
|
+
const p = propagating(2);
|
|
329
|
+
const r = await ensureOpsBackend(TARGET, { run: p.run, sleep: p.sleep });
|
|
330
|
+
expect(r.ok).toBe(true);
|
|
331
|
+
expect(p.g.state.ring && p.g.state.key).toBe(true);
|
|
332
|
+
expect(p.sleeps.length).toBe(2);
|
|
333
|
+
expect(p.g.calls.filter((c) => c.startsWith("kms keyrings describe")).length).toBe(3);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("fails loudly with the next step when the API never comes up", async () => {
|
|
337
|
+
const p = propagating(Number.POSITIVE_INFINITY);
|
|
338
|
+
const r = await ensureOpsBackend(TARGET, { run: p.run, sleep: p.sleep });
|
|
339
|
+
expect(r.ok).toBe(false);
|
|
340
|
+
if (r.ok) return;
|
|
341
|
+
expect(r.error).toContain("Reading key ring caelo failed");
|
|
342
|
+
expect(r.error).toContain("still reported as disabled");
|
|
343
|
+
expect(r.error).toContain("gcloud services list --enabled --project=acme");
|
|
344
|
+
expect(r.error).toContain("re-run the same command");
|
|
345
|
+
// Waited a few minutes in total, then stopped — no endless loop.
|
|
346
|
+
const waited = p.sleeps.reduce((a, b) => a + b, 0);
|
|
347
|
+
expect(waited).toBeGreaterThanOrEqual(3 * 60_000);
|
|
348
|
+
expect(waited).toBeLessThanOrEqual(5 * 60_000);
|
|
349
|
+
expect(p.g.state.ring).toBe(false);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("does not wait in a dry run", async () => {
|
|
353
|
+
const p = propagating(Number.POSITIVE_INFINITY);
|
|
354
|
+
const r = await ensureOpsBackend(TARGET, { run: p.run, sleep: p.sleep, dryRun: true });
|
|
355
|
+
expect(r.ok).toBe(true);
|
|
356
|
+
expect(p.sleeps).toEqual([]);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
304
360
|
describe("cost table (CLAUDE.md §11.C)", () => {
|
|
305
361
|
it("lists the state bucket + KMS key at under $0.10/mo without float noise in the total", () => {
|
|
306
362
|
const est = estimateGcpCost({
|
package/src/ops-backend.ts
CHANGED
|
@@ -31,7 +31,15 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
31
31
|
import { tmpdir } from "node:os";
|
|
32
32
|
import { join } from "node:path";
|
|
33
33
|
import { gcloud as defaultGcloud } from "./gcloud.js";
|
|
34
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
type GcloudRunner,
|
|
36
|
+
realSleep,
|
|
37
|
+
retryWhileServiceEnabling,
|
|
38
|
+
runWithRetry,
|
|
39
|
+
SERVICE_ENABLE_DELAYS_MS,
|
|
40
|
+
SERVICE_NOT_ENABLED,
|
|
41
|
+
type Sleep,
|
|
42
|
+
} from "./gcloud-retry.js";
|
|
35
43
|
import { operatorMember } from "./provisioner-identity.js";
|
|
36
44
|
import {
|
|
37
45
|
gcsBackendRecord,
|
|
@@ -144,9 +152,16 @@ export async function ensureOpsBackend(
|
|
|
144
152
|
target: OpsBackendTarget,
|
|
145
153
|
deps: { run?: GcloudRunner; sleep?: Sleep; dryRun?: boolean } = {},
|
|
146
154
|
): Promise<OpsBackendResult> {
|
|
147
|
-
const run = deps.run ?? defaultGcloud;
|
|
148
155
|
const sleep = deps.sleep ?? realSleep;
|
|
149
156
|
const dryRun = deps.dryRun ?? false;
|
|
157
|
+
// `services enable` returns before the enablement has propagated: for a
|
|
158
|
+
// few minutes the first KMS (or storage) call still answers
|
|
159
|
+
// SERVICE_DISABLED (#1060). Every call waits that out — on a re-run whose
|
|
160
|
+
// APIs are long on it costs nothing. A dry run only reads and treats a
|
|
161
|
+
// disabled API as "not there yet", so it does not wait.
|
|
162
|
+
const run = dryRun
|
|
163
|
+
? (deps.run ?? defaultGcloud)
|
|
164
|
+
: retryWhileServiceEnabling(deps.run ?? defaultGcloud, sleep);
|
|
150
165
|
const { projectId, region } = target;
|
|
151
166
|
const project = `--project=${projectId}`;
|
|
152
167
|
const bucket = opsBucketName(projectId);
|
|
@@ -157,9 +172,12 @@ export async function ensureOpsBackend(
|
|
|
157
172
|
];
|
|
158
173
|
const actions: string[] = [];
|
|
159
174
|
const act = (what: string) => actions.push(dryRun ? `would ${what}` : what);
|
|
175
|
+
const waitedMinutes = Math.round(SERVICE_ENABLE_DELAYS_MS.reduce((a, b) => a + b, 0) / 60_000);
|
|
160
176
|
const fail = (what: string, r: { stderr: string; exitCode: number }): OpsBackendResult => ({
|
|
161
177
|
ok: false,
|
|
162
|
-
error:
|
|
178
|
+
error: SERVICE_NOT_ENABLED.test(r.stderr)
|
|
179
|
+
? `${what} failed: the API is still reported as disabled ${waitedMinutes} minutes after it was enabled (${reason(r.stderr, r.exitCode)}). Check that ${OPS_APIS.join(" and ")} show as enabled in project ${projectId} (gcloud services list --enabled --project=${projectId}), then re-run the same command — it picks up where it stopped.`
|
|
180
|
+
: `${what} failed: ${reason(r.stderr, r.exitCode)}`,
|
|
163
181
|
});
|
|
164
182
|
|
|
165
183
|
// --- APIs -------------------------------------------------------------
|
package/src/preview-plan.ts
CHANGED
|
@@ -31,6 +31,13 @@ export interface PlannedStep {
|
|
|
31
31
|
readonly diffs: readonly string[];
|
|
32
32
|
/** Properties that force the replace (replace steps only). */
|
|
33
33
|
readonly replaceReasons: readonly string[];
|
|
34
|
+
/**
|
|
35
|
+
* The inputs the program declares, for create steps only (#1079: they name
|
|
36
|
+
* the cloud resource a create would make, e.g. a service account's
|
|
37
|
+
* `accountId`, so adopt-existing.ts can tell whether it exists already).
|
|
38
|
+
* Secret values arrive masked by the engine. Never written to a plan file.
|
|
39
|
+
*/
|
|
40
|
+
readonly inputs?: Readonly<Record<string, unknown>>;
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
/**
|
|
@@ -116,6 +123,7 @@ export function collectPreviewSteps(): PreviewCollector {
|
|
|
116
123
|
urn: m.urn,
|
|
117
124
|
diffs: [...(m.diffs ?? [])],
|
|
118
125
|
replaceReasons: op === "replace" ? [...(m.keys ?? [])] : [],
|
|
126
|
+
...(op === "create" && m.new?.inputs ? { inputs: { ...m.new.inputs } } : {}),
|
|
119
127
|
});
|
|
120
128
|
},
|
|
121
129
|
steps: () => [...byUrn.values()],
|
package/src/remote-mcp.test.ts
CHANGED
|
@@ -284,6 +284,8 @@ describe("planSecretFiles / planRequestTimeout (upgrade)", () => {
|
|
|
284
284
|
);
|
|
285
285
|
expect(plan.ok && plan.flags).toEqual([
|
|
286
286
|
"--remove-secrets=/secrets/mcp-oauth-client/client.json",
|
|
287
|
+
"--remove-volume-mount=/secrets/mcp-oauth-client",
|
|
288
|
+
"--remove-volume=mcp-oauth-client",
|
|
287
289
|
"--update-secrets=/secrets/operator-oauth-client/client.json=caelo-production-operator-oauth-client:latest",
|
|
288
290
|
]);
|
|
289
291
|
// Converged: nothing left to remove or mount.
|
package/src/stack-contract.ts
CHANGED
|
@@ -443,6 +443,12 @@ export const SERVICE_SECRET_FILES: Readonly<Record<CloudRunSlug, readonly Secret
|
|
|
443
443
|
|
|
444
444
|
/** A secret file an older contract mounted; upgrade unmounts it in the roll. */
|
|
445
445
|
export interface RetiredSecretFile {
|
|
446
|
+
/**
|
|
447
|
+
* The contract name of the secret the file was mounted from (gcp id
|
|
448
|
+
* `caelo-<env>-<secret>`). Names the volume behind the mount — and a
|
|
449
|
+
* volume left behind without one (#954) — so the roll removes it too.
|
|
450
|
+
*/
|
|
451
|
+
readonly secret: string;
|
|
446
452
|
readonly mountDir: string;
|
|
447
453
|
readonly fileName: string;
|
|
448
454
|
readonly why: string;
|
|
@@ -450,13 +456,15 @@ export interface RetiredSecretFile {
|
|
|
450
456
|
|
|
451
457
|
/**
|
|
452
458
|
* Secret files older contracts mounted that nothing reads any more, per
|
|
453
|
-
* service. The roll removes each mount (`--remove-secrets=<path>`)
|
|
454
|
-
*
|
|
459
|
+
* service. The roll removes each mount (`--remove-secrets=<path>`) and the
|
|
460
|
+
* volume of that secret (`--remove-volume=<name>`) in the same revision that
|
|
461
|
+
* mounts its replacement (stack-converge.ts planSecretFiles).
|
|
455
462
|
*/
|
|
456
463
|
export const RETIRED_SECRET_FILES: Readonly<Record<CloudRunSlug, readonly RetiredSecretFile[]>> = {
|
|
457
464
|
admin: [],
|
|
458
465
|
gateway: [
|
|
459
466
|
{
|
|
467
|
+
secret: "mcp-oauth-client",
|
|
460
468
|
mountDir: "/secrets/mcp-oauth-client",
|
|
461
469
|
fileName: "client.json",
|
|
462
470
|
why: "the Google sign-in client is mounted from operator-oauth-client now (#914)",
|
|
@@ -14,13 +14,20 @@
|
|
|
14
14
|
|
|
15
15
|
import { describe, expect, it } from "bun:test";
|
|
16
16
|
import { databaseUrlFromEnv } from "@caelo-cms/shared";
|
|
17
|
-
import type {
|
|
17
|
+
import type { GcloudResult } from "./gcloud.js";
|
|
18
18
|
import {
|
|
19
|
+
type CloudRunSlug,
|
|
20
|
+
OPERATOR_OAUTH_CLIENT_FILE,
|
|
21
|
+
RETIRED_SECRET_FILES,
|
|
22
|
+
} from "./stack-contract.js";
|
|
23
|
+
import {
|
|
24
|
+
findVolumesWithMissingSecrets,
|
|
19
25
|
type LiveEnvValue,
|
|
20
26
|
liveContainerEnv,
|
|
21
27
|
liveEnvHasInlinePassword,
|
|
22
28
|
planContractEnv,
|
|
23
29
|
planEnvUpdate,
|
|
30
|
+
planSecretFiles,
|
|
24
31
|
serviceRollArgs,
|
|
25
32
|
} from "./stack-converge.js";
|
|
26
33
|
|
|
@@ -460,3 +467,132 @@ describe("liveEnvHasInlinePassword", () => {
|
|
|
460
467
|
expect(liveEnvHasInlinePassword(services.gateway.env)).toBe(false);
|
|
461
468
|
});
|
|
462
469
|
});
|
|
470
|
+
|
|
471
|
+
describe("retiring a secret file removes its volume too (#954)", () => {
|
|
472
|
+
// The shape searchviu's gateway was left in: gcloud names a volume it
|
|
473
|
+
// mints for `--update-secrets` after the secret plus a random suffix.
|
|
474
|
+
const RETIRED_VOLUME = "caelo-production-mcp-oauth-client-mac-won";
|
|
475
|
+
const OPERATOR_VOLUME = "caelo-production-operator-oauth-client-puw-tuq";
|
|
476
|
+
const operatorVolume = {
|
|
477
|
+
name: OPERATOR_VOLUME,
|
|
478
|
+
secret: {
|
|
479
|
+
secretName: "caelo-production-operator-oauth-client",
|
|
480
|
+
items: [{ key: "latest", path: "client.json" }],
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
const retiredVolume = {
|
|
484
|
+
name: RETIRED_VOLUME,
|
|
485
|
+
secret: {
|
|
486
|
+
secretName: "projects/42/secrets/caelo-production-mcp-oauth-client",
|
|
487
|
+
items: [{ key: "latest", path: "client.json" }],
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
const plan = (live: Parameters<typeof planSecretFiles>[0]) =>
|
|
491
|
+
planSecretFiles(live, [OPERATOR_OAUTH_CLIENT_FILE], "production", RETIRED_SECRET_FILES.gateway);
|
|
492
|
+
|
|
493
|
+
it("a retired mount: removes the file, the mount and the volume behind it in the roll", () => {
|
|
494
|
+
const r = plan({
|
|
495
|
+
executionEnvironment: null,
|
|
496
|
+
volumes: [retiredVolume],
|
|
497
|
+
mounts: [{ name: RETIRED_VOLUME, mountPath: "/secrets/mcp-oauth-client" }],
|
|
498
|
+
});
|
|
499
|
+
expect(r.ok).toBe(true);
|
|
500
|
+
if (!r.ok) return;
|
|
501
|
+
expect(r.flags).toContain("--remove-secrets=/secrets/mcp-oauth-client/client.json");
|
|
502
|
+
expect(r.flags).toContain(`--remove-volume=${RETIRED_VOLUME}`);
|
|
503
|
+
expect(r.flags).toContain("--remove-volume-mount=/secrets/mcp-oauth-client");
|
|
504
|
+
expect(r.changes.some((c) => c.startsWith(`volume ${RETIRED_VOLUME}: removed`))).toBe(true);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("an orphaned retired volume (no mount, the searchviu state): removes it and says so", () => {
|
|
508
|
+
const r = plan({
|
|
509
|
+
executionEnvironment: null,
|
|
510
|
+
volumes: [retiredVolume, operatorVolume],
|
|
511
|
+
mounts: [{ name: OPERATOR_VOLUME, mountPath: "/secrets/operator-oauth-client" }],
|
|
512
|
+
});
|
|
513
|
+
expect(r).toEqual({
|
|
514
|
+
ok: true,
|
|
515
|
+
flags: [`--remove-volume=${RETIRED_VOLUME}`],
|
|
516
|
+
changes: [
|
|
517
|
+
`volume ${RETIRED_VOLUME}: removed (the Google sign-in client is mounted from operator-oauth-client now (#914); nothing mounts it)`,
|
|
518
|
+
],
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("a converged service plans nothing", () => {
|
|
523
|
+
const r = plan({
|
|
524
|
+
executionEnvironment: null,
|
|
525
|
+
volumes: [operatorVolume],
|
|
526
|
+
mounts: [{ name: OPERATOR_VOLUME, mountPath: "/secrets/operator-oauth-client" }],
|
|
527
|
+
});
|
|
528
|
+
expect(r).toEqual({ ok: true, flags: [], changes: [] });
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("refuses a volume of the retired secret mounted somewhere else instead of pulling it", () => {
|
|
532
|
+
const r = plan({
|
|
533
|
+
executionEnvironment: null,
|
|
534
|
+
volumes: [retiredVolume, operatorVolume],
|
|
535
|
+
mounts: [
|
|
536
|
+
{ name: OPERATOR_VOLUME, mountPath: "/secrets/operator-oauth-client" },
|
|
537
|
+
{ name: RETIRED_VOLUME, mountPath: "/elsewhere" },
|
|
538
|
+
],
|
|
539
|
+
});
|
|
540
|
+
expect(r.ok).toBe(false);
|
|
541
|
+
if (r.ok) return;
|
|
542
|
+
expect(r.error).toContain(`--remove-volume=${RETIRED_VOLUME}`);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
describe("preflight: a volume pointing at a deleted secret", () => {
|
|
546
|
+
const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
|
|
547
|
+
const missing: GcloudResult = {
|
|
548
|
+
ok: false,
|
|
549
|
+
stdout: "",
|
|
550
|
+
stderr:
|
|
551
|
+
"ERROR: (gcloud.secrets.describe) NOT_FOUND: Secret [projects/42/secrets/x] not found",
|
|
552
|
+
exitCode: 1,
|
|
553
|
+
};
|
|
554
|
+
const where = {
|
|
555
|
+
serviceName: "caelo-production-gateway-532b35d",
|
|
556
|
+
projectId: "acme",
|
|
557
|
+
region: "europe-west1",
|
|
558
|
+
};
|
|
559
|
+
const live = {
|
|
560
|
+
executionEnvironment: null,
|
|
561
|
+
volumes: [retiredVolume, operatorVolume],
|
|
562
|
+
mounts: [{ name: OPERATOR_VOLUME, mountPath: "/secrets/operator-oauth-client" }],
|
|
563
|
+
};
|
|
564
|
+
const gcloud = (gone: string) => {
|
|
565
|
+
const calls: string[] = [];
|
|
566
|
+
const run = async (args: string[]) => {
|
|
567
|
+
calls.push(args.join(" "));
|
|
568
|
+
return args[2] === gone ? missing : ok("name");
|
|
569
|
+
};
|
|
570
|
+
return { run, calls };
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
it("names the volume and the exact fix", async () => {
|
|
574
|
+
const g = gcloud("caelo-production-mcp-oauth-client");
|
|
575
|
+
const problems = await findVolumesWithMissingSecrets(live, where, [], g.run);
|
|
576
|
+
expect(problems).toHaveLength(1);
|
|
577
|
+
expect(problems[0]).toContain(`volume "${RETIRED_VOLUME}" (not mounted)`);
|
|
578
|
+
expect(problems[0]).toContain("caelo-production-mcp-oauth-client, which no longer exists");
|
|
579
|
+
expect(problems[0]).toContain(
|
|
580
|
+
`gcloud run services update caelo-production-gateway-532b35d --region=europe-west1 --project=acme --remove-volume=${RETIRED_VOLUME}`,
|
|
581
|
+
);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("skips a volume the roll removes anyway", async () => {
|
|
585
|
+
const g = gcloud("caelo-production-mcp-oauth-client");
|
|
586
|
+
expect(await findVolumesWithMissingSecrets(live, where, [RETIRED_VOLUME], g.run)).toEqual([]);
|
|
587
|
+
expect(g.calls.some((c) => c.includes("mcp-oauth-client"))).toBe(false);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it("a mounted volume's fix removes the mount too", async () => {
|
|
591
|
+
const g = gcloud("caelo-production-operator-oauth-client");
|
|
592
|
+
const [problem] = await findVolumesWithMissingSecrets(live, where, [RETIRED_VOLUME], g.run);
|
|
593
|
+
expect(problem).toContain(
|
|
594
|
+
`--remove-volume-mount=/secrets/operator-oauth-client --remove-volume=${OPERATOR_VOLUME}`,
|
|
595
|
+
);
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
});
|