@homeflare/alchemy 0.19.0 → 0.20.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/linux/index.js +140 -93
- package/dist/linux/index.js.map +11 -10
- package/dist/linux/region.d.ts +11 -0
- package/dist/linux/region.d.ts.map +1 -1
- package/dist/linux/remote-file-lifecycle.d.ts.map +1 -1
- package/dist/linux/remote-file-plan.d.ts +22 -0
- package/dist/linux/remote-file-plan.d.ts.map +1 -1
- package/dist/linux/remote-file.d.ts +4 -1
- package/dist/linux/remote-file.d.ts.map +1 -1
- package/dist/linux/unit-handlers.d.ts.map +1 -1
- package/dist/linux/unit-lifecycle.d.ts +1 -5
- package/dist/linux/unit-lifecycle.d.ts.map +1 -1
- package/dist/linux/unit-preflight.d.ts +50 -0
- package/dist/linux/unit-preflight.d.ts.map +1 -0
- package/dist/proxmox/index.d.ts +15 -3
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +4 -1
- package/dist/proxmox/index.js.map +1 -1
- package/docs/adopted-deploys.md +13 -0
- package/docs/linux-host.md +11 -1
- package/docs/provider-standard.md +138 -0
- package/docs/upstream-conformance.md +164 -0
- package/package.json +1 -1
- package/src/linux/region.ts +15 -0
- package/src/linux/remote-file-lifecycle.test.ts +0 -88
- package/src/linux/remote-file-lifecycle.ts +25 -7
- package/src/linux/remote-file-plan.ts +37 -1
- package/src/linux/remote-file-region.test.ts +181 -0
- package/src/linux/remote-file.ts +4 -1
- package/src/linux/unit-handlers.ts +12 -5
- package/src/linux/unit-lifecycle.ts +12 -30
- package/src/linux/unit-preflight.test.ts +128 -0
- package/src/linux/unit-preflight.ts +116 -0
- package/src/linux/unit-rename.test.ts +76 -0
- package/src/proxmox/ceph-adopt.test.ts +210 -0
- package/src/proxmox/constraints-forms.test.ts +14 -6
- package/src/proxmox/constraints-node-forms.test.ts +12 -3
- package/src/proxmox/index.test.ts +7 -4
- package/src/proxmox/index.ts +15 -3
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RemoteFile in MANAGED REGION mode against the fake Linux host: the promise that every byte
|
|
3
|
+
* outside the markers survives, and the identity rules that keep it true when a declaration
|
|
4
|
+
* changes. Split from remote-file-lifecycle.test.ts, which covers whole-file mode.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { fakeLinuxHost } from './fake-linux-host.ts';
|
|
8
|
+
import { fileProblems } from './remote-file-form.ts';
|
|
9
|
+
import {
|
|
10
|
+
deleteFile,
|
|
11
|
+
diffFile,
|
|
12
|
+
readFileAttributes,
|
|
13
|
+
reconcileFile,
|
|
14
|
+
} from './remote-file-lifecycle.ts';
|
|
15
|
+
|
|
16
|
+
const decode = (bytes: Uint8Array | undefined) => new TextDecoder().decode(bytes);
|
|
17
|
+
|
|
18
|
+
const host = (euid = 0) =>
|
|
19
|
+
fakeLinuxHost({
|
|
20
|
+
dirs: { '/etc': 0, '/etc/example': 0, '/home/someone': 1001 },
|
|
21
|
+
euid,
|
|
22
|
+
groups: { root: 0, staff: 50 },
|
|
23
|
+
users: {
|
|
24
|
+
root: { gid: 0, home: '/root', uid: 0 },
|
|
25
|
+
someone: { gid: 1001, home: '/home/someone', uid: 1001 },
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('region mode', () => {
|
|
30
|
+
const VENDOR = '# vendor ruleset\nanchor "system"\nrule one\n';
|
|
31
|
+
const region = { name: 'homeflare' };
|
|
32
|
+
const block = { content: 'anchor "declared"\n', path: '/etc/vendor.conf', region };
|
|
33
|
+
|
|
34
|
+
const withVendor = () => {
|
|
35
|
+
const fake = host();
|
|
36
|
+
fake.files.set('/etc/vendor.conf', {
|
|
37
|
+
bytes: new TextEncoder().encode(VENDOR),
|
|
38
|
+
gid: 0,
|
|
39
|
+
kind: 'file',
|
|
40
|
+
mode: 0o600,
|
|
41
|
+
uid: 0,
|
|
42
|
+
});
|
|
43
|
+
return fake;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
test('editing the block leaves the rest of the file byte-identical', async () => {
|
|
47
|
+
const fake = withVendor();
|
|
48
|
+
const output = await reconcileFile(fake.runner, block);
|
|
49
|
+
const after = decode(fake.files.get('/etc/vendor.conf')?.bytes);
|
|
50
|
+
expect(after.startsWith(VENDOR)).toBe(true);
|
|
51
|
+
await reconcileFile(fake.runner, { ...block, content: 'anchor "changed"\n' }, output);
|
|
52
|
+
expect(decode(fake.files.get('/etc/vendor.conf')?.bytes).startsWith(VENDOR)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("the other owner's mode and uid are preserved, never re-declared", async () => {
|
|
56
|
+
const fake = withVendor();
|
|
57
|
+
await reconcileFile(fake.runner, block);
|
|
58
|
+
expect(fake.files.get('/etc/vendor.conf')?.mode).toBe(0o600);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a change anywhere else in the file is not this resource’s drift', async () => {
|
|
62
|
+
const fake = withVendor();
|
|
63
|
+
const output = await reconcileFile(fake.runner, block);
|
|
64
|
+
const edited = `${decode(fake.files.get('/etc/vendor.conf')?.bytes)}rule two\n`;
|
|
65
|
+
fake.files.set('/etc/vendor.conf', {
|
|
66
|
+
bytes: new TextEncoder().encode(edited),
|
|
67
|
+
gid: 0,
|
|
68
|
+
kind: 'file',
|
|
69
|
+
mode: 0o600,
|
|
70
|
+
uid: 0,
|
|
71
|
+
});
|
|
72
|
+
expect(await diffFile(fake.runner, block, output)).toEqual({ action: 'noop' });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('delete removes only the block', async () => {
|
|
76
|
+
const fake = withVendor();
|
|
77
|
+
const output = await reconcileFile(fake.runner, block);
|
|
78
|
+
await deleteFile(fake.runner, output);
|
|
79
|
+
expect(decode(fake.files.get('/etc/vendor.conf')?.bytes)).toBe(VENDOR);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('a missing file is a refusal, because a typo is likelier than an intention', async () => {
|
|
83
|
+
const fake = host();
|
|
84
|
+
await expect(
|
|
85
|
+
reconcileFile(fake.runner, { ...block, path: '/etc/example/absent.conf' }),
|
|
86
|
+
).rejects.toThrow(/does not create the file it lives in/);
|
|
87
|
+
const created = await reconcileFile(fake.runner, {
|
|
88
|
+
...block,
|
|
89
|
+
create: true,
|
|
90
|
+
path: '/etc/example/absent.conf',
|
|
91
|
+
});
|
|
92
|
+
expect(created.region).toEqual(region);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 🔴 BOTH OF THESE SHIPPED AS `update` AND WERE FOUND BY REVIEW (2026-09-22). They are the two
|
|
97
|
+
* ways the region promise could be broken from an ordinary edit to a stack file.
|
|
98
|
+
*/
|
|
99
|
+
test('renaming the region moves the block; the old markers do not survive', async () => {
|
|
100
|
+
const fake = withVendor();
|
|
101
|
+
const output = await reconcileFile(fake.runner, block);
|
|
102
|
+
const renamed = { ...block, region: { name: 'homeflare-mcp' } };
|
|
103
|
+
// ⛔ An update, not a noop: the new block is not in the file yet.
|
|
104
|
+
expect(await diffFile(fake.runner, renamed, output)).toEqual({ action: 'update' });
|
|
105
|
+
const moved = await reconcileFile(fake.runner, renamed, output);
|
|
106
|
+
const after = decode(fake.files.get('/etc/vendor.conf')?.bytes);
|
|
107
|
+
expect(after.startsWith(VENDOR)).toBe(true);
|
|
108
|
+
expect(after).not.toContain('BEGIN homeflare\n');
|
|
109
|
+
expect(after).toContain('BEGIN homeflare-mcp');
|
|
110
|
+
// ⛔ The stored digest must describe the file AFTER the old block was taken out.
|
|
111
|
+
const live = await readFileAttributes(fake.runner, '/etc/vendor.conf', renamed.region);
|
|
112
|
+
expect(moved.sha256).toBe(live?.sha256 ?? '');
|
|
113
|
+
await deleteFile(fake.runner, moved);
|
|
114
|
+
expect(decode(fake.files.get('/etc/vendor.conf')?.bytes)).toBe(VENDOR);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('changing the comment token is a rename, not a reformat', async () => {
|
|
118
|
+
const fake = withVendor();
|
|
119
|
+
const output = await reconcileFile(fake.runner, block);
|
|
120
|
+
const retokened = { ...block, region: { comment: '//', name: 'homeflare' } };
|
|
121
|
+
await reconcileFile(fake.runner, retokened, output);
|
|
122
|
+
const after = decode(fake.files.get('/etc/vendor.conf')?.bytes);
|
|
123
|
+
expect(after).toContain('// BEGIN homeflare');
|
|
124
|
+
expect(after).not.toContain('# BEGIN homeflare');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('a file this resource borrowed is never taken over by dropping the region', async () => {
|
|
128
|
+
const fake = withVendor();
|
|
129
|
+
const output = await reconcileFile(fake.runner, block);
|
|
130
|
+
const whole = { content: block.content, path: block.path };
|
|
131
|
+
// ⛔ At plan time, before a single byte moves.
|
|
132
|
+
await expect(diffFile(fake.runner, whole, output)).rejects.toThrow(/two different claims/);
|
|
133
|
+
await expect(reconcileFile(fake.runner, whole, output)).rejects.toThrow(/two different claims/);
|
|
134
|
+
expect(decode(fake.files.get('/etc/vendor.conf')?.bytes).startsWith(VENDOR)).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('adding a region to a file this resource owned whole is refused the same way', async () => {
|
|
138
|
+
const fake = host();
|
|
139
|
+
const whole = { content: 'a = 1\n', path: '/etc/example/whole.conf' };
|
|
140
|
+
const output = await reconcileFile(fake.runner, whole);
|
|
141
|
+
await expect(
|
|
142
|
+
diffFile(fake.runner, { ...whole, region: { name: 'homeflare' } }, output),
|
|
143
|
+
).rejects.toThrow(/two different claims/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('a rename onto a block someone else already owns is refused', async () => {
|
|
147
|
+
const fake = withVendor();
|
|
148
|
+
const output = await reconcileFile(fake.runner, block);
|
|
149
|
+
const taken = `${decode(fake.files.get('/etc/vendor.conf')?.bytes)}# BEGIN other\nnot ours\n# END other\n`;
|
|
150
|
+
fake.files.set('/etc/vendor.conf', {
|
|
151
|
+
bytes: new TextEncoder().encode(taken),
|
|
152
|
+
gid: 0,
|
|
153
|
+
kind: 'file',
|
|
154
|
+
mode: 0o600,
|
|
155
|
+
uid: 0,
|
|
156
|
+
});
|
|
157
|
+
await expect(
|
|
158
|
+
reconcileFile(fake.runner, { ...block, region: { name: 'other' } }, output),
|
|
159
|
+
).rejects.toThrow(/already carries that managed region/);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('mode and owner without create are refused at validation', () => {
|
|
163
|
+
expect(fileProblems({ ...block, mode: 0o600 })).toHaveLength(1);
|
|
164
|
+
expect(fileProblems({ ...block, create: true, mode: 0o600 })).toEqual([]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('a block already there with other content needs --adopt', async () => {
|
|
168
|
+
const fake = withVendor();
|
|
169
|
+
fake.files.set('/etc/vendor.conf', {
|
|
170
|
+
bytes: new TextEncoder().encode(
|
|
171
|
+
`${VENDOR}# BEGIN homeflare\nsomething else\n# END homeflare\n`,
|
|
172
|
+
),
|
|
173
|
+
gid: 0,
|
|
174
|
+
kind: 'file',
|
|
175
|
+
mode: 0o600,
|
|
176
|
+
uid: 0,
|
|
177
|
+
});
|
|
178
|
+
await expect(reconcileFile(fake.runner, block)).rejects.toThrow(/managed region/);
|
|
179
|
+
await expect(reconcileFile(fake.runner, block, undefined, true)).resolves.toBeDefined();
|
|
180
|
+
});
|
|
181
|
+
});
|
package/src/linux/remote-file.ts
CHANGED
|
@@ -6,10 +6,13 @@
|
|
|
6
6
|
* the host already says what we declare; then read back and compared.
|
|
7
7
|
* - read — lstat plus the SHA-256 of the file and of the part this resource owns.
|
|
8
8
|
* - diff — the declared digest against the stored AND the on-disk one, plus mode and owner.
|
|
9
|
-
* - replace — only when `path` changes; create-before-delete.
|
|
9
|
+
* - replace — only when `path` changes; create-before-delete. A region renamed inside one file
|
|
10
|
+
* is an update that writes the new block, verifies it, then takes the old one out.
|
|
10
11
|
* - delete — removes the file, or in region mode only the block.
|
|
11
12
|
*
|
|
12
13
|
* ⛔ NEVER A SECRET — see remote-file-form.ts.
|
|
14
|
+
* ⛔ THE REGION IS PART OF THE IDENTITY. Renaming it, or changing its comment token, moves the block;
|
|
15
|
+
* swapping between owning the whole file and owning a block is refused (remote-file-plan.ts).
|
|
13
16
|
* ⚠️ NOTHING IS ADOPTED WITHOUT `--adopt` (docs/ownership.md): a file already at the path, or a
|
|
14
17
|
* block already carrying the markers, reads as `Unowned`.
|
|
15
18
|
* ★ IT IS A SEPARATE RESOURCE FROM `Host.File`, not a flag on it: the two have different attributes
|
|
@@ -17,6 +17,7 @@ import { adoptsAtApply } from '../ownership/adopt.ts';
|
|
|
17
17
|
import { noteUnfinished } from '../ownership/resume.ts';
|
|
18
18
|
import type { SystemdUnitAttributes, SystemdUnitProps } from './unit-form.ts';
|
|
19
19
|
import { deleteUnit, diffUnit, readUnit, reconcileUnit } from './unit-lifecycle.ts';
|
|
20
|
+
import { assertRenameTarget } from './unit-preflight.ts';
|
|
20
21
|
|
|
21
22
|
export const readHandler = (
|
|
22
23
|
runner: HostRunner,
|
|
@@ -46,11 +47,17 @@ export const diffHandler = (
|
|
|
46
47
|
* when a rendered config file's digest is templated into the same deploy.
|
|
47
48
|
*/
|
|
48
49
|
const name = resolvedString(news, 'name');
|
|
49
|
-
return Effect.succeed(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
if (name === undefined || name === output.name) return Effect.succeed(undefined);
|
|
51
|
+
/**
|
|
52
|
+
* ⛔ THE NAME IS ENOUGH TO REFUSE A MASKED TARGET, and the refusal has to happen here: the old
|
|
53
|
+
* unit is removed before the new one is reconciled. The unit file cannot be compared while
|
|
54
|
+
* `content` is still an Output, so this is the name-only half of assertReplaceable
|
|
55
|
+
* (unit-preflight.ts).
|
|
56
|
+
*/
|
|
57
|
+
return lift(async () => {
|
|
58
|
+
await assertRenameTarget(runner, output, name);
|
|
59
|
+
return { action: 'replace' as const, deleteFirst: true };
|
|
60
|
+
});
|
|
54
61
|
};
|
|
55
62
|
|
|
56
63
|
export const reconcileHandler = (
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
* start fail; unmasking here would silently overrule whoever masked it.
|
|
20
20
|
*/
|
|
21
21
|
import type { Diff } from 'alchemy/Diff';
|
|
22
|
-
import {
|
|
22
|
+
import type { HostRunner } from '../launchd/runner.ts';
|
|
23
|
+
import { daemonReload, disableUnit, showUnit, stopUnit } from './systemctl.ts';
|
|
23
24
|
import {
|
|
24
25
|
type SystemdUnitAttributes,
|
|
25
26
|
type SystemdUnitProps,
|
|
@@ -27,21 +28,14 @@ import {
|
|
|
27
28
|
configDigest,
|
|
28
29
|
digestOf,
|
|
29
30
|
unitPathFor,
|
|
30
|
-
unitProblems,
|
|
31
31
|
unitText,
|
|
32
32
|
} from './unit-form.ts';
|
|
33
|
-
import {
|
|
33
|
+
import { assertMayWrite, assertReplaceable, assertUsable, assertValid } from './unit-preflight.ts';
|
|
34
34
|
import { attributesOf, refuse, settle } from './unit-settle.ts';
|
|
35
35
|
|
|
36
36
|
const encoder = new TextEncoder();
|
|
37
37
|
const decoder = new TextDecoder();
|
|
38
38
|
|
|
39
|
-
/** Throw every refusal at once, so one plan shows the whole list. */
|
|
40
|
-
export const assertValid = (props: SystemdUnitProps, expect?: string): void => {
|
|
41
|
-
const found = unitProblems(props, expect);
|
|
42
|
-
if (found.length > 0) throw refuse(props.name, found.join('; '));
|
|
43
|
-
};
|
|
44
|
-
|
|
45
39
|
/** What is on the host now: the unit file's digest and systemd's view. `undefined` when neither. */
|
|
46
40
|
export const readUnit = async (
|
|
47
41
|
runner: HostRunner,
|
|
@@ -53,26 +47,6 @@ export const readUnit = async (
|
|
|
53
47
|
return attributesOf(props, bytes === undefined ? '' : digestOf(decoder.decode(bytes)), status);
|
|
54
48
|
};
|
|
55
49
|
|
|
56
|
-
/** ⛔ Writing a unit file and driving systemd are root's, so refuse up front rather than mid-apply. */
|
|
57
|
-
export const assertMayWrite = (runner: HostRunner, name: string): void => {
|
|
58
|
-
if (canActAsRoot(runner)) return;
|
|
59
|
-
throw refuse(
|
|
60
|
-
name,
|
|
61
|
-
'a systemd unit is root-owned and systemctl needs root. Point the runner at a destination ' +
|
|
62
|
-
'whose ssh user is root, or provide a privileged HostRunner. This provider never calls sudo.',
|
|
63
|
-
);
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
const assertUsable = (props: SystemdUnitProps, status: UnitStatus): void => {
|
|
67
|
-
if (status.loadState === 'masked' || status.unitFileState === 'masked') {
|
|
68
|
-
throw refuse(
|
|
69
|
-
props.name,
|
|
70
|
-
'is masked. If that is stale, `systemctl unmask` it deliberately, then redeploy. Nothing ' +
|
|
71
|
-
'was written.',
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
|
|
76
50
|
export const diffUnit = async (
|
|
77
51
|
runner: HostRunner,
|
|
78
52
|
news: SystemdUnitProps,
|
|
@@ -82,8 +56,10 @@ export const diffUnit = async (
|
|
|
82
56
|
assertValid(news, expect);
|
|
83
57
|
// ★ The unit's name or its directory is its identity on the host: a change is a replace, and
|
|
84
58
|
// delete-first, because two unit files for one name cannot both be the one systemd reads.
|
|
59
|
+
// ⛔ Which is why everything the new unit's reconcile would refuse is refused HERE, at plan
|
|
60
|
+
// time (unit-preflight.ts): after the delete it would be too late.
|
|
85
61
|
if (news.name !== output.name || unitPathFor(news) !== output.unitPath) {
|
|
86
|
-
|
|
62
|
+
await assertReplaceable(runner, news, output, expect);
|
|
87
63
|
return { action: 'replace', deleteFirst: true };
|
|
88
64
|
}
|
|
89
65
|
const desired = digestOf(unitText(news));
|
|
@@ -129,6 +105,12 @@ export const reconcileUnit = async (
|
|
|
129
105
|
const before = await runner.readFile(path);
|
|
130
106
|
const liveSha = before === undefined ? undefined : digestOf(decoder.decode(before));
|
|
131
107
|
const moved = output !== undefined && (output.name !== props.name || output.unitPath !== path);
|
|
108
|
+
/**
|
|
109
|
+
* ★ A rename the diff could not see — a name that was still an unresolved Output at plan time
|
|
110
|
+
* (unit-handlers.ts diffHandler) — arrives here as an update. Run the same read-only checks a
|
|
111
|
+
* plan-time replace runs, BEFORE deleteUnit, or the old unit is already gone when we refuse.
|
|
112
|
+
*/
|
|
113
|
+
if (moved && output !== undefined) await assertReplaceable(runner, props, output, expect);
|
|
132
114
|
const prior = moved ? undefined : output;
|
|
133
115
|
/**
|
|
134
116
|
* ⛔ A UNIT THIS RESOURCE DOES NOT OWN IS NEVER OVERWRITTEN. Where the engine's probe never
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A systemd rename is a delete-first replace, so every refusal has to fire at PLAN time. Caught
|
|
3
|
+
* only in reconcile, Alchemy has already stopped and removed the old unit (Apply.ts,
|
|
4
|
+
* deleteOldGenerations under `node.deleteFirst`).
|
|
5
|
+
*
|
|
6
|
+
* ★ The last case is the one the diff cannot see: a name that was still an Output at plan time
|
|
7
|
+
* arrives at reconcile as an update (unit-handlers.ts). The same refusals have to fire there,
|
|
8
|
+
* before deleteUnit.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import { fakeLinuxHost } from './fake-linux-host.ts';
|
|
12
|
+
import { renderUnit } from './unit-form.ts';
|
|
13
|
+
import { deleteUnit, diffUnit, reconcileUnit } from './unit-lifecycle.ts';
|
|
14
|
+
|
|
15
|
+
const NAME = 'hf-example.service';
|
|
16
|
+
const PATH = `/etc/systemd/system/${NAME}`;
|
|
17
|
+
const RENAMED = 'hf-renamed.service';
|
|
18
|
+
const RENAMED_PATH = `/etc/systemd/system/${RENAMED}`;
|
|
19
|
+
|
|
20
|
+
const body = (exec: string) =>
|
|
21
|
+
renderUnit([
|
|
22
|
+
{ lines: [['Description', 'an example']], name: 'Unit' },
|
|
23
|
+
{
|
|
24
|
+
lines: [
|
|
25
|
+
['ExecStart', exec],
|
|
26
|
+
['Restart', 'on-failure'],
|
|
27
|
+
],
|
|
28
|
+
name: 'Service',
|
|
29
|
+
},
|
|
30
|
+
{ lines: [['WantedBy', 'multi-user.target']], name: 'Install' },
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const props = { content: body('/usr/bin/example'), name: NAME };
|
|
34
|
+
const renamed = { ...props, name: RENAMED };
|
|
35
|
+
const host = () => fakeLinuxHost({ dirs: { '/etc/systemd/system': 0 } });
|
|
36
|
+
|
|
37
|
+
const running = async () => {
|
|
38
|
+
const fake = host();
|
|
39
|
+
const output = await reconcileUnit(fake.runner, props, undefined);
|
|
40
|
+
return { fake, output };
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
describe('a rename is refused at plan time, and the old unit is still running', () => {
|
|
44
|
+
test('a masked new name', async () => {
|
|
45
|
+
const { fake, output } = await running();
|
|
46
|
+
fake.placeUnit(RENAMED_PATH, body('/usr/bin/other'), { masked: true });
|
|
47
|
+
await expect(diffUnit(fake.runner, renamed, output)).rejects.toThrow(/is masked/);
|
|
48
|
+
expect(fake.units.get(NAME)?.active).toBe(true);
|
|
49
|
+
expect(fake.files.has(PATH)).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a new name whose file belongs to someone else', async () => {
|
|
53
|
+
const { fake, output } = await running();
|
|
54
|
+
fake.placeUnit(RENAMED_PATH, body('/usr/bin/other'), { active: true, enabled: true });
|
|
55
|
+
await expect(diffUnit(fake.runner, renamed, output)).rejects.toThrow(/not this resource/);
|
|
56
|
+
expect(fake.units.get(NAME)?.active).toBe(true);
|
|
57
|
+
expect(fake.units.get(RENAMED)?.active).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('a file byte-identical to our render is our own leftover, not a claim', async () => {
|
|
61
|
+
const { fake, output } = await running();
|
|
62
|
+
fake.placeUnit(RENAMED_PATH, props.content);
|
|
63
|
+
expect(await diffUnit(fake.runner, renamed, output)).toEqual({
|
|
64
|
+
action: 'replace',
|
|
65
|
+
deleteFirst: true,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('a runner that will not write the new path', async () => {
|
|
70
|
+
const { fake, output } = await running();
|
|
71
|
+
const refusing = {
|
|
72
|
+
...fake.runner,
|
|
73
|
+
checkWrite: async () => {
|
|
74
|
+
throw new Error('HostRunner.checkWrite refused');
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
await expect(diffUnit(refusing, renamed, output)).rejects.toThrow(/checkWrite/);
|
|
78
|
+
expect(fake.units.get(NAME)?.active).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('the old unit must be deletable before a delete-first replace is promised', async () => {
|
|
82
|
+
const { fake, output } = await running();
|
|
83
|
+
const unprivileged = { ...fake.runner, effectiveUid: () => 1001, privileged: false };
|
|
84
|
+
await expect(diffUnit(unprivileged, renamed, output)).rejects.toThrow(/never calls sudo/);
|
|
85
|
+
expect(fake.units.get(NAME)?.active).toBe(true);
|
|
86
|
+
expect(fake.files.has(PATH)).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('a free name', () => {
|
|
91
|
+
test('plans a delete-first replace, and delete-then-reconcile leaves only the new unit', async () => {
|
|
92
|
+
const { fake, output } = await running();
|
|
93
|
+
expect(await diffUnit(fake.runner, renamed, output)).toEqual({
|
|
94
|
+
action: 'replace',
|
|
95
|
+
deleteFirst: true,
|
|
96
|
+
});
|
|
97
|
+
await deleteUnit(fake.runner, output);
|
|
98
|
+
const next = await reconcileUnit(fake.runner, renamed, output);
|
|
99
|
+
expect(next).toMatchObject({
|
|
100
|
+
active: true,
|
|
101
|
+
enabled: true,
|
|
102
|
+
name: RENAMED,
|
|
103
|
+
unitPath: RENAMED_PATH,
|
|
104
|
+
});
|
|
105
|
+
expect(fake.files.has(PATH)).toBe(false);
|
|
106
|
+
expect(fake.files.has(RENAMED_PATH)).toBe(true);
|
|
107
|
+
expect(fake.units.get(NAME)?.active ?? false).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('a rename the diff could not see is refused at apply, before the old unit stops', () => {
|
|
112
|
+
test('a masked new name', async () => {
|
|
113
|
+
const { fake, output } = await running();
|
|
114
|
+
fake.placeUnit(RENAMED_PATH, body('/usr/bin/other'), { masked: true });
|
|
115
|
+
await expect(reconcileUnit(fake.runner, renamed, output)).rejects.toThrow(/is masked/);
|
|
116
|
+
expect(fake.units.get(NAME)?.active).toBe(true);
|
|
117
|
+
expect(fake.files.has(PATH)).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('a new name whose file belongs to someone else', async () => {
|
|
121
|
+
const { fake, output } = await running();
|
|
122
|
+
fake.placeUnit(RENAMED_PATH, body('/usr/bin/other'), { active: true, enabled: true });
|
|
123
|
+
await expect(reconcileUnit(fake.runner, renamed, output)).rejects.toThrow(/not this resource/);
|
|
124
|
+
expect(fake.units.get(NAME)?.active).toBe(true);
|
|
125
|
+
expect(fake.files.has(PATH)).toBe(true);
|
|
126
|
+
expect(fake.units.get(RENAMED)?.active).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything Systemd.Unit checks before it touches the host — split from unit-lifecycle.ts so that
|
|
3
|
+
* a reconcile and a plan-time REPLACE run the very same checks.
|
|
4
|
+
*
|
|
5
|
+
* ★ WHY A REPLACE IS CHECKED AT PLAN TIME. A name or directory change is a delete-first replace,
|
|
6
|
+
* and Alchemy runs `delete` on the old unit BEFORE `reconcile` on the new one (Apply.ts,
|
|
7
|
+
* deleteOldGenerations under `node.deleteFirst`). A refusal that fired only in reconcile would
|
|
8
|
+
* therefore arrive after the old unit was already stopped and its file removed: nothing running,
|
|
9
|
+
* and a plan that had promised a clean swap.
|
|
10
|
+
* ★ EVERY CHECK HERE IS READ-ONLY. systemd has no `print-disabled` equivalent in this family —
|
|
11
|
+
* masking is `systemctl show`'s LoadState / UnitFileState, which `showUnit` already returns.
|
|
12
|
+
* Validation, that show, a file read and `HostRunner.checkWrite` write nothing. `systemctl
|
|
13
|
+
* enable` itself stays an apply step; the ways it is known to fail (no `[Install]`, a directory
|
|
14
|
+
* systemd does not search) are `unitProblems`, checked here before the plan may promise a swap.
|
|
15
|
+
*/
|
|
16
|
+
import { type HostRunner, canActAsRoot } from '../launchd/runner.ts';
|
|
17
|
+
import { type UnitStatus, showUnit } from './systemctl.ts';
|
|
18
|
+
import {
|
|
19
|
+
type SystemdUnitAttributes,
|
|
20
|
+
type SystemdUnitProps,
|
|
21
|
+
UNIT_WRITE,
|
|
22
|
+
digestOf,
|
|
23
|
+
unitPathFor,
|
|
24
|
+
unitProblems,
|
|
25
|
+
unitText,
|
|
26
|
+
} from './unit-form.ts';
|
|
27
|
+
import { refuse } from './unit-settle.ts';
|
|
28
|
+
|
|
29
|
+
const decoder = new TextDecoder();
|
|
30
|
+
|
|
31
|
+
/** Throw every refusal at once, so one plan shows the whole list. */
|
|
32
|
+
export const assertValid = (props: SystemdUnitProps, expect?: string): void => {
|
|
33
|
+
const found = unitProblems(props, expect);
|
|
34
|
+
if (found.length > 0) throw refuse(props.name, found.join('; '));
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** ⛔ Writing a unit file and driving systemd are root's, so refuse up front rather than mid-apply. */
|
|
38
|
+
export const assertMayWrite = (runner: HostRunner, name: string): void => {
|
|
39
|
+
if (canActAsRoot(runner)) return;
|
|
40
|
+
throw refuse(
|
|
41
|
+
name,
|
|
42
|
+
'a systemd unit is root-owned and systemctl needs root. Point the runner at a destination ' +
|
|
43
|
+
'whose ssh user is root, or provide a privileged HostRunner. This provider never calls sudo.',
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* ⛔ A MASKED UNIT IS SOMEONE'S DECISION, NOT DRIFT. `systemctl mask` survives reboots and makes
|
|
49
|
+
* start fail; unmasking here would silently overrule whoever masked it.
|
|
50
|
+
*/
|
|
51
|
+
export const assertUsable = (props: Pick<SystemdUnitProps, 'name'>, status: UnitStatus): void => {
|
|
52
|
+
if (status.loadState === 'masked' || status.unitFileState === 'masked') {
|
|
53
|
+
throw refuse(
|
|
54
|
+
props.name,
|
|
55
|
+
'is masked. If that is stale, `systemctl unmask` it deliberately, then redeploy. Nothing ' +
|
|
56
|
+
'was written.',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* ⛔ A UNIT THIS RESOURCE DOES NOT OWN IS NEVER OVERWRITTEN. Alchemy's adoption probe guards a
|
|
63
|
+
* create, but NOT the new name of a replace — the engine reads nothing there — so a rename onto
|
|
64
|
+
* a unit file someone else already has would stop the old unit and write ours over theirs.
|
|
65
|
+
* ★ A file byte-identical to our render is exempt: it is this declaration's own leftover (a deploy
|
|
66
|
+
* that died between write and reload), and rewriting it changes nothing.
|
|
67
|
+
*/
|
|
68
|
+
export const assertUnclaimed = async (
|
|
69
|
+
runner: HostRunner,
|
|
70
|
+
props: SystemdUnitProps,
|
|
71
|
+
): Promise<void> => {
|
|
72
|
+
const path = unitPathFor(props);
|
|
73
|
+
const bytes = await runner.readFile(path);
|
|
74
|
+
const live = bytes === undefined ? undefined : digestOf(decoder.decode(bytes));
|
|
75
|
+
if (live !== undefined && live !== digestOf(unitText(props))) {
|
|
76
|
+
throw refuse(
|
|
77
|
+
props.name,
|
|
78
|
+
`${path} is already on this host and is not this resource. Remove it, or declare it as a ` +
|
|
79
|
+
'new resource and deploy with --adopt.',
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The half of a rename check that needs only the NEW NAME: the old unit deletable, the new one
|
|
86
|
+
* writable and not masked.
|
|
87
|
+
* ★ ON ITS OWN it is for the rename unit-handlers.ts `diffHandler` spots while `content` is still
|
|
88
|
+
* an Output. That plan is a delete-first replace too, so a masked target refused only in
|
|
89
|
+
* reconcile would again arrive after the old unit was stopped. The unit file itself cannot be
|
|
90
|
+
* compared until `content` resolves, so `assertUnclaimed` stays reconcile's job there.
|
|
91
|
+
*/
|
|
92
|
+
export const assertRenameTarget = async (
|
|
93
|
+
runner: HostRunner,
|
|
94
|
+
old: SystemdUnitAttributes,
|
|
95
|
+
name: string,
|
|
96
|
+
): Promise<void> => {
|
|
97
|
+
assertMayWrite(runner, old.name);
|
|
98
|
+
assertMayWrite(runner, name);
|
|
99
|
+
assertUsable({ name }, await showUnit(runner, name));
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A rename, checked at plan time: the new identity must be valid, writable, not masked and free,
|
|
104
|
+
* and the old one deletable — before the plan may promise a delete-first replace.
|
|
105
|
+
*/
|
|
106
|
+
export const assertReplaceable = async (
|
|
107
|
+
runner: HostRunner,
|
|
108
|
+
next: SystemdUnitProps,
|
|
109
|
+
old: SystemdUnitAttributes,
|
|
110
|
+
expect?: string,
|
|
111
|
+
): Promise<void> => {
|
|
112
|
+
assertValid(next, expect);
|
|
113
|
+
await assertRenameTarget(runner, old, next.name);
|
|
114
|
+
await assertUnclaimed(runner, next);
|
|
115
|
+
await runner.checkWrite?.(unitPathFor(next), UNIT_WRITE);
|
|
116
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rename a plan sees while `content` is still an Output. `diffHandler` cannot run `diffUnit`
|
|
3
|
+
* there, but it still plans a delete-first replace — so the refusals that need only the new NAME
|
|
4
|
+
* have to fire in the plan, before Alchemy stops and removes the old unit.
|
|
5
|
+
*
|
|
6
|
+
* ★ The fully resolved rename is unit-preflight.test.ts. 🔴 Found by review (2026-09-22): this
|
|
7
|
+
* branch returned the replace with no check at all.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
import { fakeLinuxHost } from './fake-linux-host.ts';
|
|
12
|
+
import { renderUnit } from './unit-form.ts';
|
|
13
|
+
import { diffHandler } from './unit-handlers.ts';
|
|
14
|
+
import { diffUnit, reconcileUnit } from './unit-lifecycle.ts';
|
|
15
|
+
|
|
16
|
+
const NAME = 'hf-example.service';
|
|
17
|
+
const PATH = `/etc/systemd/system/${NAME}`;
|
|
18
|
+
const RENAMED = 'hf-renamed.service';
|
|
19
|
+
const RENAMED_PATH = `/etc/systemd/system/${RENAMED}`;
|
|
20
|
+
const body = (exec: string) =>
|
|
21
|
+
renderUnit([
|
|
22
|
+
{ lines: [['Description', 'example']], name: 'Unit' },
|
|
23
|
+
{ lines: [['ExecStart', exec]], name: 'Service' },
|
|
24
|
+
{ lines: [['WantedBy', 'multi-user.target']], name: 'Install' },
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const props = { content: body('/usr/bin/example'), name: NAME };
|
|
28
|
+
// ★ An Effect is what isResolved() calls unresolved — the stand-in for a rendered digest.
|
|
29
|
+
const pending = { content: Effect.succeed(props.content), name: RENAMED } as never;
|
|
30
|
+
|
|
31
|
+
const running = async () => {
|
|
32
|
+
const fake = fakeLinuxHost({ dirs: { '/etc/systemd/system': 0 } });
|
|
33
|
+
const output = await reconcileUnit(fake.runner, props, undefined);
|
|
34
|
+
return { fake, output, writes: () => fake.calls.filter((call) => call[0] === 'write').length };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe('a rename while content is still an Output', () => {
|
|
38
|
+
test('a free name plans a delete-first replace and writes nothing', async () => {
|
|
39
|
+
const { fake, output, writes } = await running();
|
|
40
|
+
const before = writes();
|
|
41
|
+
const diff = await Effect.runPromise(diffHandler(fake.runner, 'id', pending, output));
|
|
42
|
+
expect(diff).toEqual({ action: 'replace', deleteFirst: true });
|
|
43
|
+
expect(writes()).toBe(before);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('a MASKED target is refused by the plan, with the old unit still running', async () => {
|
|
47
|
+
const { fake, output } = await running();
|
|
48
|
+
fake.placeUnit(RENAMED_PATH, body('/usr/bin/other'), { masked: true });
|
|
49
|
+
await expect(
|
|
50
|
+
Effect.runPromise(diffHandler(fake.runner, 'id', pending, output)),
|
|
51
|
+
).rejects.toThrow(/is masked/);
|
|
52
|
+
expect(fake.units.get(NAME)?.active).toBe(true);
|
|
53
|
+
expect(fake.files.has(PATH)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('an unprivileged runner is refused before anything is removed', async () => {
|
|
57
|
+
const { fake, output } = await running();
|
|
58
|
+
const unprivileged = { ...fake.runner, effectiveUid: () => 1001, privileged: false };
|
|
59
|
+
await expect(
|
|
60
|
+
Effect.runPromise(diffHandler(unprivileged, 'id', pending, output)),
|
|
61
|
+
).rejects.toThrow(/never calls sudo/);
|
|
62
|
+
expect(fake.units.get(NAME)?.active).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('a resolved rename', () => {
|
|
67
|
+
test('is read-only: the plan writes nothing, whatever it decided', async () => {
|
|
68
|
+
const { fake, output, writes } = await running();
|
|
69
|
+
const before = writes();
|
|
70
|
+
expect(await diffUnit(fake.runner, { ...props, name: RENAMED }, output)).toEqual({
|
|
71
|
+
action: 'replace',
|
|
72
|
+
deleteFirst: true,
|
|
73
|
+
});
|
|
74
|
+
expect(writes()).toBe(before);
|
|
75
|
+
});
|
|
76
|
+
});
|