omp-conductor 0.14.0 → 0.15.0
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/README.md +336 -169
- package/package.json +8 -5
- package/schema/config.schema.json +609 -0
- package/src/board.ts +19 -32
- package/src/brief-upgrade.ts +1 -1
- package/src/briefs/orchestrator.md +9 -5
- package/src/briefs/policy.md +4 -4
- package/src/briefs/probes/gates.md +51 -0
- package/src/briefs/probes/project-context.md +59 -0
- package/src/briefs/probes/release-procedure.md +81 -0
- package/src/cli.ts +235 -199
- package/src/config-schema.ts +352 -0
- package/src/config.ts +1046 -796
- package/src/confinement.ts +54 -0
- package/src/daemon.ts +442 -374
- package/src/escalate.ts +43 -3
- package/src/fleet.ts +317 -43
- package/src/generate-schema.ts +21 -0
- package/src/graph.ts +3 -3
- package/src/host.ts +16 -0
- package/src/omp.ts +21 -1
- package/src/orchestrator-tick.ts +298 -39
- package/src/privileged.ts +264 -0
- package/src/reports.ts +1 -1
- package/src/session-host.ts +3 -0
- package/src/setup-host.ts +209 -24
- package/src/setup-install.ts +320 -0
- package/src/setup-probe.ts +412 -0
- package/src/{plugin.ts → setup-wizard.ts} +790 -465
- package/src/setup.ts +264 -20
- package/src/types.ts +2 -2
- package/src/upgrade.ts +44 -10
- package/src/verbs/server.ts +32 -9
- package/src/wizard-ui.ts +249 -0
- package/src/worker.ts +6 -1
- package/skills/conductor-onboarding/SKILL.md +0 -748
- package/skills/conductor-update/SKILL.md +0 -51
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place conductor runs a command as root.
|
|
3
|
+
*
|
|
4
|
+
* Every host-provisioning step used to be *printed* as a `sudo …` line for the
|
|
5
|
+
* operator to retype, which is not caution — it is an audit trail nobody keeps
|
|
6
|
+
* and a transcription error nobody catches. This module keeps the audit and
|
|
7
|
+
* removes the retyping: the caller composes structured steps, this renders all
|
|
8
|
+
* of them with their exact argv, takes **one** confirm, and then runs them.
|
|
9
|
+
*
|
|
10
|
+
* Three invariants, each of which a convenient shortcut would break:
|
|
11
|
+
*
|
|
12
|
+
* - **The CLI never escalates itself.** Only the individual steps below are
|
|
13
|
+
* privileged. `sudo omp-conductor setup host` would resolve the config,
|
|
14
|
+
* `$OMP_CONDUCTOR_HOME`, `homedir()` and the unit's own `User=` as root and
|
|
15
|
+
* bake the wrong account into a unit for a fleet that runs as somebody
|
|
16
|
+
* else — a timer that goes green while writing state no worker can read.
|
|
17
|
+
* {@link assertNoSelfExec} refuses to build such a step at all.
|
|
18
|
+
* - **One confirm, everything named.** A confirm that authorises commands it
|
|
19
|
+
* did not print is not consent, so the render happens first and lists the
|
|
20
|
+
* exact argv — including which steps deliberately run *without* `sudo`.
|
|
21
|
+
* - **A failure hands the work back.** The first non-`bestEffort` failure
|
|
22
|
+
* stops the batch and prints the failed title, its stderr, and every
|
|
23
|
+
* not-yet-run command verbatim, so an operator can finish by hand from the
|
|
24
|
+
* terminal they are already looking at.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { WizardUi } from "./wizard-ui.ts";
|
|
28
|
+
|
|
29
|
+
export interface PrivilegedStep {
|
|
30
|
+
/** One line naming what this step accomplishes, shown in the plan. */
|
|
31
|
+
title: string;
|
|
32
|
+
/** The exact argv. No shell: no quoting rules, no word splitting, no `&&`. */
|
|
33
|
+
argv: readonly string[];
|
|
34
|
+
/**
|
|
35
|
+
* A failure here is reported and the batch continues. For steps whose failure
|
|
36
|
+
* is not a broken install — reloading a systemd that was never running, say.
|
|
37
|
+
*/
|
|
38
|
+
bestEffort?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Runs as the invoking account with **no** `sudo` prefix, while still
|
|
41
|
+
* appearing in the same plan under the same confirm.
|
|
42
|
+
*
|
|
43
|
+
* `setup graph`'s index-only clones are the case this exists for: a
|
|
44
|
+
* root-owned clone under the fleet user's cache is precisely the failure the
|
|
45
|
+
* escalation guard exists to prevent, but the operator must still see the
|
|
46
|
+
* clone and the timer install as one plan they approve once.
|
|
47
|
+
*/
|
|
48
|
+
unprivileged?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One spawn the runner needs. Injectable so tests never touch a real `sudo`. */
|
|
52
|
+
export interface PrivilegedSpawn {
|
|
53
|
+
argv: readonly string[];
|
|
54
|
+
/**
|
|
55
|
+
* `sudo -v` needs the terminal for its password prompt, so its stdio is
|
|
56
|
+
* inherited whole and nothing is captured.
|
|
57
|
+
*/
|
|
58
|
+
interactive: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface PrivilegedResult {
|
|
62
|
+
exitCode: number;
|
|
63
|
+
/** Captured stderr, or `""` for an interactive spawn that owned the terminal. */
|
|
64
|
+
stderr: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface PrivilegedDeps {
|
|
68
|
+
spawn(request: PrivilegedSpawn): Promise<PrivilegedResult>;
|
|
69
|
+
/** `undefined` on a platform without uids, which is never privileged here. */
|
|
70
|
+
getuid(): number | undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type PrivilegedOutcome =
|
|
74
|
+
/** The confirm was answered no, or dismissed. Nothing ran. */
|
|
75
|
+
| { kind: "declined" }
|
|
76
|
+
/** Every step ran; `bestEffort` failures are listed but did not stop the batch. */
|
|
77
|
+
| { kind: "completed"; softFailures: { step: PrivilegedStep; stderr: string }[] }
|
|
78
|
+
/** A required step failed. `remaining` are the steps that never ran. */
|
|
79
|
+
| {
|
|
80
|
+
kind: "failed";
|
|
81
|
+
step: PrivilegedStep;
|
|
82
|
+
exitCode: number;
|
|
83
|
+
stderr: string;
|
|
84
|
+
remaining: readonly PrivilegedStep[];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export interface RunPrivilegedOptions {
|
|
88
|
+
deps?: PrivilegedDeps;
|
|
89
|
+
/** Confirm title. Defaults to a generic one; callers name their verb. */
|
|
90
|
+
title?: string;
|
|
91
|
+
/** Extra lines shown above the step list — what this batch is for. */
|
|
92
|
+
preamble?: readonly string[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Shell-quoted rendering of one step, exactly as an operator would type it.
|
|
97
|
+
*
|
|
98
|
+
* The steps are argv precisely so nothing here is load-bearing at execution
|
|
99
|
+
* time; this is for the plan, the failure remainder and the print-only modes.
|
|
100
|
+
*/
|
|
101
|
+
export function formatStep(step: PrivilegedStep, asRoot = false): string {
|
|
102
|
+
const prefix = step.unprivileged || asRoot ? [] : ["sudo"];
|
|
103
|
+
return [...prefix, ...step.argv].map(shellQuote).join(" ");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function shellQuote(value: string): string {
|
|
107
|
+
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Refuses to build a step that re-runs conductor itself.
|
|
112
|
+
*
|
|
113
|
+
* Called on every step before anything is rendered, because the failure it
|
|
114
|
+
* prevents is invisible: `sudo omp-conductor …` exits 0 and leaves a unit
|
|
115
|
+
* naming the wrong account. Cheap, and it turns a future refactor's mistake
|
|
116
|
+
* into a test failure rather than a broken fleet.
|
|
117
|
+
*/
|
|
118
|
+
function assertNoSelfExec(steps: readonly PrivilegedStep[]): void {
|
|
119
|
+
for (const step of steps) {
|
|
120
|
+
const offender = step.argv.find((a) => {
|
|
121
|
+
const base = a.split("/").pop() ?? a;
|
|
122
|
+
return base === "omp-conductor" || base === "cli.ts";
|
|
123
|
+
});
|
|
124
|
+
if (offender !== undefined) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`privileged step "${step.title}" would re-exec conductor (${offender}) — ` +
|
|
127
|
+
"only individual structured steps are ever privileged",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function spawnReal(request: PrivilegedSpawn): Promise<PrivilegedResult> {
|
|
134
|
+
const proc = Bun.spawn(request.argv as string[], {
|
|
135
|
+
// stdin is inherited even for captured steps: a `sudo` whose timestamp
|
|
136
|
+
// expired mid-batch must be able to prompt rather than fail on a closed fd.
|
|
137
|
+
stdin: "inherit",
|
|
138
|
+
// stdout is inherited so a long step (`systemctl start` on a oneshot that
|
|
139
|
+
// indexes for minutes) is visible while it runs instead of after it.
|
|
140
|
+
stdout: "inherit",
|
|
141
|
+
stderr: request.interactive ? "inherit" : "pipe",
|
|
142
|
+
});
|
|
143
|
+
const [exitCode, stderr] = await Promise.all([
|
|
144
|
+
proc.exited,
|
|
145
|
+
request.interactive ? Promise.resolve("") : new Response(proc.stderr).text(),
|
|
146
|
+
]);
|
|
147
|
+
return { exitCode, stderr };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const DEFAULT_PRIVILEGED_DEPS: PrivilegedDeps = {
|
|
151
|
+
spawn: spawnReal,
|
|
152
|
+
getuid: () => process.getuid?.(),
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Renders `steps`, takes one confirm, then runs them in order.
|
|
157
|
+
*
|
|
158
|
+
* As root, every step runs directly: there is nothing to escalate, and a `sudo`
|
|
159
|
+
* prefix on a root-run fleet is an extra dependency for no gain. Otherwise one
|
|
160
|
+
* `sudo -v` primes the credential with the terminal it needs, and each
|
|
161
|
+
* privileged step then runs as `sudo -- <argv>` — `--` so a step whose first
|
|
162
|
+
* argument begins with a dash cannot be read as a `sudo` flag.
|
|
163
|
+
*/
|
|
164
|
+
export async function runPrivileged(
|
|
165
|
+
steps: readonly PrivilegedStep[],
|
|
166
|
+
ui: WizardUi,
|
|
167
|
+
options: RunPrivilegedOptions = {},
|
|
168
|
+
): Promise<PrivilegedOutcome> {
|
|
169
|
+
assertNoSelfExec(steps);
|
|
170
|
+
const deps = options.deps ?? DEFAULT_PRIVILEGED_DEPS;
|
|
171
|
+
const asRoot = deps.getuid() === 0;
|
|
172
|
+
const needsSudo = !asRoot && steps.some((s) => s.unprivileged !== true);
|
|
173
|
+
|
|
174
|
+
ui.notify(
|
|
175
|
+
[
|
|
176
|
+
...(options.preamble ?? []),
|
|
177
|
+
...(options.preamble === undefined || options.preamble.length === 0 ? [] : [""]),
|
|
178
|
+
...steps.flatMap((step, index) => [
|
|
179
|
+
`${index + 1}. ${step.title}${step.unprivileged === true && !asRoot ? " (as you, not root)" : ""}`,
|
|
180
|
+
` ${formatStep(step, asRoot)}`,
|
|
181
|
+
]),
|
|
182
|
+
"",
|
|
183
|
+
asRoot
|
|
184
|
+
? "Running as root — these run directly, with no sudo."
|
|
185
|
+
: needsSudo
|
|
186
|
+
? "sudo will ask for your password once, before the first step."
|
|
187
|
+
: "None of these need root.",
|
|
188
|
+
"Nothing has been run yet.",
|
|
189
|
+
].join("\n"),
|
|
190
|
+
"info",
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const go = await ui.confirm(
|
|
194
|
+
options.title ?? "Run these steps now?",
|
|
195
|
+
`${steps.length} step(s), in the order shown. Anything that fails stops the rest and prints what is left.`,
|
|
196
|
+
);
|
|
197
|
+
// `undefined` is a dismissal rather than a "no", but for a batch that has not
|
|
198
|
+
// started they mean the same thing: run nothing.
|
|
199
|
+
if (go !== true) return { kind: "declined" };
|
|
200
|
+
|
|
201
|
+
if (needsSudo) {
|
|
202
|
+
const primed = await deps.spawn({ argv: ["sudo", "-v"], interactive: true });
|
|
203
|
+
if (primed.exitCode !== 0) {
|
|
204
|
+
return {
|
|
205
|
+
kind: "failed",
|
|
206
|
+
step: { title: "authenticate with sudo", argv: ["sudo", "-v"] },
|
|
207
|
+
exitCode: primed.exitCode,
|
|
208
|
+
stderr: primed.stderr,
|
|
209
|
+
remaining: steps,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const softFailures: { step: PrivilegedStep; stderr: string }[] = [];
|
|
215
|
+
for (const [index, step] of steps.entries()) {
|
|
216
|
+
ui.notify(`[${index + 1}/${steps.length}] ${step.title}`, "info");
|
|
217
|
+
const argv =
|
|
218
|
+
step.unprivileged === true || asRoot ? step.argv : ["sudo", "--", ...step.argv];
|
|
219
|
+
const result = await deps.spawn({ argv, interactive: false });
|
|
220
|
+
if (result.exitCode === 0) continue;
|
|
221
|
+
if (step.bestEffort === true) {
|
|
222
|
+
softFailures.push({ step, stderr: result.stderr });
|
|
223
|
+
ui.notify(` continued past a best-effort failure: ${firstLine(result.stderr)}`, "warning");
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const remaining = steps.slice(index + 1);
|
|
227
|
+
ui.notify(formatFailure(step, result, remaining, asRoot), "error");
|
|
228
|
+
return { kind: "failed", step, exitCode: result.exitCode, stderr: result.stderr, remaining };
|
|
229
|
+
}
|
|
230
|
+
return { kind: "completed", softFailures };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function firstLine(stderr: string): string {
|
|
234
|
+
const line = stderr.trim().split("\n")[0];
|
|
235
|
+
return line === undefined || line === "" ? "no stderr" : line;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* What the operator is left holding when a step fails: the step, why, and the
|
|
240
|
+
* rest of the batch verbatim so finishing by hand is a paste rather than a
|
|
241
|
+
* reconstruction.
|
|
242
|
+
*/
|
|
243
|
+
export function formatFailure(
|
|
244
|
+
step: PrivilegedStep,
|
|
245
|
+
result: PrivilegedResult,
|
|
246
|
+
remaining: readonly PrivilegedStep[],
|
|
247
|
+
asRoot = false,
|
|
248
|
+
): string {
|
|
249
|
+
return [
|
|
250
|
+
`Stopped: ${step.title} exited ${result.exitCode}.`,
|
|
251
|
+
` ${formatStep(step, asRoot)}`,
|
|
252
|
+
...(result.stderr.trim() === ""
|
|
253
|
+
? [" (no stderr — its output is above)"]
|
|
254
|
+
: result.stderr.trimEnd().split("\n").map((l) => ` ${l}`)),
|
|
255
|
+
...(remaining.length === 0
|
|
256
|
+
? ["", "Nothing was left to run."]
|
|
257
|
+
: [
|
|
258
|
+
"",
|
|
259
|
+
`${remaining.length} step(s) did NOT run. To finish by hand:`,
|
|
260
|
+
"",
|
|
261
|
+
...remaining.map((s) => ` ${formatStep(s, asRoot)}`),
|
|
262
|
+
]),
|
|
263
|
+
].join("\n");
|
|
264
|
+
}
|
package/src/reports.ts
CHANGED
|
@@ -284,7 +284,7 @@ export function telegramReportSend(p: ProjectConfig): ReportSend {
|
|
|
284
284
|
"definitive",
|
|
285
285
|
);
|
|
286
286
|
}
|
|
287
|
-
return await sendTelegram(token, chatId, text);
|
|
287
|
+
return await sendTelegram(token, chatId, text, { topicId: p.escalation.telegramTopicId });
|
|
288
288
|
};
|
|
289
289
|
}
|
|
290
290
|
|
package/src/session-host.ts
CHANGED
|
@@ -49,6 +49,8 @@ export interface SessionHostSpec {
|
|
|
49
49
|
* Absent, the verbs are registered and every one of them fails closed.
|
|
50
50
|
*/
|
|
51
51
|
verbSocketPath?: string;
|
|
52
|
+
/** Deny every tool but reading and searching (#307) — the setup probes. */
|
|
53
|
+
readOnly?: boolean;
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
/** Parent → child. */
|
|
@@ -184,6 +186,7 @@ export async function runSessionHost(
|
|
|
184
186
|
...(spec.resume === undefined ? {} : { resume: spec.resume }),
|
|
185
187
|
...(spec.releaseGrants === undefined ? {} : { releaseGrants: spec.releaseGrants }),
|
|
186
188
|
...(spec.verbSocketPath === undefined ? {} : { verbSocketPath: spec.verbSocketPath }),
|
|
189
|
+
...(spec.readOnly === undefined ? {} : { readOnly: spec.readOnly }),
|
|
187
190
|
// The release audit lives in the daemon's state directory, which this
|
|
188
191
|
// process may not be able to write and must not be trusted to. It
|
|
189
192
|
// becomes a message; the parent performs the durable write.
|
package/src/setup-host.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
2
3
|
import { homedir, userInfo } from "node:os";
|
|
3
4
|
import { dirname, join } from "node:path";
|
|
4
|
-
import { configPath, stateDir } from "./config.ts";
|
|
5
|
+
import { configPath, loadConfig, resolveCaps, stateDir } from "./config.ts";
|
|
5
6
|
import { isPaused, runDaemon, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
|
|
6
7
|
import { DEFAULT_FLEET_AGENT_NAME } from "./fleet.ts";
|
|
7
8
|
import {
|
|
@@ -14,11 +15,13 @@ import {
|
|
|
14
15
|
type StopResult,
|
|
15
16
|
} from "./lifecycle.ts";
|
|
16
17
|
import {
|
|
18
|
+
legacyArmedMarkerPath,
|
|
17
19
|
readTickConfig,
|
|
18
20
|
TICK_CONFIG_FILE,
|
|
21
|
+
tickConfigMatchesProject,
|
|
19
22
|
type TickConfig,
|
|
20
23
|
} from "./orchestrator-tick.ts";
|
|
21
|
-
import type { Caps, ProjectConfig } from "./types.ts";
|
|
24
|
+
import type { Caps, ConductorConfig, ProjectConfig } from "./types.ts";
|
|
22
25
|
|
|
23
26
|
export const DEFAULT_TICK_INTERVAL_SECONDS = 900;
|
|
24
27
|
export const STAGED_SERVICE_NAME = "omp-conductor.service";
|
|
@@ -36,6 +39,21 @@ export interface HostRuntimePlan {
|
|
|
36
39
|
tick?: PlannedWrite<TickConfig>;
|
|
37
40
|
installCommands: readonly string[];
|
|
38
41
|
cliSource: "global" | "plugin";
|
|
42
|
+
/** Absolute path of the unit systemd actually reads. */
|
|
43
|
+
installedPath: string;
|
|
44
|
+
/**
|
|
45
|
+
* What installing would do to the unit **systemd reads**, which is a different
|
|
46
|
+
* question from {@link HostRuntimePlan.service}'s action — that one compares the
|
|
47
|
+
* staged copy under the state directory.
|
|
48
|
+
*
|
|
49
|
+
* The distinction is load-bearing: staging has always happened during setup,
|
|
50
|
+
* so on any fleet configured before the install was executed the staged file is
|
|
51
|
+
* already current (`service.action === "keep"`) while `/etc/systemd/system`
|
|
52
|
+
* holds nothing at all. Gating the install offer on the staged action therefore
|
|
53
|
+
* skipped exactly the fleets that had never installed the unit, and told them
|
|
54
|
+
* the installed unit matched.
|
|
55
|
+
*/
|
|
56
|
+
installedAction: PlannedWrite<string>["action"];
|
|
39
57
|
}
|
|
40
58
|
|
|
41
59
|
export interface ServiceRuntime {
|
|
@@ -66,6 +84,143 @@ function actionFor(path: string, content: string): PlannedWrite<string>["action"
|
|
|
66
84
|
return "update";
|
|
67
85
|
}
|
|
68
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Which account this host's fleet belongs to, and how we know.
|
|
89
|
+
*
|
|
90
|
+
* Two sources, in order of authority: the installed unit's own `User=` is what
|
|
91
|
+
* systemd will actually run as, and the owner of `$OMP_CONDUCTOR_HOME` is what
|
|
92
|
+
* owns the config, the store and the state directory. Either one disagreeing
|
|
93
|
+
* with the invoking account means staging would write the wrong identity.
|
|
94
|
+
*/
|
|
95
|
+
export interface FleetAccount {
|
|
96
|
+
name: string | undefined;
|
|
97
|
+
source: "installed unit" | "$OMP_CONDUCTOR_HOME owner" | "nothing on this host";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface EscalationDeps {
|
|
101
|
+
invokingUser(): string;
|
|
102
|
+
/** `$SUDO_USER`, which `sudo` sets and `sudo -i` / `su -` clear. */
|
|
103
|
+
sudoUser(): string | undefined;
|
|
104
|
+
/** `User=` from the installed unit, or `undefined` when there is no unit. */
|
|
105
|
+
unitUser(): string | undefined;
|
|
106
|
+
/** Owner of `$OMP_CONDUCTOR_HOME`, or `undefined` when it does not exist. */
|
|
107
|
+
homeOwner(): string | undefined;
|
|
108
|
+
conductorHome(): string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** uid → account name. `id -un` because no Node builtin maps a foreign uid. */
|
|
112
|
+
function accountName(uid: number): string {
|
|
113
|
+
const ran = spawnSync("id", ["-un", String(uid)], { encoding: "utf8" });
|
|
114
|
+
const name = ran.status === 0 ? (ran.stdout ?? "").trim() : "";
|
|
115
|
+
return name === "" ? `uid ${uid}` : name;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function installedUnitUser(): string | undefined {
|
|
119
|
+
try {
|
|
120
|
+
const unit = readFileSync(join(SYSTEMD_UNIT_DIR, STAGED_SERVICE_NAME), "utf8");
|
|
121
|
+
const match = /^User=(.*)$/m.exec(unit);
|
|
122
|
+
if (match === null) return undefined;
|
|
123
|
+
// Unquoted in what we generate, but systemd accepts quotes and a
|
|
124
|
+
// hand-edited unit is exactly the case this guard has to read correctly.
|
|
125
|
+
const value = match[1]?.trim().replace(/^"(.*)"$/, "$1") ?? "";
|
|
126
|
+
return value === "" ? undefined : value;
|
|
127
|
+
} catch {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const DEFAULT_ESCALATION_DEPS: EscalationDeps = {
|
|
133
|
+
invokingUser: () => userInfo().username,
|
|
134
|
+
sudoUser: () => process.env["SUDO_USER"],
|
|
135
|
+
unitUser: installedUnitUser,
|
|
136
|
+
homeOwner: () => {
|
|
137
|
+
try {
|
|
138
|
+
return accountName(statSync(dirname(configPath())).uid);
|
|
139
|
+
} catch {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
conductorHome: () => dirname(configPath()),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** The account this host's fleet runs as, from whichever source knows. */
|
|
147
|
+
export function resolveFleetAccount(deps: EscalationDeps = DEFAULT_ESCALATION_DEPS): FleetAccount {
|
|
148
|
+
const fromUnit = deps.unitUser();
|
|
149
|
+
if (fromUnit !== undefined) return { name: fromUnit, source: "installed unit" };
|
|
150
|
+
const fromHome = deps.homeOwner();
|
|
151
|
+
if (fromHome !== undefined) return { name: fromHome, source: "$OMP_CONDUCTOR_HOME owner" };
|
|
152
|
+
return { name: undefined, source: "nothing on this host" };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type EscalationVerdict = { kind: "ok" } | { kind: "refuse"; message: string };
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Refuses an *escalated* invocation of any setup path — and only that.
|
|
159
|
+
*
|
|
160
|
+
* The distinction is the whole point. Staging derives the unit's `User=` and
|
|
161
|
+
* `HOME=` from the invoking account ({@link defaultServiceRuntime}), so a
|
|
162
|
+
* `sudo omp-conductor setup host` writes `User=root` + `HOME=/root` into a unit
|
|
163
|
+
* for a fleet that runs as somebody else, and the config it loads, the state
|
|
164
|
+
* directory it writes and the indexes it points at all resolve as root too.
|
|
165
|
+
* Nothing about that announces itself: the unit starts, the timer goes green,
|
|
166
|
+
* and the fleet reads none of it. So it is refused rather than accommodated.
|
|
167
|
+
*
|
|
168
|
+
* `uid 0` is *not* the test. A fleet that legitimately runs as root — root owns
|
|
169
|
+
* `$OMP_CONDUCTOR_HOME` and the unit says `User=root` — is the case this verb
|
|
170
|
+
* exists for on single-tenant boxes, and refusing there would break exactly the
|
|
171
|
+
* hosts it targets. Two checks instead:
|
|
172
|
+
*
|
|
173
|
+
* - `$SUDO_USER` is set, which is `sudo` announcing the escalation itself;
|
|
174
|
+
* - the invoking account disagrees with the fleet account, which is what
|
|
175
|
+
* catches `sudo -i` and `su -` — both clear `$SUDO_USER`, so the
|
|
176
|
+
* environment check alone is not sufficient.
|
|
177
|
+
*/
|
|
178
|
+
export function checkEscalation(
|
|
179
|
+
verb: string,
|
|
180
|
+
deps: EscalationDeps = DEFAULT_ESCALATION_DEPS,
|
|
181
|
+
): EscalationVerdict {
|
|
182
|
+
const invoking = deps.invokingUser();
|
|
183
|
+
const sudoUser = deps.sudoUser();
|
|
184
|
+
const fleet = resolveFleetAccount(deps);
|
|
185
|
+
|
|
186
|
+
if (sudoUser !== undefined && sudoUser !== "") {
|
|
187
|
+
return {
|
|
188
|
+
kind: "refuse",
|
|
189
|
+
message: refusal(verb, invoking, sudoUser, fleet, deps.conductorHome()),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
if (fleet.name !== undefined && fleet.name !== invoking) {
|
|
193
|
+
return {
|
|
194
|
+
kind: "refuse",
|
|
195
|
+
message: refusal(verb, invoking, undefined, fleet, deps.conductorHome()),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return { kind: "ok" };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function refusal(
|
|
202
|
+
verb: string,
|
|
203
|
+
invoking: string,
|
|
204
|
+
sudoUser: string | undefined,
|
|
205
|
+
fleet: FleetAccount,
|
|
206
|
+
home: string,
|
|
207
|
+
): string {
|
|
208
|
+
const fleetName = fleet.name ?? sudoUser ?? "the fleet's own account";
|
|
209
|
+
return [
|
|
210
|
+
`omp-conductor: run ${verb} as the account the fleet runs as, not escalated.`,
|
|
211
|
+
sudoUser === undefined
|
|
212
|
+
? `This is running as "${invoking}", but the fleet runs as "${fleetName}" (${fleet.source}).`
|
|
213
|
+
: `This is running as "${invoking}" under sudo from "${sudoUser}"` +
|
|
214
|
+
(fleet.name === undefined ? "." : `, and the fleet runs as "${fleet.name}" (${fleet.source}).`),
|
|
215
|
+
`As "${invoking}" the config, ${home}, ~/.cache and the unit's own User= all resolve`,
|
|
216
|
+
`as "${invoking}" instead, and the result is a unit that starts and a fleet that reads`,
|
|
217
|
+
"none of it. Nothing has been written.",
|
|
218
|
+
"",
|
|
219
|
+
`Run it as "${fleetName}". Only the individual install steps need root, and`,
|
|
220
|
+
`${verb} runs those for you with sudo after showing you each one.`,
|
|
221
|
+
].join("\n");
|
|
222
|
+
}
|
|
223
|
+
|
|
69
224
|
|
|
70
225
|
function defaultServiceRuntime(telegramStateDir: string): ServiceRuntime {
|
|
71
226
|
const home = homedir();
|
|
@@ -86,16 +241,19 @@ function defaultServiceRuntime(telegramStateDir: string): ServiceRuntime {
|
|
|
86
241
|
};
|
|
87
242
|
}
|
|
88
243
|
|
|
89
|
-
export function
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
)
|
|
244
|
+
export function totalConfiguredWorkers(cfg: ConductorConfig = loadConfig()): number {
|
|
245
|
+
return cfg.projects.reduce(
|
|
246
|
+
(total, project) => total + resolveCaps(project, cfg.defaults).maxConcurrentWorkers,
|
|
247
|
+
0,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function renderDaemonService(runtime: ServiceRuntime, totalWorkers: number): string {
|
|
94
252
|
const command =
|
|
95
253
|
runtime.cli === undefined
|
|
96
|
-
? [runtime.bun, runtime.packageCli, "daemon", "--
|
|
97
|
-
: [runtime.cli, "daemon", "--
|
|
98
|
-
const memoryMax =
|
|
254
|
+
? [runtime.bun, runtime.packageCli, "daemon", "--port", String(DEFAULT_PORT)]
|
|
255
|
+
: [runtime.cli, "daemon", "--port", String(DEFAULT_PORT)];
|
|
256
|
+
const memoryMax = totalWorkers <= 1 ? "3G" : "5G";
|
|
99
257
|
return [
|
|
100
258
|
"[Unit]",
|
|
101
259
|
"Description=omp-conductor dispatch daemon",
|
|
@@ -128,6 +286,19 @@ function tickSearchRoots(project: ProjectConfig): string[] {
|
|
|
128
286
|
return roots.filter((root, index) => roots.indexOf(root) === index);
|
|
129
287
|
}
|
|
130
288
|
|
|
289
|
+
/**
|
|
290
|
+
* The tick config for one project's fleet cwd.
|
|
291
|
+
*
|
|
292
|
+
* Everything per-project here used to be shared, and that was two bugs: one
|
|
293
|
+
* `<stateDir>/armed` marker meant arming project A armed B as well, and one
|
|
294
|
+
* `agentName` meant every pane claimed the identity `fleet`, so herdr recovery
|
|
295
|
+
* and tick ownership could not tell two fleets apart.
|
|
296
|
+
*
|
|
297
|
+
* An existing config keeps an explicit `armedFile`/`agentName` only when it
|
|
298
|
+
* differs from those shared defaults. A value equal to a shared default cannot
|
|
299
|
+
* have been a deliberate per-project choice — it *is* the collision — so it is
|
|
300
|
+
* rewritten; anything else is operator intent and survives untouched.
|
|
301
|
+
*/
|
|
131
302
|
function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrite<TickConfig> {
|
|
132
303
|
let existing: { path: string; config: TickConfig } | undefined;
|
|
133
304
|
for (const root of tickSearchRoots(project)) {
|
|
@@ -135,24 +306,37 @@ function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrit
|
|
|
135
306
|
if (result.kind === "invalid") {
|
|
136
307
|
throw new Error(`tick config invalid at ${result.path}: ${result.problem}; fix or remove it before setup`);
|
|
137
308
|
}
|
|
138
|
-
|
|
309
|
+
// A config stamped for another project is that project's file: the search
|
|
310
|
+
// roots overlap, and restamping it here would hand this project's identity
|
|
311
|
+
// to the other fleet's cwd.
|
|
312
|
+
if (result.kind === "ok" && tickConfigMatchesProject(result.config, project.name)) {
|
|
139
313
|
existing = { path: result.path, config: result.config };
|
|
140
314
|
break;
|
|
141
315
|
}
|
|
142
316
|
}
|
|
143
317
|
|
|
318
|
+
const armedFile = join(stateDir(), `armed-${project.name}`);
|
|
144
319
|
const path = existing?.path ?? join(project.workspaceRoot, TICK_CONFIG_FILE);
|
|
145
320
|
const config: TickConfig = existing === undefined
|
|
146
321
|
? {
|
|
147
322
|
intervalSeconds: DEFAULT_TICK_INTERVAL_SECONDS,
|
|
148
|
-
|
|
323
|
+
project: project.name,
|
|
324
|
+
armedFile,
|
|
149
325
|
accessFile: join(telegramStateDir, "access.json"),
|
|
150
|
-
agentName:
|
|
326
|
+
agentName: project.name,
|
|
151
327
|
}
|
|
152
328
|
: {
|
|
153
329
|
...existing.config,
|
|
154
|
-
|
|
330
|
+
project: project.name,
|
|
331
|
+
armedFile:
|
|
332
|
+
existing.config.armedFile === undefined || existing.config.armedFile === legacyArmedMarkerPath()
|
|
333
|
+
? armedFile
|
|
334
|
+
: existing.config.armedFile,
|
|
155
335
|
accessFile: existing.config.accessFile ?? join(telegramStateDir, "access.json"),
|
|
336
|
+
agentName:
|
|
337
|
+
existing.config.agentName === undefined || existing.config.agentName === DEFAULT_FLEET_AGENT_NAME
|
|
338
|
+
? project.name
|
|
339
|
+
: existing.config.agentName,
|
|
156
340
|
};
|
|
157
341
|
const content = `${JSON.stringify(config, null, 2)}\n`;
|
|
158
342
|
return { path, action: actionFor(path, content), content, value: config };
|
|
@@ -160,12 +344,16 @@ function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrit
|
|
|
160
344
|
|
|
161
345
|
export function planHostRuntime(
|
|
162
346
|
project: ProjectConfig,
|
|
163
|
-
|
|
347
|
+
_caps: Caps,
|
|
164
348
|
telegramStateDir: string,
|
|
165
349
|
runtime: ServiceRuntime = defaultServiceRuntime(telegramStateDir),
|
|
350
|
+
// Callers that know the fleet pass the sum of resolved maxConcurrentWorkers.
|
|
351
|
+
// Defaulting through loadConfig() would throw in install tests that stage
|
|
352
|
+
// files before a config exists, and would hide a missing total at the call site.
|
|
353
|
+
totalWorkers: number = 1,
|
|
166
354
|
): HostRuntimePlan {
|
|
167
355
|
const servicePath = join(stateDir(), STAGED_SERVICE_NAME);
|
|
168
|
-
const serviceContent = renderDaemonService(
|
|
356
|
+
const serviceContent = renderDaemonService(runtime, totalWorkers);
|
|
169
357
|
const service: PlannedWrite<string> = {
|
|
170
358
|
path: servicePath,
|
|
171
359
|
action: actionFor(servicePath, serviceContent),
|
|
@@ -185,6 +373,8 @@ export function planHostRuntime(
|
|
|
185
373
|
`sudo systemctl restart ${STAGED_SERVICE_NAME}`,
|
|
186
374
|
],
|
|
187
375
|
cliSource: runtime.cli === undefined ? "plugin" : "global",
|
|
376
|
+
installedPath,
|
|
377
|
+
installedAction: actionFor(installedPath, serviceContent),
|
|
188
378
|
};
|
|
189
379
|
}
|
|
190
380
|
|
|
@@ -242,7 +432,7 @@ export interface SetupSmokeResult {
|
|
|
242
432
|
}
|
|
243
433
|
|
|
244
434
|
export interface SetupSmokeDeps {
|
|
245
|
-
paused(): boolean;
|
|
435
|
+
paused(project: string): boolean;
|
|
246
436
|
runOnce(project: string): Promise<void>;
|
|
247
437
|
living(): DaemonRecord | undefined;
|
|
248
438
|
health(port: number): Promise<{ ok: boolean; body?: string }>;
|
|
@@ -265,7 +455,7 @@ export async function runSetupSmoke(
|
|
|
265
455
|
project: string,
|
|
266
456
|
deps: SetupSmokeDeps = DEFAULT_SMOKE_DEPS,
|
|
267
457
|
): Promise<SetupSmokeResult> {
|
|
268
|
-
if (!deps.paused()) throw new Error("setup smoke requires paused dispatch");
|
|
458
|
+
if (!deps.paused(project)) throw new Error("setup smoke requires paused dispatch");
|
|
269
459
|
await deps.runOnce(project);
|
|
270
460
|
const existing = deps.living();
|
|
271
461
|
if (existing !== undefined) {
|
|
@@ -283,8 +473,3 @@ export async function runSetupSmoke(
|
|
|
283
473
|
await deps.stop();
|
|
284
474
|
}
|
|
285
475
|
}
|
|
286
|
-
|
|
287
|
-
/** Absolute path of the unit systemd actually reads. */
|
|
288
|
-
export function installedUnitPath(): string {
|
|
289
|
-
return join(SYSTEMD_UNIT_DIR, STAGED_SERVICE_NAME);
|
|
290
|
-
}
|