@caelo-cms/provisioning 0.10.46 → 0.10.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adc-impersonation.d.ts.map +1 -1
- package/dist/adc-impersonation.js +23 -1
- package/dist/adc-impersonation.js.map +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 +18362 -0
- package/dist/cli.js +122 -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 +19 -1
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +31 -3
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +24 -5
- 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/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/state-attach.d.ts +23 -0
- package/dist/state-attach.d.ts.map +1 -0
- package/dist/state-attach.js +112 -0
- package/dist/state-attach.js.map +1 -0
- package/dist/state-backend.d.ts +11 -0
- package/dist/state-backend.d.ts.map +1 -1
- package/dist/state-backend.js +20 -0
- package/dist/state-backend.js.map +1 -1
- package/dist/state-command.d.ts +20 -0
- package/dist/state-command.d.ts.map +1 -0
- package/dist/state-command.js +237 -0
- package/dist/state-command.js.map +1 -0
- package/dist/state-migrate.d.ts +121 -0
- package/dist/state-migrate.d.ts.map +1 -0
- package/dist/state-migrate.js +290 -0
- package/dist/state-migrate.js.map +1 -0
- package/dist/state-pulumi.d.ts +17 -0
- package/dist/state-pulumi.d.ts.map +1 -0
- package/dist/state-pulumi.js +218 -0
- package/dist/state-pulumi.js.map +1 -0
- package/dist/state-store.d.ts +27 -0
- package/dist/state-store.d.ts.map +1 -1
- package/dist/state-store.js +65 -0
- package/dist/state-store.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 +12 -0
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +31 -0
- 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 +82 -0
- package/dist/upgrader-assess.d.ts.map +1 -0
- package/dist/upgrader-assess.js +116 -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 +131 -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 +9 -2
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +8 -6
- 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 +291 -83
- 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 +382 -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 +293 -0
- package/src/release-manifest.ts +430 -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 +391 -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 +265 -0
- package/src/upgrader-assess.ts +233 -0
- package/src/upgrader-gcp.ts +123 -0
- package/src/upgrader-job.test.ts +408 -0
- package/src/upgrader-job.ts +499 -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,209 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Installs, re-pins and removes the agent's systemd --user units (#696
|
|
5
|
+
* Phase 4c). Every command goes through a {@link HostRunner} and the unit
|
|
6
|
+
* directory is a parameter, so tests run against a fake and a temp dir —
|
|
7
|
+
* nothing here touches the real systemd of the machine running the tests.
|
|
8
|
+
*
|
|
9
|
+
* User units stop with the user's last session unless the user "lingers";
|
|
10
|
+
* {@link installAgentUnits} turns lingering on (allowed for oneself on
|
|
11
|
+
* systemd-logind's default policy) and, where it may not, says which one
|
|
12
|
+
* command an administrator runs.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
closeSync,
|
|
17
|
+
ftruncateSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
openSync,
|
|
20
|
+
readFileSync,
|
|
21
|
+
rmSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
writeSync,
|
|
24
|
+
} from "node:fs";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import type { HostRunner } from "../self-hosted-release.js";
|
|
28
|
+
import {
|
|
29
|
+
type AgentUnitSpec,
|
|
30
|
+
agentServiceUnit,
|
|
31
|
+
agentTimerUnit,
|
|
32
|
+
pinnedAgentVersion,
|
|
33
|
+
} from "./units.js";
|
|
34
|
+
|
|
35
|
+
/** The host the units go to. */
|
|
36
|
+
export interface AgentHost {
|
|
37
|
+
readonly runner: HostRunner;
|
|
38
|
+
/** `~/.config/systemd/user` (or `$XDG_CONFIG_HOME/systemd/user`). */
|
|
39
|
+
readonly unitDir: string;
|
|
40
|
+
/** The operator's login name (for lingering). */
|
|
41
|
+
readonly user: string;
|
|
42
|
+
readonly platform: NodeJS.Platform;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The real host: this user's systemd user-unit directory. */
|
|
46
|
+
export function currentAgentHost(runner: HostRunner): AgentHost {
|
|
47
|
+
const config = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
48
|
+
return {
|
|
49
|
+
runner,
|
|
50
|
+
unitDir: join(config, "systemd", "user"),
|
|
51
|
+
user: process.env.USER || process.env.LOGNAME || "",
|
|
52
|
+
platform: process.platform,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Why the agent cannot be installed on this host, or null. Linux with a
|
|
58
|
+
* systemd user manager is the supported target; macOS (Docker Desktop) is
|
|
59
|
+
* a development setup, and there `upgrade` is run by hand.
|
|
60
|
+
*/
|
|
61
|
+
export async function agentHostProblem(host: AgentHost): Promise<string | null> {
|
|
62
|
+
if (host.platform !== "linux") {
|
|
63
|
+
return `the update agent runs on Linux hosts with systemd (this is ${host.platform}). Install updates with the upgrade command instead.`;
|
|
64
|
+
}
|
|
65
|
+
const r = await host.runner.run(["systemctl", "--user", "show-environment"]);
|
|
66
|
+
if (r.exitCode !== 0) {
|
|
67
|
+
return `systemd's user manager is not reachable (\`systemctl --user\`: ${r.stderr.trim().slice(0, 200) || `exit ${r.exitCode}`}). Log in to the host as this user directly (not via \`su\`), or install updates with the upgrade command.`;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `<unitDir>/<name>.service` + `.timer`. */
|
|
73
|
+
export function agentUnitPaths(host: Pick<AgentHost, "unitDir">, name: string) {
|
|
74
|
+
return {
|
|
75
|
+
service: join(host.unitDir, `${name}.service`),
|
|
76
|
+
timer: join(host.unitDir, `${name}.timer`),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type AgentUnitsResult =
|
|
81
|
+
| { readonly ok: true; readonly warnings: readonly string[] }
|
|
82
|
+
| { readonly ok: false; readonly error: string };
|
|
83
|
+
|
|
84
|
+
async function systemctl(host: AgentHost, ...args: string[]): Promise<string | null> {
|
|
85
|
+
const r = await host.runner.run(["systemctl", "--user", ...args]);
|
|
86
|
+
return r.exitCode === 0
|
|
87
|
+
? null
|
|
88
|
+
: `systemctl --user ${args.join(" ")}: ${r.stderr.trim().slice(0, 300) || `exit ${r.exitCode}`}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Write both units, reload, enable + start the timer, and make sure the
|
|
93
|
+
* user lingers. Idempotent: re-running rewrites the same files.
|
|
94
|
+
*/
|
|
95
|
+
export async function installAgentUnits(
|
|
96
|
+
spec: AgentUnitSpec,
|
|
97
|
+
host: AgentHost,
|
|
98
|
+
): Promise<AgentUnitsResult> {
|
|
99
|
+
const paths = agentUnitPaths(host, spec.name);
|
|
100
|
+
try {
|
|
101
|
+
mkdirSync(host.unitDir, { recursive: true });
|
|
102
|
+
writeFileSync(paths.service, agentServiceUnit(spec), { mode: 0o644 });
|
|
103
|
+
writeFileSync(paths.timer, agentTimerUnit(spec), { mode: 0o644 });
|
|
104
|
+
} catch (e) {
|
|
105
|
+
return { ok: false, error: `could not write the units: ${(e as Error).message}` };
|
|
106
|
+
}
|
|
107
|
+
for (const args of [["daemon-reload"], ["enable", "--now", `${spec.name}.timer`]]) {
|
|
108
|
+
const error = await systemctl(host, ...args);
|
|
109
|
+
if (error) return { ok: false, error };
|
|
110
|
+
}
|
|
111
|
+
const warnings: string[] = [];
|
|
112
|
+
const linger = await ensureLinger(host);
|
|
113
|
+
if (linger) warnings.push(linger);
|
|
114
|
+
return { ok: true, warnings };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Lingering on for the user; returns a warning when it could not be. */
|
|
118
|
+
async function ensureLinger(host: AgentHost): Promise<string | null> {
|
|
119
|
+
if (!host.user) return "could not tell the user name, so lingering was not checked";
|
|
120
|
+
const show = await host.runner.run(["loginctl", "show-user", host.user, "--property=Linger"]);
|
|
121
|
+
if (show.exitCode === 0 && show.stdout.trim() === "Linger=yes") return null;
|
|
122
|
+
const enable = await host.runner.run(["loginctl", "enable-linger", host.user]);
|
|
123
|
+
if (enable.exitCode === 0) return null;
|
|
124
|
+
return `the agent only runs while ${host.user} is logged in: lingering could not be switched on (${enable.stderr.trim().slice(0, 200) || `exit ${enable.exitCode}`}). Have an administrator run \`sudo loginctl enable-linger ${host.user}\` once.`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The release the installed service runs, or null when it is not installed. */
|
|
128
|
+
export function installedAgentVersion(host: AgentHost, name: string): string | null {
|
|
129
|
+
const { service } = agentUnitPaths(host, name);
|
|
130
|
+
try {
|
|
131
|
+
return pinnedAgentVersion(readFileSync(service, "utf8"));
|
|
132
|
+
} catch (e) {
|
|
133
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
134
|
+
throw e;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Re-pin an installed agent to `spec.version` (after an update). Does
|
|
140
|
+
* nothing when the agent is not installed.
|
|
141
|
+
*/
|
|
142
|
+
export async function repinAgentUnits(
|
|
143
|
+
spec: AgentUnitSpec,
|
|
144
|
+
host: AgentHost,
|
|
145
|
+
): Promise<AgentUnitsResult & { readonly installed?: boolean }> {
|
|
146
|
+
const { service } = agentUnitPaths(host, spec.name);
|
|
147
|
+
try {
|
|
148
|
+
// "r+" opens only an existing file (ENOENT otherwise), so the check and
|
|
149
|
+
// the rewrite are one step — no window for the file to change between.
|
|
150
|
+
const fd = openSync(service, "r+");
|
|
151
|
+
try {
|
|
152
|
+
ftruncateSync(fd, 0);
|
|
153
|
+
writeSync(fd, agentServiceUnit(spec), 0, "utf8");
|
|
154
|
+
} finally {
|
|
155
|
+
closeSync(fd);
|
|
156
|
+
}
|
|
157
|
+
} catch (e) {
|
|
158
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") {
|
|
159
|
+
return { ok: true, warnings: [], installed: false };
|
|
160
|
+
}
|
|
161
|
+
return { ok: false, error: `could not rewrite ${service}: ${(e as Error).message}` };
|
|
162
|
+
}
|
|
163
|
+
const error = await systemctl(host, "daemon-reload");
|
|
164
|
+
return error ? { ok: false, error } : { ok: true, warnings: [], installed: true };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Stop + disable the timer and delete both units. */
|
|
168
|
+
export async function uninstallAgentUnits(
|
|
169
|
+
host: AgentHost,
|
|
170
|
+
name: string,
|
|
171
|
+
): Promise<AgentUnitsResult> {
|
|
172
|
+
const warnings: string[] = [];
|
|
173
|
+
const disabled = await systemctl(host, "disable", "--now", `${name}.timer`);
|
|
174
|
+
if (disabled) warnings.push(disabled);
|
|
175
|
+
const paths = agentUnitPaths(host, name);
|
|
176
|
+
rmSync(paths.service, { force: true });
|
|
177
|
+
rmSync(paths.timer, { force: true });
|
|
178
|
+
const reload = await systemctl(host, "daemon-reload");
|
|
179
|
+
if (reload) warnings.push(reload);
|
|
180
|
+
return { ok: true, warnings };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** `systemctl --user status` of both units (text for the terminal). */
|
|
184
|
+
export async function agentUnitStatus(host: AgentHost, name: string): Promise<string> {
|
|
185
|
+
const r = await host.runner.run([
|
|
186
|
+
"systemctl",
|
|
187
|
+
"--user",
|
|
188
|
+
"--no-pager",
|
|
189
|
+
"status",
|
|
190
|
+
`${name}.timer`,
|
|
191
|
+
`${name}.service`,
|
|
192
|
+
]);
|
|
193
|
+
// status exits 3 for an inactive oneshot service; the text is what matters.
|
|
194
|
+
return (r.stdout || r.stderr).trim();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The argv that shows the agent's log (journald). */
|
|
198
|
+
export function agentLogsArgv(name: string, opts: { lines: number; follow: boolean }): string[] {
|
|
199
|
+
return [
|
|
200
|
+
"journalctl",
|
|
201
|
+
"--user",
|
|
202
|
+
"--no-pager",
|
|
203
|
+
"-u",
|
|
204
|
+
`${name}.service`,
|
|
205
|
+
"-n",
|
|
206
|
+
String(opts.lines),
|
|
207
|
+
...(opts.follow ? ["-f"] : []),
|
|
208
|
+
];
|
|
209
|
+
}
|
|
@@ -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
|
+
}
|