@homeflare/alchemy 0.25.0 → 0.26.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/dist/launchd/index.js +51 -47
- package/dist/launchd/index.js.map +5 -4
- package/dist/launchd/sudo-guard-mode.d.ts +62 -0
- package/dist/launchd/sudo-guard-mode.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +1 -38
- package/dist/launchd/sudo-guard.d.ts.map +1 -1
- package/dist/linux/fake-linux-host.d.ts.map +1 -1
- package/dist/linux/fake-sudo-root.d.ts +26 -0
- package/dist/linux/fake-sudo-root.d.ts.map +1 -0
- package/dist/linux/fake-sudo.d.ts +56 -0
- package/dist/linux/fake-sudo.d.ts.map +1 -0
- package/dist/linux/fake-systemd.d.ts +15 -0
- package/dist/linux/fake-systemd.d.ts.map +1 -1
- package/dist/linux/index.d.ts +2 -0
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +1151 -8
- package/dist/linux/index.js.map +24 -7
- package/dist/linux/sudo-allowlist-chown.d.ts +12 -0
- package/dist/linux/sudo-allowlist-chown.d.ts.map +1 -0
- package/dist/linux/sudo-allowlist-dir.d.ts +6 -0
- package/dist/linux/sudo-allowlist-dir.d.ts.map +1 -0
- package/dist/linux/sudo-allowlist.d.ts +40 -0
- package/dist/linux/sudo-allowlist.d.ts.map +1 -0
- package/dist/linux/sudo-exec.d.ts +31 -0
- package/dist/linux/sudo-exec.d.ts.map +1 -0
- package/dist/linux/sudo-guard.d.ts +36 -0
- package/dist/linux/sudo-guard.d.ts.map +1 -0
- package/dist/linux/sudo-runner.d.ts +37 -0
- package/dist/linux/sudo-runner.d.ts.map +1 -0
- package/dist/linux/sudo-stage.d.ts +22 -0
- package/dist/linux/sudo-stage.d.ts.map +1 -0
- package/dist/linux/sudo-write.d.ts +25 -0
- package/dist/linux/sudo-write.d.ts.map +1 -0
- package/dist/linux/unit-form.d.ts +43 -0
- package/dist/linux/unit-form.d.ts.map +1 -1
- package/dist/linux/unit-lifecycle.d.ts.map +1 -1
- package/dist/linux/unit-settle.d.ts.map +1 -1
- package/dist/litellm/fake-litellm.d.ts.map +1 -1
- package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -1
- package/docs/launchd-sudo.md +12 -3
- package/docs/linux-host.md +5 -3
- package/docs/linux-sudo.md +187 -0
- package/package.json +1 -1
- package/src/launchd/sudo-guard-mode.ts +111 -0
- package/src/launchd/sudo-guard.ts +3 -79
- package/src/launchd/sudo-modes.test.ts +38 -1
- package/src/linux/fake-linux-host.ts +2 -0
- package/src/linux/fake-sudo-root.ts +157 -0
- package/src/linux/fake-sudo.ts +142 -0
- package/src/linux/fake-systemd.ts +36 -2
- package/src/linux/index.ts +2 -0
- package/src/linux/sudo-allowlist-chown.ts +71 -0
- package/src/linux/sudo-allowlist-dir.ts +103 -0
- package/src/linux/sudo-allowlist.test.ts +252 -0
- package/src/linux/sudo-allowlist.ts +223 -0
- package/src/linux/sudo-exec.ts +118 -0
- package/src/linux/sudo-guard.test.ts +136 -0
- package/src/linux/sudo-guard.ts +142 -0
- package/src/linux/sudo-lifecycle.test.ts +151 -0
- package/src/linux/sudo-runner.test.ts +206 -0
- package/src/linux/sudo-runner.ts +164 -0
- package/src/linux/sudo-stage.ts +50 -0
- package/src/linux/sudo-write.ts +167 -0
- package/src/linux/unit-activating.test.ts +164 -0
- package/src/linux/unit-form.ts +53 -1
- package/src/linux/unit-lifecycle.ts +9 -2
- package/src/linux/unit-settle.ts +8 -4
- package/src/litellm/fake-litellm.ts +2 -0
- package/src/proxmox/client.test.ts +17 -3
- package/src/proxmox/fake-pve-lxc.ts +10 -1
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `started: false` against a unit that is `ActiveState=activating` — the timer-driven `Type=oneshot`
|
|
3
|
+
* scenario `isUnitRunning` (unit-form.ts) exists for, and the crash-restart (`auto-restart`)
|
|
4
|
+
* `SubState` that scenario must not be confused with. Split from unit-lifecycle.test.ts to stay
|
|
5
|
+
* under this file's line cap; see that file for the rest of the family's behaviour.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ THE BUG THIS GUARDS: before `isUnitRunning`, `diffUnit` read `ActiveState=activating` on a
|
|
8
|
+
* `started: false` unit as drift and planned `update`; `settle` then called `systemctl stop` on a
|
|
9
|
+
* check that was already running, mid-run, driven by its own `.timer`. A redeploy must plan to
|
|
10
|
+
* nothing while the run is in progress — these tests are that redeploy.
|
|
11
|
+
* ⛔ THE SECOND BUG THAT ALMOST SHIPPED WITH THE FIRST FIX: an `activating` unit whose `SubState`
|
|
12
|
+
* is `auto-restart` (or `auto-restart-queued`) is systemd's crash-restart backoff, not a start —
|
|
13
|
+
* see isUnitRunning's own comment. A `started: false` unit that is crash-looping must still be
|
|
14
|
+
* stopped; the "auto-restart backoff is still drift" test below is that case.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, test } from 'bun:test';
|
|
17
|
+
import { fakeLinuxHost } from './fake-linux-host.ts';
|
|
18
|
+
import { type SystemdUnitProps, isUnitRunning, renderUnit } from './unit-form.ts';
|
|
19
|
+
import { diffUnit, readUnit, reconcileUnit } from './unit-lifecycle.ts';
|
|
20
|
+
|
|
21
|
+
const NAME = 'hf-node-check.service';
|
|
22
|
+
const PATH = `/etc/systemd/system/${NAME}`;
|
|
23
|
+
/** ⛔ No `[Install]`: a timer-driven oneshot is `enabled: false` — its `.timer` starts it, not `systemctl enable`. */
|
|
24
|
+
const content = renderUnit([
|
|
25
|
+
{ lines: [['Description', 'a PVE node check']], name: 'Unit' },
|
|
26
|
+
{
|
|
27
|
+
lines: [
|
|
28
|
+
['Type', 'oneshot'],
|
|
29
|
+
['ExecStart', '/usr/bin/check-node'],
|
|
30
|
+
],
|
|
31
|
+
name: 'Service',
|
|
32
|
+
},
|
|
33
|
+
]);
|
|
34
|
+
const props: SystemdUnitProps = { content, enabled: false, name: NAME, started: false };
|
|
35
|
+
const host = () => fakeLinuxHost({ dirs: { '/etc/systemd/system': 0 } });
|
|
36
|
+
const verbs = (calls: string[][]) =>
|
|
37
|
+
calls.filter((call) => call[0] === 'systemctl').map((call) => call[1]);
|
|
38
|
+
|
|
39
|
+
/** `readUnit` narrowed to defined, so callers can pass it straight to diffUnit/reconcileUnit. */
|
|
40
|
+
const outputFor = async (fake: ReturnType<typeof host>, of: SystemdUnitProps) => {
|
|
41
|
+
const found = await readUnit(fake.runner, of);
|
|
42
|
+
if (found === undefined) throw new Error(`${of.name} not found on the fake host`);
|
|
43
|
+
return found;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
describe('isUnitRunning — the one predicate diff and settle share', () => {
|
|
47
|
+
test('active always counts as running, whatever started declares', () => {
|
|
48
|
+
expect(isUnitRunning('active', 'running', true)).toBe(true);
|
|
49
|
+
expect(isUnitRunning('active', 'running', false)).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
test('a genuine start counts as running only when started is declared', () => {
|
|
52
|
+
expect(isUnitRunning('activating', 'start', true)).toBe(true);
|
|
53
|
+
expect(isUnitRunning('activating', 'start', false)).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
// ⛔ Added on adversarial review: `auto-restart`/`auto-restart-queued` are ALSO
|
|
56
|
+
// `ActiveState=activating` (systemd's crash-restart backoff, service.c), not a fresh start —
|
|
57
|
+
// see isUnitRunning's own comment. Without this, started: false would stop enforcing itself on
|
|
58
|
+
// exactly the unit most in need of it: one that is crash-looping.
|
|
59
|
+
test('auto-restart backoff always counts as running — it is a crash loop, not a fresh start', () => {
|
|
60
|
+
expect(isUnitRunning('activating', 'auto-restart', false)).toBe(true);
|
|
61
|
+
expect(isUnitRunning('activating', 'auto-restart-queued', false)).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
test('inactive and failed never count as running', () => {
|
|
64
|
+
expect(isUnitRunning('inactive', 'dead', true)).toBe(false);
|
|
65
|
+
expect(isUnitRunning('failed', 'failed', false)).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('started: false against a oneshot mid-run', () => {
|
|
70
|
+
test('activating, everything else converged: diff is noop', async () => {
|
|
71
|
+
const fake = host();
|
|
72
|
+
fake.placeUnit(PATH, content, { activeState: 'activating', enabled: false, subState: 'start' });
|
|
73
|
+
const output = await outputFor(fake, props);
|
|
74
|
+
expect(await diffUnit(fake.runner, props, output)).toEqual({ action: 'noop' });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('active is drift: diff is update, and settle stops it', async () => {
|
|
78
|
+
const fake = host();
|
|
79
|
+
fake.placeUnit(PATH, content, { active: true, enabled: false });
|
|
80
|
+
const output = await outputFor(fake, props);
|
|
81
|
+
expect(await diffUnit(fake.runner, props, output)).toEqual({ action: 'update' });
|
|
82
|
+
const before = fake.calls.length;
|
|
83
|
+
const settled = await reconcileUnit(fake.runner, props, output);
|
|
84
|
+
expect(verbs(fake.calls.slice(before))).toContain('stop');
|
|
85
|
+
expect(settled.active).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('activating during an unrelated unit-file update: settle does not stop it', async () => {
|
|
89
|
+
const fake = host();
|
|
90
|
+
fake.placeUnit(PATH, content, { activeState: 'activating', enabled: false, subState: 'start' });
|
|
91
|
+
const output = await outputFor(fake, props);
|
|
92
|
+
const changed = { ...props, content: content.replace('check-node', 'check-node-v2') };
|
|
93
|
+
expect(await diffUnit(fake.runner, changed, output)).toEqual({ action: 'update' });
|
|
94
|
+
const before = fake.calls.length;
|
|
95
|
+
await reconcileUnit(fake.runner, changed, output);
|
|
96
|
+
expect(verbs(fake.calls.slice(before))).not.toContain('stop');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('failed is not running either: diff is noop', async () => {
|
|
100
|
+
const fake = host();
|
|
101
|
+
fake.placeUnit(PATH, content, { activeState: 'failed', enabled: false });
|
|
102
|
+
const output = await outputFor(fake, props);
|
|
103
|
+
expect(await diffUnit(fake.runner, props, output)).toEqual({ action: 'noop' });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ⛔ ADVERSARIAL-REVIEW CASE: `activating` is not only a fresh start. A unit that was started by
|
|
107
|
+
// hand and now crash-loops (`Restart=on-failure`) sits in `ActiveState=activating`,
|
|
108
|
+
// `SubState=auto-restart` for nearly all of its wall-clock time. The oneshot exemption above
|
|
109
|
+
// must NOT swallow this one — started: false has to keep stopping it, same as plain `active`.
|
|
110
|
+
test('auto-restart backoff is still drift: diff is update, and settle still stops it', async () => {
|
|
111
|
+
const fake = host();
|
|
112
|
+
const crashy = renderUnit([
|
|
113
|
+
{ lines: [['Description', 'started by hand, now crash-looping']], name: 'Unit' },
|
|
114
|
+
{
|
|
115
|
+
lines: [
|
|
116
|
+
['ExecStart', '/usr/bin/flaky'],
|
|
117
|
+
['Restart', 'on-failure'],
|
|
118
|
+
['RestartSec', '5'],
|
|
119
|
+
],
|
|
120
|
+
name: 'Service',
|
|
121
|
+
},
|
|
122
|
+
]);
|
|
123
|
+
const crashyProps: SystemdUnitProps = {
|
|
124
|
+
content: crashy,
|
|
125
|
+
enabled: false,
|
|
126
|
+
name: NAME,
|
|
127
|
+
started: false,
|
|
128
|
+
};
|
|
129
|
+
fake.placeUnit(PATH, crashy, {
|
|
130
|
+
activeState: 'activating',
|
|
131
|
+
enabled: false,
|
|
132
|
+
subState: 'auto-restart',
|
|
133
|
+
});
|
|
134
|
+
const output = await outputFor(fake, crashyProps);
|
|
135
|
+
expect(await diffUnit(fake.runner, crashyProps, output)).toEqual({ action: 'update' });
|
|
136
|
+
const before = fake.calls.length;
|
|
137
|
+
const settled = await reconcileUnit(fake.runner, crashyProps, output);
|
|
138
|
+
expect(verbs(fake.calls.slice(before))).toContain('stop');
|
|
139
|
+
expect(settled.active).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('started: true (or omitted) keeps counting activating as running', () => {
|
|
144
|
+
test('no start is issued while the unit is already activating', async () => {
|
|
145
|
+
const fake = host();
|
|
146
|
+
const enabledContent = renderUnit([
|
|
147
|
+
{ lines: [['Description', 'a long-running service']], name: 'Unit' },
|
|
148
|
+
{ lines: [['ExecStart', '/usr/bin/thing']], name: 'Service' },
|
|
149
|
+
{ lines: [['WantedBy', 'multi-user.target']], name: 'Install' },
|
|
150
|
+
]);
|
|
151
|
+
const started: SystemdUnitProps = { content: enabledContent, name: NAME };
|
|
152
|
+
fake.placeUnit(PATH, enabledContent, {
|
|
153
|
+
activeState: 'activating',
|
|
154
|
+
enabled: true,
|
|
155
|
+
subState: 'start',
|
|
156
|
+
});
|
|
157
|
+
const output = await outputFor(fake, started);
|
|
158
|
+
expect(await diffUnit(fake.runner, started, output)).toEqual({ action: 'noop' });
|
|
159
|
+
const before = fake.calls.length;
|
|
160
|
+
const settled = await reconcileUnit(fake.runner, started, output);
|
|
161
|
+
expect(verbs(fake.calls.slice(before))).not.toContain('start');
|
|
162
|
+
expect(settled.active).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
});
|
package/src/linux/unit-form.ts
CHANGED
|
@@ -90,6 +90,57 @@ export const digestOf = (text: string): string => sha256Hex(new TextEncoder().en
|
|
|
90
90
|
export const configDigest = (restartOn: readonly string[] | undefined): string =>
|
|
91
91
|
digestOf((restartOn ?? []).join('\n'));
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Whether `activeState`/`subState` count as "the unit is running" against a declared `started` —
|
|
95
|
+
* the ONE place this decision is made, so `diffUnit` (unit-lifecycle.ts) and `settle`
|
|
96
|
+
* (unit-settle.ts) can never disagree about it.
|
|
97
|
+
*
|
|
98
|
+
* ⛔ WHY THIS EXISTS: a `Type=oneshot` service driven by a `.timer` (no `[Install]`, declared
|
|
99
|
+
* `started: false, enabled: false` — its timer starts it, not this resource) reports
|
|
100
|
+
* `ActiveState=activating` for the run's whole duration, not an instant. Written for the PVE
|
|
101
|
+
* node-check unit that first hit this: ~1.2s, every 60s. A plan taken mid-run must not read that
|
|
102
|
+
* run as drift, and a deploy must not `systemctl stop` a check that is already in flight.
|
|
103
|
+
* ★ systemd.service(5) §`Type=`, `oneshot` (man7.org mirror, read 2026-09-23): with no
|
|
104
|
+
* `RemainAfterExit=`, the service manager holds the unit `activating` for as long as
|
|
105
|
+
* `ExecStart=` runs, then moves it straight to `dead`/`inactive` (or `failed`) — it never passes
|
|
106
|
+
* through `active`. So for `started: false`, `activating` is exactly a run in progress, and
|
|
107
|
+
* `active` is the one state a oneshot never reaches on its own: that is the drift, and the only
|
|
108
|
+
* thing settle stops.
|
|
109
|
+
* ⛔ CRASH-LOOP GUARD, added on adversarial review: `ActiveState=activating` is not only "starting
|
|
110
|
+
* for the first time" — a service with `Restart=` waiting out its `RestartSec=` backoff between
|
|
111
|
+
* crash attempts is ALSO `activating`, with `SubState=auto-restart` (or `auto-restart-queued`).
|
|
112
|
+
* A unit declared `started: false` that is crash-looping sits there for nearly all of its
|
|
113
|
+
* wall-clock time. Without this guard the oneshot exemption above would read that loop as
|
|
114
|
+
* converged forever, and a deploy would never again issue the `stop` that ends it — silently
|
|
115
|
+
* defeating `started: false` on exactly the unit that most needs it enforced. So only a genuine
|
|
116
|
+
* start (`SubState` anything else — `start`, `start-pre`, `start-post`, …) gets the exemption;
|
|
117
|
+
* an auto-restart substate counts as running regardless of `started`, same as `active` does.
|
|
118
|
+
* ★ SOURCE, not measured against a live host: systemd's own `service.c`
|
|
119
|
+
* `state_translation_table` (github.com/systemd/systemd, read 2026-09-23) maps both
|
|
120
|
+
* `SERVICE_AUTO_RESTART` and `SERVICE_AUTO_RESTART_QUEUED` to `UNIT_ACTIVATING`, and
|
|
121
|
+
* `unit-def.c`'s `service_state_table` names their `SubState` strings `"auto-restart"` and
|
|
122
|
+
* `"auto-restart-queued"`. ⚠️ REASONED NOT MEASURED: no unit was actually crash-looped and read
|
|
123
|
+
* back with `systemctl show` for this change — the systemd source is the citation, unlike
|
|
124
|
+
* systemctl.ts's own header, which is a live-host trace.
|
|
125
|
+
* ⚠️ THE TRADE-OFF THIS ACCEPTS: a `Type=notify` (or other long-running type) unit stuck in a
|
|
126
|
+
* genuine `activating`/`start` with `started: false` — never reaching `active` and never
|
|
127
|
+
* crash-looping either — reads as converged, not drift, until it reaches `active`. A unit hung
|
|
128
|
+
* like that is exactly the case a human should look at, not one this predicate auto-corrects by
|
|
129
|
+
* stopping it out from under whatever else depends on it.
|
|
130
|
+
* ★ `started: true` (the default, when omitted) is unchanged: `active` or `activating` (any
|
|
131
|
+
* `subState`) both count as running, so a start already in flight is never started twice.
|
|
132
|
+
*/
|
|
133
|
+
export const isUnitRunning = (
|
|
134
|
+
activeState: string,
|
|
135
|
+
subState: string | undefined,
|
|
136
|
+
started: boolean,
|
|
137
|
+
): boolean => {
|
|
138
|
+
if (activeState === 'active') return true;
|
|
139
|
+
if (activeState !== 'activating') return false;
|
|
140
|
+
if (subState === 'auto-restart' || subState === 'auto-restart-queued') return true;
|
|
141
|
+
return started;
|
|
142
|
+
};
|
|
143
|
+
|
|
93
144
|
/** Sections → an INI unit file. ★ Keys and values pass through untouched; see the file header. */
|
|
94
145
|
export const renderUnit = (sections: readonly UnitSection[]): string =>
|
|
95
146
|
`${sections
|
|
@@ -104,7 +155,8 @@ export const unitText = (props: SystemdUnitProps): string =>
|
|
|
104
155
|
export const unitPathFor = (props: SystemdUnitProps): string =>
|
|
105
156
|
`${props.directory ?? DEFAULT_UNIT_DIRECTORY}/${props.name}`;
|
|
106
157
|
|
|
107
|
-
|
|
158
|
+
/** ★ Exported so sudo-allowlist.ts validates a systemctl unit operand against the same pattern. */
|
|
159
|
+
export const NAME = /^[A-Za-z0-9][A-Za-z0-9_.@:-]*\.([a-z]+)$/;
|
|
108
160
|
/** systemd's own unit types. ★ Fixed vocabulary from `systemd.unit(5)`, not a guess at directives. */
|
|
109
161
|
const TYPES = new Set([
|
|
110
162
|
'automount',
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
UNIT_WRITE,
|
|
28
28
|
configDigest,
|
|
29
29
|
digestOf,
|
|
30
|
+
isUnitRunning,
|
|
30
31
|
unitPathFor,
|
|
31
32
|
unitText,
|
|
32
33
|
} from './unit-form.ts';
|
|
@@ -75,10 +76,16 @@ export const diffUnit = async (
|
|
|
75
76
|
const live = await readUnit(runner, news);
|
|
76
77
|
if (live === undefined || live.unitSha256 !== desired) return update();
|
|
77
78
|
if (live.enabled !== (news.enabled !== false)) return update();
|
|
78
|
-
if (live.active !== (news.started !== false)) return update();
|
|
79
79
|
// ⚠️ systemd's own word for "the file on disk is newer than what I loaded" — a write that landed
|
|
80
|
-
// without its reload. It is drift that neither digest can see.
|
|
80
|
+
// without its reload. It is drift that neither digest can see. ★ Also the freshest read of
|
|
81
|
+
// ActiveState/SubState — not `live.active`, which is a raw "active or activating" reading with
|
|
82
|
+
// no SubState in it. Whether the current state counts as drift against `started` is the
|
|
83
|
+
// isUnitRunning policy (unit-form.ts), shared with settle so the two can never disagree.
|
|
81
84
|
const status = await showUnit(runner, news.name);
|
|
85
|
+
const wantStarted = news.started !== false;
|
|
86
|
+
if (isUnitRunning(status.activeState, status.subState, wantStarted) !== wantStarted) {
|
|
87
|
+
return update();
|
|
88
|
+
}
|
|
82
89
|
return status.needDaemonReload ? update() : { action: 'noop' };
|
|
83
90
|
};
|
|
84
91
|
|
package/src/linux/unit-settle.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type SystemdUnitAttributes,
|
|
14
14
|
type SystemdUnitProps,
|
|
15
15
|
configDigest,
|
|
16
|
+
isUnitRunning,
|
|
16
17
|
unitPathFor,
|
|
17
18
|
} from './unit-form.ts';
|
|
18
19
|
import {
|
|
@@ -81,10 +82,13 @@ export const settle = async (
|
|
|
81
82
|
try {
|
|
82
83
|
if (wantEnabled && (!isEnabled || step.changed)) await enableUnit(runner, props.name);
|
|
83
84
|
if (!wantEnabled && isEnabled) await disableUnit(runner, props.name);
|
|
84
|
-
|
|
85
|
+
// ★ isUnitRunning (unit-form.ts): the same started-aware policy diffUnit uses, so a unit this
|
|
86
|
+
// deploy never touches — a `started: false` oneshot mid-run — is never stopped here either.
|
|
87
|
+
// `subState` is what tells that mid-run apart from a crash-restart backoff, which still stops.
|
|
88
|
+
const running = isUnitRunning(step.status.activeState, step.status.subState, wantStarted);
|
|
85
89
|
if (!wantStarted) {
|
|
86
|
-
if (
|
|
87
|
-
} else if (!
|
|
90
|
+
if (running) await stopUnit(runner, props.name);
|
|
91
|
+
} else if (!running) await startUnit(runner, props.name);
|
|
88
92
|
else if (step.changed || step.configChanged) await restartUnit(runner, props.name);
|
|
89
93
|
} catch (cause) {
|
|
90
94
|
return undo(cause instanceof Error ? cause.message : String(cause));
|
|
@@ -96,7 +100,7 @@ export const settle = async (
|
|
|
96
100
|
* as drift, in either direction.
|
|
97
101
|
*/
|
|
98
102
|
const after = await showUnit(runner, props.name);
|
|
99
|
-
if (wantStarted && after.activeState
|
|
103
|
+
if (wantStarted && !isUnitRunning(after.activeState, after.subState, wantStarted)) {
|
|
100
104
|
return undo(`systemctl reported success but the unit is ${after.activeState}.`);
|
|
101
105
|
}
|
|
102
106
|
const settled = attributesOf(props, step.desired, after);
|
|
@@ -48,6 +48,8 @@ export const startFakeLitellm = (options?: {
|
|
|
48
48
|
new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' }, status });
|
|
49
49
|
|
|
50
50
|
const server = Bun.serve({
|
|
51
|
+
// ⚠️ Loopback, not the wildcard default — see proxmox/fake-pve-lxc.ts for the race.
|
|
52
|
+
hostname: '127.0.0.1',
|
|
51
53
|
port: 0,
|
|
52
54
|
fetch: async (req) => {
|
|
53
55
|
const url = new URL(req.url);
|
|
@@ -12,7 +12,11 @@ import { PveError, pveWith } from './client.ts';
|
|
|
12
12
|
import type { PveCredential, PveTarget } from './credentials.ts';
|
|
13
13
|
import { orderedMembers, resetLastGoodForTest } from './members.ts';
|
|
14
14
|
|
|
15
|
-
type ServeOptions = {
|
|
15
|
+
type ServeOptions = {
|
|
16
|
+
fetch(request: Request): Response | Promise<Response>;
|
|
17
|
+
hostname: string;
|
|
18
|
+
port: number;
|
|
19
|
+
};
|
|
16
20
|
const { Bun } = globalThis as unknown as {
|
|
17
21
|
Bun: { serve(options: ServeOptions): { port: number; stop(closeActive: boolean): void } };
|
|
18
22
|
};
|
|
@@ -84,6 +88,8 @@ const counting = (data: unknown) => {
|
|
|
84
88
|
hits += 1;
|
|
85
89
|
return Response.json({ data });
|
|
86
90
|
},
|
|
91
|
+
// ⚠️ Loopback, not the wildcard default — see fake-pve-lxc.ts for the measured race.
|
|
92
|
+
hostname: '127.0.0.1',
|
|
87
93
|
port: 0,
|
|
88
94
|
});
|
|
89
95
|
return { hits: () => hits, port: server.port, stop: () => server.stop(true) };
|
|
@@ -106,7 +112,11 @@ describe('cluster member failover', () => {
|
|
|
106
112
|
|
|
107
113
|
it('does not fail over on HTTP 500 from the first member', async () => {
|
|
108
114
|
resetLastGoodForTest();
|
|
109
|
-
const bad = Bun.serve({
|
|
115
|
+
const bad = Bun.serve({
|
|
116
|
+
fetch: () => new Response('fail', { status: 500 }),
|
|
117
|
+
hostname: '127.0.0.1',
|
|
118
|
+
port: 0,
|
|
119
|
+
});
|
|
110
120
|
const good = counting([]);
|
|
111
121
|
try {
|
|
112
122
|
const error = await run(pveWith(target([A, B]), CRED, 'GET', 'pools').pipe(Effect.flip), {
|
|
@@ -144,7 +154,11 @@ describe('cluster member failover', () => {
|
|
|
144
154
|
|
|
145
155
|
it('does not re-send a write after a transport timeout', async () => {
|
|
146
156
|
resetLastGoodForTest();
|
|
147
|
-
const hang = Bun.serve({
|
|
157
|
+
const hang = Bun.serve({
|
|
158
|
+
fetch: () => new Promise<Response>(() => {}),
|
|
159
|
+
hostname: '127.0.0.1',
|
|
160
|
+
port: 0,
|
|
161
|
+
});
|
|
148
162
|
const backup = counting('UPID:1');
|
|
149
163
|
try {
|
|
150
164
|
await run(
|
|
@@ -185,7 +185,16 @@ export const fakePve = (): FakePve => {
|
|
|
185
185
|
return json({ data: null, message: 'unhandled\n' }, 501);
|
|
186
186
|
};
|
|
187
187
|
|
|
188
|
-
|
|
188
|
+
// 🔴 LOOPBACK, NOT THE DEFAULT WILDCARD — AND THAT WAS THE FLAKE. Measured 2026-09-23 on
|
|
189
|
+
// macOS: `Bun.serve({ port: 0 })` binds 0.0.0.0, and the kernel will hand a wildcard
|
|
190
|
+
// port-0 bind a port some OTHER socket already holds on 127.0.0.1 (16,373 binds in, it
|
|
191
|
+
// did). Loopback traffic then goes to the more specific listener, so this fake's own
|
|
192
|
+
// requests reached another process's server — another test suite's fake, when several
|
|
193
|
+
// agents run `bun test` at once — and a plan failed with "Transport error" on a GET to a
|
|
194
|
+
// server that was up. A 127.0.0.1 port-0 bind is never handed a port in use on
|
|
195
|
+
// 127.0.0.1, and nothing more specific can shadow it. tests/loopback-servers.test.ts
|
|
196
|
+
// keeps every test server this way.
|
|
197
|
+
const server = Bun.serve({ fetch: handle, hostname: '127.0.0.1', port: 0 });
|
|
189
198
|
const port = String(server.port);
|
|
190
199
|
const remapped = (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
191
200
|
const url = new URL(input instanceof Request ? input.url : String(input));
|