@caelo-cms/provisioning 0.10.50 → 0.10.52
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 +1 -1
- package/caddy/Caddyfile.staging +1 -1
- package/dist/adapter.d.ts +4 -6
- package/dist/adapter.d.ts.map +1 -1
- package/dist/admin-internal.d.ts +41 -0
- package/dist/admin-internal.d.ts.map +1 -0
- package/dist/admin-internal.js +169 -0
- package/dist/admin-internal.js.map +1 -0
- package/dist/agent/units.js +1 -1
- package/dist/agent/units.js.map +1 -1
- package/dist/agent-db.js +169 -65
- package/dist/aws-task-roles.d.ts +27 -0
- package/dist/aws-task-roles.d.ts.map +1 -0
- package/dist/aws-task-roles.js +35 -0
- package/dist/aws-task-roles.js.map +1 -0
- package/dist/cli.js +34 -1
- package/dist/cli.js.map +1 -1
- package/dist/compose.js +2 -2
- package/dist/compose.js.map +1 -1
- package/dist/deployed-release.d.ts +3 -2
- package/dist/deployed-release.d.ts.map +1 -1
- package/dist/deployed-release.js +12 -5
- package/dist/deployed-release.js.map +1 -1
- package/dist/gateway-credentials.d.ts.map +1 -1
- package/dist/gateway-credentials.js +6 -3
- package/dist/gateway-credentials.js.map +1 -1
- package/dist/gcloud.d.ts +2 -2
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +3 -3
- 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 +12 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/ops-backend.js +1 -1
- package/dist/owner-reset.d.ts +37 -0
- package/dist/owner-reset.d.ts.map +1 -0
- package/dist/owner-reset.js +80 -0
- package/dist/owner-reset.js.map +1 -0
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +3 -0
- package/dist/release-contract.js.map +1 -1
- package/dist/stack-contract.d.ts +9 -10
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +21 -10
- package/dist/stack-contract.js.map +1 -1
- package/dist/staging-admin.d.ts +12 -20
- package/dist/staging-admin.d.ts.map +1 -1
- package/dist/staging-admin.js +7 -159
- package/dist/staging-admin.js.map +1 -1
- package/dist/state-store.d.ts +8 -0
- package/dist/state-store.d.ts.map +1 -1
- package/dist/state-store.js +10 -2
- package/dist/state-store.js.map +1 -1
- package/dist/upgrader-job.d.ts.map +1 -1
- package/dist/upgrader-job.js +7 -1
- package/dist/upgrader-job.js.map +1 -1
- package/dist/upgrader-setup.d.ts +5 -0
- package/dist/upgrader-setup.d.ts.map +1 -1
- package/dist/upgrader-setup.js +40 -6
- package/dist/upgrader-setup.js.map +1 -1
- package/dist/wizards/gcp-cost.js +1 -1
- package/dist/wizards/gcp.js +1 -1
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +7 -5
- package/src/adapter.ts +4 -6
- package/src/admin-internal.ts +212 -0
- package/src/agent/units.ts +1 -1
- package/src/aws-task-roles.ts +58 -0
- package/src/caddy-redirects.docker.test.ts +8 -1
- package/src/checkpoint-secrets.test.ts +0 -1
- package/src/cli.ts +41 -1
- package/src/compose.ts +2 -2
- package/src/deployed-release.test.ts +22 -4
- package/src/deployed-release.ts +14 -7
- package/src/error-pages-stacks.test.ts +38 -0
- package/src/gateway-credentials.ts +6 -3
- package/src/gateway-env-isolation.test.ts +212 -0
- package/src/gcloud.ts +5 -3
- package/src/index.test.ts +6 -6
- package/src/infra-contract.test.ts +5 -3
- package/src/infra-contract.ts +12 -1
- package/src/ops-backend.ts +1 -1
- package/src/owner-reset.test.ts +165 -0
- package/src/owner-reset.ts +106 -0
- package/src/release-contract.ts +3 -0
- package/src/release-manifest.test.ts +1 -0
- package/src/runtime-secrets.test.ts +25 -8
- package/src/stack-contract.test.ts +9 -18
- package/src/stack-contract.ts +25 -16
- package/src/stack-converge-secrets.test.ts +45 -10
- package/src/stack-converge.test.ts +19 -10
- package/src/staging-admin.ts +18 -181
- package/src/state-store.ts +11 -2
- package/src/upgrader-job.test.ts +17 -1
- package/src/upgrader-job.ts +13 -3
- package/src/upgrader-setup.test.ts +12 -1
- package/src/upgrader-setup.ts +41 -5
- package/src/welcome-page.test.ts +40 -0
- package/src/wizards/gcp-cost.ts +1 -1
- package/src/wizards/gcp.ts +1 -1
- package/stacks/aws/README.md +3 -1
- package/stacks/aws/index.ts +62 -43
- package/stacks/azure/README.md +3 -1
- package/stacks/azure/index.ts +8 -14
- package/stacks/gcp/README.md +1 -1
- package/stacks/gcp/index.ts +2 -5
- package/stacks/gcp-firebase/index.ts +0 -3
- package/static/welcome.html +93 -111
|
@@ -268,12 +268,7 @@ const CONVERGED_GATEWAY_DB = [
|
|
|
268
268
|
name: "PUBLIC_DATABASE_URL",
|
|
269
269
|
value: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
|
|
270
270
|
},
|
|
271
|
-
{
|
|
272
|
-
name: "GATEWAY_DATABASE_URL",
|
|
273
|
-
value: "postgresql://gateway_role@10.20.0.3:5432/cms_admin?sslmode=require",
|
|
274
|
-
},
|
|
275
271
|
secretRef("PUBLIC_DATABASE_PASSWORD", "public-role-password"),
|
|
276
|
-
secretRef("GATEWAY_DATABASE_PASSWORD", "gateway-role-password"),
|
|
277
272
|
];
|
|
278
273
|
|
|
279
274
|
describe("planContractEnv", () => {
|
|
@@ -776,10 +771,10 @@ describe("ensureStackInvariants", () => {
|
|
|
776
771
|
const gatewayGrants = calls.filter(
|
|
777
772
|
(c) => c.startsWith("secrets add-iam-policy-binding") && c.includes(GW_SA),
|
|
778
773
|
);
|
|
779
|
-
// #613 — never admin_role's postgres-password
|
|
780
|
-
// gate key is the one secret the
|
|
774
|
+
// #613 — never admin_role's postgres-password; #635 — nor
|
|
775
|
+
// gateway_role's. #650 — the staging gate key is the one secret the
|
|
776
|
+
// gateway shares with the admin.
|
|
781
777
|
expect(gatewayGrants).toEqual([
|
|
782
|
-
`secrets add-iam-policy-binding caelo-production-gateway-role-password --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
|
|
783
778
|
`secrets add-iam-policy-binding caelo-production-public-role-password --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
|
|
784
779
|
`secrets add-iam-policy-binding caelo-production-staging-gate-secret --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
|
|
785
780
|
]);
|
|
@@ -802,7 +797,8 @@ describe("removeRetiredIamBindings (#613)", () => {
|
|
|
802
797
|
],
|
|
803
798
|
});
|
|
804
799
|
const outcomes = await removeRetiredIamBindings(gcp, { run, sleep: async () => {} });
|
|
805
|
-
|
|
800
|
+
// postgres-password removed; gateway-role-password (#635) already clean.
|
|
801
|
+
expect(outcomes.map((o) => o.status)).toEqual(["removed", "absent"]);
|
|
806
802
|
expect(calls).toContain(REMOVE);
|
|
807
803
|
// The admin's own binding on the same secret is never touched.
|
|
808
804
|
expect(calls.some((c) => c.includes("remove") && c.includes(RUN_SA))).toBe(false);
|
|
@@ -815,10 +811,23 @@ describe("removeRetiredIamBindings (#613)", () => {
|
|
|
815
811
|
],
|
|
816
812
|
});
|
|
817
813
|
const outcomes = await removeRetiredIamBindings(firebase, { run, sleep: async () => {} });
|
|
818
|
-
expect(outcomes.map((o) => o.status)).toEqual(["absent"]);
|
|
814
|
+
expect(outcomes.map((o) => o.status)).toEqual(["absent", "absent"]);
|
|
819
815
|
expect(calls.filter((c) => c.includes("remove-iam-policy-binding"))).toEqual([]);
|
|
820
816
|
});
|
|
821
817
|
|
|
818
|
+
it("takes the gateway SA off gateway_role's password (#635)", async () => {
|
|
819
|
+
const { run, calls } = fakeGcloud({
|
|
820
|
+
"secrets get-iam-policy caelo-production-gateway-role-password": [
|
|
821
|
+
policy([{ role: "roles/secretmanager.secretAccessor", members: [GW_SA] }]),
|
|
822
|
+
],
|
|
823
|
+
});
|
|
824
|
+
const outcomes = await removeRetiredIamBindings(gcp, { run, sleep: async () => {} });
|
|
825
|
+
expect(outcomes.map((o) => o.status)).toEqual(["absent", "removed"]);
|
|
826
|
+
expect(calls).toContain(
|
|
827
|
+
`secrets remove-iam-policy-binding caelo-production-gateway-role-password --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
|
|
828
|
+
);
|
|
829
|
+
});
|
|
830
|
+
|
|
822
831
|
it("reports a failed removal instead of throwing", async () => {
|
|
823
832
|
const { run } = fakeGcloud({
|
|
824
833
|
"secrets get-iam-policy caelo-production-postgres-password": [
|
package/src/staging-admin.ts
CHANGED
|
@@ -3,51 +3,25 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* #678 — `staging-domain enable` tells the admin itself what the operator
|
|
5
5
|
* decided, instead of printing "now set the same host in the admin".
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* through Cloud Logging, which would put the password into the project's
|
|
11
|
-
* logs. The admin endpoint (`POST /api/internal/staging/provision`) answers
|
|
12
|
-
* over TLS straight to this process, and the password is never written
|
|
13
|
-
* anywhere (CLAUDE.md §7, #659).
|
|
14
|
-
*
|
|
15
|
-
* Getting there, as the operator's own gcloud identity — the same path the
|
|
16
|
-
* Power-MCP shim takes (packages/mcp-server/src/ingress-auth.ts):
|
|
17
|
-
*
|
|
18
|
-
* 1. the admin Cloud Run service and its URL;
|
|
19
|
-
* 2. the install's internal secret from Secret Manager (in memory only),
|
|
20
|
-
* which signs a 5-minute token for scope `staging.provision`;
|
|
21
|
-
* 3. a JWT signed as `caelo-mcp@<project>` through the IAM Credentials
|
|
22
|
-
* API — the admin's IAP lets that service account in, and every
|
|
23
|
-
* IAP-allowlisted operator may sign as it (`cms-provision upgrade`
|
|
24
|
-
* grants it) — so the request passes IAP first, then Caelo's token.
|
|
6
|
+
* It calls `POST /api/internal/staging/provision` through admin-internal.ts
|
|
7
|
+
* (IAP as caelo-mcp + the internal token, scope `staging.provision`), because
|
|
8
|
+
* the answer carries the generated basic-auth password back to the CLI, which
|
|
9
|
+
* prints it once and never stores it.
|
|
25
10
|
*/
|
|
26
11
|
|
|
27
|
-
import {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
12
|
+
import {
|
|
13
|
+
type AdminInternalDeps,
|
|
14
|
+
type AdminInternalResult,
|
|
15
|
+
type AdminInternalTarget,
|
|
16
|
+
callAdminInternal,
|
|
17
|
+
} from "./admin-internal.js";
|
|
33
18
|
|
|
34
19
|
/** Token scope the admin endpoint checks (apps/admin …/api/internal/staging/provision). */
|
|
35
20
|
export const STAGING_PROVISION_SCOPE = "staging.provision";
|
|
36
21
|
const ENDPOINT = "/api/internal/staging/provision";
|
|
37
|
-
/** Signed JWTs live at most 1 hour; one call needs a few seconds. */
|
|
38
|
-
const IAP_JWT_LIFETIME_S = 600;
|
|
39
22
|
|
|
40
|
-
export
|
|
41
|
-
|
|
42
|
-
readonly region: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface StagingAdminDeps {
|
|
46
|
-
readonly run?: Run;
|
|
47
|
-
readonly fetch?: typeof fetch;
|
|
48
|
-
/** Seconds since epoch (IAP JWT); tests pin it. */
|
|
49
|
-
readonly now?: () => number;
|
|
50
|
-
}
|
|
23
|
+
export type StagingAdminTarget = AdminInternalTarget;
|
|
24
|
+
export type StagingAdminDeps = AdminInternalDeps;
|
|
51
25
|
|
|
52
26
|
/** Main's draft pages, as the admin reports them. */
|
|
53
27
|
export interface DraftPages {
|
|
@@ -80,7 +54,7 @@ export interface StagingApply {
|
|
|
80
54
|
readonly draftPages: DraftPages;
|
|
81
55
|
}
|
|
82
56
|
|
|
83
|
-
export type StagingAdminResult<T> =
|
|
57
|
+
export type StagingAdminResult<T> = AdminInternalResult<T>;
|
|
84
58
|
|
|
85
59
|
export interface ApplyRequest {
|
|
86
60
|
readonly host?: string;
|
|
@@ -110,149 +84,12 @@ async function callStagingAdmin<T>(
|
|
|
110
84
|
body: Record<string, unknown>,
|
|
111
85
|
deps: StagingAdminDeps,
|
|
112
86
|
): Promise<StagingAdminResult<T>> {
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const found = await findService(run, scope, "admin");
|
|
118
|
-
if (!found.ok) return { ok: false, error: found.error };
|
|
119
|
-
const url = await run([
|
|
120
|
-
"run",
|
|
121
|
-
"services",
|
|
122
|
-
"describe",
|
|
123
|
-
found.service,
|
|
124
|
-
...scope,
|
|
125
|
-
"--format=value(status.url)",
|
|
126
|
-
]);
|
|
127
|
-
const adminUrl = url.stdout.trim();
|
|
128
|
-
if (!url.ok || !adminUrl) {
|
|
129
|
-
return { ok: false, error: `read the admin's URL: ${url.stderr.trim() || "empty"}` };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const secretId = gcpSecretId(GCP_STACK_ENV, "internal-secret");
|
|
133
|
-
const secret = await run([
|
|
134
|
-
"secrets",
|
|
135
|
-
"versions",
|
|
136
|
-
"access",
|
|
137
|
-
"latest",
|
|
138
|
-
`--secret=${secretId}`,
|
|
139
|
-
`--project=${target.projectId}`,
|
|
140
|
-
]);
|
|
141
|
-
const internalSecret = secret.stdout.trim();
|
|
142
|
-
if (!secret.ok || internalSecret.length < 32) {
|
|
143
|
-
return {
|
|
144
|
-
ok: false,
|
|
145
|
-
error: `read the install's internal secret (${secretId}) from Secret Manager: ${secret.stderr.trim() || "missing or too short"}. Your gcloud account needs secretmanager.versions.access on the project.`,
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Installs from before #678 may not have the IAM Credentials API on;
|
|
150
|
-
// enabling an enabled API is a no-op (codex review on #681).
|
|
151
|
-
const api = await run([
|
|
152
|
-
"services",
|
|
153
|
-
"enable",
|
|
154
|
-
"iamcredentials.googleapis.com",
|
|
155
|
-
`--project=${target.projectId}`,
|
|
156
|
-
]);
|
|
157
|
-
if (!api.ok) {
|
|
158
|
-
return {
|
|
159
|
-
ok: false,
|
|
160
|
-
error: `enable the IAM Credentials API (iamcredentials.googleapis.com), needed to pass the admin's IAP: ${api.stderr.trim()}`,
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
const iap = await signIapJwt(run, doFetch, target.projectId, adminUrl, deps.now);
|
|
164
|
-
if (!iap.ok) return iap;
|
|
165
|
-
|
|
166
|
-
const token = await mintInternalToken(internalSecret, STAGING_PROVISION_SCOPE);
|
|
167
|
-
let res: Response;
|
|
168
|
-
try {
|
|
169
|
-
res = await doFetch(`${adminUrl}${ENDPOINT}`, {
|
|
170
|
-
method: "POST",
|
|
171
|
-
headers: {
|
|
172
|
-
authorization: `Bearer ${iap.value}`,
|
|
173
|
-
"x-caelo-internal-token": token,
|
|
174
|
-
"content-type": "application/json",
|
|
175
|
-
},
|
|
176
|
-
body: JSON.stringify(body),
|
|
177
|
-
});
|
|
178
|
-
} catch (e) {
|
|
179
|
-
return { ok: false, error: `reach the admin at ${adminUrl}: ${String(e)}` };
|
|
180
|
-
}
|
|
181
|
-
const text = await res.text();
|
|
182
|
-
if (res.headers.get("x-goog-iap-generated-response") === "true") {
|
|
183
|
-
return {
|
|
184
|
-
ok: false,
|
|
185
|
-
error: `Google IAP refused the request (HTTP ${res.status}: ${text.trim().slice(0, 200)}) — ${mcpIapServiceAccountEmail(target.projectId)} is not allowed on the admin's IAP yet. Run \`cms-provision upgrade\` (it allowlists that service account), then run this command again.`,
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
if (res.status === 404) {
|
|
189
|
-
return {
|
|
190
|
-
ok: false,
|
|
191
|
-
error: `the admin has no ${ENDPOINT} yet (it runs a release from before #678). Run \`cms-provision upgrade\`, then run this command again.`,
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
let parsed: { ok?: boolean; error?: string } & Record<string, unknown>;
|
|
195
|
-
try {
|
|
196
|
-
parsed = JSON.parse(text) as typeof parsed;
|
|
197
|
-
} catch {
|
|
198
|
-
return {
|
|
199
|
-
ok: false,
|
|
200
|
-
error: `the admin answered HTTP ${res.status} with a non-JSON body: ${text.slice(0, 200)}`,
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
if (!res.ok || parsed.ok !== true) {
|
|
204
|
-
return {
|
|
205
|
-
ok: false,
|
|
206
|
-
error: `the admin refused (HTTP ${res.status}): ${parsed.error ?? text.slice(0, 300)}`,
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
return { ok: true, value: parsed as unknown as T };
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/** A JWT signed as the install's MCP service account, for the admin's IAP. */
|
|
213
|
-
async function signIapJwt(
|
|
214
|
-
run: Run,
|
|
215
|
-
doFetch: typeof fetch,
|
|
216
|
-
projectId: string,
|
|
217
|
-
adminUrl: string,
|
|
218
|
-
now: (() => number) | undefined,
|
|
219
|
-
): Promise<StagingAdminResult<string>> {
|
|
220
|
-
const access = await run(["auth", "print-access-token"]);
|
|
221
|
-
const accessToken = access.stdout.trim();
|
|
222
|
-
if (!access.ok || !accessToken) {
|
|
223
|
-
return {
|
|
224
|
-
ok: false,
|
|
225
|
-
error: `get an access token from gcloud: ${access.stderr.trim() || "empty"}. Run \`gcloud auth login\` with the account you provisioned with.`,
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
const serviceAccount = mcpIapServiceAccountEmail(projectId);
|
|
229
|
-
const iat = now ? now() : Math.floor(Date.now() / 1000);
|
|
230
|
-
const res = await doFetch(
|
|
231
|
-
`https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${encodeURIComponent(serviceAccount)}:signJwt`,
|
|
232
|
-
{
|
|
233
|
-
method: "POST",
|
|
234
|
-
headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
|
|
235
|
-
body: JSON.stringify({
|
|
236
|
-
payload: JSON.stringify({
|
|
237
|
-
iss: serviceAccount,
|
|
238
|
-
sub: serviceAccount,
|
|
239
|
-
aud: `${new URL(adminUrl).origin}/*`,
|
|
240
|
-
iat,
|
|
241
|
-
exp: iat + IAP_JWT_LIFETIME_S,
|
|
242
|
-
}),
|
|
243
|
-
}),
|
|
244
|
-
},
|
|
87
|
+
const r = await callAdminInternal<T>(
|
|
88
|
+
target,
|
|
89
|
+
{ endpoint: ENDPOINT, scope: STAGING_PROVISION_SCOPE, body, introducedBy: "#678" },
|
|
90
|
+
deps,
|
|
245
91
|
);
|
|
246
|
-
|
|
247
|
-
const detail = (await res.text().catch(() => "")).slice(0, 300);
|
|
248
|
-
return {
|
|
249
|
-
ok: false,
|
|
250
|
-
error: `sign an IAP token as ${serviceAccount} (HTTP ${res.status}): ${detail} — your account needs roles/iam.serviceAccountTokenCreator on it; \`cms-provision upgrade\` grants it to everyone the admin's IAP lets in.`,
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
const { signedJwt } = (await res.json()) as { signedJwt?: string };
|
|
254
|
-
if (!signedJwt) return { ok: false, error: "IAM signJwt answered without a signedJwt" };
|
|
255
|
-
return { ok: true, value: signedJwt };
|
|
92
|
+
return r.ok ? { ok: true, value: r.value.answer } : r;
|
|
256
93
|
}
|
|
257
94
|
|
|
258
95
|
/**
|
package/src/state-store.ts
CHANGED
|
@@ -162,6 +162,15 @@ export class GcsStateStore implements StateStore {
|
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Another run holds the install's state lock. Distinct from every other
|
|
167
|
+
* lock failure (no access, gcloud missing), which callers must not report
|
|
168
|
+
* as "busy" — retrying later will not fix those.
|
|
169
|
+
*/
|
|
170
|
+
export class StateLockHeldError extends Error {
|
|
171
|
+
override readonly name = "StateLockHeldError";
|
|
172
|
+
}
|
|
173
|
+
|
|
165
174
|
/** `gs://<bucket>/install/state.lock` — see {@link acquireBucketStateLock}. */
|
|
166
175
|
export function bucketStateLockUrl(bucket: string): string {
|
|
167
176
|
return `gs://${bucket}/${OPS_INSTALL_PREFIX}/state.lock`;
|
|
@@ -197,7 +206,7 @@ export function acquireBucketStateLock(
|
|
|
197
206
|
if (!r.ok) {
|
|
198
207
|
if (/PreconditionFailed|412|At least one of the pre-conditions/i.test(r.stderr)) {
|
|
199
208
|
const held = run(["storage", "cat", url]);
|
|
200
|
-
throw new
|
|
209
|
+
throw new StateLockHeldError(
|
|
201
210
|
`Another run is changing this install's state (${held.ok ? held.stdout.trim() : url}). Re-run once it has finished; if none is running, remove the stale lock: gcloud storage rm ${url}`,
|
|
202
211
|
);
|
|
203
212
|
}
|
|
@@ -221,7 +230,7 @@ export function acquireLocalStateLock(root: string, purpose: string): () => void
|
|
|
221
230
|
writeFileSync(path, lockBody(purpose), { flag: "wx", mode: 0o600 });
|
|
222
231
|
} catch (e) {
|
|
223
232
|
if ((e as NodeJS.ErrnoException).code === "EEXIST") {
|
|
224
|
-
throw new
|
|
233
|
+
throw new StateLockHeldError(
|
|
225
234
|
`Another run is changing this install's state (${readFileSync(path, "utf8").trim()}). Re-run once it has finished; if none is running, remove the stale lock: rm ${path}`,
|
|
226
235
|
);
|
|
227
236
|
}
|
package/src/upgrader-job.test.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { GcloudResult } from "./gcloud.js";
|
|
|
14
14
|
import type { GcloudRunner } from "./gcloud-retry.js";
|
|
15
15
|
import type { InstallMetadata } from "./install-state.js";
|
|
16
16
|
import type { ReleaseManifest, ReleaseManifestResult } from "./release-manifest.js";
|
|
17
|
+
import { StateLockHeldError } from "./state-store.js";
|
|
17
18
|
import type { HealthOutcome, HealthProbe } from "./upgrade/health.js";
|
|
18
19
|
import {
|
|
19
20
|
type ClaimedUpdate,
|
|
@@ -162,6 +163,7 @@ function setup(
|
|
|
162
163
|
deep?: HealthOutcome;
|
|
163
164
|
migrate?: { ok: boolean; error?: string };
|
|
164
165
|
locked?: boolean;
|
|
166
|
+
lockDenied?: boolean;
|
|
165
167
|
} = {},
|
|
166
168
|
) {
|
|
167
169
|
const order: string[] = [];
|
|
@@ -188,7 +190,8 @@ function setup(
|
|
|
188
190
|
},
|
|
189
191
|
lockState: () => {
|
|
190
192
|
order.push("lock");
|
|
191
|
-
if (opts.locked) throw new
|
|
193
|
+
if (opts.locked) throw new StateLockHeldError("held by caelo installer");
|
|
194
|
+
if (opts.lockDenied) throw new Error("storage.objects.list denied");
|
|
192
195
|
return () => order.push("unlock");
|
|
193
196
|
},
|
|
194
197
|
readInstall: () => (opts.meta === undefined ? META : opts.meta),
|
|
@@ -286,6 +289,19 @@ describe("runUpgraderJob", () => {
|
|
|
286
289
|
expect(busy.order.some((l) => l.startsWith("manifest") || l.startsWith("gcloud"))).toBe(false);
|
|
287
290
|
});
|
|
288
291
|
|
|
292
|
+
it("does not call a lock it cannot take 'busy': an access failure asks for Cloud Shell", async () => {
|
|
293
|
+
const denied = setup({ lockDenied: true });
|
|
294
|
+
const r = await runUpgraderJob(denied.deps);
|
|
295
|
+
expect(r.status).toBe("needs_cloud_shell");
|
|
296
|
+
const end = denied.q.end();
|
|
297
|
+
expect(end?.codes).toEqual(["state-access"]);
|
|
298
|
+
expect(end?.reason).not.toContain("another install operation");
|
|
299
|
+
expect(end?.reason).toContain("storage.objects.list denied");
|
|
300
|
+
expect(denied.order.some((l) => l.startsWith("manifest") || l.startsWith("gcloud"))).toBe(
|
|
301
|
+
false,
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
|
|
289
305
|
it("retries recording the end, and fails the run when it cannot", async () => {
|
|
290
306
|
const { deps, q } = setup();
|
|
291
307
|
let calls = 0;
|
package/src/upgrader-job.ts
CHANGED
|
@@ -43,6 +43,7 @@ import type { GcloudRunner, Sleep } from "./gcloud-retry.js";
|
|
|
43
43
|
import { gcpNamePrefix, upgraderJobName } from "./gcp-names.js";
|
|
44
44
|
import type { InstallMetadata } from "./install-state.js";
|
|
45
45
|
import type { ReleaseManifest, ReleaseManifestResult } from "./release-manifest.js";
|
|
46
|
+
import { StateLockHeldError } from "./state-store.js";
|
|
46
47
|
import {
|
|
47
48
|
type AppRollPlan,
|
|
48
49
|
executeAppRoll,
|
|
@@ -224,10 +225,19 @@ export async function runUpgraderJob(deps: UpgraderJobDeps): Promise<UpgraderRun
|
|
|
224
225
|
try {
|
|
225
226
|
releaseLock = deps.lockState();
|
|
226
227
|
} catch (e) {
|
|
228
|
+
if (e instanceof StateLockHeldError) {
|
|
229
|
+
return stopWith(
|
|
230
|
+
"failed",
|
|
231
|
+
["state-locked"],
|
|
232
|
+
`Nothing was changed: another install operation is running on this site (${errorText(e)}). Try again when it has finished.`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
// Not contention: the job cannot reach its own ops bucket. Retrying
|
|
236
|
+
// will not help; a Cloud Shell upgrade re-converges the job's grants.
|
|
227
237
|
return stopWith(
|
|
228
|
-
"
|
|
229
|
-
["state-
|
|
230
|
-
`Nothing was changed:
|
|
238
|
+
"needs_cloud_shell",
|
|
239
|
+
["state-access"],
|
|
240
|
+
`Nothing was changed: the upgrader could not take the install's state lock (${errorText(e)}). Run the upgrade once with Cloud Shell; it repairs the upgrader's access.`,
|
|
231
241
|
);
|
|
232
242
|
}
|
|
233
243
|
|
|
@@ -13,6 +13,7 @@ import type { GcloudResult } from "./gcloud.js";
|
|
|
13
13
|
import {
|
|
14
14
|
ensureUpgraderJob,
|
|
15
15
|
UPGRADER_FORBIDDEN_ROLES,
|
|
16
|
+
UPGRADER_OPS_LIST_ROLE,
|
|
16
17
|
UPGRADER_SQL_BACKUP_ROLE,
|
|
17
18
|
type UpgraderSetupTarget,
|
|
18
19
|
} from "./upgrader-setup.js";
|
|
@@ -83,6 +84,7 @@ describe("ensureUpgraderJob", () => {
|
|
|
83
84
|
"iam service-accounts add-iam-policy-binding caelo-production-upgrader@acme.iam.gserviceaccount.com → roles/iam.serviceAccountUser",
|
|
84
85
|
"iam service-accounts add-iam-policy-binding caelo-mcp@acme.iam.gserviceaccount.com → roles/iam.serviceAccountTokenCreator",
|
|
85
86
|
"storage buckets add-iam-policy-binding gs://acme-caelo-ops → roles/storage.objectUser",
|
|
87
|
+
"storage buckets add-iam-policy-binding gs://acme-caelo-ops → projects/acme/roles/caeloUpgraderOpsList",
|
|
86
88
|
].sort(),
|
|
87
89
|
);
|
|
88
90
|
// The bucket grant only reaches install.json, not the Pulumi state.
|
|
@@ -90,6 +92,12 @@ describe("ensureUpgraderJob", () => {
|
|
|
90
92
|
expect(bucket?.condition).toContain(
|
|
91
93
|
'resource.name.startsWith("projects/_/buckets/acme-caelo-ops/objects/install/")',
|
|
92
94
|
);
|
|
95
|
+
// gcloud storage cp lists the bucket before every upload; GCS checks the
|
|
96
|
+
// list against the bucket itself, so this grant cannot carry the
|
|
97
|
+
// install/ condition — and so it carries nothing but the list.
|
|
98
|
+
const list = mine.find((g) => g.role.endsWith("/caeloUpgraderOpsList"));
|
|
99
|
+
expect(list?.condition).toBe("None");
|
|
100
|
+
expect(UPGRADER_OPS_LIST_ROLE.permissions).toEqual(["storage.objects.list"]);
|
|
93
101
|
});
|
|
94
102
|
|
|
95
103
|
it("never grants a forbidden role, a setIamPolicy permission, or a project-wide iam role", async () => {
|
|
@@ -101,7 +109,10 @@ describe("ensureUpgraderJob", () => {
|
|
|
101
109
|
}
|
|
102
110
|
if (g.role.startsWith("roles/iam.")) expect(g.where).toStartWith("iam service-accounts");
|
|
103
111
|
}
|
|
104
|
-
const role = calls.find(
|
|
112
|
+
const role = calls.find(
|
|
113
|
+
(c) =>
|
|
114
|
+
c.slice(0, 3).join(" ") === "iam roles create" && c[3] === UPGRADER_SQL_BACKUP_ROLE.roleId,
|
|
115
|
+
);
|
|
105
116
|
const perms = (flag(role ?? [], "permissions") ?? "").split(",");
|
|
106
117
|
expect(perms.sort()).toEqual([...UPGRADER_SQL_BACKUP_ROLE.permissions].sort());
|
|
107
118
|
// Nothing but the one write it needs: create a backup.
|
package/src/upgrader-setup.ts
CHANGED
|
@@ -31,7 +31,13 @@
|
|
|
31
31
|
* health check signs its IAP credential as that account
|
|
32
32
|
* (upgrade/health.ts). The operator-access sync job keeps it there.
|
|
33
33
|
* - `roles/storage.objectUser` on the ops bucket, conditioned to the
|
|
34
|
-
* `install/` prefix (install.json) — not the Pulumi state.
|
|
34
|
+
* `install/` prefix (install.json, the state lock) — not the Pulumi state.
|
|
35
|
+
* - the custom role `caeloUpgraderOpsList` (`storage.objects.list` only)
|
|
36
|
+
* on the ops bucket, unconditioned: `gcloud storage cp` lists the
|
|
37
|
+
* destination prefix and reads the bucket's storage layout before every
|
|
38
|
+
* upload, and GCS checks both against the bucket, never an object path,
|
|
39
|
+
* so no `install/` condition can admit them. It reveals object names,
|
|
40
|
+
* not contents.
|
|
35
41
|
* - `roles/logging.logWriter`.
|
|
36
42
|
*
|
|
37
43
|
* It gets no `*.setIamPolicy`, no service-account admin, no secret
|
|
@@ -71,6 +77,18 @@ export const UPGRADER_SQL_BACKUP_ROLE: CustomRoleSpec = {
|
|
|
71
77
|
],
|
|
72
78
|
};
|
|
73
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Listing the ops bucket, which every `gcloud storage cp` into it needs (see
|
|
82
|
+
* the file header). Names only — reads and writes stay on `install/`.
|
|
83
|
+
*/
|
|
84
|
+
export const UPGRADER_OPS_LIST_ROLE: CustomRoleSpec = {
|
|
85
|
+
roleId: "caeloUpgraderOpsList",
|
|
86
|
+
title: "Caelo upgrader: list the ops bucket",
|
|
87
|
+
description:
|
|
88
|
+
"Lets the Caelo upgrader job list object names in the install's ops bucket (gcloud storage cp needs it). Names only; object reads/writes stay limited to install/.",
|
|
89
|
+
permissions: ["storage.objects.list"],
|
|
90
|
+
};
|
|
91
|
+
|
|
74
92
|
/** Project-level roles of the upgrader's service account. */
|
|
75
93
|
export const UPGRADER_PROJECT_ROLES = [
|
|
76
94
|
"roles/run.developer",
|
|
@@ -176,10 +194,12 @@ export async function ensureUpgraderJob(
|
|
|
176
194
|
};
|
|
177
195
|
}
|
|
178
196
|
|
|
179
|
-
// 1. The backup
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
197
|
+
// 1. The backup and ops-bucket-list roles.
|
|
198
|
+
for (const role of [UPGRADER_SQL_BACKUP_ROLE, UPGRADER_OPS_LIST_ROLE]) {
|
|
199
|
+
const roleError = await ensureCustomRole(run, projectId, role);
|
|
200
|
+
if (roleError) return { ok: false, done, error: roleError };
|
|
201
|
+
done.push(`custom role ${role.roleId}`);
|
|
202
|
+
}
|
|
183
203
|
|
|
184
204
|
// 2. The job's own service account.
|
|
185
205
|
const describe = await run([
|
|
@@ -276,6 +296,22 @@ export async function ensureUpgraderJob(
|
|
|
276
296
|
return fail(`grant the upgrader install.json in gs://${bucket}`, bucketGrant);
|
|
277
297
|
done.push(`upgrader: roles/storage.objectUser on gs://${bucket}/${OPS_INSTALL_PREFIX}/`);
|
|
278
298
|
|
|
299
|
+
const listRole = customRoleName(projectId, UPGRADER_OPS_LIST_ROLE.roleId);
|
|
300
|
+
const listGrant = await retry([
|
|
301
|
+
"storage",
|
|
302
|
+
"buckets",
|
|
303
|
+
"add-iam-policy-binding",
|
|
304
|
+
`gs://${bucket}`,
|
|
305
|
+
`--member=${member}`,
|
|
306
|
+
`--role=${listRole}`,
|
|
307
|
+
"--condition=None",
|
|
308
|
+
project,
|
|
309
|
+
"--quiet",
|
|
310
|
+
"--format=none",
|
|
311
|
+
]);
|
|
312
|
+
if (!listGrant.ok) return fail(`grant the upgrader object listing on gs://${bucket}`, listGrant);
|
|
313
|
+
done.push(`upgrader: ${listRole} on gs://${bucket}`);
|
|
314
|
+
|
|
279
315
|
// 6. Its database login (migration 0267's platform_updater role).
|
|
280
316
|
const sqlUser = await ensureCloudSqlIamUser(run, {
|
|
281
317
|
projectId,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #781 — contracts of `static/welcome.html`, the placeholder the GCP
|
|
5
|
+
* wizard uploads to a fresh static bucket. It must stay fully
|
|
6
|
+
* self-contained (the bucket holds nothing else, and a first impression
|
|
7
|
+
* that fetches from third parties leaks the visitor), keep the
|
|
8
|
+
* `{{ADMIN_URL}}` placeholder the wizard substitutes, and carry the
|
|
9
|
+
* Caelatum sky instead of the old animated mesh.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from "bun:test";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
|
|
15
|
+
const html = readFileSync(new URL("../static/welcome.html", import.meta.url), "utf8");
|
|
16
|
+
|
|
17
|
+
describe("welcome.html", () => {
|
|
18
|
+
it("makes no external requests", () => {
|
|
19
|
+
// Any absolute or protocol-relative URL, stylesheet/script/image
|
|
20
|
+
// reference or CSS import/url() would be a network fetch.
|
|
21
|
+
expect(html).not.toMatch(/https?:\/\//i);
|
|
22
|
+
expect(html).not.toMatch(/["'(]\s*\/\//);
|
|
23
|
+
expect(html).not.toMatch(/<link\b/i);
|
|
24
|
+
expect(html).not.toMatch(/<script\b/i);
|
|
25
|
+
expect(html).not.toMatch(/<img\b/i);
|
|
26
|
+
expect(html).not.toMatch(/@import|url\(|@font-face/i);
|
|
27
|
+
expect(html).not.toMatch(/\b(src|href)\s*=/i);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("keeps the admin URL placeholder the wizard substitutes", () => {
|
|
31
|
+
expect(html).toContain("{{ADMIN_URL}}");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("uses the sky + relief plate with the inline mark, not the mesh", () => {
|
|
35
|
+
expect(html).toContain("#c3dbf2 0%, #dce9f6 40%, #efe7ef 72%, #f8e6d8 100%");
|
|
36
|
+
expect(html).toContain("0 0 0 5px var(--plate-ring)");
|
|
37
|
+
expect(html).toMatch(/<svg viewBox="0 0 32 32" aria-hidden="true"/);
|
|
38
|
+
expect(html).not.toMatch(/class="blob|@keyframes (drift|pulse)/);
|
|
39
|
+
});
|
|
40
|
+
});
|
package/src/wizards/gcp-cost.ts
CHANGED
|
@@ -104,7 +104,7 @@ export function estimateGcpCost(inputs: CostEstimateInputs): {
|
|
|
104
104
|
name: "Quality checks (Lighthouse on the admin)",
|
|
105
105
|
monthlyUsd: 1,
|
|
106
106
|
notes:
|
|
107
|
-
"~
|
|
107
|
+
"~2-4 min of admin CPU per audited Stage (up to 5 pages x 5 runs); ~$1/mo at a few Stages a day",
|
|
108
108
|
},
|
|
109
109
|
{
|
|
110
110
|
name: "Cloud Run gateway",
|
package/src/wizards/gcp.ts
CHANGED
|
@@ -1965,7 +1965,7 @@ async function stepFinalize(installId: string, setupLink: "print" | "secret"): P
|
|
|
1965
1965
|
` click ${bold("New token")} (scope ${bold("chat")}: Caelo's AI does the work; ${bold("admin")}: your agent drives Caelo's tools),`,
|
|
1966
1966
|
" copy the bearer (shown once) and run the `claude mcp add` command the page shows.",
|
|
1967
1967
|
" The admin is behind Google IAP, so run `gcloud auth application-default login` once on that machine.",
|
|
1968
|
-
` Details: ${cyan("https://caelo-cms.com/mcp")}`,
|
|
1968
|
+
` Details: ${cyan("https://caelo-cms.com/docs/mcp/")}`,
|
|
1969
1969
|
...adminNote,
|
|
1970
1970
|
"",
|
|
1971
1971
|
bold("Lifecycle commands:"),
|
package/stacks/aws/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Pulumi stack provisioning Caelo on AWS managed services. Implements the shared `
|
|
|
10
10
|
| Blob storage | Two S3 buckets (`caelo-<env>-media` + `caelo-<env>-static`) |
|
|
11
11
|
| CDN | CloudFront distribution with two origins (S3 static + S3 media); static files are served as files, no edge compute on the request path |
|
|
12
12
|
| Container runtime | ECS Fargate cluster + four services (admin / gateway / orchestrator / runner) |
|
|
13
|
-
| Secret store | AWS Secrets Manager (postgres-password, public-role-password, gateway-role-password, csrf-secret, cookie-secret, anthropic-api-key, resend-api-key). The gateway gets `
|
|
13
|
+
| Secret store | AWS Secrets Manager (postgres-password, public-role-password, gateway-role-password, csrf-secret, cookie-secret, anthropic-api-key, resend-api-key). The gateway gets `publicDatabaseUrlOut` only, never a cms_admin URL of any role (#613, #635). |
|
|
14
14
|
| DNS + cert | ACM cert (us-east-1, DNS-validated, supports `*.<domain>`) |
|
|
15
15
|
| Edge-log sink | CloudFront access logs delivered to the CloudWatch log group `/caelo/<env>/cloudfront` in us-east-1 (queryable by P12A analytics plugin) |
|
|
16
16
|
|
|
@@ -18,6 +18,8 @@ The Caelo runtime never knows it's on AWS — it just consumes `DATABASE_URL` /
|
|
|
18
18
|
|
|
19
19
|
## First-time install
|
|
20
20
|
|
|
21
|
+
Run this stack from a checkout of the Caelo repository after `bun install` at its root. `@pulumi/aws` + `@pulumi/awsx` are devDependencies of `@caelo-cms/provisioning`: the CLI wizard does not provision AWS yet, so the published package does not make every GCP and self-hosted install download them (the one-click install from Cloud Shell runs in a 5 GB home directory). They move back to `dependencies` when the AWS wizard ships.
|
|
22
|
+
|
|
21
23
|
```bash
|
|
22
24
|
cd packages/provisioning/stacks/aws
|
|
23
25
|
pulumi stack init prod
|