@caelo-cms/provisioning 0.10.47 → 0.10.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/caddy/Caddyfile.production +1 -1
- package/caddy/Caddyfile.staging +1 -1
- package/dist/agent/apply.d.ts +45 -0
- package/dist/agent/apply.d.ts.map +1 -0
- package/dist/agent/apply.js +83 -0
- package/dist/agent/apply.js.map +1 -0
- package/dist/agent/cli-provenance.d.ts +42 -0
- package/dist/agent/cli-provenance.d.ts.map +1 -0
- package/dist/agent/cli-provenance.js +138 -0
- package/dist/agent/cli-provenance.js.map +1 -0
- package/dist/agent/command.d.ts +79 -0
- package/dist/agent/command.d.ts.map +1 -0
- package/dist/agent/command.js +454 -0
- package/dist/agent/command.js.map +1 -0
- package/dist/agent/config.d.ts +30 -0
- package/dist/agent/config.d.ts.map +1 -0
- package/dist/agent/config.js +60 -0
- package/dist/agent/config.js.map +1 -0
- package/dist/agent/lock.d.ts +13 -0
- package/dist/agent/lock.d.ts.map +1 -0
- package/dist/agent/lock.js +100 -0
- package/dist/agent/lock.js.map +1 -0
- package/dist/agent/queue.d.ts +35 -0
- package/dist/agent/queue.d.ts.map +1 -0
- package/dist/agent/queue.js +50 -0
- package/dist/agent/queue.js.map +1 -0
- package/dist/agent/run.d.ts +97 -0
- package/dist/agent/run.d.ts.map +1 -0
- package/dist/agent/run.js +198 -0
- package/dist/agent/run.js.map +1 -0
- package/dist/agent/systemd.d.ts +55 -0
- package/dist/agent/systemd.d.ts.map +1 -0
- package/dist/agent/systemd.js +171 -0
- package/dist/agent/systemd.js.map +1 -0
- package/dist/agent/units.d.ts +44 -0
- package/dist/agent/units.d.ts.map +1 -0
- package/dist/agent/units.js +127 -0
- package/dist/agent/units.js.map +1 -0
- package/dist/agent-db.js +18402 -0
- package/dist/cli.js +119 -16
- package/dist/cli.js.map +1 -1
- package/dist/cloudsql-iam-user.d.ts +39 -0
- package/dist/cloudsql-iam-user.d.ts.map +1 -0
- package/dist/cloudsql-iam-user.js +119 -0
- package/dist/cloudsql-iam-user.js.map +1 -0
- package/dist/compose.d.ts +16 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +26 -1
- package/dist/compose.js.map +1 -1
- package/dist/dns/index.d.ts +2 -0
- package/dist/dns/index.d.ts.map +1 -1
- package/dist/dns/index.js +3 -2
- package/dist/dns/index.js.map +1 -1
- package/dist/dns/manual.d.ts +5 -0
- package/dist/dns/manual.d.ts.map +1 -1
- package/dist/dns/manual.js +28 -12
- package/dist/dns/manual.js.map +1 -1
- package/dist/gcloud.d.ts +2 -0
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +1 -1
- package/dist/gcloud.js.map +1 -1
- package/dist/gcp-names.d.ts +15 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +23 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.js +7 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job-runner.d.ts +13 -0
- package/dist/install-job-runner.d.ts.map +1 -0
- package/dist/install-job-runner.js +155 -0
- package/dist/install-job-runner.js.map +1 -0
- package/dist/install-job.d.ts +210 -0
- package/dist/install-job.d.ts.map +1 -0
- package/dist/install-job.js +449 -0
- package/dist/install-job.js.map +1 -0
- package/dist/install-state.d.ts +10 -1
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +10 -2
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +19 -4
- package/dist/lifecycle.js.map +1 -1
- package/dist/migration-runner.d.ts +5 -0
- package/dist/migration-runner.d.ts.map +1 -1
- package/dist/migration-runner.js +1 -1
- package/dist/migration-runner.js.map +1 -1
- package/dist/operator-access.d.ts +14 -8
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +16 -102
- package/dist/operator-access.js.map +1 -1
- package/dist/release-manifest.d.ts +2 -41
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +18 -121
- package/dist/release-manifest.js.map +1 -1
- package/dist/run-mode.d.ts +27 -0
- package/dist/run-mode.d.ts.map +1 -0
- package/dist/run-mode.js +22 -0
- package/dist/run-mode.js.map +1 -0
- package/dist/runtime-secrets.d.ts +19 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +44 -0
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/self-hosted-roles.d.ts +22 -1
- package/dist/self-hosted-roles.d.ts.map +1 -1
- package/dist/self-hosted-roles.js +81 -12
- package/dist/self-hosted-roles.js.map +1 -1
- package/dist/self-hosted-upgrade.d.ts +17 -0
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +16 -1
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-contract.d.ts +6 -0
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +10 -1
- package/dist/stack-contract.js.map +1 -1
- package/dist/upgrade/converge.d.ts +6 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +59 -2
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/gcp-lookup.d.ts +6 -0
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
- package/dist/upgrade/gcp-lookup.js +25 -0
- package/dist/upgrade/gcp-lookup.js.map +1 -1
- package/dist/upgrade/release-images.d.ts +26 -2
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +35 -2
- package/dist/upgrade/release-images.js.map +1 -1
- package/dist/upgrade/upgrade.d.ts +5 -0
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +28 -1
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-assess.d.ts +85 -0
- package/dist/upgrader-assess.d.ts.map +1 -0
- package/dist/upgrader-assess.js +122 -0
- package/dist/upgrader-assess.js.map +1 -0
- package/dist/upgrader-gcp.d.ts +17 -0
- package/dist/upgrader-gcp.d.ts.map +1 -0
- package/dist/upgrader-gcp.js +87 -0
- package/dist/upgrader-gcp.js.map +1 -0
- package/dist/upgrader-job.d.ts +133 -0
- package/dist/upgrader-job.d.ts.map +1 -0
- package/dist/upgrader-job.js +259 -0
- package/dist/upgrader-job.js.map +1 -0
- package/dist/upgrader-reporter.d.ts +35 -0
- package/dist/upgrader-reporter.d.ts.map +1 -0
- package/dist/upgrader-reporter.js +79 -0
- package/dist/upgrader-reporter.js.map +1 -0
- package/dist/upgrader-setup.d.ts +59 -0
- package/dist/upgrader-setup.d.ts.map +1 -0
- package/dist/upgrader-setup.js +308 -0
- package/dist/upgrader-setup.js.map +1 -0
- package/dist/wizard.d.ts +6 -0
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +32 -4
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp-firebase.d.ts +3 -0
- package/dist/wizards/gcp-firebase.d.ts.map +1 -1
- package/dist/wizards/gcp-firebase.js +1 -0
- package/dist/wizards/gcp-firebase.js.map +1 -1
- package/dist/wizards/gcp-install-job.d.ts +74 -0
- package/dist/wizards/gcp-install-job.d.ts.map +1 -0
- package/dist/wizards/gcp-install-job.js +398 -0
- package/dist/wizards/gcp-install-job.js.map +1 -0
- package/dist/wizards/gcp-prompts.d.ts +3 -0
- package/dist/wizards/gcp-prompts.d.ts.map +1 -0
- package/dist/wizards/gcp-prompts.js +25 -0
- package/dist/wizards/gcp-prompts.js.map +1 -0
- package/dist/wizards/gcp-pulumi.d.ts +4 -2
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +1 -1
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts +40 -0
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +223 -59
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +6 -4
- package/src/adapter.ts +97 -0
- package/src/adc-impersonation.ts +176 -0
- package/src/admin-domain.test.ts +254 -0
- package/src/admin-domain.ts +245 -0
- package/src/admin-memory.test.ts +151 -0
- package/src/agent/apply.test.ts +185 -0
- package/src/agent/apply.ts +124 -0
- package/src/agent/cli-provenance.test.ts +159 -0
- package/src/agent/cli-provenance.ts +191 -0
- package/src/agent/command.test.ts +77 -0
- package/src/agent/command.ts +564 -0
- package/src/agent/config.ts +86 -0
- package/src/agent/lock-config.test.ts +97 -0
- package/src/agent/lock.ts +110 -0
- package/src/agent/queue.test.ts +10 -0
- package/src/agent/queue.ts +91 -0
- package/src/agent/run.test.ts +437 -0
- package/src/agent/run.ts +295 -0
- package/src/agent/systemd.ts +209 -0
- package/src/agent/units.test.ts +223 -0
- package/src/agent/units.ts +163 -0
- package/src/azure-front-door.test.ts +119 -0
- package/src/azure-front-door.ts +56 -0
- package/src/bootstrap-token.ts +20 -0
- package/src/caddy.ts +112 -0
- package/src/cdn-copy.ts +91 -0
- package/src/cert-wait.test.ts +194 -0
- package/src/cert-wait.ts +163 -0
- package/src/checkpoint-secrets.test.ts +380 -0
- package/src/checkpoint-secrets.ts +283 -0
- package/src/cli-routing.test.ts +45 -0
- package/src/cli-routing.ts +43 -0
- package/src/cli.ts +1491 -0
- package/src/cloudsql-iam-user.ts +147 -0
- package/src/compose.ts +334 -0
- package/src/default-firewall.test.ts +411 -0
- package/src/default-firewall.ts +342 -0
- package/src/deployed-release.test.ts +231 -0
- package/src/deployed-release.ts +220 -0
- package/src/dns/cloudflare.ts +182 -0
- package/src/dns/groups.test.ts +76 -0
- package/src/dns/groups.ts +86 -0
- package/src/dns/index.ts +56 -0
- package/src/dns/manual.ts +130 -0
- package/src/dns/types.ts +27 -0
- package/src/gateway-credentials.test.ts +180 -0
- package/src/gateway-credentials.ts +144 -0
- package/src/gcloud-components.test.ts +76 -0
- package/src/gcloud-components.ts +85 -0
- package/src/gcloud-retry.ts +41 -0
- package/src/gcloud-runner.test.ts +61 -0
- package/src/gcloud.test.ts +99 -0
- package/src/gcloud.ts +322 -0
- package/src/gcp-names.ts +135 -0
- package/src/index.test.ts +332 -0
- package/src/index.ts +114 -0
- package/src/infra-contract.ts +32 -0
- package/src/install-job-runner.ts +166 -0
- package/src/install-job.test.ts +566 -0
- package/src/install-job.ts +542 -0
- package/src/install-state.test.ts +239 -0
- package/src/install-state.ts +583 -0
- package/src/internal-token.ts +35 -0
- package/src/lifecycle.ts +900 -0
- package/src/mcp-iap-stacks.test.ts +58 -0
- package/src/mcp-iap.test.ts +149 -0
- package/src/mcp-iap.ts +131 -0
- package/src/media-volume.test.ts +296 -0
- package/src/migration-runner.ts +365 -0
- package/src/operator-access-stacks.test.ts +55 -0
- package/src/operator-access.test.ts +332 -0
- package/src/operator-access.ts +554 -0
- package/src/ops-backend.test.ts +320 -0
- package/src/ops-backend.ts +414 -0
- package/src/provisioner-identity.test.ts +482 -0
- package/src/provisioner-identity.ts +407 -0
- package/src/redirects-emit.ts +175 -0
- package/src/region-data.ts +428 -0
- package/src/regions.test.ts +277 -0
- package/src/regions.ts +370 -0
- package/src/release-contract.ts +57 -0
- package/src/release-manifest.test.ts +300 -0
- package/src/release-manifest.ts +310 -0
- package/src/run-mode.ts +40 -0
- package/src/runtime-secrets.test.ts +627 -0
- package/src/runtime-secrets.ts +754 -0
- package/src/self-command.test.ts +14 -0
- package/src/self-command.ts +21 -0
- package/src/self-hosted-compose.test.ts +177 -0
- package/src/self-hosted-release.ts +240 -0
- package/src/self-hosted-roles.integration.test.ts +161 -0
- package/src/self-hosted-roles.test.ts +124 -0
- package/src/self-hosted-roles.ts +203 -0
- package/src/self-hosted-upgrade.test.ts +394 -0
- package/src/self-hosted-upgrade.ts +548 -0
- package/src/stack-contract-parity.test.ts +199 -0
- package/src/stack-contract.test.ts +391 -0
- package/src/stack-contract.ts +888 -0
- package/src/stack-converge-secrets.test.ts +421 -0
- package/src/stack-converge.test.ts +833 -0
- package/src/stack-converge.ts +909 -0
- package/src/staging-admin.test.ts +246 -0
- package/src/staging-admin.ts +324 -0
- package/src/state-attach.test.ts +211 -0
- package/src/state-attach.ts +132 -0
- package/src/state-backend.test.ts +145 -0
- package/src/state-backend.ts +262 -0
- package/src/state-command.ts +293 -0
- package/src/state-migrate.test.ts +479 -0
- package/src/state-migrate.ts +434 -0
- package/src/state-pulumi.ts +289 -0
- package/src/state-store.test.ts +212 -0
- package/src/state-store.ts +243 -0
- package/src/upgrade/app-roll.ts +486 -0
- package/src/upgrade/backup.ts +109 -0
- package/src/upgrade/converge.ts +437 -0
- package/src/upgrade/gcp-lookup.ts +167 -0
- package/src/upgrade/health.test.ts +279 -0
- package/src/upgrade/health.ts +305 -0
- package/src/upgrade/release-images.test.ts +119 -0
- package/src/upgrade/release-images.ts +393 -0
- package/src/upgrade/reporter.ts +46 -0
- package/src/upgrade/upgrade.test.ts +626 -0
- package/src/upgrade/upgrade.ts +249 -0
- package/src/upgrader-assess.test.ts +306 -0
- package/src/upgrader-assess.ts +250 -0
- package/src/upgrader-gcp.ts +123 -0
- package/src/upgrader-job.test.ts +411 -0
- package/src/upgrader-job.ts +502 -0
- package/src/upgrader-reporter.test.ts +46 -0
- package/src/upgrader-reporter.ts +105 -0
- package/src/upgrader-setup.test.ts +169 -0
- package/src/upgrader-setup.ts +355 -0
- package/src/wizard.ts +425 -0
- package/src/wizards/gcp-cost.ts +188 -0
- package/src/wizards/gcp-firebase.ts +72 -0
- package/src/wizards/gcp-install-job.ts +506 -0
- package/src/wizards/gcp-prompts.ts +26 -0
- package/src/wizards/gcp-pulumi.test.ts +246 -0
- package/src/wizards/gcp-pulumi.ts +516 -0
- package/src/wizards/gcp.ts +1935 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The agent's systemd units (agent/units.ts) and their installation
|
|
5
|
+
* (agent/systemd.ts) — against a temp unit directory and a fake runner, so
|
|
6
|
+
* no real systemd is touched.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
10
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import type { CommandResult, HostRunner } from "../self-hosted-release.js";
|
|
14
|
+
import {
|
|
15
|
+
type AgentHost,
|
|
16
|
+
agentHostProblem,
|
|
17
|
+
agentLogsArgv,
|
|
18
|
+
installAgentUnits,
|
|
19
|
+
installedAgentVersion,
|
|
20
|
+
repinAgentUnits,
|
|
21
|
+
uninstallAgentUnits,
|
|
22
|
+
} from "./systemd.js";
|
|
23
|
+
import {
|
|
24
|
+
type AgentUnitSpec,
|
|
25
|
+
agentServiceUnit,
|
|
26
|
+
agentTimerUnit,
|
|
27
|
+
agentUnitName,
|
|
28
|
+
pinnedAgentVersion,
|
|
29
|
+
} from "./units.js";
|
|
30
|
+
|
|
31
|
+
const SPEC: AgentUnitSpec = {
|
|
32
|
+
name: "caelo-agent-example-com",
|
|
33
|
+
domain: "example.com",
|
|
34
|
+
version: "0.10.47",
|
|
35
|
+
bunPath: "/home/op/.bun/bin/bun",
|
|
36
|
+
caeloDir: "/srv/caelo site/.caelo",
|
|
37
|
+
workDir: "/srv/caelo site",
|
|
38
|
+
path: "/home/op/.bun/bin:/usr/local/bin:/usr/bin:/bin",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe("agent units", () => {
|
|
42
|
+
it("names one agent per install after its domain, kept apart by the install's folder", () => {
|
|
43
|
+
expect(agentUnitName("Example.com", "/srv/a/.caelo")).toMatch(
|
|
44
|
+
/^caelo-agent-example-com-[0-9a-f]{8}$/,
|
|
45
|
+
);
|
|
46
|
+
expect(agentUnitName("a.b.example.com", "/srv/one/.caelo")).not.toBe(
|
|
47
|
+
agentUnitName("a-b.example.com", "/srv/two/.caelo"),
|
|
48
|
+
);
|
|
49
|
+
expect(agentUnitName("example.com", "/srv/a/.caelo")).toBe(
|
|
50
|
+
agentUnitName("example.com", "/srv/a/.caelo"),
|
|
51
|
+
);
|
|
52
|
+
expect(() => agentUnitName("...", "/srv/a/.caelo")).toThrow();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("runs the pinned CLI as a oneshot, as the user, never as root", () => {
|
|
56
|
+
const unit = agentServiceUnit(SPEC);
|
|
57
|
+
expect(unit).toContain("Type=oneshot");
|
|
58
|
+
expect(unit).toContain(
|
|
59
|
+
'ExecStart="/home/op/.bun/bin/bun" "x" "--bun" "@caelo-cms/provisioning@0.10.47" "agent" "run"',
|
|
60
|
+
);
|
|
61
|
+
expect(unit).toContain('WorkingDirectory="/srv/caelo site"');
|
|
62
|
+
expect(unit).toContain('Environment="CAELO_SELF_HOSTED_DIR=/srv/caelo site/.caelo"');
|
|
63
|
+
expect(unit).toContain("UMask=0077");
|
|
64
|
+
expect(unit).toContain("NoNewPrivileges=yes");
|
|
65
|
+
expect(unit).not.toMatch(/^User=|^Group=|docker\.sock/m);
|
|
66
|
+
expect(unit).not.toContain("DOCKER_HOST");
|
|
67
|
+
expect(pinnedAgentVersion(unit)).toBe("0.10.47");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("passes a rootless DOCKER_HOST through and escapes systemd specifiers", () => {
|
|
71
|
+
const unit = agentServiceUnit({
|
|
72
|
+
...SPEC,
|
|
73
|
+
dockerHost: "unix:///run/user/1000/docker.sock",
|
|
74
|
+
workDir: "/srv/100%",
|
|
75
|
+
});
|
|
76
|
+
expect(unit).toContain('Environment="DOCKER_HOST=unix:///run/user/1000/docker.sock"');
|
|
77
|
+
expect(unit).toContain('WorkingDirectory="/srv/100%%"');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("after an update, runs the provenance-verified CLI instead of bun x", () => {
|
|
81
|
+
const cliPath =
|
|
82
|
+
"/srv/caelo site/.caelo/agent/cli/caelo-0.11.0/node_modules/@caelo-cms/provisioning/dist/cli.js";
|
|
83
|
+
const unit = agentServiceUnit({ ...SPEC, version: "0.11.0", cliPath });
|
|
84
|
+
expect(unit).toContain(`ExecStart="/home/op/.bun/bin/bun" "${cliPath}" "agent" "run"`);
|
|
85
|
+
expect(unit).not.toContain('"x"');
|
|
86
|
+
expect(pinnedAgentVersion(unit)).toBe("0.11.0");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("refuses values that cannot be one systemd word", () => {
|
|
90
|
+
expect(() => agentServiceUnit({ ...SPEC, workDir: '/srv/"x"' })).toThrow();
|
|
91
|
+
expect(() => agentServiceUnit({ ...SPEC, path: "/bin\nExecStartPre=/evil" })).toThrow();
|
|
92
|
+
expect(() => agentServiceUnit({ ...SPEC, version: "latest" })).toThrow();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("polls a minute after each run ends, from boot", () => {
|
|
96
|
+
const timer = agentTimerUnit(SPEC);
|
|
97
|
+
expect(timer).toContain("OnUnitInactiveSec=60s");
|
|
98
|
+
expect(timer).toContain("OnBootSec=2min");
|
|
99
|
+
expect(timer).toContain("OnActiveSec=1min");
|
|
100
|
+
expect(timer).toContain("Unit=caelo-agent-example-com.service");
|
|
101
|
+
expect(timer).toContain("WantedBy=timers.target");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("shows the service's journal", () => {
|
|
105
|
+
expect(agentLogsArgv("caelo-agent-x", { lines: 50, follow: true })).toEqual([
|
|
106
|
+
"journalctl",
|
|
107
|
+
"--user",
|
|
108
|
+
"--no-pager",
|
|
109
|
+
"-u",
|
|
110
|
+
"caelo-agent-x.service",
|
|
111
|
+
"-n",
|
|
112
|
+
"50",
|
|
113
|
+
"-f",
|
|
114
|
+
]);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
type Rule = (line: string) => Partial<CommandResult> | undefined;
|
|
119
|
+
|
|
120
|
+
function fakeRunner(rules: Rule[] = []): HostRunner & { calls: string[] } {
|
|
121
|
+
const calls: string[] = [];
|
|
122
|
+
return {
|
|
123
|
+
calls,
|
|
124
|
+
async run(argv) {
|
|
125
|
+
const line = argv.join(" ");
|
|
126
|
+
calls.push(line);
|
|
127
|
+
const hit = rules.map((r) => r(line)).find((r) => r !== undefined);
|
|
128
|
+
return { exitCode: 0, stdout: "", stderr: "", ...hit };
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
describe("agent units on the host", () => {
|
|
134
|
+
let dir: string;
|
|
135
|
+
beforeEach(() => {
|
|
136
|
+
dir = mkdtempSync(join(tmpdir(), "caelo-agent-units-"));
|
|
137
|
+
});
|
|
138
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
139
|
+
|
|
140
|
+
const hostWith = (runner: HostRunner, platform: NodeJS.Platform = "linux"): AgentHost => ({
|
|
141
|
+
runner,
|
|
142
|
+
unitDir: join(dir, "systemd", "user"),
|
|
143
|
+
user: "op",
|
|
144
|
+
platform,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("supports Linux with a systemd user manager only", async () => {
|
|
148
|
+
expect(await agentHostProblem(hostWith(fakeRunner(), "darwin"))).toContain("Linux");
|
|
149
|
+
const noBus = fakeRunner([
|
|
150
|
+
(l) =>
|
|
151
|
+
l.includes("show-environment")
|
|
152
|
+
? { exitCode: 1, stderr: "Failed to connect to bus" }
|
|
153
|
+
: undefined,
|
|
154
|
+
]);
|
|
155
|
+
expect(await agentHostProblem(hostWith(noBus))).toContain("Failed to connect to bus");
|
|
156
|
+
expect(await agentHostProblem(hostWith(fakeRunner()))).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("writes both units, enables the timer and switches lingering on — idempotently", async () => {
|
|
160
|
+
const runner = fakeRunner([
|
|
161
|
+
(l) => (l.startsWith("loginctl show-user") ? { stdout: "Linger=no\n" } : undefined),
|
|
162
|
+
]);
|
|
163
|
+
const host = hostWith(runner);
|
|
164
|
+
for (let i = 0; i < 2; i++) {
|
|
165
|
+
expect(await installAgentUnits(SPEC, host)).toEqual({ ok: true, warnings: [] });
|
|
166
|
+
}
|
|
167
|
+
const service = readFileSync(join(host.unitDir, `${SPEC.name}.service`), "utf8");
|
|
168
|
+
expect(service).toBe(agentServiceUnit(SPEC));
|
|
169
|
+
expect(existsSync(join(host.unitDir, `${SPEC.name}.timer`))).toBe(true);
|
|
170
|
+
expect(runner.calls).toContain("systemctl --user daemon-reload");
|
|
171
|
+
expect(runner.calls).toContain(`systemctl --user enable --now ${SPEC.name}.timer`);
|
|
172
|
+
expect(runner.calls).toContain("loginctl enable-linger op");
|
|
173
|
+
expect(installedAgentVersion(host, SPEC.name)).toBe("0.10.47");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("says what an administrator runs when lingering may not be switched on", async () => {
|
|
177
|
+
const runner = fakeRunner([
|
|
178
|
+
(l) =>
|
|
179
|
+
l.startsWith("loginctl enable-linger")
|
|
180
|
+
? { exitCode: 1, stderr: "Access denied" }
|
|
181
|
+
: undefined,
|
|
182
|
+
]);
|
|
183
|
+
const r = await installAgentUnits(SPEC, hostWith(runner));
|
|
184
|
+
expect(r.ok && r.warnings.join(" ")).toContain("sudo loginctl enable-linger op");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("fails loudly when systemd refuses the timer", async () => {
|
|
188
|
+
const runner = fakeRunner([
|
|
189
|
+
(l) => (l.includes("enable --now") ? { exitCode: 1, stderr: "Unit not found" } : undefined),
|
|
190
|
+
]);
|
|
191
|
+
const r = await installAgentUnits(SPEC, hostWith(runner));
|
|
192
|
+
expect(r).toEqual({
|
|
193
|
+
ok: false,
|
|
194
|
+
error: `systemctl --user enable --now ${SPEC.name}.timer: Unit not found`,
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("re-pins an installed agent after an update, and leaves an absent one absent", async () => {
|
|
199
|
+
const runner = fakeRunner();
|
|
200
|
+
const host = hostWith(runner);
|
|
201
|
+
expect(await repinAgentUnits({ ...SPEC, version: "0.11.0" }, host)).toMatchObject({
|
|
202
|
+
ok: true,
|
|
203
|
+
installed: false,
|
|
204
|
+
});
|
|
205
|
+
expect(installedAgentVersion(host, SPEC.name)).toBeNull();
|
|
206
|
+
await installAgentUnits(SPEC, host);
|
|
207
|
+
expect(await repinAgentUnits({ ...SPEC, version: "0.11.0" }, host)).toMatchObject({
|
|
208
|
+
ok: true,
|
|
209
|
+
installed: true,
|
|
210
|
+
});
|
|
211
|
+
expect(installedAgentVersion(host, SPEC.name)).toBe("0.11.0");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("uninstalls: timer off, both files gone", async () => {
|
|
215
|
+
const runner = fakeRunner();
|
|
216
|
+
const host = hostWith(runner);
|
|
217
|
+
await installAgentUnits(SPEC, host);
|
|
218
|
+
expect((await uninstallAgentUnits(host, SPEC.name)).ok).toBe(true);
|
|
219
|
+
expect(runner.calls).toContain(`systemctl --user disable --now ${SPEC.name}.timer`);
|
|
220
|
+
expect(existsSync(join(host.unitDir, `${SPEC.name}.service`))).toBe(false);
|
|
221
|
+
expect(existsSync(join(host.unitDir, `${SPEC.name}.timer`))).toBe(false);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The systemd units of `caelo-agent` (#696 Phase 4c), as text. Pure, so the
|
|
5
|
+
* exact files are unit-tested; agent/systemd.ts installs them.
|
|
6
|
+
*
|
|
7
|
+
* Why a systemd --user timer on the host, and not a container: the agent
|
|
8
|
+
* has to run `docker compose` for the install, and the only way a container
|
|
9
|
+
* could is the Docker socket — root on the host for anything that gets into
|
|
10
|
+
* that container (Watchtower, the usual answer, was archived in 2025 and
|
|
11
|
+
* verifies no signatures). The agent instead runs as the operator who ran
|
|
12
|
+
* `init`, a member of the `docker` group like that operator already is to
|
|
13
|
+
* run Compose at all; nothing new gets Docker access, and no unit runs as
|
|
14
|
+
* root.
|
|
15
|
+
*
|
|
16
|
+
* Shape: a oneshot service (one poll: heartbeat, then claim and run at most
|
|
17
|
+
* one request) started by a timer every {@link PLATFORM_UPDATE_AGENT_POLL_SECONDS}
|
|
18
|
+
* seconds after the previous run ended — systemd never starts the service
|
|
19
|
+
* while a run (an upgrade can take many minutes) is still active.
|
|
20
|
+
*
|
|
21
|
+
* The service runs the provisioning CLI pinned to an exact release
|
|
22
|
+
* (`bun x --bun @caelo-cms/provisioning@<version> agent run`); a successful
|
|
23
|
+
* update re-pins it to the release it installed.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createHash } from "node:crypto";
|
|
27
|
+
import { PLATFORM_UPDATE_AGENT_POLL_SECONDS } from "@caelo-cms/shared";
|
|
28
|
+
|
|
29
|
+
/** Everything the units name; all paths absolute. */
|
|
30
|
+
export interface AgentUnitSpec {
|
|
31
|
+
/** Unit base name (`caelo-agent-<domain>-<hash>`), see {@link agentUnitName}. */
|
|
32
|
+
readonly name: string;
|
|
33
|
+
readonly domain: string;
|
|
34
|
+
/** The provisioning release the service runs. */
|
|
35
|
+
readonly version: string;
|
|
36
|
+
/** The `bun` binary (the one that ran `init`). */
|
|
37
|
+
readonly bunPath: string;
|
|
38
|
+
/** The install's `.caelo/` directory. */
|
|
39
|
+
readonly caeloDir: string;
|
|
40
|
+
/** Where `.caelo/` lives (the service's working directory). */
|
|
41
|
+
readonly workDir: string;
|
|
42
|
+
/** PATH for the service: docker, cosign and bun must be on it. */
|
|
43
|
+
readonly path: string;
|
|
44
|
+
/** DOCKER_HOST of the installing shell (rootless Docker), when set. */
|
|
45
|
+
readonly dockerHost?: string;
|
|
46
|
+
/**
|
|
47
|
+
* The provenance-verified CLI of {@link version} (agent/cli-provenance.ts)
|
|
48
|
+
* — what an update re-pins the agent to. Without it the service runs the
|
|
49
|
+
* release the operator installed with (`bun x`, as they did by hand).
|
|
50
|
+
*/
|
|
51
|
+
readonly cliPath?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The longest a single run (one full upgrade) may take before systemd stops it. */
|
|
55
|
+
export const AGENT_RUN_TIMEOUT_MINUTES = 80;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `caelo-agent-<domain>-<hash>`: one agent per install, several installs
|
|
59
|
+
* per host possible. The slug is for people; the hash of the install's
|
|
60
|
+
* `.caelo/` path keeps two installs apart even when their slugs collide
|
|
61
|
+
* (`a.b.example.com` vs `a-b.example.com`).
|
|
62
|
+
*/
|
|
63
|
+
export function agentUnitName(domain: string, caeloDir: string): string {
|
|
64
|
+
const slug = domain
|
|
65
|
+
.toLowerCase()
|
|
66
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
67
|
+
.replace(/^-+|-+$/g, "")
|
|
68
|
+
.slice(0, 60);
|
|
69
|
+
if (!slug) throw new Error(`cannot name the agent unit after domain ${JSON.stringify(domain)}`);
|
|
70
|
+
const hash = createHash("sha256").update(caeloDir).digest("hex").slice(0, 8);
|
|
71
|
+
return `caelo-agent-${slug}-${hash}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The package the service runs, pinned. */
|
|
75
|
+
export function agentPackageRef(version: string): string {
|
|
76
|
+
if (!/^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$/.test(version)) {
|
|
77
|
+
throw new Error(`not a release version: ${JSON.stringify(version)}`);
|
|
78
|
+
}
|
|
79
|
+
return `@caelo-cms/provisioning@${version}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* One systemd word: double-quoted, `%` escaped (systemd specifiers). Values
|
|
84
|
+
* with a quote, backslash or control character are refused rather than
|
|
85
|
+
* escaped — they are paths and versions, and such a path is a mistake.
|
|
86
|
+
*/
|
|
87
|
+
function word(value: string): string {
|
|
88
|
+
if (!value || /["\\\p{Cc}]/u.test(value)) {
|
|
89
|
+
throw new Error(`cannot put ${JSON.stringify(value)} into a systemd unit`);
|
|
90
|
+
}
|
|
91
|
+
return `"${value.replace(/%/g, "%%")}"`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `Environment="KEY=value"`. */
|
|
95
|
+
function environment(key: string, value: string): string {
|
|
96
|
+
return `Environment=${word(`${key}=${value}`)}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The argv of one agent run (also what `agent status` shows). */
|
|
100
|
+
export function agentRunArgv(
|
|
101
|
+
spec: Pick<AgentUnitSpec, "bunPath" | "version" | "cliPath">,
|
|
102
|
+
): string[] {
|
|
103
|
+
if (spec.cliPath) return [spec.bunPath, spec.cliPath, "agent", "run"];
|
|
104
|
+
return [spec.bunPath, "x", "--bun", agentPackageRef(spec.version), "agent", "run"];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `<name>.service`. */
|
|
108
|
+
export function agentServiceUnit(spec: AgentUnitSpec): string {
|
|
109
|
+
return [
|
|
110
|
+
"# SPDX-License-Identifier: MPL-2.0",
|
|
111
|
+
"# Generated by cms-provision (agent install) — do not edit; re-run it instead.",
|
|
112
|
+
"[Unit]",
|
|
113
|
+
`Description=Caelo update agent for ${spec.domain}`,
|
|
114
|
+
"Documentation=https://caelo-cms.com/install-self-hosted",
|
|
115
|
+
"After=network-online.target",
|
|
116
|
+
"",
|
|
117
|
+
"[Service]",
|
|
118
|
+
"Type=oneshot",
|
|
119
|
+
`WorkingDirectory=${word(spec.workDir)}`,
|
|
120
|
+
environment("CAELO_SELF_HOSTED_DIR", spec.caeloDir),
|
|
121
|
+
environment("PATH", spec.path),
|
|
122
|
+
...(spec.dockerHost ? [environment("DOCKER_HOST", spec.dockerHost)] : []),
|
|
123
|
+
`ExecStart=${agentRunArgv(spec).map(word).join(" ")}`,
|
|
124
|
+
`TimeoutStartSec=${AGENT_RUN_TIMEOUT_MINUTES}min`,
|
|
125
|
+
// Backups, config.json and the lock file: the operator's only.
|
|
126
|
+
"UMask=0077",
|
|
127
|
+
"NoNewPrivileges=yes",
|
|
128
|
+
"Nice=5",
|
|
129
|
+
"",
|
|
130
|
+
].join("\n");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** `<name>.timer`. */
|
|
134
|
+
export function agentTimerUnit(spec: Pick<AgentUnitSpec, "name" | "domain">): string {
|
|
135
|
+
return [
|
|
136
|
+
"# SPDX-License-Identifier: MPL-2.0",
|
|
137
|
+
"# Generated by cms-provision (agent install) — do not edit; re-run it instead.",
|
|
138
|
+
"[Unit]",
|
|
139
|
+
`Description=Poll for Caelo updates for ${spec.domain}`,
|
|
140
|
+
"",
|
|
141
|
+
"[Timer]",
|
|
142
|
+
// The first poll a minute after the timer starts (install, boot);
|
|
143
|
+
// then a minute after each run ends.
|
|
144
|
+
"OnActiveSec=1min",
|
|
145
|
+
"OnBootSec=2min",
|
|
146
|
+
`OnUnitInactiveSec=${PLATFORM_UPDATE_AGENT_POLL_SECONDS}s`,
|
|
147
|
+
"AccuracySec=5s",
|
|
148
|
+
`Unit=${spec.name}.service`,
|
|
149
|
+
"",
|
|
150
|
+
"[Install]",
|
|
151
|
+
"WantedBy=timers.target",
|
|
152
|
+
"",
|
|
153
|
+
].join("\n");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The release an installed service file runs, or null when it names none. */
|
|
157
|
+
export function pinnedAgentVersion(serviceUnit: string): string | null {
|
|
158
|
+
const exec = /^ExecStart=.*$/m.exec(serviceUnit)?.[0] ?? "";
|
|
159
|
+
const m =
|
|
160
|
+
/@caelo-cms\/provisioning@([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)"/.exec(exec) ??
|
|
161
|
+
/\/caelo-([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)\/node_modules\//.exec(exec);
|
|
162
|
+
return m?.[1] ?? null;
|
|
163
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The public site on every provider is object storage behind the provider's
|
|
5
|
+
* CDN, with no edge compute on the request path (CLAUDE.md §11.B Tier 1).
|
|
6
|
+
* Epic #676 removed the per-provider A/B edge routers; these checks keep them
|
|
7
|
+
* from coming back and pin the Azure primary domain to its Front Door
|
|
8
|
+
* endpoint. Stack checks are pure-string, like the other stack tests.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { resolve } from "node:path";
|
|
14
|
+
import { azureFrontDoorDnsRecords } from "./azure-front-door.js";
|
|
15
|
+
|
|
16
|
+
const stackDir = (provider: string) => resolve(import.meta.dir, `../stacks/${provider}`);
|
|
17
|
+
const stack = (provider: string) => readFileSync(`${stackDir(provider)}/index.ts`, "utf8");
|
|
18
|
+
|
|
19
|
+
describe("azureFrontDoorDnsRecords", () => {
|
|
20
|
+
const records = azureFrontDoorDnsRecords({
|
|
21
|
+
domain: "example.com",
|
|
22
|
+
endpointHostName: "caeloproduction-site-abc123.z01.azurefd.net",
|
|
23
|
+
validationToken: "tok-123",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("points the primary domain at the Front Door endpoint", () => {
|
|
27
|
+
expect(records).toContainEqual(
|
|
28
|
+
expect.objectContaining({
|
|
29
|
+
hostname: "example.com",
|
|
30
|
+
type: "CNAME",
|
|
31
|
+
value: "caeloproduction-site-abc123.z01.azurefd.net",
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("publishes the managed-certificate validation token", () => {
|
|
37
|
+
expect(records).toContainEqual(
|
|
38
|
+
expect.objectContaining({ hostname: "_dnsauth.example.com", type: "TXT", value: "tok-123" }),
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("refuses an endpoint without a hostname instead of emitting a dangling record", () => {
|
|
43
|
+
expect(() =>
|
|
44
|
+
azureFrontDoorDnsRecords({
|
|
45
|
+
domain: "example.com",
|
|
46
|
+
endpointHostName: "",
|
|
47
|
+
validationToken: "t",
|
|
48
|
+
}),
|
|
49
|
+
).toThrow(/no hostName/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("refuses a custom domain without a validation token", () => {
|
|
53
|
+
expect(() =>
|
|
54
|
+
azureFrontDoorDnsRecords({
|
|
55
|
+
domain: "example.com",
|
|
56
|
+
endpointHostName: "x.azurefd.net",
|
|
57
|
+
validationToken: "",
|
|
58
|
+
}),
|
|
59
|
+
).toThrow(/no validation token/);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("azure stack — static site behind Front Door", () => {
|
|
64
|
+
const src = stack("azure");
|
|
65
|
+
|
|
66
|
+
it("builds its DNS records from the Front Door endpoint and custom domain", () => {
|
|
67
|
+
expect(src).toContain("new azure.cdn.AFDEndpoint(");
|
|
68
|
+
expect(src).toContain("new azure.cdn.AFDCustomDomain(");
|
|
69
|
+
expect(src).toMatch(
|
|
70
|
+
/pulumi\s*\.all\(\[frontDoorEndpoint\.hostName, siteDomain\.validationProperties\]\)[\s\S]*?azureFrontDoorDnsRecords\(/,
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("routes the custom domain to the static-website origin", () => {
|
|
75
|
+
expect(src).toContain("new azure.storage.StorageAccountStaticWebsite(");
|
|
76
|
+
expect(src).toMatch(
|
|
77
|
+
/new azure\.cdn\.Route\([\s\S]*?customDomains: \[\{ id: siteDomain\.id \}\]/,
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("keeps owning the published $web container and edge-caches the route", () => {
|
|
82
|
+
expect(src).toMatch(/new azure\.storage\.BlobContainer\([\s\S]*?containerName: "\$web"/);
|
|
83
|
+
expect(src).toMatch(/new azure\.cdn\.Route\([\s\S]*?cacheConfiguration: \{/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("exposes no Container App to the public internet", () => {
|
|
87
|
+
expect(src).toContain("external: false");
|
|
88
|
+
expect(src).not.toMatch(/external:\s*(true|args\.)/);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("aws stack — edge-log sink", () => {
|
|
93
|
+
const src = stack("aws");
|
|
94
|
+
|
|
95
|
+
it("delivers CloudFront access logs to the log group it reports as the sink", () => {
|
|
96
|
+
expect(src).toMatch(
|
|
97
|
+
/new aws\.cloudwatch\.LogDeliverySource\([\s\S]*?logType: "ACCESS_LOGS",\s*resourceArn: distribution\.arn/,
|
|
98
|
+
);
|
|
99
|
+
expect(src).toContain("destinationResourceArn: edgeLogGroup.arn");
|
|
100
|
+
expect(src).toMatch(/cloudwatch:\/\/us-east-1\/\$\{edgeLogGroup\.name\}/);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe.each(["aws", "azure", "gcp", "gcp-firebase", "self-hosted"])(
|
|
105
|
+
"%s stack — no edge router",
|
|
106
|
+
(provider) => {
|
|
107
|
+
const src = stack(provider);
|
|
108
|
+
|
|
109
|
+
it("runs no edge compute for the public site", () => {
|
|
110
|
+
expect(src).not.toContain("edge-router");
|
|
111
|
+
expect(src).not.toContain("lambdaFunctionAssociations");
|
|
112
|
+
expect(src).not.toContain("ab-routing.json");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("ships no edge handler source", () => {
|
|
116
|
+
expect(existsSync(`${stackDir(provider)}/edge-handler.ts`)).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DNS records the Azure stack asks the operator's registrar to hold.
|
|
5
|
+
*
|
|
6
|
+
* The public site is served by Azure Front Door (CLAUDE.md §11.B Tier 1:
|
|
7
|
+
* object storage + edge CDN + managed TLS, no compute on the request path),
|
|
8
|
+
* so the primary domain points at the Front Door endpoint, and Front Door's
|
|
9
|
+
* managed certificate needs the `_dnsauth` TXT record to prove domain
|
|
10
|
+
* ownership before it issues.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { DnsRecord } from "./adapter.js";
|
|
14
|
+
|
|
15
|
+
/** Inputs the Azure stack resolves from its Front Door resources. */
|
|
16
|
+
export interface AzureFrontDoorDnsInputs {
|
|
17
|
+
/** The site's primary domain (`caelo-azure:domain`). */
|
|
18
|
+
readonly domain: string;
|
|
19
|
+
/** `AFDEndpoint.hostName`, e.g. `caelo-prod-abc123.z01.azurefd.net`. */
|
|
20
|
+
readonly endpointHostName: string;
|
|
21
|
+
/** `AFDCustomDomain.validationProperties.validationToken`. */
|
|
22
|
+
readonly validationToken: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The records for the primary domain: a CNAME to the Front Door endpoint and
|
|
27
|
+
* the TXT record that validates the custom domain for its managed certificate.
|
|
28
|
+
*
|
|
29
|
+
* @throws when the endpoint hostname or validation token is empty — a record
|
|
30
|
+
* pointing at nothing would read fine in the DNS guidance page and then fail
|
|
31
|
+
* at the registrar (no fallbacks pre-1.0, CLAUDE.md §2).
|
|
32
|
+
*/
|
|
33
|
+
export function azureFrontDoorDnsRecords(input: AzureFrontDoorDnsInputs): DnsRecord[] {
|
|
34
|
+
if (input.endpointHostName.length === 0) {
|
|
35
|
+
throw new Error("azure-front-door: the Front Door endpoint has no hostName yet");
|
|
36
|
+
}
|
|
37
|
+
if (input.validationToken.length === 0) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`azure-front-door: the custom domain ${input.domain} has no validation token yet`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return [
|
|
43
|
+
{
|
|
44
|
+
hostname: input.domain,
|
|
45
|
+
type: "CNAME",
|
|
46
|
+
value: input.endpointHostName,
|
|
47
|
+
purpose: "Primary domain → Azure Front Door endpoint (static site)",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
hostname: `_dnsauth.${input.domain}`,
|
|
51
|
+
type: "TXT",
|
|
52
|
+
value: input.validationToken,
|
|
53
|
+
purpose: "Validates the primary domain for Front Door's managed TLS certificate",
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* P14 — owner bootstrap token. cms-provision generates one at first
|
|
5
|
+
* `up`; the operator visits /setup?token=<…> to create the first
|
|
6
|
+
* Owner. Single-use, 24h TTL.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface BootstrapToken {
|
|
10
|
+
readonly token: string;
|
|
11
|
+
readonly expiresAt: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function generateBootstrapToken(): BootstrapToken {
|
|
15
|
+
const bytes = new Uint8Array(32);
|
|
16
|
+
crypto.getRandomValues(bytes);
|
|
17
|
+
const token = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
18
|
+
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
|
19
|
+
return { token, expiresAt };
|
|
20
|
+
}
|