@caelo-cms/provisioning 0.10.61 → 0.10.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/caddy/Caddyfile.production +7 -0
- package/caddy/Caddyfile.staging +7 -0
- package/dist/admin-internal-answer.d.ts +12 -0
- package/dist/admin-internal-answer.d.ts.map +1 -0
- package/dist/admin-internal-answer.js +32 -0
- package/dist/admin-internal-answer.js.map +1 -0
- package/dist/admin-internal-google.d.ts +4 -0
- package/dist/admin-internal-google.d.ts.map +1 -0
- package/dist/admin-internal-google.js +161 -0
- package/dist/admin-internal-google.js.map +1 -0
- package/dist/admin-internal-self-hosted.d.ts +30 -0
- package/dist/admin-internal-self-hosted.d.ts.map +1 -0
- package/dist/admin-internal-self-hosted.js +92 -0
- package/dist/admin-internal-self-hosted.js.map +1 -0
- package/dist/admin-internal.d.ts +33 -12
- package/dist/admin-internal.d.ts.map +1 -1
- package/dist/admin-internal.js +27 -159
- package/dist/admin-internal.js.map +1 -1
- package/dist/agent/command.d.ts +7 -0
- package/dist/agent/command.d.ts.map +1 -1
- package/dist/agent/command.js +1 -0
- package/dist/agent/command.js.map +1 -1
- package/dist/agent/lock.d.ts +13 -1
- package/dist/agent/lock.d.ts.map +1 -1
- package/dist/agent/lock.js +62 -5
- package/dist/agent/lock.js.map +1 -1
- package/dist/agent/run.d.ts +5 -1
- package/dist/agent/run.d.ts.map +1 -1
- package/dist/agent/run.js +8 -1
- package/dist/agent/run.js.map +1 -1
- package/dist/agent-db.js +192 -90
- package/dist/caddy.d.ts.map +1 -1
- package/dist/caddy.js +11 -1
- package/dist/caddy.js.map +1 -1
- package/dist/cli-routing.js +1 -1
- package/dist/cli-routing.js.map +1 -1
- package/dist/cli.js +95 -38
- package/dist/cli.js.map +1 -1
- package/dist/compose.d.ts +7 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +5 -0
- package/dist/compose.js.map +1 -1
- package/dist/converge-job-gcp.d.ts.map +1 -1
- package/dist/converge-job-gcp.js +10 -7
- package/dist/converge-job-gcp.js.map +1 -1
- package/dist/converge-job-preflight.d.ts +21 -0
- package/dist/converge-job-preflight.d.ts.map +1 -0
- package/dist/converge-job-preflight.js +111 -0
- package/dist/converge-job-preflight.js.map +1 -0
- package/dist/converge-job.d.ts +8 -2
- package/dist/converge-job.d.ts.map +1 -1
- package/dist/converge-job.js +14 -0
- package/dist/converge-job.js.map +1 -1
- package/dist/gateway-credentials.d.ts +5 -3
- package/dist/gateway-credentials.d.ts.map +1 -1
- package/dist/gateway-credentials.js +5 -8
- package/dist/gateway-credentials.js.map +1 -1
- package/dist/gcloud.d.ts +29 -0
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +66 -0
- package/dist/gcloud.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.d.ts.map +1 -1
- package/dist/infra-contract.js +10 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job.d.ts +3 -1
- package/dist/install-job.d.ts.map +1 -1
- package/dist/install-job.js.map +1 -1
- package/dist/install-state.d.ts +10 -0
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +12 -0
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts +12 -0
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +113 -28
- package/dist/lifecycle.js.map +1 -1
- package/dist/mcp-iap.d.ts +2 -0
- package/dist/mcp-iap.d.ts.map +1 -1
- package/dist/mcp-iap.js +50 -17
- package/dist/mcp-iap.js.map +1 -1
- package/dist/owner-reset.d.ts +16 -19
- package/dist/owner-reset.d.ts.map +1 -1
- package/dist/owner-reset.js +92 -19
- package/dist/owner-reset.js.map +1 -1
- package/dist/preview-plan.d.ts +71 -0
- package/dist/preview-plan.d.ts.map +1 -0
- package/dist/preview-plan.js +168 -0
- package/dist/preview-plan.js.map +1 -0
- package/dist/public-role-isolation.d.ts +2 -0
- package/dist/public-role-isolation.d.ts.map +1 -1
- package/dist/public-role-isolation.js +9 -3
- package/dist/public-role-isolation.js.map +1 -1
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +4 -0
- package/dist/release-contract.js.map +1 -1
- package/dist/self-hosted-internal-secret.d.ts +27 -0
- package/dist/self-hosted-internal-secret.d.ts.map +1 -0
- package/dist/self-hosted-internal-secret.js +69 -0
- package/dist/self-hosted-internal-secret.js.map +1 -0
- package/dist/self-hosted-release.d.ts +3 -1
- package/dist/self-hosted-release.d.ts.map +1 -1
- package/dist/self-hosted-release.js +6 -2
- package/dist/self-hosted-release.js.map +1 -1
- package/dist/self-hosted-upgrade.d.ts +13 -1
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +26 -2
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-converge.d.ts +5 -2
- package/dist/stack-converge.d.ts.map +1 -1
- package/dist/stack-converge.js +73 -8
- package/dist/stack-converge.js.map +1 -1
- package/dist/staging-admin.d.ts +4 -1
- package/dist/staging-admin.d.ts.map +1 -1
- package/dist/staging-admin.js.map +1 -1
- package/dist/state-backend.d.ts +3 -2
- package/dist/state-backend.d.ts.map +1 -1
- package/dist/state-backend.js +3 -2
- package/dist/state-backend.js.map +1 -1
- package/dist/state-command.d.ts +17 -0
- package/dist/state-command.d.ts.map +1 -1
- package/dist/state-command.js +34 -5
- package/dist/state-command.js.map +1 -1
- package/dist/state-migrate.d.ts +14 -3
- package/dist/state-migrate.d.ts.map +1 -1
- package/dist/state-migrate.js +31 -7
- package/dist/state-migrate.js.map +1 -1
- package/dist/state-pulumi.d.ts +0 -2
- package/dist/state-pulumi.d.ts.map +1 -1
- package/dist/state-pulumi.js +19 -20
- package/dist/state-pulumi.js.map +1 -1
- package/dist/static-security-headers.d.ts +86 -0
- package/dist/static-security-headers.d.ts.map +1 -0
- package/dist/static-security-headers.js +126 -0
- package/dist/static-security-headers.js.map +1 -0
- package/dist/static-svg-gcp.d.ts +28 -0
- package/dist/static-svg-gcp.d.ts.map +1 -0
- package/dist/static-svg-gcp.js +106 -0
- package/dist/static-svg-gcp.js.map +1 -0
- package/dist/upgrade/app-roll.d.ts +3 -0
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +12 -5
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/backup.d.ts +2 -0
- package/dist/upgrade/backup.d.ts.map +1 -1
- package/dist/upgrade/backup.js +5 -4
- package/dist/upgrade/backup.js.map +1 -1
- package/dist/upgrade/converge.d.ts +7 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +19 -7
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/gcp-lookup.d.ts +38 -3
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
- package/dist/upgrade/gcp-lookup.js +31 -9
- package/dist/upgrade/gcp-lookup.js.map +1 -1
- package/dist/upgrade/health.d.ts +31 -5
- package/dist/upgrade/health.d.ts.map +1 -1
- package/dist/upgrade/health.js +114 -25
- package/dist/upgrade/health.js.map +1 -1
- package/dist/upgrade/release-images.d.ts +17 -0
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +23 -12
- package/dist/upgrade/release-images.js.map +1 -1
- package/dist/upgrade/reporter.d.ts +2 -0
- package/dist/upgrade/reporter.d.ts.map +1 -1
- package/dist/upgrade/reporter.js +1 -0
- package/dist/upgrade/reporter.js.map +1 -1
- package/dist/upgrade/upgrade.d.ts +11 -1
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +5 -2
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-gcp.d.ts +12 -2
- package/dist/upgrader-gcp.d.ts.map +1 -1
- package/dist/upgrader-gcp.js +22 -13
- package/dist/upgrader-gcp.js.map +1 -1
- package/dist/upgrader-iam-preflight.d.ts +16 -3
- package/dist/upgrader-iam-preflight.d.ts.map +1 -1
- package/dist/upgrader-iam-preflight.js +9 -4
- package/dist/upgrader-iam-preflight.js.map +1 -1
- package/dist/upgrader-job.d.ts.map +1 -1
- package/dist/upgrader-job.js +10 -8
- package/dist/upgrader-job.js.map +1 -1
- package/dist/upgrader-reporter.d.ts.map +1 -1
- package/dist/upgrader-reporter.js +1 -0
- package/dist/upgrader-reporter.js.map +1 -1
- package/dist/wizards/gcp-pulumi.d.ts +70 -23
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +118 -39
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +67 -11
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/admin-internal-answer.ts +43 -0
- package/src/admin-internal-google.ts +187 -0
- package/src/admin-internal-self-hosted.test.ts +70 -0
- package/src/admin-internal-self-hosted.ts +128 -0
- package/src/admin-internal.ts +59 -174
- package/src/admin-memory.test.ts +1 -0
- package/src/agent/command.ts +3 -0
- package/src/agent/lock-config.test.ts +57 -2
- package/src/agent/lock.ts +68 -5
- package/src/agent/run.test.ts +18 -1
- package/src/agent/run.ts +17 -6
- package/src/caddy-redirects.test.ts +1 -0
- package/src/caddy.ts +12 -0
- package/src/cli-routing.ts +1 -1
- package/src/cli.ts +102 -44
- package/src/compose.ts +12 -0
- package/src/converge-job-gcp.ts +16 -11
- package/src/converge-job-preflight.test.ts +108 -0
- package/src/converge-job-preflight.ts +124 -0
- package/src/converge-job.test.ts +50 -0
- package/src/converge-job.ts +38 -2
- package/src/gateway-credentials.test.ts +4 -3
- package/src/gateway-credentials.ts +6 -10
- package/src/gateway-env-isolation.test.ts +1 -0
- package/src/gcloud.test.ts +93 -1
- package/src/gcloud.ts +82 -0
- package/src/index.test.ts +38 -1
- package/src/infra-contract.test.ts +4 -3
- package/src/infra-contract.ts +10 -1
- package/src/install-job.ts +5 -2
- package/src/install-state.ts +13 -0
- package/src/lifecycle.ts +134 -28
- package/src/mcp-iap.test.ts +66 -5
- package/src/mcp-iap.ts +55 -17
- package/src/owner-reset-providers.test.ts +370 -0
- package/src/owner-reset.test.ts +2 -2
- package/src/owner-reset.ts +108 -19
- package/src/preview-plan.test.ts +233 -0
- package/src/preview-plan.ts +226 -0
- package/src/public-role-isolation.test.ts +13 -2
- package/src/public-role-isolation.ts +14 -4
- package/src/release-contract.ts +4 -0
- package/src/self-hosted-compose.test.ts +25 -0
- package/src/self-hosted-internal-secret.test.ts +61 -0
- package/src/self-hosted-internal-secret.ts +80 -0
- package/src/self-hosted-release.ts +10 -3
- package/src/self-hosted-upgrade.test.ts +35 -1
- package/src/self-hosted-upgrade.ts +40 -3
- package/src/stack-contract.test.ts +1 -0
- package/src/stack-converge.test.ts +89 -5
- package/src/stack-converge.ts +81 -8
- package/src/staging-admin.test.ts +1 -1
- package/src/staging-admin.ts +2 -1
- package/src/state-backend.ts +3 -2
- package/src/state-command.test.ts +31 -0
- package/src/state-command.ts +35 -8
- package/src/state-migrate.test.ts +58 -7
- package/src/state-migrate.ts +48 -10
- package/src/state-pulumi.ts +20 -21
- package/src/static-security-headers.test.ts +124 -0
- package/src/static-security-headers.ts +154 -0
- package/src/static-svg-gcp.test.ts +133 -0
- package/src/static-svg-gcp.ts +140 -0
- package/src/upgrade/app-roll.ts +18 -7
- package/src/upgrade/backup.ts +14 -5
- package/src/upgrade/converge.ts +25 -7
- package/src/upgrade/gcp-lookup.test.ts +80 -1
- package/src/upgrade/gcp-lookup.ts +61 -11
- package/src/upgrade/health.test.ts +132 -8
- package/src/upgrade/health.ts +169 -31
- package/src/upgrade/release-images.test.ts +17 -0
- package/src/upgrade/release-images.ts +26 -14
- package/src/upgrade/reporter.ts +3 -0
- package/src/upgrade/upgrade.test.ts +137 -8
- package/src/upgrade/upgrade.ts +14 -2
- package/src/upgrader-gcp.ts +28 -13
- package/src/upgrader-iam-preflight.test.ts +6 -3
- package/src/upgrader-iam-preflight.ts +26 -5
- package/src/upgrader-job.ts +14 -6
- package/src/upgrader-reporter.ts +1 -0
- package/src/wizards/gcp-pulumi.test.ts +1 -1
- package/src/wizards/gcp-pulumi.ts +147 -38
- package/src/wizards/gcp.ts +89 -9
- package/src/wizards/stack-work-dir.test.ts +175 -0
- package/stacks/aws/index.ts +40 -0
- package/stacks/azure/index.ts +22 -1
- package/stacks/gcp/README.md +4 -0
- package/stacks/gcp/index.ts +6 -2
- package/stacks/gcp/Pulumi.production.yaml +0 -16
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* admin-internal.ts on Google Cloud, as the operator's own gcloud identity —
|
|
5
|
+
* the same path the Power-MCP shim takes (packages/mcp-server/src/ingress-auth.ts):
|
|
6
|
+
*
|
|
7
|
+
* 1. the admin's URL: on gcp-firebase the admin Cloud Run service's own
|
|
8
|
+
* URL (IAP sits on the service); on gcp `https://admin.<domain>` (IAP
|
|
9
|
+
* sits on the load balancer's admin backend, and the service's
|
|
10
|
+
* run.app URL accepts only load-balancer traffic);
|
|
11
|
+
* 2. the install's internal secret from Secret Manager (in memory only),
|
|
12
|
+
* which signs a 5-minute token for the endpoint's scope;
|
|
13
|
+
* 3. a JWT signed as `caelo-mcp@<project>` through the IAM Credentials
|
|
14
|
+
* API with `aud` = `<admin origin>/*` — the audience IAP's service-
|
|
15
|
+
* account JWT path checks on both resource kinds
|
|
16
|
+
* (cloud.google.com/iap/docs/authentication-howto). The admin's IAP
|
|
17
|
+
* lets that service account in, and every IAP-allowlisted operator may
|
|
18
|
+
* sign as it (`cms-provision upgrade` grants both), so the request
|
|
19
|
+
* passes IAP first, then Caelo's token.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { findService } from "./admin-domain.js";
|
|
23
|
+
import type {
|
|
24
|
+
AdminInternalAnswer,
|
|
25
|
+
AdminInternalCall,
|
|
26
|
+
AdminInternalDeps,
|
|
27
|
+
AdminInternalResult,
|
|
28
|
+
GoogleAdminTarget,
|
|
29
|
+
} from "./admin-internal.js";
|
|
30
|
+
import { readAdminAnswer } from "./admin-internal-answer.js";
|
|
31
|
+
import { gcloud as defaultGcloud, type GcloudResult } from "./gcloud.js";
|
|
32
|
+
import { GCP_STACK_ENV, gcpSecretId, mcpIapServiceAccountEmail } from "./gcp-names.js";
|
|
33
|
+
import { mintInternalToken } from "./internal-token.js";
|
|
34
|
+
|
|
35
|
+
type Run = (args: string[]) => Promise<GcloudResult>;
|
|
36
|
+
|
|
37
|
+
/** Signed JWTs live at most 1 hour; one call needs a few seconds. */
|
|
38
|
+
const IAP_JWT_LIFETIME_S = 600;
|
|
39
|
+
|
|
40
|
+
/** The admin call on a gcp / gcp-firebase install (see the file comment). */
|
|
41
|
+
export async function callGoogleAdmin<T>(
|
|
42
|
+
target: GoogleAdminTarget,
|
|
43
|
+
call: AdminInternalCall,
|
|
44
|
+
deps: AdminInternalDeps,
|
|
45
|
+
): Promise<AdminInternalAnswer<T>> {
|
|
46
|
+
const run = deps.run ?? defaultGcloud;
|
|
47
|
+
const doFetch = deps.fetch ?? fetch;
|
|
48
|
+
|
|
49
|
+
const admin = await adminUrl(run, target);
|
|
50
|
+
if (!admin.ok) return admin;
|
|
51
|
+
|
|
52
|
+
const secretId = gcpSecretId(GCP_STACK_ENV, "internal-secret");
|
|
53
|
+
const secret = await run([
|
|
54
|
+
"secrets",
|
|
55
|
+
"versions",
|
|
56
|
+
"access",
|
|
57
|
+
"latest",
|
|
58
|
+
`--secret=${secretId}`,
|
|
59
|
+
`--project=${target.projectId}`,
|
|
60
|
+
]);
|
|
61
|
+
const internalSecret = secret.stdout.trim();
|
|
62
|
+
if (!secret.ok || internalSecret.length < 32) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
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.`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Installs from before #678 may not have the IAM Credentials API on;
|
|
70
|
+
// enabling an enabled API is a no-op (codex review on #681).
|
|
71
|
+
const api = await run([
|
|
72
|
+
"services",
|
|
73
|
+
"enable",
|
|
74
|
+
"iamcredentials.googleapis.com",
|
|
75
|
+
`--project=${target.projectId}`,
|
|
76
|
+
]);
|
|
77
|
+
if (!api.ok) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
error: `enable the IAM Credentials API (iamcredentials.googleapis.com), needed to pass the admin's IAP: ${api.stderr.trim()}`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const iap = await signIapJwt(run, doFetch, target.projectId, admin.value, deps.now);
|
|
84
|
+
if (!iap.ok) return iap;
|
|
85
|
+
|
|
86
|
+
const token = await mintInternalToken(internalSecret, call.scope);
|
|
87
|
+
let res: Response;
|
|
88
|
+
try {
|
|
89
|
+
res = await doFetch(`${admin.value}${call.endpoint}`, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: {
|
|
92
|
+
authorization: `Bearer ${iap.value}`,
|
|
93
|
+
"x-caelo-internal-token": token,
|
|
94
|
+
"content-type": "application/json",
|
|
95
|
+
},
|
|
96
|
+
body: JSON.stringify(call.body),
|
|
97
|
+
});
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
error: `reach the admin at ${admin.value}: ${String(e)}${unreachableHint(target)}`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const text = await res.text();
|
|
105
|
+
if (res.headers.get("x-goog-iap-generated-response") === "true") {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
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.`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const answer = readAdminAnswer<T>(res.status, text, call, "cms-provision upgrade");
|
|
112
|
+
return answer.ok ? { ok: true, value: { answer: answer.value, adminUrl: admin.value } } : answer;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Where the admin answers behind IAP (see the file comment, step 1). */
|
|
116
|
+
async function adminUrl(run: Run, target: GoogleAdminTarget): Promise<AdminInternalResult<string>> {
|
|
117
|
+
if (target.provider === "gcp") return { ok: true, value: `https://admin.${target.domain}` };
|
|
118
|
+
const scope = [`--region=${target.region}`, `--project=${target.projectId}`];
|
|
119
|
+
const found = await findService(run, scope, "admin");
|
|
120
|
+
if (!found.ok) return { ok: false, error: found.error };
|
|
121
|
+
const url = await run([
|
|
122
|
+
"run",
|
|
123
|
+
"services",
|
|
124
|
+
"describe",
|
|
125
|
+
found.service,
|
|
126
|
+
...scope,
|
|
127
|
+
"--format=value(status.url)",
|
|
128
|
+
]);
|
|
129
|
+
const value = url.stdout.trim();
|
|
130
|
+
if (!url.ok || !value) {
|
|
131
|
+
return { ok: false, error: `read the admin's URL: ${url.stderr.trim() || "empty"}` };
|
|
132
|
+
}
|
|
133
|
+
return { ok: true, value };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** On gcp the admin's host must resolve to the load balancer and have its certificate. */
|
|
137
|
+
function unreachableHint(target: GoogleAdminTarget): string {
|
|
138
|
+
return target.provider === "gcp"
|
|
139
|
+
? ` — admin.${target.domain} must resolve to the install's load balancer with an ACTIVE certificate (check \`dig +short admin.${target.domain}\`).`
|
|
140
|
+
: "";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A JWT signed as the install's MCP service account, for the admin's IAP. */
|
|
144
|
+
async function signIapJwt(
|
|
145
|
+
run: Run,
|
|
146
|
+
doFetch: typeof fetch,
|
|
147
|
+
projectId: string,
|
|
148
|
+
adminUrl: string,
|
|
149
|
+
now: (() => number) | undefined,
|
|
150
|
+
): Promise<AdminInternalResult<string>> {
|
|
151
|
+
const access = await run(["auth", "print-access-token"]);
|
|
152
|
+
const accessToken = access.stdout.trim();
|
|
153
|
+
if (!access.ok || !accessToken) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
error: `get an access token from gcloud: ${access.stderr.trim() || "empty"}. Run \`gcloud auth login\` with the account you provisioned with.`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
const serviceAccount = mcpIapServiceAccountEmail(projectId);
|
|
160
|
+
const iat = now ? now() : Math.floor(Date.now() / 1000);
|
|
161
|
+
const res = await doFetch(
|
|
162
|
+
`https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${encodeURIComponent(serviceAccount)}:signJwt`,
|
|
163
|
+
{
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
|
|
166
|
+
body: JSON.stringify({
|
|
167
|
+
payload: JSON.stringify({
|
|
168
|
+
iss: serviceAccount,
|
|
169
|
+
sub: serviceAccount,
|
|
170
|
+
aud: `${new URL(adminUrl).origin}/*`,
|
|
171
|
+
iat,
|
|
172
|
+
exp: iat + IAP_JWT_LIFETIME_S,
|
|
173
|
+
}),
|
|
174
|
+
}),
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
if (!res.ok) {
|
|
178
|
+
const detail = (await res.text().catch(() => "")).slice(0, 300);
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
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.`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const { signedJwt } = (await res.json()) as { signedJwt?: string };
|
|
185
|
+
if (!signedJwt) return { ok: false, error: "IAM signJwt answered without a signedJwt" };
|
|
186
|
+
return { ok: true, value: signedJwt };
|
|
187
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #770 — the script `docker compose exec caelo-admin bun -e …` runs, executed
|
|
5
|
+
* here by a real bun against a stand-in admin on 127.0.0.1:$PORT: it reads
|
|
6
|
+
* the request from stdin, sends the token in `x-caelo-internal-token` and
|
|
7
|
+
* prints the admin's answer as one JSON line.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
11
|
+
import { IN_CONTAINER_FETCH } from "./admin-internal-self-hosted.js";
|
|
12
|
+
|
|
13
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
14
|
+
const received: { path: string; token: string | null; body: unknown }[] = [];
|
|
15
|
+
|
|
16
|
+
beforeAll(() => {
|
|
17
|
+
server = Bun.serve({
|
|
18
|
+
hostname: "127.0.0.1",
|
|
19
|
+
port: 0,
|
|
20
|
+
async fetch(req) {
|
|
21
|
+
received.push({
|
|
22
|
+
path: new URL(req.url).pathname,
|
|
23
|
+
token: req.headers.get("x-caelo-internal-token"),
|
|
24
|
+
body: await req.json(),
|
|
25
|
+
});
|
|
26
|
+
return Response.json({ ok: true, echoed: true }, { status: 201 });
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
afterAll(() => server.stop(true));
|
|
31
|
+
|
|
32
|
+
async function runScript(stdin: string, port: string): Promise<string> {
|
|
33
|
+
const proc = Bun.spawn([process.execPath, "-e", IN_CONTAINER_FETCH], {
|
|
34
|
+
env: { PATH: process.env.PATH ?? "", PORT: port },
|
|
35
|
+
stdin: Buffer.from(stdin),
|
|
36
|
+
stdout: "pipe",
|
|
37
|
+
stderr: "pipe",
|
|
38
|
+
});
|
|
39
|
+
const out = await new Response(proc.stdout).text();
|
|
40
|
+
expect(await proc.exited).toBe(0);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("IN_CONTAINER_FETCH", () => {
|
|
45
|
+
it("POSTs the stdin request to the admin's own port and prints its answer", async () => {
|
|
46
|
+
const out = await runScript(
|
|
47
|
+
JSON.stringify({ endpoint: "/api/internal/owner-reset", token: "t.sig", body: { a: 1 } }),
|
|
48
|
+
String(server.port),
|
|
49
|
+
);
|
|
50
|
+
expect(JSON.parse(out)).toEqual({ status: 201, text: '{"ok":true,"echoed":true}' });
|
|
51
|
+
expect(received).toEqual([
|
|
52
|
+
{ path: "/api/internal/owner-reset", token: "t.sig", body: { a: 1 } },
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("reports an admin that does not answer instead of failing silently", async () => {
|
|
57
|
+
const closed = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() });
|
|
58
|
+
const port = String(closed.port);
|
|
59
|
+
closed.stop(true);
|
|
60
|
+
const out = await runScript(
|
|
61
|
+
JSON.stringify({ endpoint: "/api/internal/x", token: "t", body: {} }),
|
|
62
|
+
port,
|
|
63
|
+
);
|
|
64
|
+
expect(Object.keys(JSON.parse(out))).toEqual(["unreachable"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("holds no secret: it is visible in the host's process list", () => {
|
|
68
|
+
expect(IN_CONTAINER_FETCH).not.toMatch(/CAELO_INTERNAL_SECRET|[0-9a-f]{32}/);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* admin-internal.ts on a self-hosted install: from the install's host into
|
|
5
|
+
* the admin container, never over the internet.
|
|
6
|
+
*
|
|
7
|
+
* The root of trust is the host: whoever may run `docker compose` for the
|
|
8
|
+
* install already controls its database, its files and its secrets, so
|
|
9
|
+
* reaching the admin's internal endpoints grants nothing new. The CLI signs
|
|
10
|
+
* a 5-minute token for the endpoint's scope with `.caelo/internal-secret.json`
|
|
11
|
+
* (the secret the Compose file gives the admin as `CAELO_INTERNAL_SECRET`)
|
|
12
|
+
* and runs one `fetch` inside the admin container (`docker compose exec`)
|
|
13
|
+
* against the admin's own port on 127.0.0.1 — the admin image has bun but
|
|
14
|
+
* no curl.
|
|
15
|
+
*
|
|
16
|
+
* Nothing secret travels in process arguments, which other users of the
|
|
17
|
+
* host can read in `ps`: the token and the request body go in on the exec's
|
|
18
|
+
* stdin, the answer comes back on its stdout. `docker compose exec` output
|
|
19
|
+
* is not part of the container's log (`docker compose logs` shows only the
|
|
20
|
+
* container's main process), so the answer is written nowhere.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type {
|
|
24
|
+
AdminInternalAnswer,
|
|
25
|
+
AdminInternalCall,
|
|
26
|
+
AdminInternalDeps,
|
|
27
|
+
SelfHostedAdminTarget,
|
|
28
|
+
} from "./admin-internal.js";
|
|
29
|
+
import { readAdminAnswer } from "./admin-internal-answer.js";
|
|
30
|
+
import { mintInternalToken } from "./internal-token.js";
|
|
31
|
+
import { selfCommand } from "./self-command.js";
|
|
32
|
+
import { internalSecretPath, readInternalSecret } from "./self-hosted-internal-secret.js";
|
|
33
|
+
import { bunHostRunner } from "./self-hosted-release.js";
|
|
34
|
+
import { compose } from "./self-hosted-upgrade.js";
|
|
35
|
+
|
|
36
|
+
/** The admin's Compose service (compose.ts). */
|
|
37
|
+
const ADMIN_SERVICE = "caelo-admin";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What runs inside the admin container. It reads `{ endpoint, token, body }`
|
|
41
|
+
* from stdin, POSTs to the admin's own port and prints one JSON line:
|
|
42
|
+
* `{ status, text }`, or `{ unreachable }` when the admin does not answer.
|
|
43
|
+
* It holds no secret itself — it is visible in `ps` on the host.
|
|
44
|
+
*/
|
|
45
|
+
export const IN_CONTAINER_FETCH = `(async () => {
|
|
46
|
+
const out = (o) => process.stdout.write(JSON.stringify(o));
|
|
47
|
+
const req = JSON.parse(await Bun.stdin.text());
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch("http://127.0.0.1:" + process.env.PORT + req.endpoint, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { "x-caelo-internal-token": req.token, "content-type": "application/json" },
|
|
52
|
+
body: JSON.stringify(req.body),
|
|
53
|
+
});
|
|
54
|
+
out({ status: res.status, text: await res.text() });
|
|
55
|
+
} catch (e) {
|
|
56
|
+
out({ unreachable: String(e) });
|
|
57
|
+
}
|
|
58
|
+
})();`;
|
|
59
|
+
|
|
60
|
+
/** The admin call on a self-hosted install (see the file comment). */
|
|
61
|
+
export async function callSelfHostedAdmin<T>(
|
|
62
|
+
target: SelfHostedAdminTarget,
|
|
63
|
+
call: AdminInternalCall,
|
|
64
|
+
deps: AdminInternalDeps,
|
|
65
|
+
): Promise<AdminInternalAnswer<T>> {
|
|
66
|
+
const host = deps.host ?? bunHostRunner;
|
|
67
|
+
const restart = selfCommand("up");
|
|
68
|
+
let secret: string | null;
|
|
69
|
+
try {
|
|
70
|
+
secret = readInternalSecret(target.caeloDir);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
return { ok: false, error: (e as Error).message };
|
|
73
|
+
}
|
|
74
|
+
if (secret === null) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
error: `${internalSecretPath(target.caeloDir)} does not exist. Run \`${restart}\` (it creates the secret and restarts the admin with it), then run this command again.`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const token = await mintInternalToken(secret, call.scope);
|
|
82
|
+
const r = await host.run(
|
|
83
|
+
compose(target.caeloDir, "exec", "-T", ADMIN_SERVICE, "bun", "-e", IN_CONTAINER_FETCH),
|
|
84
|
+
{ stdinText: JSON.stringify({ endpoint: call.endpoint, token, body: call.body }) },
|
|
85
|
+
);
|
|
86
|
+
if (r.exitCode !== 0) {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
error: `run a request inside the ${ADMIN_SERVICE} container (docker compose exec, exit ${r.exitCode}): ${tail(r.stderr) || "no output"}. The admin must be running — start it with \`${restart}\`, then run this command again.`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
let reply: { status?: unknown; text?: unknown; unreachable?: unknown };
|
|
93
|
+
try {
|
|
94
|
+
reply = JSON.parse(r.stdout) as typeof reply;
|
|
95
|
+
} catch {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
error: `the request inside the ${ADMIN_SERVICE} container printed no answer: ${tail(r.stdout) || tail(r.stderr) || "empty"}`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (typeof reply.unreachable === "string") {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
error: `the admin did not answer on its own port inside the container (${reply.unreachable}). Check \`docker compose -f ${target.caeloDir}/docker-compose.yml logs ${ADMIN_SERVICE}\`, then run this command again.`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (typeof reply.status !== "number" || typeof reply.text !== "string") {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
error: `the request inside the ${ADMIN_SERVICE} container answered an unexpected shape`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (reply.status === 401) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
error: `the admin refused the internal token (HTTP 401: ${reply.text.slice(0, 200)}) — the running admin does not hold the secret in ${internalSecretPath(target.caeloDir)} (it was started from an older Compose file). Run \`${restart}\` to restart it with that secret, then run this command again.`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const answer = readAdminAnswer<T>(reply.status, reply.text, call, "cms-provision upgrade");
|
|
120
|
+
return answer.ok
|
|
121
|
+
? { ok: true, value: { answer: answer.value, adminUrl: target.adminUrl } }
|
|
122
|
+
: answer;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The last lines of a command's output, for an error message. */
|
|
126
|
+
function tail(text: string): string {
|
|
127
|
+
return text.trim().split("\n").slice(-5).join("\n").slice(0, 500);
|
|
128
|
+
}
|
package/src/admin-internal.ts
CHANGED
|
@@ -1,45 +1,67 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MPL-2.0
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The CLI's channel to the admin's `/api/internal/*` endpoints
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* nothing is written anywhere (CLAUDE.md §7, #659).
|
|
4
|
+
* The CLI's channel to the admin's `/api/internal/*` endpoints, for commands
|
|
5
|
+
* that need an answer BACK from the admin (a generated password, a reset
|
|
6
|
+
* link). A one-shot job could only report through the platform's logs, which
|
|
7
|
+
* would put that secret into them; this call answers straight to the CLI
|
|
8
|
+
* process, and nothing is written anywhere (CLAUDE.md §7, #659).
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Each provider reaches the admin its own way (CLAUDE.md §11.B), as the
|
|
11
|
+
* operator's own identity on that provider:
|
|
13
12
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
13
|
+
* - `gcp-firebase` / `gcp` (admin-internal-google.ts): through the admin's
|
|
14
|
+
* Google IAP with a JWT signed as `caelo-mcp@<project>`, plus a token
|
|
15
|
+
* signed with the internal secret from Secret Manager. gcp-firebase
|
|
16
|
+
* addresses the admin's Cloud Run URL (IAP on the service); gcp addresses
|
|
17
|
+
* `admin.<domain>` (IAP on the load balancer backend; the Cloud Run URL
|
|
18
|
+
* takes no traffic from outside the load balancer).
|
|
19
|
+
* - `self-hosted` (admin-internal-self-hosted.ts): from the install's host
|
|
20
|
+
* into the admin container (`docker compose exec`), plus a token signed
|
|
21
|
+
* with `.caelo/internal-secret.json`.
|
|
22
|
+
*
|
|
23
|
+
* aws and azure have no such channel yet (#952).
|
|
21
24
|
*/
|
|
22
25
|
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
26
|
+
import { callGoogleAdmin } from "./admin-internal-google.js";
|
|
27
|
+
import { callSelfHostedAdmin } from "./admin-internal-self-hosted.js";
|
|
28
|
+
import type { GcloudResult } from "./gcloud.js";
|
|
29
|
+
import type { HostRunner } from "./self-hosted-release.js";
|
|
27
30
|
|
|
28
|
-
|
|
31
|
+
/** Which admin to call, and what the provider needs to reach it. */
|
|
32
|
+
export type AdminInternalTarget = GoogleAdminTarget | SelfHostedAdminTarget;
|
|
29
33
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
34
|
+
/** A Google Cloud install (IAP in front of the admin). */
|
|
35
|
+
export type GoogleAdminTarget =
|
|
36
|
+
| {
|
|
37
|
+
readonly provider: "gcp-firebase";
|
|
38
|
+
readonly projectId: string;
|
|
39
|
+
readonly region: string;
|
|
40
|
+
}
|
|
41
|
+
| {
|
|
42
|
+
readonly provider: "gcp";
|
|
43
|
+
readonly projectId: string;
|
|
44
|
+
/** The install's domain; the admin answers on `admin.<domain>`. */
|
|
45
|
+
readonly domain: string;
|
|
46
|
+
};
|
|
32
47
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
readonly
|
|
48
|
+
/** A self-hosted install on this host. */
|
|
49
|
+
export interface SelfHostedAdminTarget {
|
|
50
|
+
readonly provider: "self-hosted";
|
|
51
|
+
/** The install's `.caelo/` directory (Compose file, internal secret). */
|
|
52
|
+
readonly caeloDir: string;
|
|
53
|
+
/** The admin's public origin, for the links the operator opens. */
|
|
54
|
+
readonly adminUrl: string;
|
|
36
55
|
}
|
|
37
56
|
|
|
38
57
|
export interface AdminInternalDeps {
|
|
39
|
-
|
|
58
|
+
/** gcloud (Google providers). */
|
|
59
|
+
readonly run?: (args: string[]) => Promise<GcloudResult>;
|
|
40
60
|
readonly fetch?: typeof fetch;
|
|
41
61
|
/** Seconds since epoch (IAP JWT); tests pin it. */
|
|
42
62
|
readonly now?: () => number;
|
|
63
|
+
/** `docker compose` on the install's host (self-hosted). */
|
|
64
|
+
readonly host?: HostRunner;
|
|
43
65
|
}
|
|
44
66
|
|
|
45
67
|
/** One endpoint call: where, under which token scope, with what body. */
|
|
@@ -55,158 +77,21 @@ export interface AdminInternalCall {
|
|
|
55
77
|
|
|
56
78
|
export type AdminInternalResult<T> = { ok: true; value: T } | { ok: false; error: string };
|
|
57
79
|
|
|
80
|
+
/** What a transport hands back: the admin's answer and the URL it serves on. */
|
|
81
|
+
export type AdminInternalAnswer<T> = AdminInternalResult<{ answer: T; adminUrl: string }>;
|
|
82
|
+
|
|
58
83
|
/**
|
|
59
84
|
* POST `call.body` to the endpoint and return the admin's JSON answer and
|
|
60
|
-
* the admin URL
|
|
61
|
-
*
|
|
62
|
-
*
|
|
85
|
+
* the admin URL the operator opens (to build links). Every failure names
|
|
86
|
+
* the fix: a gate refusal or a pre-endpoint admin points at the command that
|
|
87
|
+
* repairs it, a refusal carries the admin's own message.
|
|
63
88
|
*/
|
|
64
|
-
export
|
|
89
|
+
export function callAdminInternal<T>(
|
|
65
90
|
target: AdminInternalTarget,
|
|
66
91
|
call: AdminInternalCall,
|
|
67
92
|
deps: AdminInternalDeps = {},
|
|
68
|
-
): Promise<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 };
|
|
93
|
+
): Promise<AdminInternalAnswer<T>> {
|
|
94
|
+
return target.provider === "self-hosted"
|
|
95
|
+
? callSelfHostedAdmin<T>(target, call, deps)
|
|
96
|
+
: callGoogleAdmin<T>(target, call, deps);
|
|
212
97
|
}
|
package/src/admin-memory.test.ts
CHANGED
|
@@ -76,6 +76,7 @@ describe("self-hosted compose", () => {
|
|
|
76
76
|
postgresPassword: "pw",
|
|
77
77
|
rolePasswords: { admin: "a", public: "b" },
|
|
78
78
|
caeloSecretKek: "0".repeat(64),
|
|
79
|
+
internalSecret: "i".repeat(48),
|
|
79
80
|
diskSize: "20Gi",
|
|
80
81
|
ports: { http: 80, https: 443 },
|
|
81
82
|
project: "caelo-example-com",
|
package/src/agent/command.ts
CHANGED
|
@@ -59,6 +59,8 @@ export interface AgentCliContext {
|
|
|
59
59
|
convergeRoles(): Promise<ConvergeOutcome>;
|
|
60
60
|
/** The Compose file for `images` from config.json. */
|
|
61
61
|
renderCompose(images: ReleaseImages): string;
|
|
62
|
+
/** Re-write the Caddyfile (config + domains table) and reload Caddy (#891). */
|
|
63
|
+
refreshCaddy(): Promise<{ ok: true } | { ok: false; error: string }>;
|
|
62
64
|
randomSecret(): string;
|
|
63
65
|
}
|
|
64
66
|
|
|
@@ -544,6 +546,7 @@ async function agentApply(argv: readonly string[], ctx: AgentCliContext): Promis
|
|
|
544
546
|
runner: bunHostRunner,
|
|
545
547
|
renderCompose: (images) => ctx.renderCompose(images),
|
|
546
548
|
convergeRoles: () => ctx.convergeRoles(),
|
|
549
|
+
refreshCaddy: () => ctx.refreshCaddy(),
|
|
547
550
|
log,
|
|
548
551
|
},
|
|
549
552
|
),
|