@caelo-cms/provisioning 0.10.61 → 0.10.64
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
|
@@ -3,11 +3,24 @@
|
|
|
3
3
|
/** The install's upgrade lock (agent/lock.ts) and the agent's settings (agent/config.ts). */
|
|
4
4
|
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
chmodSync,
|
|
8
|
+
existsSync,
|
|
9
|
+
mkdtempSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
utimesSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from "node:fs";
|
|
7
15
|
import { tmpdir } from "node:os";
|
|
8
16
|
import { join } from "node:path";
|
|
9
17
|
import { AGENT_DB_PORT_DEFAULT, agentConfigFor, agentDbConnection } from "./config.js";
|
|
10
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
acquireUpgradeLock,
|
|
20
|
+
UpgradeLockAccessError,
|
|
21
|
+
UpgradeLockHeldError,
|
|
22
|
+
upgradeLockPath,
|
|
23
|
+
} from "./lock.js";
|
|
11
24
|
|
|
12
25
|
describe("acquireUpgradeLock", () => {
|
|
13
26
|
let dir: string;
|
|
@@ -48,6 +61,48 @@ describe("acquireUpgradeLock", () => {
|
|
|
48
61
|
acquireUpgradeLock(dir, "the update agent")();
|
|
49
62
|
expect(readdirSync(dir)).toEqual([]);
|
|
50
63
|
});
|
|
64
|
+
|
|
65
|
+
it("contention is an UpgradeLockHeldError (#804)", () => {
|
|
66
|
+
acquireUpgradeLock(dir, "the update agent", { pid: 101, alive: () => true });
|
|
67
|
+
expect(() => acquireUpgradeLock(dir, "x", { pid: 102, alive: () => true })).toThrow(
|
|
68
|
+
UpgradeLockHeldError,
|
|
69
|
+
);
|
|
70
|
+
writeFileSync(upgradeLockPath(dir), "{not json");
|
|
71
|
+
expect(() => acquireUpgradeLock(dir, "x")).toThrow(UpgradeLockHeldError);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Root ignores permission bits, so the EACCES case only runs as a normal user.
|
|
75
|
+
it.skipIf(process.getuid?.() === 0)(
|
|
76
|
+
"a directory it cannot write is an access error naming the lock file, not busy (#804)",
|
|
77
|
+
() => {
|
|
78
|
+
chmodSync(dir, 0o500);
|
|
79
|
+
try {
|
|
80
|
+
let thrown: unknown;
|
|
81
|
+
try {
|
|
82
|
+
acquireUpgradeLock(dir, "the update agent");
|
|
83
|
+
} catch (e) {
|
|
84
|
+
thrown = e;
|
|
85
|
+
}
|
|
86
|
+
expect(thrown).toBeInstanceOf(UpgradeLockAccessError);
|
|
87
|
+
expect((thrown as Error).message).toContain(upgradeLockPath(dir));
|
|
88
|
+
expect((thrown as Error).message).toContain("write access");
|
|
89
|
+
expect((thrown as Error).message).not.toMatch(/running on this install|try again when/i);
|
|
90
|
+
} finally {
|
|
91
|
+
chmodSync(dir, 0o700);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
it("a missing install directory is an access error, not busy (#804)", () => {
|
|
97
|
+
let thrown: unknown;
|
|
98
|
+
try {
|
|
99
|
+
acquireUpgradeLock(join(dir, "gone"), "the update agent");
|
|
100
|
+
} catch (e) {
|
|
101
|
+
thrown = e;
|
|
102
|
+
}
|
|
103
|
+
expect(thrown).toBeInstanceOf(UpgradeLockAccessError);
|
|
104
|
+
expect((thrown as Error).message).toContain("does not exist");
|
|
105
|
+
});
|
|
51
106
|
});
|
|
52
107
|
|
|
53
108
|
describe("agentConfigFor", () => {
|
package/src/agent/lock.ts
CHANGED
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
* into place, so the lock file never exists half-written: a reader that
|
|
14
14
|
* finds it can always trust its pid. (An unreadable file is only taken over
|
|
15
15
|
* once it is older than any run could be.)
|
|
16
|
+
*
|
|
17
|
+
* Only real contention throws {@link UpgradeLockHeldError} (#804): a live
|
|
18
|
+
* holder, a young unreadable record, a creation race. Anything else — the
|
|
19
|
+
* directory is not writable, the disk is full — throws
|
|
20
|
+
* {@link UpgradeLockAccessError} naming the lock file and the fix, so
|
|
21
|
+
* callers can tell "busy, try later" from "broken, fix the server" (the GCP
|
|
22
|
+
* upgrader's StateLockHeldError, #799).
|
|
16
23
|
*/
|
|
17
24
|
|
|
18
25
|
import { linkSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -23,6 +30,36 @@ export function upgradeLockPath(caeloDir: string): string {
|
|
|
23
30
|
return join(caeloDir, "upgrade.lock");
|
|
24
31
|
}
|
|
25
32
|
|
|
33
|
+
/** Another upgrade holds the lock: the one failure that waiting fixes. */
|
|
34
|
+
export class UpgradeLockHeldError extends Error {
|
|
35
|
+
override readonly name = "UpgradeLockHeldError";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The lock could not be taken for a reason waiting does not fix (no write
|
|
40
|
+
* access, a full disk); the message names the lock file and the fix.
|
|
41
|
+
*/
|
|
42
|
+
export class UpgradeLockAccessError extends Error {
|
|
43
|
+
override readonly name = "UpgradeLockAccessError";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** {@link UpgradeLockAccessError}'s message for filesystem error `e`. */
|
|
47
|
+
function lockAccessFailure(caeloDir: string, e: unknown): string {
|
|
48
|
+
const path = upgradeLockPath(caeloDir);
|
|
49
|
+
const code = (e as NodeJS.ErrnoException | null)?.code;
|
|
50
|
+
const said = e instanceof Error ? e.message : String(e);
|
|
51
|
+
if (code === "EACCES" || code === "EPERM" || code === "EROFS") {
|
|
52
|
+
return `the upgrade lock ${path} cannot be written (${said}). Give the account the update runs as write access to ${caeloDir} (the agent runs as root through its systemd unit), then try again.`;
|
|
53
|
+
}
|
|
54
|
+
if (code === "ENOSPC" || code === "EDQUOT") {
|
|
55
|
+
return `the upgrade lock ${path} cannot be written because the disk is full (${said}). Free space on the server, then try again.`;
|
|
56
|
+
}
|
|
57
|
+
if (code === "ENOENT") {
|
|
58
|
+
return `the upgrade lock ${path} cannot be created: ${caeloDir} does not exist (${said}). Run the command from the install's directory, or re-run the installer to restore it.`;
|
|
59
|
+
}
|
|
60
|
+
return `the upgrade lock ${path} could not be taken (${said}). Check that ${caeloDir} is writable and the disk has space, then try again.`;
|
|
61
|
+
}
|
|
62
|
+
|
|
26
63
|
interface LockRecord {
|
|
27
64
|
readonly pid: number;
|
|
28
65
|
readonly holder: string;
|
|
@@ -56,12 +93,27 @@ function processAlive(pid: number): boolean {
|
|
|
56
93
|
* Take the install's upgrade lock for `holder`.
|
|
57
94
|
*
|
|
58
95
|
* @returns the release function (idempotent)
|
|
59
|
-
* @throws when
|
|
96
|
+
* @throws UpgradeLockHeldError when another upgrade holds it, naming that holder
|
|
97
|
+
* @throws UpgradeLockAccessError for anything else (permissions, disk space)
|
|
60
98
|
*/
|
|
61
99
|
export function acquireUpgradeLock(
|
|
62
100
|
caeloDir: string,
|
|
63
101
|
holder: string,
|
|
64
102
|
deps: { readonly pid?: number; readonly alive?: (pid: number) => boolean } = {},
|
|
103
|
+
): () => void {
|
|
104
|
+
try {
|
|
105
|
+
return takeUpgradeLock(caeloDir, holder, deps);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
if (e instanceof UpgradeLockHeldError) throw e;
|
|
108
|
+
throw new UpgradeLockAccessError(lockAccessFailure(caeloDir, e), { cause: e });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** {@link acquireUpgradeLock} without the classification of its errors. */
|
|
113
|
+
function takeUpgradeLock(
|
|
114
|
+
caeloDir: string,
|
|
115
|
+
holder: string,
|
|
116
|
+
deps: { readonly pid?: number; readonly alive?: (pid: number) => boolean } = {},
|
|
65
117
|
): () => void {
|
|
66
118
|
const path = upgradeLockPath(caeloDir);
|
|
67
119
|
const alive = deps.alive ?? processAlive;
|
|
@@ -86,18 +138,29 @@ export function acquireUpgradeLock(
|
|
|
86
138
|
} catch (e) {
|
|
87
139
|
if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
|
|
88
140
|
}
|
|
141
|
+
let text: string;
|
|
142
|
+
try {
|
|
143
|
+
text = readFileSync(path, "utf8");
|
|
144
|
+
} catch (e) {
|
|
145
|
+
// Released between the link and the read: try again. A file this
|
|
146
|
+
// account may not read is an access problem, not a busy lock.
|
|
147
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
148
|
+
throw e;
|
|
149
|
+
}
|
|
89
150
|
let held: Partial<LockRecord> | null = null;
|
|
90
151
|
try {
|
|
91
|
-
held = JSON.parse(
|
|
152
|
+
held = JSON.parse(text) as Partial<LockRecord>;
|
|
92
153
|
} catch {
|
|
93
154
|
// Corrupt (never half-written, see above): take it over only when old.
|
|
94
155
|
}
|
|
95
156
|
if (held === null) {
|
|
96
157
|
if (Date.now() - lockMtime(path) < UNREADABLE_LOCK_MAX_AGE_MS) {
|
|
97
|
-
throw new
|
|
158
|
+
throw new UpgradeLockHeldError(
|
|
159
|
+
`another upgrade holds ${path} (its record is unreadable).`,
|
|
160
|
+
);
|
|
98
161
|
}
|
|
99
162
|
} else if (typeof held.pid === "number" && alive(held.pid)) {
|
|
100
|
-
throw new
|
|
163
|
+
throw new UpgradeLockHeldError(
|
|
101
164
|
`${held.holder ?? "another upgrade"} is running on this install (pid ${held.pid}, since ${held.at ?? "?"}).`,
|
|
102
165
|
);
|
|
103
166
|
}
|
|
@@ -106,5 +169,5 @@ export function acquireUpgradeLock(
|
|
|
106
169
|
} finally {
|
|
107
170
|
rmSync(temp, { force: true });
|
|
108
171
|
}
|
|
109
|
-
throw new
|
|
172
|
+
throw new UpgradeLockHeldError(`could not take ${path}; another process keeps creating it.`);
|
|
110
173
|
}
|
package/src/agent/run.test.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { StoredUpdatePolicy } from "@caelo-cms/shared";
|
|
|
12
12
|
import type { ReleaseManifest, ReleaseManifestResult } from "../release-manifest.js";
|
|
13
13
|
import type { SelfHostedRelease } from "../self-hosted-release.js";
|
|
14
14
|
import type { ClaimedUpdate, UpdateEnd, UpdateFacts } from "../upgrader-job.js";
|
|
15
|
+
import { UpgradeLockAccessError, UpgradeLockHeldError } from "./lock.js";
|
|
15
16
|
import type { AgentHeartbeat, AgentQueue } from "./queue.js";
|
|
16
17
|
import { type AgentApplyRequest, type AgentRunDeps, runAgentOnce } from "./run.js";
|
|
17
18
|
|
|
@@ -216,10 +217,26 @@ describe("runAgentOnce", () => {
|
|
|
216
217
|
name: "another upgrade holds the lock",
|
|
217
218
|
over: {
|
|
218
219
|
lock: () => {
|
|
219
|
-
throw new
|
|
220
|
+
throw new UpgradeLockHeldError(
|
|
221
|
+
"`cms-provision upgrade` is running on this install (pid 42).",
|
|
222
|
+
);
|
|
220
223
|
},
|
|
221
224
|
},
|
|
222
225
|
expect: "failed/state-locked",
|
|
226
|
+
reason: /Try again when it has finished/,
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
name: "a lock the agent cannot write is not reported as busy (#804)",
|
|
230
|
+
over: {
|
|
231
|
+
lock: () => {
|
|
232
|
+
throw new UpgradeLockAccessError(
|
|
233
|
+
"the upgrade lock /opt/caelo/.caelo/upgrade.lock cannot be written (EACCES). Give the account write access, then try again.",
|
|
234
|
+
);
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
expect: "failed/lock-access",
|
|
238
|
+
reason:
|
|
239
|
+
/^Nothing was changed: the upgrade lock \/opt\/caelo\/.caelo\/upgrade.lock cannot be written \(EACCES\)\. Give the account write access, then try again\.$/,
|
|
223
240
|
},
|
|
224
241
|
{
|
|
225
242
|
name: "a malformed release.json",
|
package/src/agent/run.ts
CHANGED
|
@@ -39,6 +39,7 @@ import type { UpdateEnd, UpdateEndStatus } from "../upgrader-job.js";
|
|
|
39
39
|
import { recordUpdateEnd } from "../upgrader-job.js";
|
|
40
40
|
import { eventReporter, type JobLog } from "../upgrader-reporter.js";
|
|
41
41
|
import type { VerifiedCli } from "./cli-provenance.js";
|
|
42
|
+
import { UpgradeLockHeldError } from "./lock.js";
|
|
42
43
|
import type { AgentQueue } from "./queue.js";
|
|
43
44
|
|
|
44
45
|
/** What the target release's `agent apply` is told (argv of the child). */
|
|
@@ -70,7 +71,11 @@ export interface AgentRunDeps {
|
|
|
70
71
|
readonly readInstalled: () => SelfHostedRelease | null;
|
|
71
72
|
/** fetchVerifiedReleaseManifest against the fixed registry. */
|
|
72
73
|
readonly fetchManifest: (version: string) => Promise<ReleaseManifestResult>;
|
|
73
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* Take the install's upgrade lock (agent/lock.ts): throws
|
|
76
|
+
* UpgradeLockHeldError when another upgrade holds it, any other error
|
|
77
|
+
* when the lock cannot be written.
|
|
78
|
+
*/
|
|
74
79
|
readonly lock: () => () => void;
|
|
75
80
|
/**
|
|
76
81
|
* Install the target release's CLI and verify its npm signatures +
|
|
@@ -169,11 +174,17 @@ export async function runAgentOnce(deps: AgentRunDeps): Promise<AgentRunResult>
|
|
|
169
174
|
try {
|
|
170
175
|
release = deps.lock();
|
|
171
176
|
} catch (e) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
+
// #804 — only another upgrade holding the lock is "busy"; a lock the
|
|
178
|
+
// agent cannot write (permissions, a full disk) is a server problem
|
|
179
|
+
// that waiting never fixes.
|
|
180
|
+
if (e instanceof UpgradeLockHeldError) {
|
|
181
|
+
return await stopWith(
|
|
182
|
+
"failed",
|
|
183
|
+
["state-locked"],
|
|
184
|
+
`Nothing was changed: ${errorText(e)} Try again when it has finished.`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return await stopWith("failed", ["lock-access"], `Nothing was changed: ${errorText(e)}`);
|
|
177
188
|
}
|
|
178
189
|
|
|
179
190
|
r.phase("verify");
|
|
@@ -63,6 +63,7 @@ describe("Compose — Caddy picks up new redirects (#587)", () => {
|
|
|
63
63
|
postgresPassword: "pgpw",
|
|
64
64
|
rolePasswords: { admin: "a", public: "p" },
|
|
65
65
|
caeloSecretKek: "0".repeat(64),
|
|
66
|
+
internalSecret: "i".repeat(48),
|
|
66
67
|
diskSize: "20Gi",
|
|
67
68
|
ports: { http: 80, https: 443 },
|
|
68
69
|
project: "caelo-example-com",
|
package/src/caddy.ts
CHANGED
|
@@ -49,12 +49,21 @@
|
|
|
49
49
|
* icon MIME the media library stores and `<link rel="icon" type>`
|
|
50
50
|
* declares. file_server only fills Content-Type when no earlier handler
|
|
51
51
|
* set it, so the `header` line wins over the host's mime.types name.
|
|
52
|
+
*
|
|
53
|
+
* #891 — every response of a public vhost is `nosniff`, and every SVG
|
|
54
|
+
* gets the sandboxing CSP (`SVG_DOCUMENT_CSP`, @caelo-cms/shared): an
|
|
55
|
+
* uploaded SVG opened directly must not run script on the site's origin,
|
|
56
|
+
* which also serves `/api/*`. Caddy's `path` matcher is case-insensitive,
|
|
57
|
+
* so `*.svg` covers `.SVG` too. `cms-provision upgrade` re-writes this file
|
|
58
|
+
* (self-hosted-upgrade.ts), so existing installs get the headers too.
|
|
52
59
|
*/
|
|
53
60
|
|
|
54
61
|
import {
|
|
55
62
|
CONTENT_HASHED_PATH_PATTERN,
|
|
56
63
|
caddyRedirectsImport,
|
|
57
64
|
IMMUTABLE_CACHE_CONTROL,
|
|
65
|
+
NOSNIFF,
|
|
66
|
+
SVG_DOCUMENT_CSP,
|
|
58
67
|
} from "@caelo-cms/shared";
|
|
59
68
|
|
|
60
69
|
export interface CaddyDomainSpec {
|
|
@@ -208,6 +217,9 @@ function vhost(d: CaddyDomainSpec, spec: CaddyfileSpec): string {
|
|
|
208
217
|
header @content_hashed Cache-Control "${IMMUTABLE_CACHE_CONTROL}"
|
|
209
218
|
@ico path *.ico
|
|
210
219
|
header @ico Content-Type "image/x-icon"
|
|
220
|
+
header X-Content-Type-Options "${NOSNIFF}"
|
|
221
|
+
@svg path *.svg
|
|
222
|
+
header @svg Content-Security-Policy "${SVG_DOCUMENT_CSP}"
|
|
211
223
|
handle /api/* {
|
|
212
224
|
reverse_proxy ${spec.gatewayUpstream}
|
|
213
225
|
}
|
package/src/cli-routing.ts
CHANGED
|
@@ -93,7 +93,7 @@ export const CLI_COMMANDS: readonly CliCommandDoc[] = [
|
|
|
93
93
|
},
|
|
94
94
|
{
|
|
95
95
|
name: "upgrade",
|
|
96
|
-
args: "[--version x.y.z] [--channel stable|rc|beta] [--region <r>] [--skip-verify] [--skip-health-check] [--install <id|domain|all>]",
|
|
96
|
+
args: "[--version x.y.z] [--channel stable|rc|beta] [--region <r>] [--skip-verify] [--skip-health-check] [--install <id|domain|all>] [--project <gcp-project-id>]",
|
|
97
97
|
summary:
|
|
98
98
|
"Roll the install to a newer release (latest stable by default) after verifying the image signatures.",
|
|
99
99
|
},
|
package/src/cli.ts
CHANGED
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
normalizeStagingHost,
|
|
46
46
|
stagingHostProblem,
|
|
47
47
|
} from "@caelo-cms/shared";
|
|
48
|
+
import type { AdminInternalTarget } from "./admin-internal.js";
|
|
48
49
|
import type { AgentCliContext } from "./agent/command.js";
|
|
49
50
|
import type { AgentConfig } from "./agent/config.js";
|
|
50
51
|
import { generateBootstrapToken } from "./bootstrap-token.js";
|
|
@@ -74,6 +75,7 @@ import {
|
|
|
74
75
|
} from "./compose.js";
|
|
75
76
|
import { tightenPrivateTree, writePrivateFile } from "./private-file.js";
|
|
76
77
|
import { selfCommand } from "./self-command.js";
|
|
78
|
+
import { ensureInternalSecret, writeInternalSecret } from "./self-hosted-internal-secret.js";
|
|
77
79
|
import {
|
|
78
80
|
bunHostRunner,
|
|
79
81
|
readRelease,
|
|
@@ -277,15 +279,23 @@ function emitConfig(cfg: CaeloConfig, extraDomains: CaddyDomainSpec[] = []): voi
|
|
|
277
279
|
cfg.caeloSecretKek = randomSecret(32);
|
|
278
280
|
saveConfig(cfg);
|
|
279
281
|
}
|
|
282
|
+
emitComposeFiles(cfg);
|
|
283
|
+
writeCaddyfile(cfg, extraDomains);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Write the Caddyfile for `cfg` — the seed hosts (admin, primary domain,
|
|
288
|
+
* staging host) plus `extraDomains`, the domains added via
|
|
289
|
+
* /settings/domains that regenerate-caddy / upgrade read from the live
|
|
290
|
+
* domains table. Never touches the Compose file: `upgrade` calls this after
|
|
291
|
+
* it rolled the containers onto the new Compose file (#891).
|
|
292
|
+
*/
|
|
293
|
+
function writeCaddyfile(cfg: CaeloConfig, extraDomains: CaddyDomainSpec[]): void {
|
|
280
294
|
if (!cfg.stagingDomain) {
|
|
281
295
|
cfg.stagingDomain = defaultStagingHost(cfg.domain);
|
|
282
296
|
saveConfig(cfg);
|
|
283
297
|
}
|
|
284
298
|
if (!cfg.tls) throw new Error("config has no tls — it is back-filled on load; this is a bug");
|
|
285
|
-
emitComposeFiles(cfg);
|
|
286
|
-
// Seed the install with the admin host, the primary domain and the
|
|
287
|
-
// staging host. Extra domains added via /settings/domains are appended by
|
|
288
|
-
// regenerate-caddy when it queries the live domains table.
|
|
289
299
|
const caddy = generateCaddyfile({
|
|
290
300
|
ownerEmail: cfg.ownerEmail,
|
|
291
301
|
tls: cfg.tls,
|
|
@@ -339,6 +349,12 @@ function renderCompose(cfg: CaeloConfig, images: ComposeImages): string {
|
|
|
339
349
|
postgresPassword: cfg.postgresPassword,
|
|
340
350
|
rolePasswords: cfg.rolePasswords,
|
|
341
351
|
caeloSecretKek: cfg.caeloSecretKek,
|
|
352
|
+
// #770 — every render (up, upgrade, agent apply) hands the admin the
|
|
353
|
+
// secret host-side commands sign their /api/internal/* tokens with.
|
|
354
|
+
internalSecret: ensureInternalSecret(CAELO_DIR, {
|
|
355
|
+
randomSecret: () => randomSecret(48),
|
|
356
|
+
report: (line) => console.log(line),
|
|
357
|
+
}),
|
|
342
358
|
anthropicApiKey: cfg.anthropicApiKey,
|
|
343
359
|
resendApiKey: cfg.resendApiKey,
|
|
344
360
|
diskSize: "20Gi",
|
|
@@ -382,6 +398,7 @@ function agentContext(cfg: CaeloConfig): AgentCliContext {
|
|
|
382
398
|
});
|
|
383
399
|
},
|
|
384
400
|
renderCompose: (images) => renderCompose(cfg, images),
|
|
401
|
+
refreshCaddy: () => refreshCaddyfile(cfg),
|
|
385
402
|
randomSecret: () => randomSecret(32),
|
|
386
403
|
};
|
|
387
404
|
}
|
|
@@ -478,6 +495,24 @@ async function refreshCaddy(
|
|
|
478
495
|
return reloadCaddy(CAELO_DIR, bunHostRunner);
|
|
479
496
|
}
|
|
480
497
|
|
|
498
|
+
/**
|
|
499
|
+
* Re-render only the Caddyfile (config + the domains table) and reload the
|
|
500
|
+
* running Caddy — what `upgrade` runs after the roll, so an install picks up
|
|
501
|
+
* the edge headers of the release it moved to (#891) without its Compose
|
|
502
|
+
* file being re-written behind the upgrade's back.
|
|
503
|
+
*/
|
|
504
|
+
async function refreshCaddyfile(
|
|
505
|
+
cfg: CaeloConfig,
|
|
506
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
507
|
+
try {
|
|
508
|
+
writeCaddyfile(cfg, await fetchExtraDomains());
|
|
509
|
+
} catch (e) {
|
|
510
|
+
return { ok: false, error: `writing ${CADDYFILE_PATH} failed: ${(e as Error).message}` };
|
|
511
|
+
}
|
|
512
|
+
const { reloadCaddy } = await import("./self-hosted-start.js");
|
|
513
|
+
return reloadCaddy(CAELO_DIR, bunHostRunner);
|
|
514
|
+
}
|
|
515
|
+
|
|
481
516
|
/**
|
|
482
517
|
* Pull, start Postgres, migrate, start everything, health-check
|
|
483
518
|
* (self-hosted-start.ts). Exits non-zero with the reason on failure; the
|
|
@@ -602,6 +637,13 @@ async function init(): Promise<void> {
|
|
|
602
637
|
composeProject: composeProjectName(domain),
|
|
603
638
|
};
|
|
604
639
|
saveConfig(cfg);
|
|
640
|
+
// P15.1 — the shared HMAC secret for /api/internal/* endpoints, read by
|
|
641
|
+
// the admin (CAELO_INTERNAL_SECRET, from the Compose file emitConfig
|
|
642
|
+
// writes next — so it must exist first, #770) and by host-side commands
|
|
643
|
+
// such as `owner-reset`. Long enough to defeat brute-force. GCP installs
|
|
644
|
+
// keep their own copy in Secret Manager (runtime-secrets.ts), rotated by
|
|
645
|
+
// `rotate-secret internal-secret`.
|
|
646
|
+
writeInternalSecret(CAELO_DIR, randomSecret(48));
|
|
605
647
|
if (images.mode === "release") {
|
|
606
648
|
recordRelease(
|
|
607
649
|
CAELO_DIR,
|
|
@@ -620,17 +662,6 @@ async function init(): Promise<void> {
|
|
|
620
662
|
mkdirSync(resolve(CAELO_DIR, BOOTSTRAP_DIR), { recursive: true, mode: 0o700 });
|
|
621
663
|
writePrivateFile(PENDING_TOKEN_PATH, JSON.stringify(tok, null, 2));
|
|
622
664
|
|
|
623
|
-
// P15.1 — mint the shared HMAC secret for /api/internal/* endpoints.
|
|
624
|
-
// Same secret is read by:
|
|
625
|
-
// - admin app (process.env.CAELO_INTERNAL_SECRET),
|
|
626
|
-
// - cms-provision pulumi-output-sync (when running in CI / by hand),
|
|
627
|
-
// - any future internal-only orchestration.
|
|
628
|
-
// Long enough to defeat brute-force. GCP installs keep their own copy in
|
|
629
|
-
// Secret Manager (runtime-secrets.ts), rotated by `rotate-secret
|
|
630
|
-
// internal-secret`.
|
|
631
|
-
const internalSecret = randomSecret(48);
|
|
632
|
-
const internalSecretPath = resolve(CAELO_DIR, "internal-secret.json");
|
|
633
|
-
writePrivateFile(internalSecretPath, JSON.stringify({ secret: internalSecret }, null, 2));
|
|
634
665
|
// #696 Phase 4c — the update agent (Linux + systemd): the fresh volume's
|
|
635
666
|
// bootstrap.sh creates its database login on Postgres's first start.
|
|
636
667
|
const { reportAgentSetup, setUpAgent } = await import("./agent/command.js");
|
|
@@ -993,13 +1024,20 @@ async function upgradeSelfHostedCommand(opts: {
|
|
|
993
1024
|
process.exit(2);
|
|
994
1025
|
}
|
|
995
1026
|
const { upgradeSelfHosted } = await import("./self-hosted-upgrade.js");
|
|
996
|
-
const { acquireUpgradeLock } = await import("./agent/lock.js");
|
|
1027
|
+
const { acquireUpgradeLock, UpgradeLockHeldError } = await import("./agent/lock.js");
|
|
997
1028
|
// The update agent rolls through the same lock: never two rolls at once.
|
|
998
1029
|
let unlock: () => void;
|
|
999
1030
|
try {
|
|
1000
1031
|
unlock = acquireUpgradeLock(CAELO_DIR, "`cms-provision upgrade`");
|
|
1001
1032
|
} catch (e) {
|
|
1002
|
-
|
|
1033
|
+
// #804 — "wait" only when another upgrade holds the lock; an access
|
|
1034
|
+
// failure's message already names the lock file and the fix.
|
|
1035
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1036
|
+
console.error(
|
|
1037
|
+
e instanceof UpgradeLockHeldError
|
|
1038
|
+
? `Nothing was changed: ${message} Wait for it to finish.`
|
|
1039
|
+
: `Nothing was changed: ${message}`,
|
|
1040
|
+
);
|
|
1003
1041
|
process.exit(1);
|
|
1004
1042
|
}
|
|
1005
1043
|
let result: Awaited<ReturnType<typeof upgradeSelfHosted>>;
|
|
@@ -1009,6 +1047,7 @@ async function upgradeSelfHostedCommand(opts: {
|
|
|
1009
1047
|
runner: bunHostRunner,
|
|
1010
1048
|
renderCompose: (images) => renderCompose(cfg, images),
|
|
1011
1049
|
convergeRoles: agentContext(cfg).convergeRoles,
|
|
1050
|
+
refreshCaddy: () => refreshCaddyfile(cfg),
|
|
1012
1051
|
log: {
|
|
1013
1052
|
step: (m) => console.log(`\n==> ${m}`),
|
|
1014
1053
|
info: (m) => console.log(` ${m}`),
|
|
@@ -1201,8 +1240,12 @@ async function lifecycleUpgrade(): Promise<void> {
|
|
|
1201
1240
|
// #607 — optional; must match the install's recorded region (upgrade
|
|
1202
1241
|
// never moves an install between regions).
|
|
1203
1242
|
const region = arg("region");
|
|
1243
|
+
// #816 — attach the install from its project first (break-glass upgrade
|
|
1244
|
+
// from a fresh Cloud Shell; nothing in $HOME has to survive).
|
|
1245
|
+
const project = arg("project");
|
|
1204
1246
|
const { upgradeCommand } = await import("./lifecycle.js");
|
|
1205
1247
|
await upgradeCommand({
|
|
1248
|
+
...(project ? { project } : {}),
|
|
1206
1249
|
...(region ? { region } : {}),
|
|
1207
1250
|
...(version ? { version } : {}),
|
|
1208
1251
|
...(channel ? { channel } : {}),
|
|
@@ -1231,42 +1274,56 @@ async function lifecycleTruncate(): Promise<void> {
|
|
|
1231
1274
|
}
|
|
1232
1275
|
|
|
1233
1276
|
/**
|
|
1234
|
-
* #769 `owner-reset [--install <id>] [--email <addr>]` — print a
|
|
1235
|
-
* reset link for a locked-out Owner, for installs whose admin
|
|
1236
|
-
* one (see owner-reset.ts for why the operator
|
|
1277
|
+
* #769 / #770 `owner-reset [--install <id>] [--email <addr>]` — print a
|
|
1278
|
+
* one-time reset link for a locked-out Owner, for installs whose admin
|
|
1279
|
+
* cannot email one (see owner-reset.ts for why the operator may). Run in the
|
|
1280
|
+
* self-hosted install's directory, or with `--install` for a cloud install.
|
|
1237
1281
|
*/
|
|
1238
1282
|
async function ownerReset(): Promise<void> {
|
|
1283
|
+
const email = arg("email");
|
|
1284
|
+
const target = selfHostedInstallPresent()
|
|
1285
|
+
? selfHostedOwnerResetTarget()
|
|
1286
|
+
: await cloudOwnerResetTarget();
|
|
1287
|
+
const { describeOwnerReset, requestOwnerReset } = await import("./owner-reset.js");
|
|
1288
|
+
const r = await requestOwnerReset(target, email ? { email } : {});
|
|
1289
|
+
if (!r.ok) {
|
|
1290
|
+
console.error(`owner-reset: ${r.error}`);
|
|
1291
|
+
process.exit(1);
|
|
1292
|
+
}
|
|
1293
|
+
// Prints the reset link ON PURPOSE: stdout is the one place it goes; it is
|
|
1294
|
+
// never written to a file or a log.
|
|
1295
|
+
for (const line of describeOwnerReset(r.value)) console.log(line);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/** The self-hosted install in this directory, reached through `docker compose exec`. */
|
|
1299
|
+
function selfHostedOwnerResetTarget(): AdminInternalTarget {
|
|
1300
|
+
const cfg = loadConfig();
|
|
1301
|
+
if (!cfg) {
|
|
1302
|
+
console.error("no config — run `cms-provision init` first");
|
|
1303
|
+
process.exit(2);
|
|
1304
|
+
}
|
|
1305
|
+
return {
|
|
1306
|
+
provider: "self-hosted",
|
|
1307
|
+
caeloDir: CAELO_DIR,
|
|
1308
|
+
adminUrl: siteUrl(cfg, selfHostedAdminHost(cfg.domain)),
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/** The cloud install `--install` names (or the only one), on a provider that has the channel. */
|
|
1313
|
+
async function cloudOwnerResetTarget(): Promise<AdminInternalTarget> {
|
|
1239
1314
|
const { installFlag, listInstalls, selectInstall } = await import("./install-state.js");
|
|
1240
1315
|
const selected = selectInstall(listInstalls(), installFlag(process.argv));
|
|
1241
1316
|
if (!selected.ok) {
|
|
1242
1317
|
console.error(selected.message);
|
|
1243
1318
|
process.exit(1);
|
|
1244
1319
|
}
|
|
1245
|
-
const
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
"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.",
|
|
1250
|
-
);
|
|
1320
|
+
const { ownerResetTarget } = await import("./owner-reset.js");
|
|
1321
|
+
const target = ownerResetTarget(selected.meta);
|
|
1322
|
+
if (!target.ok) {
|
|
1323
|
+
console.error(`owner-reset: ${target.error}`);
|
|
1251
1324
|
process.exit(1);
|
|
1252
1325
|
}
|
|
1253
|
-
|
|
1254
|
-
console.error(
|
|
1255
|
-
`install ${meta.installId} has no project or region recorded in install.json; re-run the wizard to record it.`,
|
|
1256
|
-
);
|
|
1257
|
-
process.exit(1);
|
|
1258
|
-
}
|
|
1259
|
-
const email = arg("email");
|
|
1260
|
-
const { describeOwnerReset, requestOwnerReset } = await import("./owner-reset.js");
|
|
1261
|
-
const r = await requestOwnerReset(
|
|
1262
|
-
{ projectId: meta.projectId, region: meta.region },
|
|
1263
|
-
email ? { email } : {},
|
|
1264
|
-
);
|
|
1265
|
-
if (!r.ok) {
|
|
1266
|
-
console.error(`owner-reset: ${r.error}`);
|
|
1267
|
-
process.exit(1);
|
|
1268
|
-
}
|
|
1269
|
-
for (const line of describeOwnerReset(r.value)) console.log(line);
|
|
1326
|
+
return target.value;
|
|
1270
1327
|
}
|
|
1271
1328
|
|
|
1272
1329
|
/**
|
|
@@ -1340,6 +1397,7 @@ async function domainMappingCommand(kind: "admin" | "staging"): Promise<void> {
|
|
|
1340
1397
|
if (!requested && meta.provider === "gcp-firebase" && meta.projectId && meta.region) {
|
|
1341
1398
|
const { inspectStagingAdmin } = await import("./staging-admin.js");
|
|
1342
1399
|
const inspected = await inspectStagingAdmin({
|
|
1400
|
+
provider: "gcp-firebase",
|
|
1343
1401
|
projectId: meta.projectId,
|
|
1344
1402
|
region: meta.region,
|
|
1345
1403
|
});
|
|
@@ -1425,7 +1483,7 @@ async function domainMappingCommand(kind: "admin" | "staging"): Promise<void> {
|
|
|
1425
1483
|
// the password is printed once (never stored by the CLI).
|
|
1426
1484
|
const { applyStagingAdmin, describeStagingApply } = await import("./staging-admin.js");
|
|
1427
1485
|
const applied = await applyStagingAdmin(
|
|
1428
|
-
{ projectId: meta.projectId, region: meta.region },
|
|
1486
|
+
{ provider: "gcp-firebase", projectId: meta.projectId, region: meta.region },
|
|
1429
1487
|
{
|
|
1430
1488
|
host: r.hostname,
|
|
1431
1489
|
...(rotatePassword ? { rotateBasicAuth: true } : {}),
|
package/src/compose.ts
CHANGED
|
@@ -188,6 +188,13 @@ export interface ComposeSpec {
|
|
|
188
188
|
* via the same randomHex(32) the GCP stack uses.
|
|
189
189
|
*/
|
|
190
190
|
readonly caeloSecretKek: string;
|
|
191
|
+
/**
|
|
192
|
+
* #770 — the shared HMAC secret of the admin's `/api/internal/*`
|
|
193
|
+
* endpoints (`.caelo/internal-secret.json`), so host-side commands such as
|
|
194
|
+
* `owner-reset` can reach them through `docker compose exec`. Admin only;
|
|
195
|
+
* the gateway never gets it.
|
|
196
|
+
*/
|
|
197
|
+
readonly internalSecret: string;
|
|
191
198
|
readonly anthropicApiKey?: string;
|
|
192
199
|
readonly resendApiKey?: string;
|
|
193
200
|
readonly diskSize: string;
|
|
@@ -245,6 +252,10 @@ export function generateDockerCompose(spec: ComposeSpec): string {
|
|
|
245
252
|
if (spec.agent && !isAgentDbPort(spec.agent.dbPort)) {
|
|
246
253
|
throw new Error(`agent dbPort ${spec.agent.dbPort} is not a TCP port between 1024 and 65535`);
|
|
247
254
|
}
|
|
255
|
+
// The admin refuses a shorter one on every /api/internal/* call (internal-jwt.ts).
|
|
256
|
+
if (spec.internalSecret.length < 32) {
|
|
257
|
+
throw new Error("internalSecret is shorter than 32 characters (.caelo/internal-secret.json)");
|
|
258
|
+
}
|
|
248
259
|
return `# SPDX-License-Identifier: MPL-2.0
|
|
249
260
|
# Generated by cms-provision — do not edit; re-run \`bunx cms-provision up\`.
|
|
250
261
|
# Domain: ${spec.domain}
|
|
@@ -377,6 +388,7 @@ ${serviceRuntime(
|
|
|
377
388
|
ADMIN_DATABASE_URL: "${dbUrl("admin_role", spec.rolePasswords.admin, "cms_admin")}"
|
|
378
389
|
PUBLIC_ADMIN_DATABASE_URL: "${dbUrl("admin_role", spec.rolePasswords.admin, "cms_public")}"
|
|
379
390
|
CAELO_SECRET_KEK: "${escapeYaml(spec.caeloSecretKek)}"
|
|
391
|
+
CAELO_INTERNAL_SECRET: "${escapeYaml(spec.internalSecret)}"
|
|
380
392
|
CAELO_SITE_URL: "${escapeYaml(selfHostedSiteUrl(spec.domain, spec.ports.https))}"
|
|
381
393
|
PORT: "${COMPOSE_ADMIN_PORT}"
|
|
382
394
|
CAELO_OUTPUT_ROOT: "${SITE_OUTPUT_ROOT}"
|