@homeflare/alchemy 0.37.7 → 0.38.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/caddy/admin.d.ts +42 -8
- package/dist/caddy/admin.d.ts.map +1 -1
- package/dist/caddy/config.d.ts +9 -3
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/fake-caddy.d.ts +5 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -1
- package/dist/caddy/index.js +10 -6
- package/dist/caddy/index.js.map +5 -5
- package/dist/caddy/providers.d.ts +19 -12
- package/dist/caddy/providers.d.ts.map +1 -1
- package/dist/linux/container-form.d.ts +1 -1
- package/dist/linux/container-generator.d.ts +28 -0
- package/dist/linux/container-generator.d.ts.map +1 -1
- package/dist/linux/container-lifecycle.d.ts +18 -1
- package/dist/linux/container-lifecycle.d.ts.map +1 -1
- package/dist/linux/container-preflight.d.ts +34 -0
- package/dist/linux/container-preflight.d.ts.map +1 -1
- package/dist/linux/container-settle.d.ts.map +1 -1
- package/dist/linux/fake-sudo.d.ts +14 -2
- package/dist/linux/fake-sudo.d.ts.map +1 -1
- package/dist/linux/index.js +76 -29
- package/dist/linux/index.js.map +11 -10
- package/dist/linux/sudo-exec.d.ts +16 -0
- package/dist/linux/sudo-exec.d.ts.map +1 -1
- package/dist/linux/unit-form.d.ts.map +1 -1
- package/dist/linux/unit-text.d.ts +35 -0
- package/dist/linux/unit-text.d.ts.map +1 -0
- package/docs/linux-sudo.md +18 -7
- package/package.json +1 -1
- package/src/caddy/admin-transport-scope.test.ts +127 -0
- package/src/caddy/admin.ts +45 -9
- package/src/caddy/config-adopt-provider.test.ts +2 -2
- package/src/caddy/config-format-warning.test.ts +9 -3
- package/src/caddy/config.ts +20 -4
- package/src/caddy/fake-caddy.ts +10 -1
- package/src/caddy/providers.test.ts +3 -2
- package/src/caddy/providers.ts +18 -11
- package/src/linux/container-form.ts +1 -1
- package/src/linux/container-generator.test.ts +33 -0
- package/src/linux/container-generator.ts +72 -0
- package/src/linux/container-lifecycle.test.ts +6 -3
- package/src/linux/container-lifecycle.ts +23 -2
- package/src/linux/container-preflight.ts +47 -0
- package/src/linux/container-settle.ts +12 -1
- package/src/linux/container-shadow.test.ts +131 -0
- package/src/linux/fake-sudo.ts +21 -5
- package/src/linux/sudo-exec.ts +60 -10
- package/src/linux/sudo-lifecycle.test.ts +64 -3
- package/src/linux/unit-form.ts +4 -18
- package/src/linux/unit-text.test.ts +67 -0
- package/src/linux/unit-text.ts +58 -0
package/src/linux/unit-form.ts
CHANGED
|
@@ -12,8 +12,11 @@
|
|
|
12
12
|
* ⛔ `content` IS NEVER A SECRET. Props sit unencrypted in Alchemy state and the unit file is
|
|
13
13
|
* world-readable at 0644; a unit that needs a secret takes `EnvironmentFile=` pointing at a file
|
|
14
14
|
* a secret renderer maintains.
|
|
15
|
+
* ★ THE TEXT-SHAPE CHECKS (comments/blank lines before [Section], [Install] key lookup) live in
|
|
16
|
+
* `unit-text.ts`, split out to keep this file under the house line cap.
|
|
15
17
|
*/
|
|
16
18
|
import { sha256Hex } from '../launchd/job-form.ts';
|
|
19
|
+
import { hasInstallSection, textProblems } from './unit-text.ts';
|
|
17
20
|
|
|
18
21
|
export type UnitSection = {
|
|
19
22
|
/** `Unit`, `Service`, `Timer`, `Install`, … — verbatim, including its capitalisation. */
|
|
@@ -172,8 +175,6 @@ const TYPES = new Set([
|
|
|
172
175
|
'timer',
|
|
173
176
|
]);
|
|
174
177
|
|
|
175
|
-
const INSTALL_KEYS = new Set(['WantedBy', 'RequiredBy', 'UpsertedBy', 'Also', 'Alias']);
|
|
176
|
-
|
|
177
178
|
/**
|
|
178
179
|
* Every refusal at once, so one plan shows the whole list.
|
|
179
180
|
* ⛔ THE TWO STRUCTURAL RULES, both proven by systemd's own output rather than assumed:
|
|
@@ -206,7 +207,7 @@ export const unitProblems = (props: SystemdUnitProps, expect?: string): string[]
|
|
|
206
207
|
`${directory} is not one of them. Move it, or declare enabled: false.`,
|
|
207
208
|
);
|
|
208
209
|
}
|
|
209
|
-
if (props.enabled !== false && !
|
|
210
|
+
if (props.enabled !== false && !hasInstallSection(unitText(props))) {
|
|
210
211
|
found.push(
|
|
211
212
|
'enabled units need an [Install] section with WantedBy/RequiredBy/Also/Alias; without one ' +
|
|
212
213
|
'systemd calls the unit "static" and `systemctl enable` has nothing to link. Add one, or ' +
|
|
@@ -218,18 +219,3 @@ export const unitProblems = (props: SystemdUnitProps, expect?: string): string[]
|
|
|
218
219
|
}
|
|
219
220
|
return found;
|
|
220
221
|
};
|
|
221
|
-
|
|
222
|
-
const hasInstall = (props: SystemdUnitProps): boolean => {
|
|
223
|
-
const text = unitText(props);
|
|
224
|
-
if (!text.includes('[Install]')) return false;
|
|
225
|
-
const after = text.slice(text.indexOf('[Install]'));
|
|
226
|
-
return [...INSTALL_KEYS].some((key) => new RegExp(`^${key}=`, 'm').test(after));
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
const textProblems = (text: string): string[] => {
|
|
230
|
-
const found: string[] = [];
|
|
231
|
-
if (text.trim() === '') found.push('the unit file is empty');
|
|
232
|
-
if (text.includes('\u0000')) found.push('the unit file contains NUL');
|
|
233
|
-
if (!/^\s*\[[A-Za-z]+]/.test(text)) found.push('a unit file must begin with a [Section]');
|
|
234
|
-
return found;
|
|
235
|
-
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `unit-text.ts`'s own tests — the INI text-shape checks, on their own, separate from
|
|
3
|
+
* `unit-lifecycle.test.ts`'s end-to-end fake-host behaviour.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import { hasInstallSection, textProblems } from './unit-text.ts';
|
|
7
|
+
|
|
8
|
+
// 🔴 REGRESSION, CT100 deploy 2026-09-26 00:11Z: `assertValid` refused this EXACT content — "a
|
|
9
|
+
// unit file must begin with a [Section]" — even though it is byte-identical to what systemd
|
|
10
|
+
// already loads on CT100 (sha256 verified against the live host). Leading lines copied
|
|
11
|
+
// verbatim from homeflare-ct100's src/openbao-agent/files/openbao-agent.service.
|
|
12
|
+
const CT100_OPENBAO_AGENT = [
|
|
13
|
+
"# CT100's openbao-agent.",
|
|
14
|
+
'#',
|
|
15
|
+
"# THIS FILE WAS RECOVERED FROM THE RUNNING BOX, NOT PUSHED TO IT. The repo's copy was STALE",
|
|
16
|
+
'# and would have REMOVED `Environment=BAO_NAMESPACE=homeflare` -- without which the agent',
|
|
17
|
+
'# authenticates into the root namespace, every template 403s, and the failure reads as a',
|
|
18
|
+
'# missing grant rather than a missing variable. That is the exact shape the vault policies',
|
|
19
|
+
'# README warns about: a deploy converging a stale copy over a working live one.',
|
|
20
|
+
'# So the FIRST run of deploy-ct100.sh was a --dry-run, and this is what it was for.',
|
|
21
|
+
'[Unit]',
|
|
22
|
+
'Description=OpenBao agent',
|
|
23
|
+
'After=network-online.target',
|
|
24
|
+
'Wants=network-online.target',
|
|
25
|
+
'[Service]',
|
|
26
|
+
'Environment=BAO_NAMESPACE=homeflare',
|
|
27
|
+
'ExecStart=/usr/bin/bao agent -config=/etc/openbao/agent.hcl',
|
|
28
|
+
'RuntimeDirectory=openbao',
|
|
29
|
+
'Restart=on-failure',
|
|
30
|
+
'RestartSec=10',
|
|
31
|
+
'[Install]',
|
|
32
|
+
'WantedBy=multi-user.target',
|
|
33
|
+
'',
|
|
34
|
+
].join('\n');
|
|
35
|
+
|
|
36
|
+
describe('textProblems', () => {
|
|
37
|
+
test('the CT100 openbao-agent.service fixture validates — comments may precede [Unit]', () => {
|
|
38
|
+
expect(textProblems(CT100_OPENBAO_AGENT)).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('blank lines alone may also precede the first section', () => {
|
|
42
|
+
expect(textProblems('\n\n[Unit]\nDescription=x\n')).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a ; comment is accepted the same as a # comment', () => {
|
|
46
|
+
expect(textProblems('; a semicolon comment\n[Unit]\nDescription=x\n')).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('a genuine key=value before any section still refuses — comments are not a blanket exemption', () => {
|
|
50
|
+
expect(textProblems('Description=no section at all\n[Unit]\n').join(' ')).toMatch(
|
|
51
|
+
/must begin with a \[Section\]/,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('an empty file and one with NUL are still refused', () => {
|
|
56
|
+
expect(textProblems(' \n').join(' ')).toMatch(/is empty/);
|
|
57
|
+
expect(textProblems('[Unit]\nDescription=a\u0000b\n').join(' ')).toMatch(/contains NUL/);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('hasInstallSection', () => {
|
|
62
|
+
test('true only once an [Install] key actually appears after the header', () => {
|
|
63
|
+
expect(hasInstallSection('[Unit]\n[Install]\nWantedBy=multi-user.target\n')).toBe(true);
|
|
64
|
+
expect(hasInstallSection('[Unit]\nDescription=x\n')).toBe(false);
|
|
65
|
+
expect(hasInstallSection('[Unit]\n[Install]\n')).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of `unit-form.ts`'s validation that inspect the unit file's TEXT rather than its
|
|
3
|
+
* props struct — split out so unit-form.ts stays under the file cap, and because these are one
|
|
4
|
+
* coherent concern: is this INI shape something systemd would actually load. Takes plain strings,
|
|
5
|
+
* never `SystemdUnitProps`, so it never needs to import back from unit-form.ts.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ `[Install]` KEYS ARE `unit-form.ts`'s OWN LIST, NOT SYSTEMD'S FULL VOCABULARY — WantedBy,
|
|
8
|
+
* RequiredBy, UpsertedBy, Also, Alias, per `systemd.unit(5)` §[Install]. `container-form.ts`'s
|
|
9
|
+
* own header notes it deliberately does NOT reuse this set: a `.container` file's `[Install]`
|
|
10
|
+
* supports only Alias/WantedBy/RequiredBy (`podman-systemd.unit(5)`), narrower than this.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const INSTALL_KEYS = new Set(['WantedBy', 'RequiredBy', 'UpsertedBy', 'Also', 'Alias']);
|
|
14
|
+
|
|
15
|
+
/** Whether `text` has an `[Install]` section carrying at least one key systemd actually links on. */
|
|
16
|
+
export const hasInstallSection = (text: string): boolean => {
|
|
17
|
+
if (!text.includes('[Install]')) return false;
|
|
18
|
+
const after = text.slice(text.indexOf('[Install]'));
|
|
19
|
+
return [...INSTALL_KEYS].some((key) => new RegExp(`^${key}=`, 'm').test(after));
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The first line that is neither blank nor a comment — the line whose shape actually decides
|
|
24
|
+
* whether the file "begins with a [Section]", per `textProblems` below.
|
|
25
|
+
* ★ systemd.syntax(7): "Empty lines and lines starting with '#' or ';' are ignored", with no
|
|
26
|
+
* carve-out for the position before the first section — a comment header is ordinary content
|
|
27
|
+
* there, same as anywhere else in the file.
|
|
28
|
+
*/
|
|
29
|
+
export const firstSubstantiveLine = (text: string): string | undefined =>
|
|
30
|
+
text
|
|
31
|
+
.split('\n')
|
|
32
|
+
.find((line) => !(line.trim() === '' || line.startsWith('#') || line.startsWith(';')));
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 🔴 MEASURED FALSE, CT100 deploy 2026-09-26 00:11Z: the openbao-agent.service asset's first eight
|
|
36
|
+
* lines are `#`-comments recording where the file was recovered from (verbatim from the live
|
|
37
|
+
* host — homeflare-ct100's `src/openbao-agent/files/openbao-agent.service`), and the file is
|
|
38
|
+
* byte-identical to what systemd already loads there. The old check tested the WHOLE text
|
|
39
|
+
* against `/^\s*\[[A-Za-z]+]/` — `\s*` skips blank lines and indentation, never a `#`/`;`
|
|
40
|
+
* comment — so a leading comment line failed it outright. This checks only the first
|
|
41
|
+
* SUBSTANTIVE line, so any number of leading blank lines and comments are fine and a genuine
|
|
42
|
+
* `key=value` before any section — the one real error this exists to catch — still fails, since
|
|
43
|
+
* that line is neither blank nor a comment either.
|
|
44
|
+
*/
|
|
45
|
+
export const textProblems = (text: string): string[] => {
|
|
46
|
+
const found: string[] = [];
|
|
47
|
+
if (text.trim() === '') found.push('the unit file is empty');
|
|
48
|
+
if (text.includes('\u0000')) found.push('the unit file contains NUL');
|
|
49
|
+
const first = firstSubstantiveLine(text);
|
|
50
|
+
// ★ `\s*` kept from the original regex — leading indentation on the SECTION line itself was
|
|
51
|
+
// already tolerated before this fix and stays that way; only the leading-comment shape changes.
|
|
52
|
+
if (first === undefined || !/^\s*\[[A-Za-z]+]/.test(first)) {
|
|
53
|
+
found.push(
|
|
54
|
+
'a unit file must begin with a [Section] (blank lines and #/; comments before it are fine)',
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return found;
|
|
58
|
+
};
|