@caelo-cms/provisioning 0.10.50 → 0.10.51
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/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-db.js +89 -20
- 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 +6 -1
- package/dist/infra-contract.js.map +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/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/wizards/gcp-cost.js +1 -1
- package/package.json +2 -2
- package/src/adapter.ts +4 -6
- package/src/admin-internal.ts +212 -0
- package/src/aws-task-roles.ts +58 -0
- 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/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 +4 -3
- package/src/infra-contract.ts +6 -1
- package/src/owner-reset.test.ts +165 -0
- package/src/owner-reset.ts +106 -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/wizards/gcp-cost.ts +1 -1
- package/stacks/aws/README.md +1 -1
- package/stacks/aws/index.ts +55 -43
- package/stacks/azure/README.md +1 -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
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The CLI's channel to the admin's `/api/internal/*` endpoints on a
|
|
5
|
+
* gcp-firebase install, for commands that need an answer BACK from the admin
|
|
6
|
+
* (a generated password, a reset link). A one-shot Cloud Run job could only
|
|
7
|
+
* report through Cloud Logging, which would put that secret into the
|
|
8
|
+
* project's logs; this call answers over TLS straight to the CLI process, and
|
|
9
|
+
* nothing is written anywhere (CLAUDE.md §7, #659).
|
|
10
|
+
*
|
|
11
|
+
* Getting there, as the operator's own gcloud identity — the same path the
|
|
12
|
+
* Power-MCP shim takes (packages/mcp-server/src/ingress-auth.ts):
|
|
13
|
+
*
|
|
14
|
+
* 1. the admin Cloud Run service and its URL;
|
|
15
|
+
* 2. the install's internal secret from Secret Manager (in memory only),
|
|
16
|
+
* which signs a 5-minute token for the endpoint's scope;
|
|
17
|
+
* 3. a JWT signed as `caelo-mcp@<project>` through the IAM Credentials
|
|
18
|
+
* API — the admin's IAP lets that service account in, and every
|
|
19
|
+
* IAP-allowlisted operator may sign as it (`cms-provision upgrade`
|
|
20
|
+
* grants it) — so the request passes IAP first, then Caelo's token.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { findService } from "./admin-domain.js";
|
|
24
|
+
import { gcloud as defaultGcloud, type GcloudResult } from "./gcloud.js";
|
|
25
|
+
import { GCP_STACK_ENV, gcpSecretId, mcpIapServiceAccountEmail } from "./gcp-names.js";
|
|
26
|
+
import { mintInternalToken } from "./internal-token.js";
|
|
27
|
+
|
|
28
|
+
type Run = (args: string[]) => Promise<GcloudResult>;
|
|
29
|
+
|
|
30
|
+
/** Signed JWTs live at most 1 hour; one call needs a few seconds. */
|
|
31
|
+
const IAP_JWT_LIFETIME_S = 600;
|
|
32
|
+
|
|
33
|
+
export interface AdminInternalTarget {
|
|
34
|
+
readonly projectId: string;
|
|
35
|
+
readonly region: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AdminInternalDeps {
|
|
39
|
+
readonly run?: Run;
|
|
40
|
+
readonly fetch?: typeof fetch;
|
|
41
|
+
/** Seconds since epoch (IAP JWT); tests pin it. */
|
|
42
|
+
readonly now?: () => number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One endpoint call: where, under which token scope, with what body. */
|
|
46
|
+
export interface AdminInternalCall {
|
|
47
|
+
/** Path under the admin origin, e.g. `/api/internal/owner-reset`. */
|
|
48
|
+
readonly endpoint: string;
|
|
49
|
+
/** Scope the endpoint's `requireInternalAuth` checks. */
|
|
50
|
+
readonly scope: string;
|
|
51
|
+
readonly body: Record<string, unknown>;
|
|
52
|
+
/** The issue that added the endpoint, named when an older admin answers 404. */
|
|
53
|
+
readonly introducedBy: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type AdminInternalResult<T> = { ok: true; value: T } | { ok: false; error: string };
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* POST `call.body` to the endpoint and return the admin's JSON answer and
|
|
60
|
+
* the admin URL it was reached at (to build links the operator opens).
|
|
61
|
+
* Every failure names the fix: an IAP refusal or a pre-endpoint admin points
|
|
62
|
+
* at `cms-provision upgrade`, a refusal carries the admin's own message.
|
|
63
|
+
*/
|
|
64
|
+
export async function callAdminInternal<T>(
|
|
65
|
+
target: AdminInternalTarget,
|
|
66
|
+
call: AdminInternalCall,
|
|
67
|
+
deps: AdminInternalDeps = {},
|
|
68
|
+
): Promise<AdminInternalResult<{ answer: T; adminUrl: string }>> {
|
|
69
|
+
const run = deps.run ?? defaultGcloud;
|
|
70
|
+
const doFetch = deps.fetch ?? fetch;
|
|
71
|
+
const scope = [`--region=${target.region}`, `--project=${target.projectId}`];
|
|
72
|
+
|
|
73
|
+
const found = await findService(run, scope, "admin");
|
|
74
|
+
if (!found.ok) return { ok: false, error: found.error };
|
|
75
|
+
const url = await run([
|
|
76
|
+
"run",
|
|
77
|
+
"services",
|
|
78
|
+
"describe",
|
|
79
|
+
found.service,
|
|
80
|
+
...scope,
|
|
81
|
+
"--format=value(status.url)",
|
|
82
|
+
]);
|
|
83
|
+
const adminUrl = url.stdout.trim();
|
|
84
|
+
if (!url.ok || !adminUrl) {
|
|
85
|
+
return { ok: false, error: `read the admin's URL: ${url.stderr.trim() || "empty"}` };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const secretId = gcpSecretId(GCP_STACK_ENV, "internal-secret");
|
|
89
|
+
const secret = await run([
|
|
90
|
+
"secrets",
|
|
91
|
+
"versions",
|
|
92
|
+
"access",
|
|
93
|
+
"latest",
|
|
94
|
+
`--secret=${secretId}`,
|
|
95
|
+
`--project=${target.projectId}`,
|
|
96
|
+
]);
|
|
97
|
+
const internalSecret = secret.stdout.trim();
|
|
98
|
+
if (!secret.ok || internalSecret.length < 32) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
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.`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Installs from before #678 may not have the IAM Credentials API on;
|
|
106
|
+
// enabling an enabled API is a no-op (codex review on #681).
|
|
107
|
+
const api = await run([
|
|
108
|
+
"services",
|
|
109
|
+
"enable",
|
|
110
|
+
"iamcredentials.googleapis.com",
|
|
111
|
+
`--project=${target.projectId}`,
|
|
112
|
+
]);
|
|
113
|
+
if (!api.ok) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
error: `enable the IAM Credentials API (iamcredentials.googleapis.com), needed to pass the admin's IAP: ${api.stderr.trim()}`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const iap = await signIapJwt(run, doFetch, target.projectId, adminUrl, deps.now);
|
|
120
|
+
if (!iap.ok) return iap;
|
|
121
|
+
|
|
122
|
+
const token = await mintInternalToken(internalSecret, call.scope);
|
|
123
|
+
let res: Response;
|
|
124
|
+
try {
|
|
125
|
+
res = await doFetch(`${adminUrl}${call.endpoint}`, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: {
|
|
128
|
+
authorization: `Bearer ${iap.value}`,
|
|
129
|
+
"x-caelo-internal-token": token,
|
|
130
|
+
"content-type": "application/json",
|
|
131
|
+
},
|
|
132
|
+
body: JSON.stringify(call.body),
|
|
133
|
+
});
|
|
134
|
+
} catch (e) {
|
|
135
|
+
return { ok: false, error: `reach the admin at ${adminUrl}: ${String(e)}` };
|
|
136
|
+
}
|
|
137
|
+
const text = await res.text();
|
|
138
|
+
if (res.headers.get("x-goog-iap-generated-response") === "true") {
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
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.`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (res.status === 404) {
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
error: `the admin has no ${call.endpoint} yet (it runs a release from before ${call.introducedBy}). Run \`cms-provision upgrade\`, then run this command again.`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
let parsed: { ok?: boolean; error?: string } & Record<string, unknown>;
|
|
151
|
+
try {
|
|
152
|
+
parsed = JSON.parse(text) as typeof parsed;
|
|
153
|
+
} catch {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
error: `the admin answered HTTP ${res.status} with a non-JSON body: ${text.slice(0, 200)}`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
if (!res.ok || parsed.ok !== true) {
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
error: `the admin refused (HTTP ${res.status}): ${parsed.error ?? text.slice(0, 300)}`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return { ok: true, value: { answer: parsed as unknown as T, adminUrl } };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A JWT signed as the install's MCP service account, for the admin's IAP. */
|
|
169
|
+
async function signIapJwt(
|
|
170
|
+
run: Run,
|
|
171
|
+
doFetch: typeof fetch,
|
|
172
|
+
projectId: string,
|
|
173
|
+
adminUrl: string,
|
|
174
|
+
now: (() => number) | undefined,
|
|
175
|
+
): Promise<AdminInternalResult<string>> {
|
|
176
|
+
const access = await run(["auth", "print-access-token"]);
|
|
177
|
+
const accessToken = access.stdout.trim();
|
|
178
|
+
if (!access.ok || !accessToken) {
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
error: `get an access token from gcloud: ${access.stderr.trim() || "empty"}. Run \`gcloud auth login\` with the account you provisioned with.`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const serviceAccount = mcpIapServiceAccountEmail(projectId);
|
|
185
|
+
const iat = now ? now() : Math.floor(Date.now() / 1000);
|
|
186
|
+
const res = await doFetch(
|
|
187
|
+
`https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${encodeURIComponent(serviceAccount)}:signJwt`,
|
|
188
|
+
{
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
|
|
191
|
+
body: JSON.stringify({
|
|
192
|
+
payload: JSON.stringify({
|
|
193
|
+
iss: serviceAccount,
|
|
194
|
+
sub: serviceAccount,
|
|
195
|
+
aud: `${new URL(adminUrl).origin}/*`,
|
|
196
|
+
iat,
|
|
197
|
+
exp: iat + IAP_JWT_LIFETIME_S,
|
|
198
|
+
}),
|
|
199
|
+
}),
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
if (!res.ok) {
|
|
203
|
+
const detail = (await res.text().catch(() => "")).slice(0, 300);
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
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.`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const { signedJwt } = (await res.json()) as { signedJwt?: string };
|
|
210
|
+
if (!signedJwt) return { ok: false, error: "IAM signJwt answered without a signedJwt" };
|
|
211
|
+
return { ok: true, value: signedJwt };
|
|
212
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The AWS stack's per-service ECS task roles (#635 review): which Secrets
|
|
5
|
+
* Manager entries each service's task role may read. One role per service,
|
|
6
|
+
* so the API gateway's identity cannot read a secret that unlocks a
|
|
7
|
+
* cms_admin login (CLAUDE.md §2: the public side has no path to cms_admin),
|
|
8
|
+
* mirroring the GCP gateway SA's bindings (stack-contract.ts
|
|
9
|
+
* `SERVICE_SECRET_ENV`). Kept apart from the Pulumi program so the
|
|
10
|
+
* provisioning tests can assert it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** The Secrets Manager entries the AWS stack creates (`caelo-<env>-<name>`). */
|
|
14
|
+
export type AwsSecret =
|
|
15
|
+
| "postgres-password"
|
|
16
|
+
| "public-role-password"
|
|
17
|
+
| "gateway-role-password"
|
|
18
|
+
| "csrf-secret"
|
|
19
|
+
| "cookie-secret"
|
|
20
|
+
| "anthropic-api-key"
|
|
21
|
+
| "resend-api-key";
|
|
22
|
+
|
|
23
|
+
/** A service with its own ECS task role. */
|
|
24
|
+
export type AwsTaskService = "admin" | "gateway";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The secrets each service's task role may read. The admin side (admin and
|
|
28
|
+
* its in-process workers) reads all of them; the gateway reads only
|
|
29
|
+
* public_role's password — its one database login.
|
|
30
|
+
*/
|
|
31
|
+
export const AWS_SERVICE_SECRETS: Readonly<Record<AwsTaskService, readonly AwsSecret[]>> = {
|
|
32
|
+
admin: [
|
|
33
|
+
"postgres-password",
|
|
34
|
+
"public-role-password",
|
|
35
|
+
"gateway-role-password",
|
|
36
|
+
"csrf-secret",
|
|
37
|
+
"cookie-secret",
|
|
38
|
+
"anthropic-api-key",
|
|
39
|
+
"resend-api-key",
|
|
40
|
+
],
|
|
41
|
+
gateway: ["public-role-password"],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The IAM policy document letting a task role read exactly `secretArns`.
|
|
46
|
+
*
|
|
47
|
+
* @throws on an empty list: a policy with no resource is malformed, and a
|
|
48
|
+
* service that needs no secret should get no secrets policy at all.
|
|
49
|
+
*/
|
|
50
|
+
export function secretsReadPolicy(secretArns: readonly string[]): string {
|
|
51
|
+
if (secretArns.length === 0) throw new Error("secretsReadPolicy: no secret ARNs");
|
|
52
|
+
return JSON.stringify({
|
|
53
|
+
Version: "2012-10-17",
|
|
54
|
+
Statement: [
|
|
55
|
+
{ Effect: "Allow", Action: "secretsmanager:GetSecretValue", Resource: [...secretArns] },
|
|
56
|
+
],
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -40,7 +40,6 @@ const setupLink = "https://admin.acme.test/setup?token=fakeSetupToken0123456789"
|
|
|
40
40
|
const stackOutputs = {
|
|
41
41
|
adminDatabaseUrlOut: { value: databaseUrls("10.1.2.3").admin, secret: false },
|
|
42
42
|
publicDatabaseUrlOut: { value: databaseUrls("10.1.2.3").public, secret: false },
|
|
43
|
-
gatewayDatabaseUrlOut: { value: databaseUrls("10.1.2.3").gateway, secret: false },
|
|
44
43
|
bootstrapUrlOut: { value: setupLink, secret: false },
|
|
45
44
|
bootstrapTokenExpiresAtOut: { value: "2026-10-13T00:00:00.000Z", secret: false },
|
|
46
45
|
providerOut: { value: "gcp", secret: false },
|
package/src/cli.ts
CHANGED
|
@@ -1120,6 +1120,7 @@ const handlers: Record<string, () => Promise<void>> = {
|
|
|
1120
1120
|
truncate: lifecycleTruncate,
|
|
1121
1121
|
"admin-domain": adminDomain,
|
|
1122
1122
|
"staging-domain": stagingDomain,
|
|
1123
|
+
"owner-reset": ownerReset,
|
|
1123
1124
|
// #696 — Pulumi state in the install's own project.
|
|
1124
1125
|
state: async () => (await import("./state-command.js")).stateCommand(process.argv),
|
|
1125
1126
|
attach: async () => (await import("./state-command.js")).attachCommand(process.argv),
|
|
@@ -1196,6 +1197,45 @@ async function lifecycleTruncate(): Promise<void> {
|
|
|
1196
1197
|
await truncateCommand();
|
|
1197
1198
|
}
|
|
1198
1199
|
|
|
1200
|
+
/**
|
|
1201
|
+
* #769 `owner-reset [--install <id>] [--email <addr>]` — print a one-time
|
|
1202
|
+
* reset link for a locked-out Owner, for installs whose admin cannot email
|
|
1203
|
+
* one (see owner-reset.ts for why the operator's cloud identity may).
|
|
1204
|
+
*/
|
|
1205
|
+
async function ownerReset(): Promise<void> {
|
|
1206
|
+
const { installFlag, listInstalls, selectInstall } = await import("./install-state.js");
|
|
1207
|
+
const selected = selectInstall(listInstalls(), installFlag(process.argv));
|
|
1208
|
+
if (!selected.ok) {
|
|
1209
|
+
console.error(selected.message);
|
|
1210
|
+
process.exit(1);
|
|
1211
|
+
}
|
|
1212
|
+
const meta = selected.meta;
|
|
1213
|
+
if (meta.provider !== "gcp-firebase") {
|
|
1214
|
+
console.error(
|
|
1215
|
+
`owner-reset works on gcp-firebase installs so far; this one is ${meta.provider} (issue #770). ` +
|
|
1216
|
+
"Until then, another Owner can set a new password under Settings -> Users, or set up email under Settings -> Email so /forgot can send the link.",
|
|
1217
|
+
);
|
|
1218
|
+
process.exit(1);
|
|
1219
|
+
}
|
|
1220
|
+
if (!meta.projectId || !meta.region) {
|
|
1221
|
+
console.error(
|
|
1222
|
+
`install ${meta.installId} has no project or region recorded in install.json; re-run the wizard to record it.`,
|
|
1223
|
+
);
|
|
1224
|
+
process.exit(1);
|
|
1225
|
+
}
|
|
1226
|
+
const email = arg("email");
|
|
1227
|
+
const { describeOwnerReset, requestOwnerReset } = await import("./owner-reset.js");
|
|
1228
|
+
const r = await requestOwnerReset(
|
|
1229
|
+
{ projectId: meta.projectId, region: meta.region },
|
|
1230
|
+
email ? { email } : {},
|
|
1231
|
+
);
|
|
1232
|
+
if (!r.ok) {
|
|
1233
|
+
console.error(`owner-reset: ${r.error}`);
|
|
1234
|
+
process.exit(1);
|
|
1235
|
+
}
|
|
1236
|
+
for (const line of describeOwnerReset(r.value)) console.log(line);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1199
1239
|
/**
|
|
1200
1240
|
* `admin-domain enable` — bind admin.<domain> to a gcp-firebase admin
|
|
1201
1241
|
* (see admin-domain.ts for why this runs as the operator, not Pulumi).
|
|
@@ -1495,7 +1535,7 @@ if (route.kind === "handler" || route.kind === "wizard") {
|
|
|
1495
1535
|
}
|
|
1496
1536
|
} else {
|
|
1497
1537
|
console.log(
|
|
1498
|
-
"Usage: cms-provision [wizard] [--provider <name> --domain <d> [--staging-domain <host>] --owner-email <e> --region <r>] [--local|--install-job] | <init|up|migrate|status|upgrade|backup|restore|rotate-secret|truncate|destroy|regenerate-caddy|admin-domain|staging-domain|state migrate|attach|pulumi-output-sync|version> [options]\n" +
|
|
1538
|
+
"Usage: cms-provision [wizard] [--provider <name> --domain <d> [--staging-domain <host>] --owner-email <e> --region <r>] [--local|--install-job] | <init|up|migrate|status|upgrade|backup|restore|rotate-secret|truncate|destroy|regenerate-caddy|admin-domain|staging-domain|owner-reset|state migrate|attach|pulumi-output-sync|version> [options]\n" +
|
|
1499
1539
|
"With several installs on this machine, pick one with --install <install-id or domain> (status, upgrade and backup also take --install all); in a terminal you are asked otherwise.\n" +
|
|
1500
1540
|
"Pass --no-wizard with no sub-command to print this usage instead of the wizard.",
|
|
1501
1541
|
);
|
package/src/compose.ts
CHANGED
|
@@ -388,8 +388,8 @@ ${serviceRuntime(spec.images, "gateway", "bun run apps/api-gateway/src/server.ts
|
|
|
388
388
|
depends_on:
|
|
389
389
|
postgres: { condition: service_healthy }
|
|
390
390
|
environment:
|
|
391
|
-
#
|
|
392
|
-
|
|
391
|
+
# public_role on cms_public is its only database login (#613, #635,
|
|
392
|
+
# CLAUDE.md §2: the public side has no path to the authoring database).
|
|
393
393
|
PUBLIC_DATABASE_URL: "${dbUrl("public_role", spec.rolePasswords.public, "cms_public")}"
|
|
394
394
|
GATEWAY_PORT: "${COMPOSE_GATEWAY_PORT}"
|
|
395
395
|
NODE_ENV: production`;
|
|
@@ -93,6 +93,21 @@ describe("checkDeployedRuntimeEnv", () => {
|
|
|
93
93
|
});
|
|
94
94
|
|
|
95
95
|
it("passes once both services read the password from Secret Manager", async () => {
|
|
96
|
+
const { run } = fakeGcloud({
|
|
97
|
+
...lists(),
|
|
98
|
+
"describe caelo-production-admin-abc": [
|
|
99
|
+
svc([secret("ADMIN_DATABASE_PASSWORD"), secret("PUBLIC_ADMIN_DATABASE_PASSWORD")]),
|
|
100
|
+
],
|
|
101
|
+
"describe caelo-production-gateway-def": [
|
|
102
|
+
svc([secret("PUBLIC_DATABASE_PASSWORD", "public-role-password")]),
|
|
103
|
+
],
|
|
104
|
+
});
|
|
105
|
+
expect(await checkDeployedRuntimeEnv({ projectId: "p", region: "r", run })).toEqual({
|
|
106
|
+
ok: true,
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("stops a wizard re-run on a gateway from before #635 (it needs its gateway_role pool)", async () => {
|
|
96
111
|
const { run } = fakeGcloud({
|
|
97
112
|
...lists(),
|
|
98
113
|
"describe caelo-production-admin-abc": [
|
|
@@ -105,9 +120,12 @@ describe("checkDeployedRuntimeEnv", () => {
|
|
|
105
120
|
]),
|
|
106
121
|
],
|
|
107
122
|
});
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
123
|
+
const r = await checkDeployedRuntimeEnv({ projectId: "p", region: "r", run });
|
|
124
|
+
expect(r.ok).toBe(false);
|
|
125
|
+
if (!r.ok) {
|
|
126
|
+
expect(r.error).toContain("GATEWAY_DATABASE_PASSWORD");
|
|
127
|
+
expect(r.error).toContain("Run `upgrade` first");
|
|
128
|
+
}
|
|
111
129
|
});
|
|
112
130
|
|
|
113
131
|
it("stops a wizard re-run on a gateway from before #613 (it needs the admin_role pool)", async () => {
|
|
@@ -123,7 +141,7 @@ describe("checkDeployedRuntimeEnv", () => {
|
|
|
123
141
|
const r = await checkDeployedRuntimeEnv({ projectId: "p", region: "r", run });
|
|
124
142
|
expect(r.ok).toBe(false);
|
|
125
143
|
if (!r.ok) {
|
|
126
|
-
expect(r.error).toContain("
|
|
144
|
+
expect(r.error).toContain("ADMIN_DATABASE_PASSWORD");
|
|
127
145
|
expect(r.error).toContain("Run `upgrade` first");
|
|
128
146
|
}
|
|
129
147
|
});
|
package/src/deployed-release.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { gcloud as defaultGcloud } from "./gcloud.js";
|
|
|
15
15
|
import type { GcloudRunner } from "./gcloud-retry.js";
|
|
16
16
|
import { GCP_STACK_ENV, gcpNamePrefix } from "./gcp-names.js";
|
|
17
17
|
import type { ImageDigests } from "./install-state.js";
|
|
18
|
-
import { type CloudRunSlug, SERVICE_SECRET_ENV } from "./stack-contract.js";
|
|
18
|
+
import { type CloudRunSlug, RETIRED_SERVICE_ENV, SERVICE_SECRET_ENV } from "./stack-contract.js";
|
|
19
19
|
import { liveContainerEnv } from "./stack-converge.js";
|
|
20
20
|
|
|
21
21
|
/** The `sha256:…` digest an image reference pins, or null for a tag reference. */
|
|
@@ -61,8 +61,9 @@ async function findDeployedService(
|
|
|
61
61
|
* reads the password from the URL). The wizard keeps the release an install
|
|
62
62
|
* runs, so re-running it on an install `upgrade` hasn't moved over yet would
|
|
63
63
|
* pair the old release with env it can't connect with. The same holds for a
|
|
64
|
-
* gateway from before #613: its release needs
|
|
65
|
-
* current contract no longer gives it
|
|
64
|
+
* gateway from before #613 or #635: its release needs a cms_admin pool
|
|
65
|
+
* (admin_role, then gateway_role) the current contract no longer gives it —
|
|
66
|
+
* it still carries the retired database vars.
|
|
66
67
|
* Such an install has to go through `upgrade` first, which moves the image
|
|
67
68
|
* and the env in one revision.
|
|
68
69
|
*/
|
|
@@ -88,13 +89,19 @@ export async function checkDeployedRuntimeEnv(opts: {
|
|
|
88
89
|
return { ok: false, error: `describe ${found.name}: ${describe.stderr.trim()}` };
|
|
89
90
|
}
|
|
90
91
|
const env = liveContainerEnv(describe.stdout);
|
|
91
|
-
const missing =
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
const missing = [
|
|
93
|
+
...Object.keys(SERVICE_SECRET_ENV[slug]).filter(
|
|
94
|
+
(name) => name.endsWith("_PASSWORD") && env.get(name)?.kind !== "secret",
|
|
95
|
+
),
|
|
96
|
+
// A database var the contract retired (the gateway's ADMIN_* before
|
|
97
|
+
// #613, its GATEWAY_* before #635) means the running release still
|
|
98
|
+
// needs that login.
|
|
99
|
+
...RETIRED_SERVICE_ENV[slug].filter((name) => /DATABASE/.test(name) && env.has(name)),
|
|
100
|
+
];
|
|
94
101
|
if (missing.length > 0) {
|
|
95
102
|
return {
|
|
96
103
|
ok: false,
|
|
97
|
-
error: `${found.name} does not
|
|
104
|
+
error: `${found.name} does not match the current database env (${missing.join(", ")}) — its release predates it (Secret Manager runtime secrets; for the gateway, its own database login, #613/#635). Run \`upgrade\` first: it moves the release and its env over together. Re-running the installer now would give the running release a database env it can't connect with.`,
|
|
98
105
|
};
|
|
99
106
|
}
|
|
100
107
|
}
|
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Moving an existing install's API gateway off admin_role (issue #613),
|
|
5
|
-
* as `cms-provision upgrade` does it. The env side —
|
|
6
|
-
*
|
|
7
|
-
* gateway
|
|
5
|
+
* as `cms-provision upgrade` does it. The env side — the per-role password
|
|
6
|
+
* secrets in, ADMIN_DATABASE_* out (and, since #635, GATEWAY_DATABASE_* out
|
|
7
|
+
* too: the gateway has no cms_admin login at all) — rides the gateway's
|
|
8
|
+
* roll (stack-contract.ts). gateway_role keeps getting its password until
|
|
9
|
+
* the contract release drops the role, because the previous release's
|
|
10
|
+
* gateway logs in with it until it is rolled. The database side is ordered around
|
|
8
11
|
* that roll so /api/* keeps working:
|
|
9
12
|
*
|
|
10
13
|
* 1. after migrations (which create `gateway_role` without a password):
|