@homeflare/alchemy 0.37.7 โ 0.37.8
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/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/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
|
@@ -23,6 +23,22 @@
|
|
|
23
23
|
* is a valid type in `unit-form.ts`'s `TYPES` set) would also have elevated. Grepped: no code
|
|
24
24
|
* in this package ever calls `enable`/`start`/`restart` on a unit it has not itself just
|
|
25
25
|
* written a file for, so narrowing the exemption to `stop`/`disable` costs nothing legitimate.
|
|
26
|
+
* ๐ด MEASURED, CT100 deploy 2026-09-26 00:11Z: a THIRD gap, this one refusing a call that should
|
|
27
|
+
* have been allowed. `Podman.Container caddy`'s generated `caddy.service` has
|
|
28
|
+
* `FragmentPath=/run/systemd/generator/caddy.service` โ Quadlet's generator output, never a
|
|
29
|
+
* path this runner would ever declare as a prefix (nothing writes there directly) โ so `restart`
|
|
30
|
+
* was refused outright, even though the `.container` FILE THAT GENERATED IT lives at
|
|
31
|
+
* `/etc/containers/systemd/caddy.container`, squarely under this runner's own
|
|
32
|
+
* `/etc/containers/systemd` prefix, and this SAME runner had just written it. `SourcePath` is
|
|
33
|
+
* the generator's own record of that file (systemctl.ts's header, MEASURED on CT100). So a
|
|
34
|
+
* generated unit is now also reachable when its `FragmentPath` is the generator's OWN output
|
|
35
|
+
* directory (`GENERATOR_DIRECTORIES` below) AND its `SourcePath` is under a declared prefix โ
|
|
36
|
+
* the thing this runner actually owns is the `.container` file, and the generated unit is
|
|
37
|
+
* systemd's own derivative of it, not a foreign unit. Everything the check refused before still
|
|
38
|
+
* refuses: a vendor unit's `FragmentPath` (`/usr/lib/systemd/system/...`) is not under a
|
|
39
|
+
* generator directory at all; a generated unit with no `SourcePath`, or one outside every
|
|
40
|
+
* prefix (a `.container` file elsewhere in Quadlet's OWN search path โ
|
|
41
|
+
* `podman-systemd.unit(5)` โ that this runner was never told to own), still refuses.
|
|
26
42
|
*/
|
|
27
43
|
import type { ExecResult, HostRunner } from '../launchd/runner.ts';
|
|
28
44
|
import type { Elevate } from './sudo-write.ts';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sudo-exec.d.ts","sourceRoot":"","sources":["../../src/linux/sudo-exec.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"sudo-exec.d.ts","sourceRoot":"","sources":["../../src/linux/sudo-exec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAInE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AA4D/C,iGAAiG;AACjG,eAAO,MAAM,YAAY,SACjB,UAAU,WACP,OAAO,YACN,SAAS,MAAM,EAAE,QACrB,SAAS,MAAM,EAAE,KACtB,OAAO,CAAC,UAAU,CAuDpB,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"unit-form.d.ts","sourceRoot":"","sources":["../../src/linux/unit-form.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"unit-form.d.ts","sourceRoot":"","sources":["../../src/linux/unit-form.ts"],"names":[],"mappings":"AAoBA,MAAM,MAAM,WAAW,GAAG;IACxB,yFAAyF;IACzF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,EAAE,CAAC;CACpE,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,qFAAqF;IACrF,IAAI,EAAE,MAAM,CAAC;IACb,yEAAyE;IACzE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,iGAAiG;IACjG,QAAQ,CAAC,EAAE,SAAS,WAAW,EAAE,CAAC;IAClC,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oDAAoD;IACpD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,gDAAgD;IAChD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC/B,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,oDAAoD;IACpD,UAAU,EAAE,MAAM,CAAC;IACnB,4DAA4D;IAC5D,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF,iGAAiG;AACjG,eAAO,MAAM,sBAAsB,wBAAwB,CAAC;AAE5D;;;;;;;;GAQG;AACH,eAAO,MAAM,uBAAuB,YAClC,qBAAqB,EACrB,qBAAqB,EACrB,+BAA+B,EAC/B,yBAAyB,EACzB,qBAAqB,CACb,CAAC;AAEX,mGAAmG;AACnG,eAAO,MAAM,UAAU;aAAK,GAAG,EAAE,CAAC;aAAE,IAAI,EAAE,GAAK;aAAE,GAAG,EAAE,CAAC;CAAW,CAAC;AAEnE,eAAO,MAAM,QAAQ,SAAU,MAAM,KAAG,MAAmD,CAAC;AAE5F,4FAA4F;AAC5F,eAAO,MAAM,YAAY,cAAe,SAAS,MAAM,EAAE,GAAG,SAAS,KAAG,MAChC,CAAC;AAEzC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,eAAO,MAAM,aAAa,gBACX,MAAM,YACT,MAAM,GAAG,SAAS,WACnB,OAAO,KACf,OAKF,CAAC;AAEF,kGAAkG;AAClG,eAAO,MAAM,UAAU,aAAc,SAAS,WAAW,EAAE,KAAG,MAKzC,CAAC;AAEtB,eAAO,MAAM,QAAQ,UAAW,gBAAgB,KAAG,MACA,CAAC;AAEpD,eAAO,MAAM,WAAW,UAAW,gBAAgB,KAAG,MACQ,CAAC;AAE/D,mGAAmG;AACnG,eAAO,MAAM,IAAI,QAA6C,CAAC;AAgB/D;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,UAAW,gBAAgB,WAAW,MAAM,KAAG,MAAM,EAmC7E,CAAC"}
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
export declare const INSTALL_KEYS: Set<string>;
|
|
13
|
+
/** Whether `text` has an `[Install]` section carrying at least one key systemd actually links on. */
|
|
14
|
+
export declare const hasInstallSection: (text: string) => boolean;
|
|
15
|
+
/**
|
|
16
|
+
* The first line that is neither blank nor a comment โ the line whose shape actually decides
|
|
17
|
+
* whether the file "begins with a [Section]", per `textProblems` below.
|
|
18
|
+
* โ
systemd.syntax(7): "Empty lines and lines starting with '#' or ';' are ignored", with no
|
|
19
|
+
* carve-out for the position before the first section โ a comment header is ordinary content
|
|
20
|
+
* there, same as anywhere else in the file.
|
|
21
|
+
*/
|
|
22
|
+
export declare const firstSubstantiveLine: (text: string) => string | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* ๐ด MEASURED FALSE, CT100 deploy 2026-09-26 00:11Z: the openbao-agent.service asset's first eight
|
|
25
|
+
* lines are `#`-comments recording where the file was recovered from (verbatim from the live
|
|
26
|
+
* host โ homeflare-ct100's `src/openbao-agent/files/openbao-agent.service`), and the file is
|
|
27
|
+
* byte-identical to what systemd already loads there. The old check tested the WHOLE text
|
|
28
|
+
* against `/^\s*\[[A-Za-z]+]/` โ `\s*` skips blank lines and indentation, never a `#`/`;`
|
|
29
|
+
* comment โ so a leading comment line failed it outright. This checks only the first
|
|
30
|
+
* SUBSTANTIVE line, so any number of leading blank lines and comments are fine and a genuine
|
|
31
|
+
* `key=value` before any section โ the one real error this exists to catch โ still fails, since
|
|
32
|
+
* that line is neither blank nor a comment either.
|
|
33
|
+
*/
|
|
34
|
+
export declare const textProblems: (text: string) => string[];
|
|
35
|
+
//# sourceMappingURL=unit-text.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"unit-text.d.ts","sourceRoot":"","sources":["../../src/linux/unit-text.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,eAAO,MAAM,YAAY,aAAqE,CAAC;AAE/F,qGAAqG;AACrG,eAAO,MAAM,iBAAiB,SAAU,MAAM,KAAG,OAIhD,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,SAAU,MAAM,KAAG,MAAM,GAAG,SAG6B,CAAC;AAE3F;;;;;;;;;;GAUG;AACH,eAAO,MAAM,YAAY,SAAU,MAAM,KAAG,MAAM,EAajD,CAAC"}
|
package/docs/linux-sudo.md
CHANGED
|
@@ -76,15 +76,26 @@ Each is `/usr/bin/sudo -n -- <argv>`, every program by absolute path.
|
|
|
76
76
|
`enable`/`disable`/`start`/`stop`/`restart` elevate only when the unit is not
|
|
77
77
|
**masked** (`LoadState`, read alongside `FragmentPath` in the same call โ
|
|
78
78
|
masking is someone's decision, never overridden here) and its **own
|
|
79
|
-
`FragmentPath`** is either under a declared prefix,
|
|
79
|
+
`FragmentPath`** is either under a declared prefix, empty AND the verb is
|
|
80
80
|
`stop`/`disable` โ the only two `unit-lifecycle.ts` `deleteUnit` ever sends
|
|
81
81
|
against a unit whose file it may just have removed, so a second, idempotent
|
|
82
|
-
delete pass still works
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
`
|
|
86
|
-
|
|
87
|
-
|
|
82
|
+
delete pass still works โ **or a systemd GENERATOR's own output** (measured,
|
|
83
|
+
CT100 deploy 2026-09-26: `/run/systemd/generator`, and `.late`) **whose
|
|
84
|
+
`SourcePath` is under a declared prefix.** That is exactly `Podman.Container`:
|
|
85
|
+
Quadlet's generator writes `caddy.service`'s `FragmentPath` under
|
|
86
|
+
`/run/systemd/generator`, never a path this runner would declare, but stamps
|
|
87
|
+
`SourcePath` with the `.container` file that produced it โ the thing this
|
|
88
|
+
runner actually wrote, under `/etc/containers/systemd`. `SourcePath` is read
|
|
89
|
+
in the same `systemctl show` as `FragmentPath`/`LoadState`, so no extra host
|
|
90
|
+
round trip. A generated unit with no `SourcePath`, or one outside every
|
|
91
|
+
prefix (a `.container` file elsewhere in Quadlet's OWN search path this
|
|
92
|
+
runner was never told to own), still refuses. `enable`/`start`/`restart`
|
|
93
|
+
with an empty `FragmentPath` are refused, closing a kernel-generated
|
|
94
|
+
pseudo-unit (`init.scope`, a `session-N.scope`) as a target. A vendor unit
|
|
95
|
+
like `pveproxy.service`, whose `FragmentPath` is `/usr/lib/systemd/system/โฆ`
|
|
96
|
+
โ not a generator directory โ is refused before sudo ever runs: this runner
|
|
97
|
+
cannot touch what it did not declare, directly or through a generator.
|
|
98
|
+
`--user`, `--global`, `-H`, `-M` and `--root` are refused outright,
|
|
88
99
|
anywhere in the argv. `directory-lifecycle.ts`'s own `chown` argv is bare
|
|
89
100
|
(`0`, `:0`, `0:0`); `canonicalize()` rewrites it to the `+`-forced form above
|
|
90
101
|
before the allowlist ever sees it, so the unprivileged caller never has to
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.37.
|
|
3
|
+
"version": "0.37.8",
|
|
4
4
|
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* or renders that file's contents.
|
|
19
19
|
* โ `[Install]` SUPPORTS ONLY `Alias=`/`WantedBy=`/`RequiredBy=` FOR A `.container` FILE (same doc,
|
|
20
20
|
* "Currently, only the Alias, WantedBy and RequiredBy keys are supported") โ NOT `Also=`, which
|
|
21
|
-
* plain `systemd.unit(5)` allows and unit-
|
|
21
|
+
* plain `systemd.unit(5)` allows and unit-text.ts's `INSTALL_KEYS` therefore lists. See
|
|
22
22
|
* container-generator.ts's header for why there is no `enabled` prop at all.
|
|
23
23
|
*/
|
|
24
24
|
import { type UnitSection, configDigest, digestOf, renderUnit } from './unit-form.ts';
|
|
@@ -192,4 +192,37 @@ describe('verifyGenerated, directly', () => {
|
|
|
192
192
|
}),
|
|
193
193
|
).toThrow(/earlier in Quadlet/);
|
|
194
194
|
});
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* โ REGRESSION โ found on adversarial review. Before the FragmentPath check was added, a real,
|
|
198
|
+
* already-loaded PLAIN unit (e.g. an admin's own `/etc/systemd/system/โฆ` sharing this
|
|
199
|
+
* container's service name) reported `LoadState=loaded` (it is a genuinely valid unit) and no
|
|
200
|
+
* `SourcePath` (hand-written units never carry one) โ both existing checks passed it silently,
|
|
201
|
+
* so `verifyGenerated` was not the backstop its own doc comments claimed: a shadow that slipped
|
|
202
|
+
* past `assertUnshadowed` (container-preflight.ts) would misapply silently, not throw loudly.
|
|
203
|
+
*/
|
|
204
|
+
test('a loaded plain unit with no SourcePath is still caught, via FragmentPath', () => {
|
|
205
|
+
expect(() =>
|
|
206
|
+
verifyGenerated(NAME, PATH, SERVICE, {
|
|
207
|
+
activeState: 'active',
|
|
208
|
+
fragmentPath: '/etc/systemd/system/hf-example.service',
|
|
209
|
+
known: true,
|
|
210
|
+
loadState: 'loaded',
|
|
211
|
+
needDaemonReload: false,
|
|
212
|
+
}),
|
|
213
|
+
).toThrow(/not from Quadlet.s generator/);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('a real generated FragmentPath is not mistaken for a shadow', () => {
|
|
217
|
+
expect(() =>
|
|
218
|
+
verifyGenerated(NAME, PATH, SERVICE, {
|
|
219
|
+
activeState: 'active',
|
|
220
|
+
fragmentPath: `/run/systemd/generator/${SERVICE}`,
|
|
221
|
+
known: true,
|
|
222
|
+
loadState: 'loaded',
|
|
223
|
+
needDaemonReload: false,
|
|
224
|
+
sourcePath: PATH,
|
|
225
|
+
}),
|
|
226
|
+
).not.toThrow();
|
|
227
|
+
});
|
|
195
228
|
});
|
|
@@ -55,6 +55,56 @@ import type { UnitStatus } from './systemctl.ts';
|
|
|
55
55
|
/** MEASURED on CT100 2026-09-24: `systemctl show caddy.service -p FragmentPath`. */
|
|
56
56
|
export const GENERATED_UNIT_DIRECTORY = '/run/systemd/generator';
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Whether `fragmentPath` is Quadlet's OWN generator output โ the "did daemon-reload actually
|
|
60
|
+
* produce OUR unit" question `verifyGenerated` needs, as distinct from `isShadowingFragment`
|
|
61
|
+
* below (the "does something ELSE outrank the generator" question `assertUnshadowed` needs โ the
|
|
62
|
+
* two are not complements of each other, see that function's header).
|
|
63
|
+
*/
|
|
64
|
+
export const isGeneratorFragment = (fragmentPath: string | undefined): boolean =>
|
|
65
|
+
fragmentPath !== undefined && fragmentPath.startsWith(`${GENERATED_UNIT_DIRECTORY}/`);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* โ CORRECTED ON ADVERSARIAL RE-REVIEW OF THE FIX BELOW โ the directories that ACTUALLY search
|
|
69
|
+
* BEFORE `${GENERATED_UNIT_DIRECTORY}` in systemd's real Unit File Load Path (`systemd.unit(5)`,
|
|
70
|
+
* tag v257, "Unit File Load Path"), highest to lowest precedence:
|
|
71
|
+
* /etc/systemd/system.control, /run/systemd/system.control, /run/systemd/transient,
|
|
72
|
+
* /etc/systemd/system, /etc/systemd/system.attached, /run/systemd/system,
|
|
73
|
+
* /run/systemd/system.attached, [${GENERATED_UNIT_DIRECTORY}], /usr/local/lib/systemd/system,
|
|
74
|
+
* /usr/lib/systemd/system, /run/systemd/generator.late.
|
|
75
|
+
* โ `/usr/local/lib/systemd/system` AND `/usr/lib/systemd/system` ARE DELIBERATELY ABSENT โ the
|
|
76
|
+
* original version of this check (`isGeneratorFragment`'s NEGATION: "not the generator's own
|
|
77
|
+
* output") treated ANY other FragmentPath as shadowing, including these two vendor directories,
|
|
78
|
+
* which are LOWER precedence than the generator, not higher. A plain unit there is harmlessly
|
|
79
|
+
* shadowed BY Quadlet's generated unit, same as `unit-form.ts`'s `UNIT_SEARCH_DIRECTORIES`
|
|
80
|
+
* (measured via `systemd-analyze unit-paths`) independently lists them below `/run/systemd/system`,
|
|
81
|
+
* and `systemctl.ts`'s own header distinguishes `/etc/systemd/system` from "the vendor's
|
|
82
|
+
* `/usr/lib/systemd/system`" rather than lumping the two together. Refusing for either one blocked
|
|
83
|
+
* a create/adopt apply would have handled fine โ exactly the false positive S49 ("neither looser
|
|
84
|
+
* nor stricter") forbids. `/etc/systemd/system.control`, `.attached` and `/run/systemd/transient`
|
|
85
|
+
* are included for completeness against the man page though no family here writes into them.
|
|
86
|
+
*/
|
|
87
|
+
const SHADOWING_UNIT_DIRECTORIES = [
|
|
88
|
+
'/etc/systemd/system.control',
|
|
89
|
+
'/run/systemd/system.control',
|
|
90
|
+
'/run/systemd/transient',
|
|
91
|
+
'/etc/systemd/system',
|
|
92
|
+
'/etc/systemd/system.attached',
|
|
93
|
+
'/run/systemd/system',
|
|
94
|
+
'/run/systemd/system.attached',
|
|
95
|
+
] as const;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Whether `fragmentPath` genuinely OUTRANKS Quadlet's generator โ the real question
|
|
99
|
+
* `container-preflight.ts`'s `assertUnshadowed` needs ("would writing our `.container` file and
|
|
100
|
+
* reloading still lose to this other unit?"), as opposed to the broader and wrong question the
|
|
101
|
+
* original check asked ("is this simply not the generator's own output?", which is also true of
|
|
102
|
+
* every LOWER-precedence vendor unit and wrongly refused those too).
|
|
103
|
+
*/
|
|
104
|
+
export const isShadowingFragment = (fragmentPath: string | undefined): boolean =>
|
|
105
|
+
fragmentPath !== undefined &&
|
|
106
|
+
SHADOWING_UNIT_DIRECTORIES.some((dir) => fragmentPath.startsWith(`${dir}/`));
|
|
107
|
+
|
|
58
108
|
export class QuadletGeneratorError extends Error {
|
|
59
109
|
constructor(name: string, detail: string) {
|
|
60
110
|
super(`Podman.Container ${name}: ${detail}`);
|
|
@@ -85,6 +135,19 @@ export const needsVerificationReload = (containerPath: string, status: UnitStatu
|
|
|
85
135
|
* resource is simply absent", which is what a plain `LoadState=not-found` would otherwise look
|
|
86
136
|
* like to a caller that only knows the `Systemd.Unit` shape (a unit that has genuinely never been
|
|
87
137
|
* declared also reads `not-found`).
|
|
138
|
+
*
|
|
139
|
+
* โ THE FRAGMENTPATH CHECK BELOW WAS ADDED โ found on adversarial review of `assertUnshadowed`
|
|
140
|
+
* (container-preflight.ts): before it, this function trusted `LoadState`/`SourcePath` alone, and
|
|
141
|
+
* neither one notices a genuinely-shadowing PLAIN unit. A real, already-loaded plain unit (e.g.
|
|
142
|
+
* an admin's own `/etc/systemd/system/foo.service` sharing this container's service name) reports
|
|
143
|
+
* `LoadState=loaded` โ it is a valid unit โ and `SourcePath` empty, since hand-written units never
|
|
144
|
+
* carry one; both checks above pass it silently. MEASURED directly against this function: a
|
|
145
|
+
* `status` built from exactly what `systemctl show` reports for that case (`loadState: 'loaded'`,
|
|
146
|
+
* `fragmentPath` under `/etc/systemd/system`, `sourcePath: undefined`) did not throw before this
|
|
147
|
+
* check existed. Without it, `assertUnshadowed`'s plan-time refusal was the ONLY thing standing
|
|
148
|
+
* between a shadowing plain unit and a SILENT apply-time "success" that leaves the pre-existing
|
|
149
|
+
* unit running untouched while state records attributes read back from it โ not a loud mid-apply
|
|
150
|
+
* throw, which is what earlier documentation of this fix claimed the pre-fix risk was.
|
|
88
151
|
*/
|
|
89
152
|
export const verifyGenerated = (
|
|
90
153
|
name: string,
|
|
@@ -102,6 +165,15 @@ export const verifyGenerated = (
|
|
|
102
165
|
`'systemd-analyze --generators=true verify ${serviceName}' (podman-systemd.unit(5), Podman 5.4).`,
|
|
103
166
|
);
|
|
104
167
|
}
|
|
168
|
+
if (status.fragmentPath !== undefined && !isGeneratorFragment(status.fragmentPath)) {
|
|
169
|
+
throw new QuadletGeneratorError(
|
|
170
|
+
name,
|
|
171
|
+
`${serviceName} is loaded from ${status.fragmentPath}, not from Quadletโs generator ` +
|
|
172
|
+
`(${GENERATED_UNIT_DIRECTORY}/โฆ) โ daemon-reload ran, but a plain unit at that path still ` +
|
|
173
|
+
'answers to this name, and systemd loaded IT, not ours. Move the plain unit aside โ a ' +
|
|
174
|
+
'cutover โ and redeploy.',
|
|
175
|
+
);
|
|
176
|
+
}
|
|
105
177
|
if (status.sourcePath !== undefined && status.sourcePath !== containerPath) {
|
|
106
178
|
throw new QuadletGeneratorError(
|
|
107
179
|
name,
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* like it, cares most about the NEGATIVE assertions: an adopted container that already matches is
|
|
4
4
|
* not restarted, a re-run restarts nothing, and a read that fails writes nothing. Generator-failure
|
|
5
5
|
* rollback and the never-enable guarantee are in container-generator.test.ts; secret refusal is in
|
|
6
|
-
* container-secrets.test.ts; the CT100 fixture is in container-fixture.test.ts
|
|
6
|
+
* container-secrets.test.ts; the CT100 fixture is in container-fixture.test.ts; a plain unit
|
|
7
|
+
* shadowing the service name (`assertUnshadowed`) is in container-shadow.test.ts.
|
|
7
8
|
*/
|
|
8
9
|
import { describe, expect, test } from 'bun:test';
|
|
9
10
|
import { fakeQuadletHost } from './fake-quadlet.ts';
|
|
@@ -52,7 +53,7 @@ describe('create', () => {
|
|
|
52
53
|
expect(fake.files.get(PATH)?.uid).toBe(0);
|
|
53
54
|
});
|
|
54
55
|
|
|
55
|
-
test('a start that fails removes the file this deploy wrote and
|
|
56
|
+
test('a start that fails removes the file this deploy wrote and reports the read-back state', async () => {
|
|
56
57
|
const fake = host();
|
|
57
58
|
const broken = {
|
|
58
59
|
...fake.runner,
|
|
@@ -61,8 +62,10 @@ describe('create', () => {
|
|
|
61
62
|
? { exitCode: 1, stderr: 'Job failed', stdout: '' }
|
|
62
63
|
: fake.runner.exec(argv),
|
|
63
64
|
};
|
|
65
|
+
// โ
Not a hardcoded "NOT running" claim (measured false for a refusal-before-sudo failure,
|
|
66
|
+
// container-settle.ts's header) โ a genuine read-back of what systemd now reports.
|
|
64
67
|
await expect(reconcileContainer(broken, props('example/one:1'), undefined)).rejects.toThrow(
|
|
65
|
-
/
|
|
68
|
+
/unit is now inactive/,
|
|
66
69
|
);
|
|
67
70
|
expect(fake.files.has(PATH)).toBe(false);
|
|
68
71
|
});
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
import {
|
|
37
37
|
assertMayWrite,
|
|
38
38
|
assertReplaceable,
|
|
39
|
+
assertUnshadowed,
|
|
39
40
|
assertUsable,
|
|
40
41
|
assertValid,
|
|
41
42
|
} from './container-preflight.ts';
|
|
@@ -44,7 +45,24 @@ import { attributesOf, settle } from './container-settle.ts';
|
|
|
44
45
|
const encoder = new TextEncoder();
|
|
45
46
|
const decoder = new TextDecoder();
|
|
46
47
|
|
|
47
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* What is on the host now: the `.container` file's digest and the GENERATED unit's status.
|
|
50
|
+
*
|
|
51
|
+
* โ A FILE-LESS `known` UNIT IS NOT NECESSARILY OURS โ found on adversarial review. The OLD test
|
|
52
|
+
* here was `bytes === undefined && !status.known`: absent file, unknown unit โ nothing. But a
|
|
53
|
+
* PLAIN unit under `/etc/systemd/system` with the same service name also makes `status.known`
|
|
54
|
+
* true, with no `.container` file behind it at all โ the read then reported a container that has
|
|
55
|
+
* never been declared as "exists", and a plan built on that said "adopted" for something a create
|
|
56
|
+
* would later fail to make real (`assertUnshadowed`, container-preflight.ts, has the measured
|
|
57
|
+
* precedence fact and the plan-time refusal). โ `/usr/lib/systemd/system` is DELIBERATELY NOT
|
|
58
|
+
* named alongside `/etc/systemd/system` here โ corrected on adversarial re-review: it is LOWER
|
|
59
|
+
* precedence than Quadlet's generator, not higher (`isShadowingFragment`, container-generator.ts),
|
|
60
|
+
* so a plain unit there is exists-but-not-a-shadow: `readContainer` reports it as adopted, same as
|
|
61
|
+
* before, and a create/update writes our `.container` file, which then wins for real. A REAL
|
|
62
|
+
* Quadlet generation (this or an earlier apply's own file, later removed) reads no differently
|
|
63
|
+
* here than before: its `FragmentPath` sits under Quadlet's own generator directory, which
|
|
64
|
+
* `assertUnshadowed` never refuses.
|
|
65
|
+
*/
|
|
48
66
|
export const readContainer = async (
|
|
49
67
|
runner: HostRunner,
|
|
50
68
|
props: ContainerProps,
|
|
@@ -52,7 +70,10 @@ export const readContainer = async (
|
|
|
52
70
|
const path = containerPathFor(props);
|
|
53
71
|
const bytes = await runner.readFile(path);
|
|
54
72
|
const status = await showUnit(runner, serviceNameFor(props));
|
|
55
|
-
if (bytes === undefined
|
|
73
|
+
if (bytes === undefined) {
|
|
74
|
+
if (!status.known) return undefined;
|
|
75
|
+
assertUnshadowed(props, status);
|
|
76
|
+
}
|
|
56
77
|
const sha = bytes === undefined ? '' : digestOf(decoder.decode(bytes));
|
|
57
78
|
return attributesOf(
|
|
58
79
|
props,
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { type HostRunner, canActAsRoot } from '../launchd/runner.ts';
|
|
10
10
|
import { UNIT_WRITE, digestOf } from './unit-form.ts';
|
|
11
11
|
import { type UnitStatus, showUnit } from './systemctl.ts';
|
|
12
|
+
import { GENERATED_UNIT_DIRECTORY, isShadowingFragment } from './container-generator.ts';
|
|
12
13
|
import {
|
|
13
14
|
type ContainerAttributes,
|
|
14
15
|
type ContainerProps,
|
|
@@ -73,6 +74,52 @@ export const assertUnclaimed = async (runner: HostRunner, props: ContainerProps)
|
|
|
73
74
|
}
|
|
74
75
|
};
|
|
75
76
|
|
|
77
|
+
/**
|
|
78
|
+
* โ A PLAIN UNIT SHARING THE SERVICE NAME CAN OUTRANK QUADLET'S GENERATOR, SILENTLY โ but only
|
|
79
|
+
* when it sits in one of the directories that actually search BEFORE `${GENERATED_UNIT_DIRECTORY}`
|
|
80
|
+
* in systemd's own unit load path (systemd.unit(5) "Unit File Load Path") โ `/etc/systemd/system`
|
|
81
|
+
* chief among them (`isShadowingFragment`, container-generator.ts, has the full list and the
|
|
82
|
+
* measured facts). โ CORRECTED ON ADVERSARIAL RE-REVIEW: an EARLIER version of this check refused
|
|
83
|
+
* for ANY `FragmentPath` not literally under the generator directory, including the vendor
|
|
84
|
+
* directories (`/usr/local/lib/systemd/system`, `/usr/lib/systemd/system`) โ those are LOWER
|
|
85
|
+
* precedence than the generator, not higher, so a plain unit there is harmlessly shadowed BY our
|
|
86
|
+
* generated unit once we write and reload; refusing for it was a false positive that blocked a
|
|
87
|
+
* create/adopt apply would have handled fine (exactly what S49 "neither looser nor stricter"
|
|
88
|
+
* forbids). With no `.container` file of ours on disk yet, a `FragmentPath` under one of the
|
|
89
|
+
* genuinely higher-precedence directories means some OTHER unit file โ hand-written, or shipped by
|
|
90
|
+
* a package, and admin-controlled rather than vendor-controlled โ already answers to this name:
|
|
91
|
+
* writing and reloading would generate a unit systemd never actually loads, because the
|
|
92
|
+
* higher-precedence plain one still wins.
|
|
93
|
+
* โ
TWIN OF `unit-preflight.ts`'s `assertUnclaimed`, for the one failure mode `Systemd.Unit` can't
|
|
94
|
+
* have: there is no generator standing between ITS file and the unit systemd loads, so nothing
|
|
95
|
+
* there can be shadowed the way a Quadlet generation can.
|
|
96
|
+
* โ
ONLY WHEN THE `.container` FILE ITSELF IS ABSENT (called from `readContainer` in that branch):
|
|
97
|
+
* a present file with different content is `assertUnclaimed`'s job; a present file that IS ours
|
|
98
|
+
* generating a shadowed unit is `verifyGenerated`'s job (container-generator.ts, hardened to check
|
|
99
|
+
* this same `FragmentPath` fact), at apply time, once daemon-reload has actually run โ this is the
|
|
100
|
+
* "nothing declared yet" half neither of those covers, and the one this bug leaves refusing only
|
|
101
|
+
* mid-apply instead of at plan time for the directories that genuinely shadow.
|
|
102
|
+
* โ ๏ธ NEVER FIRES WITHOUT A CONCRETE `fragmentPath` TO NAME, AND NEVER FOR A MASKED UNIT โ MEASURED
|
|
103
|
+
* against this family's own fake (`fakeQuadletHost`, unlike `sudo-lifecycle.test.ts`'s, which is
|
|
104
|
+
* a different fake and reads differently): `placeUnit(path, text, {masked: true})` still reports
|
|
105
|
+
* a real `FragmentPath`. Refusing there anyway would work, but with THIS function's generic
|
|
106
|
+
* "plain unit" wording instead of `assertUsable`'s specific, more actionable "is masked, run
|
|
107
|
+
* `systemctl unmask` deliberately" โ masking is a person's decision, not an accident to explain
|
|
108
|
+
* as a name collision. `assertUsable` stays the one place that message comes from.
|
|
109
|
+
*/
|
|
110
|
+
export const assertUnshadowed = (props: Pick<ContainerProps, 'name'>, status: UnitStatus): void => {
|
|
111
|
+
if (!status.known || status.fragmentPath === undefined) return;
|
|
112
|
+
if (status.loadState === 'masked' || status.unitFileState === 'masked') return;
|
|
113
|
+
if (!isShadowingFragment(status.fragmentPath)) return;
|
|
114
|
+
throw refuseContainer(
|
|
115
|
+
props.name,
|
|
116
|
+
`${serviceNameFor(props)} already exists as a plain unit at ${status.fragmentPath}, not a ` +
|
|
117
|
+
`Quadlet generation โ Quadlet's own output (${GENERATED_UNIT_DIRECTORY}/โฆ) would be ` +
|
|
118
|
+
'shadowed by it and never actually run: systemd loads the higher-precedence file first. ' +
|
|
119
|
+
'Move the plain unit aside โ a cutover โ before declaring this container.',
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
|
|
76
123
|
/** The half of a rename check that needs only the NEW NAME โ mirrors `assertRenameTarget`. */
|
|
77
124
|
export const assertRenameTarget = async (
|
|
78
125
|
runner: HostRunner,
|
|
@@ -79,7 +79,18 @@ export const settle = async (
|
|
|
79
79
|
await runner.removeFile(step.path).catch(() => undefined);
|
|
80
80
|
await daemonReload(runner).catch(() => undefined);
|
|
81
81
|
}
|
|
82
|
-
|
|
82
|
+
// ๐ด MEASURED FALSE, CT100 deploy 2026-09-26 00:11Z: this suffix used to assert "is NOT
|
|
83
|
+
// running" unconditionally โ even for a `detail` that was a REFUSAL before sudo ever ran
|
|
84
|
+
// (the sudo ownership check on a Quadlet unit, say), where nothing about the running
|
|
85
|
+
// container was touched. `caddy` stayed active/running through the whole failed deploy.
|
|
86
|
+
// State only what this call actually knows: read the live state back, best-effort, rather
|
|
87
|
+
// than assert either way.
|
|
88
|
+
const after = await showUnit(runner, step.serviceName).catch(() => undefined);
|
|
89
|
+
const stateNote =
|
|
90
|
+
after === undefined
|
|
91
|
+
? 'Its running state was not re-checked.'
|
|
92
|
+
: `Its unit is now ${after.activeState}.`;
|
|
93
|
+
throw refuseContainer(props.name, `${detail} ${stateNote}`);
|
|
83
94
|
};
|
|
84
95
|
try {
|
|
85
96
|
if (!wantStarted) {
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Podman.Container`'s read/plan behaviour when a PLAIN unit โ never Quadlet's own generator
|
|
3
|
+
* output โ already answers to the service name this resource would generate. Split from
|
|
4
|
+
* container-lifecycle.test.ts (see its header) because this is its own, self-contained scenario:
|
|
5
|
+
* container-preflight.ts's `assertUnshadowed`, called from `readContainer`.
|
|
6
|
+
*
|
|
7
|
+
* โ
WHY `readContainer` IS THE RIGHT LEVEL TO TEST. Alchemy's own cold-start adoption probe calls
|
|
8
|
+
* `provider.read` directly to decide "create" vs "adopted" BEFORE `diff` ever runs (alchemy
|
|
9
|
+
* beta.79 `Plan.ts` โ no state row exists yet, so there is nothing to diff against). Our
|
|
10
|
+
* provider's `read` (`readHandler`, container-handlers.ts) is a thin wrapper over
|
|
11
|
+
* `readContainer`, so a refusal here IS the plan refusing, not merely a lower layer.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
import { fakeQuadletHost } from './fake-quadlet.ts';
|
|
15
|
+
import type { ContainerProps } from './container-form.ts';
|
|
16
|
+
import { readContainer, reconcileContainer } from './container-lifecycle.ts';
|
|
17
|
+
|
|
18
|
+
const NAME = 'hf-example';
|
|
19
|
+
const PATH = `/etc/containers/systemd/${NAME}.container`;
|
|
20
|
+
const SERVICE = `${NAME}.service`;
|
|
21
|
+
const PLAIN_UNIT_PATH = `/etc/systemd/system/${SERVICE}`;
|
|
22
|
+
|
|
23
|
+
const props: ContainerProps = {
|
|
24
|
+
container: { image: 'example/one:1', network: 'host' },
|
|
25
|
+
install: { wantedBy: ['multi-user.target'] },
|
|
26
|
+
name: NAME,
|
|
27
|
+
service: { restart: 'always', restartSec: '5s' },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** A unit systemd loaded from somewhere OTHER than Quadlet's own generator โ e.g. a PLAIN unit
|
|
31
|
+
* dropped in `/etc/systemd/system`, the case `assertUnshadowed` exists for. */
|
|
32
|
+
const placePlainUnit = (fake: ReturnType<typeof fakeQuadletHost>) =>
|
|
33
|
+
fake.placeUnit(PLAIN_UNIT_PATH, '[Service]\nExecStart=/usr/bin/true\n', { active: true });
|
|
34
|
+
|
|
35
|
+
describe('a plain unit already answers to the name, and our .container file is absent', () => {
|
|
36
|
+
test('the read refuses instead of reporting the container exists', async () => {
|
|
37
|
+
const fake = fakeQuadletHost();
|
|
38
|
+
placePlainUnit(fake);
|
|
39
|
+
await expect(readContainer(fake.runner, props)).rejects.toThrow(PLAIN_UNIT_PATH);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('the refusal names Podman.Container, the unit and the fix โ not a generic crash', async () => {
|
|
43
|
+
const fake = fakeQuadletHost();
|
|
44
|
+
placePlainUnit(fake);
|
|
45
|
+
await expect(readContainer(fake.runner, props)).rejects.toThrow(
|
|
46
|
+
/Podman\.Container hf-example:.*hf-example\.service.*plain unit.*move the plain unit aside/is,
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('nothing was written before the refusal โ a plan promises this, it never risks it', async () => {
|
|
51
|
+
const fake = fakeQuadletHost();
|
|
52
|
+
placePlainUnit(fake);
|
|
53
|
+
await expect(readContainer(fake.runner, props)).rejects.toThrow();
|
|
54
|
+
expect(fake.files.has(PATH)).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('a masked unit keeps its own, more specific refusal', () => {
|
|
59
|
+
test('the read does not report it as a shadowing plain unit', async () => {
|
|
60
|
+
const fake = fakeQuadletHost();
|
|
61
|
+
fake.placeUnit(PLAIN_UNIT_PATH, '[Service]\nExecStart=/usr/bin/true\n', {
|
|
62
|
+
active: false,
|
|
63
|
+
masked: true,
|
|
64
|
+
});
|
|
65
|
+
// Masked still has a real FragmentPath on this fake (measured directly โ see
|
|
66
|
+
// container-preflight.ts's `assertUnshadowed`), so this proves the exemption, not an absence.
|
|
67
|
+
const attrs = await readContainer(fake.runner, props);
|
|
68
|
+
expect(attrs).toMatchObject({ loadState: 'masked', name: NAME });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('adopting it still refuses, with the masked message, not the shadow one', async () => {
|
|
72
|
+
const fake = fakeQuadletHost();
|
|
73
|
+
fake.placeUnit(PLAIN_UNIT_PATH, '[Service]\nExecStart=/usr/bin/true\n', {
|
|
74
|
+
active: false,
|
|
75
|
+
masked: true,
|
|
76
|
+
});
|
|
77
|
+
await expect(reconcileContainer(fake.runner, props, undefined, true)).rejects.toThrow(
|
|
78
|
+
/is masked/,
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('a real Quadlet generation is unaffected', () => {
|
|
84
|
+
test('FragmentPath under the generator directory still reads as existing', async () => {
|
|
85
|
+
const fake = fakeQuadletHost();
|
|
86
|
+
await reconcileContainer(fake.runner, props, undefined);
|
|
87
|
+
// The source file is gone but the LAST generation is still on disk, unreloaded โ exactly what
|
|
88
|
+
// a manual `rm` of the `.container` file leaves behind before the next `daemon-reload`.
|
|
89
|
+
fake.files.delete(PATH);
|
|
90
|
+
const attrs = await readContainer(fake.runner, props);
|
|
91
|
+
expect(attrs).toMatchObject({ containerPath: PATH, name: NAME, serviceName: SERVICE });
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('neither a file nor a unit exists', () => {
|
|
96
|
+
test('read answers undefined, so a plan says create โ never a refusal', async () => {
|
|
97
|
+
const fake = fakeQuadletHost();
|
|
98
|
+
expect(await readContainer(fake.runner, props)).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* โ REGRESSION FOR A FALSE POSITIVE โ found on adversarial review. An earlier version of
|
|
104
|
+
* `assertUnshadowed` refused for ANY `FragmentPath` not literally under the generator directory,
|
|
105
|
+
* including a vendor unit at `/usr/lib/systemd/system` โ which is LOWER precedence than
|
|
106
|
+
* Quadlet's generator (`isShadowingFragment`, container-generator.ts, has the measured order), so
|
|
107
|
+
* writing and reloading our `.container` file would actually win the name, same as any other
|
|
108
|
+
* cutover from a package-installed daemon to a Podman container. Refusing there blocked exactly
|
|
109
|
+
* the create/adopt an apply would have handled fine.
|
|
110
|
+
*/
|
|
111
|
+
describe('a plain unit at a directory LOWER precedence than the generator is not a shadow', () => {
|
|
112
|
+
test('a vendor unit at /usr/lib/systemd/system does not refuse โ it reads as adopted', async () => {
|
|
113
|
+
const fake = fakeQuadletHost();
|
|
114
|
+
fake.placeUnit(`/usr/lib/systemd/system/${SERVICE}`, '[Service]\nExecStart=/usr/bin/true\n', {
|
|
115
|
+
active: true,
|
|
116
|
+
});
|
|
117
|
+
const attrs = await readContainer(fake.runner, props);
|
|
118
|
+
expect(attrs).toMatchObject({ name: NAME, serviceName: SERVICE });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('a vendor unit at /usr/local/lib/systemd/system does not refuse either', async () => {
|
|
122
|
+
const fake = fakeQuadletHost();
|
|
123
|
+
fake.placeUnit(
|
|
124
|
+
`/usr/local/lib/systemd/system/${SERVICE}`,
|
|
125
|
+
'[Service]\nExecStart=/usr/bin/true\n',
|
|
126
|
+
{ active: true },
|
|
127
|
+
);
|
|
128
|
+
const attrs = await readContainer(fake.runner, props);
|
|
129
|
+
expect(attrs).toMatchObject({ name: NAME, serviceName: SERVICE });
|
|
130
|
+
});
|
|
131
|
+
});
|
package/src/linux/fake-sudo.ts
CHANGED
|
@@ -12,10 +12,15 @@
|
|
|
12
12
|
* `mktemp -d` registers a fresh 0700 directory the operator owns.
|
|
13
13
|
* โ
SUDO REFUSES A CALL THAT WAS NOT LOGGED FIRST, so every test holds the runner to "logged
|
|
14
14
|
* before it runs", exactly as the Mac fake does.
|
|
15
|
+
* โ
`hostFactory`/`prefixes` (below) let sudo-lifecycle.test.ts drive `fakeQuadletHost` (not just
|
|
16
|
+
* the plain `fakeLinuxHost`) through this SAME elevation wiring, for the Podman.Container-through
|
|
17
|
+
* -sshSudoRunner regression tests โ `fakeQuadletHost` returns the exact shape `fakeLinuxHost`
|
|
18
|
+
* does (`FakeLinuxOptions` in, the same fields out), so every handler below that reads
|
|
19
|
+
* `fake.dirs`/`fake.modes`/`fake.files` or calls `fake.runner.exec` works unchanged either way.
|
|
15
20
|
*/
|
|
16
21
|
import type { ExecResult, FileStat, HostRunner } from '../launchd/runner.ts';
|
|
17
22
|
import { SUDO_SAYS } from '../launchd/fake-sudo.ts';
|
|
18
|
-
import { fakeLinuxHost } from './fake-linux-host.ts';
|
|
23
|
+
import { type FakeLinuxOptions, fakeLinuxHost } from './fake-linux-host.ts';
|
|
19
24
|
import { type RootState, asRoot } from './fake-sudo-root.ts';
|
|
20
25
|
import { SUDO } from './sudo-allowlist.ts';
|
|
21
26
|
import { makeSshSudoRunner } from './sudo-runner.ts';
|
|
@@ -49,17 +54,28 @@ const lsLine = (path: string, stat: FileStat, acl: boolean): string => {
|
|
|
49
54
|
);
|
|
50
55
|
};
|
|
51
56
|
|
|
57
|
+
export type FakeSudoHostOptions = {
|
|
58
|
+
/** Swap `fakeLinuxHost` for a variant that models more than plain units โ `fakeQuadletHost`. */
|
|
59
|
+
readonly hostFactory?: (options: FakeLinuxOptions) => ReturnType<typeof fakeLinuxHost>;
|
|
60
|
+
/** The runner's own declared prefixes. @default PREFIXES */
|
|
61
|
+
readonly prefixes?: readonly string[];
|
|
62
|
+
};
|
|
63
|
+
|
|
52
64
|
/**
|
|
53
65
|
* `extraDirs` adds more directories to the fake filesystem (a pre-existing tree outside every
|
|
54
|
-
* prefix, say); it never changes the runner's OWN prefixes, which
|
|
66
|
+
* prefix, say); it never changes the runner's OWN prefixes, which default to `PREFIXES`.
|
|
55
67
|
* `acl` is the set of directory paths a test wants to carry the `+` (POSIX ACL) flag.
|
|
56
68
|
*/
|
|
57
69
|
export const fakeSudoHost = (
|
|
58
70
|
extraDirs: Record<string, number> = {},
|
|
59
71
|
acl: ReadonlySet<string> = new Set(),
|
|
72
|
+
{ hostFactory = fakeLinuxHost, prefixes = PREFIXES }: FakeSudoHostOptions = {},
|
|
60
73
|
) => {
|
|
61
|
-
const
|
|
62
|
-
|
|
74
|
+
const dirs: Record<string, number> = {};
|
|
75
|
+
for (const prefix of prefixes) dirs[prefix] = 0;
|
|
76
|
+
for (const [path, owner] of Object.entries(extraDirs)) dirs[path] = owner;
|
|
77
|
+
const fake = hostFactory({
|
|
78
|
+
dirs,
|
|
63
79
|
euid: OPERATOR,
|
|
64
80
|
users: { app: { gid: 60, home: '/opt/app', uid: 900 } },
|
|
65
81
|
});
|
|
@@ -127,7 +143,7 @@ export const fakeSudoHost = (
|
|
|
127
143
|
},
|
|
128
144
|
};
|
|
129
145
|
|
|
130
|
-
const runner = makeSshSudoRunner(
|
|
146
|
+
const runner = makeSshSudoRunner(prefixes, {
|
|
131
147
|
base,
|
|
132
148
|
log: (line) => logs.push(line),
|
|
133
149
|
// โ
The real stager, unmodified: it only ever calls `base.exec`/`base.writeFileAtomic`, both
|