@homeflare/alchemy 0.31.1 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -7
- package/dist/grafana/index.d.ts +6 -5
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/linux/container-form.d.ts +129 -0
- package/dist/linux/container-form.d.ts.map +1 -0
- package/dist/linux/container-generator.d.ts +74 -0
- package/dist/linux/container-generator.d.ts.map +1 -0
- package/dist/linux/container-handlers.d.ts +15 -0
- package/dist/linux/container-handlers.d.ts.map +1 -0
- package/dist/linux/container-lifecycle.d.ts +37 -0
- package/dist/linux/container-lifecycle.d.ts.map +1 -0
- package/dist/linux/container-preflight.d.ts +34 -0
- package/dist/linux/container-preflight.d.ts.map +1 -0
- package/dist/linux/container-secrets.d.ts +37 -0
- package/dist/linux/container-secrets.d.ts.map +1 -0
- package/dist/linux/container-settle.d.ts +34 -0
- package/dist/linux/container-settle.d.ts.map +1 -0
- package/dist/linux/container.d.ts +30 -0
- package/dist/linux/container.d.ts.map +1 -0
- package/dist/linux/fake-quadlet.d.ts +25 -0
- package/dist/linux/fake-quadlet.d.ts.map +1 -0
- package/dist/linux/index.d.ts +6 -3
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +641 -268
- package/dist/linux/index.js.map +15 -7
- package/dist/linux/providers.d.ts +1 -1
- package/dist/linux/providers.d.ts.map +1 -1
- package/dist/linux/systemctl.d.ts +7 -1
- package/dist/linux/systemctl.d.ts.map +1 -1
- package/dist/opnsense/alias-form.d.ts +29 -0
- package/dist/opnsense/alias-form.d.ts.map +1 -0
- package/dist/opnsense/alias.d.ts +81 -0
- package/dist/opnsense/alias.d.ts.map +1 -0
- package/dist/opnsense/category-form.d.ts +10 -0
- package/dist/opnsense/category-form.d.ts.map +1 -0
- package/dist/opnsense/category.d.ts +58 -0
- package/dist/opnsense/category.d.ts.map +1 -0
- package/dist/opnsense/credentials.d.ts +36 -0
- package/dist/opnsense/credentials.d.ts.map +1 -0
- package/dist/opnsense/fake-opnsense.d.ts +51 -0
- package/dist/opnsense/fake-opnsense.d.ts.map +1 -0
- package/dist/opnsense/group-form.d.ts +11 -0
- package/dist/opnsense/group-form.d.ts.map +1 -0
- package/dist/opnsense/group.d.ts +62 -0
- package/dist/opnsense/group.d.ts.map +1 -0
- package/dist/opnsense/index.d.ts +16 -0
- package/dist/opnsense/index.d.ts.map +1 -0
- package/dist/opnsense/index.js +211 -0
- package/dist/opnsense/index.js.map +20 -0
- package/dist/opnsense/policy.d.ts +20 -0
- package/dist/opnsense/policy.d.ts.map +1 -0
- package/dist/opnsense/providers.d.ts +13 -0
- package/dist/opnsense/providers.d.ts.map +1 -0
- package/dist/opnsense/resource.d.ts +74 -0
- package/dist/opnsense/resource.d.ts.map +1 -0
- package/dist/opnsense/wire.d.ts +39 -0
- package/dist/opnsense/wire.d.ts.map +1 -0
- package/dist/proxmox/acl.d.ts.map +1 -1
- package/dist/proxmox/api-token.d.ts.map +1 -1
- package/dist/proxmox/group-wire.d.ts +44 -5
- package/dist/proxmox/group-wire.d.ts.map +1 -1
- package/dist/proxmox/group.d.ts.map +1 -1
- package/dist/proxmox/index.js +326 -264
- package/dist/proxmox/index.js.map +13 -11
- package/dist/proxmox/role.d.ts.map +1 -1
- package/dist/proxmox/storage-form.d.ts +54 -0
- package/dist/proxmox/storage-form.d.ts.map +1 -0
- package/dist/proxmox/storage-wire.d.ts +56 -0
- package/dist/proxmox/storage-wire.d.ts.map +1 -0
- package/dist/proxmox/storage.d.ts +55 -60
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/proxmox/user-wire.d.ts +34 -5
- package/dist/proxmox/user-wire.d.ts.map +1 -1
- package/dist/proxmox/user.d.ts +6 -6
- package/dist/proxmox/user.d.ts.map +1 -1
- package/dist/unifi/fake-unifi.d.ts +48 -0
- package/dist/unifi/fake-unifi.d.ts.map +1 -0
- package/dist/unifi/firewall-zone-form.d.ts +40 -0
- package/dist/unifi/firewall-zone-form.d.ts.map +1 -0
- package/dist/unifi/firewall-zone.d.ts +50 -0
- package/dist/unifi/firewall-zone.d.ts.map +1 -0
- package/dist/unifi/index.d.ts +13 -0
- package/dist/unifi/index.d.ts.map +1 -0
- package/dist/unifi/index.js +190 -0
- package/dist/unifi/index.js.map +16 -0
- package/dist/unifi/network-form.d.ts +67 -0
- package/dist/unifi/network-form.d.ts.map +1 -0
- package/dist/unifi/network.d.ts +67 -0
- package/dist/unifi/network.d.ts.map +1 -0
- package/dist/unifi/policy.d.ts +20 -0
- package/dist/unifi/policy.d.ts.map +1 -0
- package/dist/unifi/providers.d.ts +13 -0
- package/dist/unifi/providers.d.ts.map +1 -0
- package/dist/unifi/resource.d.ts +64 -0
- package/dist/unifi/resource.d.ts.map +1 -0
- package/dist/verify/fake-engine.d.ts +14 -0
- package/dist/verify/fake-engine.d.ts.map +1 -1
- package/docs/grafana.md +47 -17
- package/docs/opnsense.md +146 -0
- package/docs/provider-standard.md +1 -1
- package/docs/quadlet-container.md +132 -0
- package/docs/unifi.md +93 -0
- package/docs/upstream-conformance.md +14 -4
- package/package.json +13 -3
- package/src/grafana/index.ts +6 -5
- package/src/linux/container-fixture.test.ts +92 -0
- package/src/linux/container-form.ts +243 -0
- package/src/linux/container-generator.test.ts +195 -0
- package/src/linux/container-generator.ts +113 -0
- package/src/linux/container-handlers.ts +73 -0
- package/src/linux/container-lifecycle.test.ts +209 -0
- package/src/linux/container-lifecycle.ts +217 -0
- package/src/linux/container-preflight.ts +97 -0
- package/src/linux/container-secrets.test.ts +171 -0
- package/src/linux/container-secrets.ts +112 -0
- package/src/linux/container-settle.ts +97 -0
- package/src/linux/container.ts +60 -0
- package/src/linux/fake-quadlet.ts +127 -0
- package/src/linux/index.ts +11 -3
- package/src/linux/providers.ts +2 -0
- package/src/linux/systemctl.ts +9 -0
- package/src/opnsense/alias-form.test.ts +89 -0
- package/src/opnsense/alias-form.ts +53 -0
- package/src/opnsense/alias.test.ts +203 -0
- package/src/opnsense/alias.ts +117 -0
- package/src/opnsense/category-form.test.ts +54 -0
- package/src/opnsense/category-form.ts +20 -0
- package/src/opnsense/category.test.ts +138 -0
- package/src/opnsense/category.ts +82 -0
- package/src/opnsense/credentials.ts +41 -0
- package/src/opnsense/fake-opnsense.ts +79 -0
- package/src/opnsense/group-form.test.ts +75 -0
- package/src/opnsense/group-form.ts +25 -0
- package/src/opnsense/group.test.ts +141 -0
- package/src/opnsense/group.ts +78 -0
- package/src/opnsense/index.ts +45 -0
- package/src/opnsense/policy.ts +49 -0
- package/src/opnsense/providers.ts +21 -0
- package/src/opnsense/resource.ts +146 -0
- package/src/opnsense/wire.ts +56 -0
- package/src/opnsense/write-refusal.test.ts +148 -0
- package/src/proxmox/acl.ts +10 -4
- package/src/proxmox/api-token.ts +20 -12
- package/src/proxmox/group-wire.ts +62 -8
- package/src/proxmox/group.ts +23 -35
- package/src/proxmox/read-failure-propagation.test.ts +183 -0
- package/src/proxmox/role.ts +9 -2
- package/src/proxmox/storage-form.ts +144 -0
- package/src/proxmox/storage-read-failure.test.ts +214 -0
- package/src/proxmox/storage-wire.ts +124 -0
- package/src/proxmox/storage.ts +165 -175
- package/src/proxmox/user-wire.ts +44 -8
- package/src/proxmox/user.ts +29 -27
- package/src/unifi/fake-unifi.ts +60 -0
- package/src/unifi/firewall-zone-form.ts +65 -0
- package/src/unifi/firewall-zone.test.ts +88 -0
- package/src/unifi/firewall-zone.ts +66 -0
- package/src/unifi/index.ts +26 -0
- package/src/unifi/network-form.ts +207 -0
- package/src/unifi/network.test.ts +209 -0
- package/src/unifi/network.ts +83 -0
- package/src/unifi/policy.ts +37 -0
- package/src/unifi/providers.ts +15 -0
- package/src/unifi/resource.test.ts +133 -0
- package/src/unifi/resource.ts +131 -0
- package/src/verify/fake-engine.ts +23 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders the SAME directives as CT100's live `caddy.container`
|
|
3
|
+
* (`/etc/containers/systemd/caddy.container`, read read-only over `ssh ct100` 2026-09-24) and checks
|
|
4
|
+
* every line against that fixture.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ NOT BYTE-FOR-BYTE, AND HERE IS EXACTLY WHY NOT. `renderContainerFile` renders a TYPED
|
|
7
|
+
* `ContainerProps` — the whole point of this family (container-form.ts's header) — and CT100's
|
|
8
|
+
* file interleaves long prose comments BETWEEN directives inside `[Unit]` and `[Container]`
|
|
9
|
+
* (why host networking, the OpenBao-unreachable note, the rate-limit warning). There is nowhere
|
|
10
|
+
* in a typed prop to attach "this comment goes between EnvironmentFile= and Environment=": the
|
|
11
|
+
* comments are the file's OWN authored documentation, not data this resource's props carry.
|
|
12
|
+
* `Systemd.Unit` has the identical limit for the same reason — `unit-form.ts`'s `renderUnit`
|
|
13
|
+
* is lines-in-order with no comment slots either. So this fixture proves DIRECTIVE parity
|
|
14
|
+
* (every key, in a stable order, with CT100's exact values) rather than text-diff parity; the
|
|
15
|
+
* `[Unit]`/`[Service]`/`[Install]` VALUES below are copied verbatim from CT100's file, and the
|
|
16
|
+
* `[Container]` ones from CT100's `[X-Container]` section (Quadlet 5.x renders that heading;
|
|
17
|
+
* this family emits the pre-5.x `[Container]` spelling, both documented (podman-systemd.unit(5))).
|
|
18
|
+
*/
|
|
19
|
+
import { expect, test } from 'bun:test';
|
|
20
|
+
import { type ContainerProps, renderContainerFile } from './container-form.ts';
|
|
21
|
+
|
|
22
|
+
// ★ Copied verbatim from CT100's /etc/containers/systemd/caddy.container, 2026-09-24.
|
|
23
|
+
const caddy: ContainerProps = {
|
|
24
|
+
container: {
|
|
25
|
+
containerName: 'caddy',
|
|
26
|
+
environment: { CADDY_CONFIG: '/etc/caddy/Caddyfile' },
|
|
27
|
+
environmentFile: ['/etc/caddy/cf-token.env'],
|
|
28
|
+
image: 'localhost/homeflare/caddy:2.11.4-hf1',
|
|
29
|
+
network: 'host',
|
|
30
|
+
volume: [
|
|
31
|
+
'/etc/caddy/Caddyfile:/etc/caddy/Caddyfile:ro,Z',
|
|
32
|
+
'/etc/caddy/rendered:/etc/caddy/rendered:ro,Z',
|
|
33
|
+
'/etc/caddy/cf-token.env:/run/cf/token.env:ro',
|
|
34
|
+
'caddy-data:/data',
|
|
35
|
+
'caddy-config:/config',
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
install: { wantedBy: ['multi-user.target'] },
|
|
39
|
+
name: 'caddy',
|
|
40
|
+
service: { restart: 'always', restartSec: '5s' },
|
|
41
|
+
unit: {
|
|
42
|
+
description: 'HomeFlare TLS front door (Caddy, DNS-01)',
|
|
43
|
+
documentation:
|
|
44
|
+
'https://gitlab.homeflare.dev/homeflare/homeflare-config/-/tree/main/hosts/ct100/caddy',
|
|
45
|
+
lines: [
|
|
46
|
+
['After', 'network-online.target'],
|
|
47
|
+
['Wants', 'network-online.target'],
|
|
48
|
+
['After', 'caddy-cf-token.service'],
|
|
49
|
+
['Wants', 'caddy-cf-token.service'],
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
test('renders every directive CT100’s live caddy.container carries', () => {
|
|
55
|
+
const rendered = renderContainerFile(caddy);
|
|
56
|
+
const expected = [
|
|
57
|
+
'[Unit]',
|
|
58
|
+
'Description=HomeFlare TLS front door (Caddy, DNS-01)',
|
|
59
|
+
'Documentation=https://gitlab.homeflare.dev/homeflare/homeflare-config/-/tree/main/hosts/ct100/caddy',
|
|
60
|
+
'After=network-online.target',
|
|
61
|
+
'Wants=network-online.target',
|
|
62
|
+
'After=caddy-cf-token.service',
|
|
63
|
+
'Wants=caddy-cf-token.service',
|
|
64
|
+
'[Container]',
|
|
65
|
+
'Image=localhost/homeflare/caddy:2.11.4-hf1',
|
|
66
|
+
'ContainerName=caddy',
|
|
67
|
+
'Network=host',
|
|
68
|
+
'EnvironmentFile=/etc/caddy/cf-token.env',
|
|
69
|
+
'Environment=CADDY_CONFIG=/etc/caddy/Caddyfile',
|
|
70
|
+
'Volume=/etc/caddy/Caddyfile:/etc/caddy/Caddyfile:ro,Z',
|
|
71
|
+
'Volume=/etc/caddy/rendered:/etc/caddy/rendered:ro,Z',
|
|
72
|
+
'Volume=/etc/caddy/cf-token.env:/run/cf/token.env:ro',
|
|
73
|
+
'Volume=caddy-data:/data',
|
|
74
|
+
'Volume=caddy-config:/config',
|
|
75
|
+
'[Service]',
|
|
76
|
+
'Restart=always',
|
|
77
|
+
'RestartSec=5s',
|
|
78
|
+
'[Install]',
|
|
79
|
+
'WantedBy=multi-user.target',
|
|
80
|
+
];
|
|
81
|
+
for (const line of expected) expect(rendered).toContain(line);
|
|
82
|
+
// Every non-blank, non-header line in our render is one of the expected lines — nothing invented.
|
|
83
|
+
const ours = rendered.split('\n').filter((line) => line !== '' && !line.startsWith('['));
|
|
84
|
+
expect(ours.sort()).toEqual(expected.filter((line) => !line.startsWith('[')).sort());
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('no comment lines — the one documented gap, not an accident', () => {
|
|
88
|
+
// Quadlet accepts `#`/`;` comments in a .container file; renderUnit (unit-form.ts) never emits
|
|
89
|
+
// one, in this family or Systemd.Unit's. CT100's own comments live only in the file this
|
|
90
|
+
// resource would MANAGE, never in state — restating them is a future prop, not a bug here.
|
|
91
|
+
expect(renderContainerFile(caddy)).not.toMatch(/^\s*[#;]/m);
|
|
92
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Podman.Container` props, attributes and the `.container` file renderer — the pure half of the
|
|
3
|
+
* family, mirroring unit-form.ts's split for Systemd.Unit.
|
|
4
|
+
*
|
|
5
|
+
* ★ WHY THE `[Container]` SECTION IS TYPED, UNLIKE `Systemd.Unit`'s SECTIONS. unit-form.ts renders
|
|
6
|
+
* `[Unit]`/`[Service]`/`[Install]` verbatim because there is no machine-readable schema of
|
|
7
|
+
* `systemd.unit(5)` to check a key against. Quadlet's `[Container]` section is different: it is
|
|
8
|
+
* ONE vendor's documented, versioned surface — `podman-systemd.unit(5)`, Podman 5.4 (checked
|
|
9
|
+
* against `containers/podman` tag `v5.4.0`,
|
|
10
|
+
* `docs/source/markdown/podman-systemd.unit.5.md`, read 2026-09-24) — so every key below is
|
|
11
|
+
* checked against that page, not invented. `[Unit]`/`[Service]`/`[Install]` stay close to
|
|
12
|
+
* unit-form.ts's own verbatim-lines approach, because THOSE three sections are still systemd's
|
|
13
|
+
* own directive set, which Quadlet passes straight through (same doc, "All options and tables
|
|
14
|
+
* available in standard systemd unit files are supported").
|
|
15
|
+
* ⛔ `[Container] Environment=` IS NEVER A SECRET — enforced in container-secrets.ts, called from
|
|
16
|
+
* `containerProblems` below. A container that needs a secret takes `EnvironmentFile=` pointing at
|
|
17
|
+
* a host path an out-of-band secret renderer (openbao-agent) maintains; this family never reads
|
|
18
|
+
* or renders that file's contents.
|
|
19
|
+
* ⛔ `[Install]` SUPPORTS ONLY `Alias=`/`WantedBy=`/`RequiredBy=` FOR A `.container` FILE (same doc,
|
|
20
|
+
* "Currently, only the Alias, WantedBy and RequiredBy keys are supported") — NOT `Also=`, which
|
|
21
|
+
* plain `systemd.unit(5)` allows and unit-form.ts's `INSTALL_KEYS` therefore lists. See
|
|
22
|
+
* container-generator.ts's header for why there is no `enabled` prop at all.
|
|
23
|
+
*/
|
|
24
|
+
import { type UnitSection, configDigest, digestOf, renderUnit } from './unit-form.ts';
|
|
25
|
+
import { podmanArgsProblems, secretLikeEnvironment, secretLikeLines } from './container-secrets.ts';
|
|
26
|
+
|
|
27
|
+
/** ★ Reused, not redefined: same digest and `restartOn`-join contract as Systemd.Unit. */
|
|
28
|
+
export { configDigest, digestOf };
|
|
29
|
+
|
|
30
|
+
export type UnitLine = readonly [key: string, value: string];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The `[Container]` keys this family supports — a deliberate SUBSET of the ~80 keys
|
|
34
|
+
* `podman-systemd.unit(5)` documents (task scope: `Image`, `ContainerName`, `Network`, `Volume`,
|
|
35
|
+
* `EnvironmentFile`, `Environment`, `PublishPort`, `Exec`, `User`, `AutoUpdate`, `PodmanArgs`).
|
|
36
|
+
* Adding another key means adding a field here and checking it against the same doc — never a
|
|
37
|
+
* free-text escape hatch, or `Environment=` secret-checking (below) could be bypassed through it.
|
|
38
|
+
*/
|
|
39
|
+
export type ContainerSection = {
|
|
40
|
+
/** `Image=`. Required. "recommended to use a fully qualified image name" (doc). */
|
|
41
|
+
readonly image: string;
|
|
42
|
+
/** `ContainerName=`. @default quadlet's own `systemd-<name>` (doc) when omitted. */
|
|
43
|
+
readonly containerName?: string;
|
|
44
|
+
/** `Network=`. MUST support `'host'` — every container this rollout declares runs host netns. */
|
|
45
|
+
readonly network?: string;
|
|
46
|
+
/** `Volume=`, repeatable. */
|
|
47
|
+
readonly volume?: readonly string[];
|
|
48
|
+
/** `EnvironmentFile=`, repeatable. The ONLY way a secret reaches the container — see file header. */
|
|
49
|
+
readonly environmentFile?: readonly string[];
|
|
50
|
+
/** `Environment=`, repeatable `KEY=VALUE`. NEVER a secret — see container-secrets.ts. */
|
|
51
|
+
readonly environment?: Readonly<Record<string, string>>;
|
|
52
|
+
/** `PublishPort=`, repeatable. Refused together with `network: 'host'` — see `containerProblems`. */
|
|
53
|
+
readonly publishPort?: readonly string[];
|
|
54
|
+
/** `Exec=`. Appended after the image's entrypoint, "the same way as ... a Kubernetes pod" (doc). */
|
|
55
|
+
readonly exec?: string;
|
|
56
|
+
/** `User=`. The numeric UID inside the container (doc: "does not need to match the UID on the host"). */
|
|
57
|
+
readonly user?: string;
|
|
58
|
+
/** `AutoUpdate=`. Podman's own two values (doc): `'registry'` or `'local'`. */
|
|
59
|
+
readonly autoUpdate?: 'registry' | 'local';
|
|
60
|
+
/** `PodmanArgs=`, repeatable. Doc: "not recommended" — the generator does not understand it. */
|
|
61
|
+
readonly podmanArgs?: readonly string[];
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type ContainerProps = {
|
|
65
|
+
/** The Quadlet file's basename, WITHOUT `.container`. Written as `<name>.container`. */
|
|
66
|
+
name: string;
|
|
67
|
+
/** @default QUADLET_DEFAULT_DIRECTORY. Must be one of QUADLET_SEARCH_DIRECTORIES (below). */
|
|
68
|
+
directory?: string;
|
|
69
|
+
/** `[Unit]` — `Description=`/`Documentation=` typed, everything else verbatim (see file header). */
|
|
70
|
+
unit?: { description?: string; documentation?: string; lines?: readonly UnitLine[] };
|
|
71
|
+
/** `[Container]` — Quadlet's own, typed and doc-checked. */
|
|
72
|
+
container: ContainerSection;
|
|
73
|
+
/** `[Service]` — `Restart=`/`RestartSec=` typed, everything else verbatim. */
|
|
74
|
+
service?: { restart?: string; restartSec?: string; lines?: readonly UnitLine[] };
|
|
75
|
+
/** `[Install]` — the three keys Quadlet honours for a `.container` file (see file header). */
|
|
76
|
+
install?: {
|
|
77
|
+
wantedBy?: readonly string[];
|
|
78
|
+
requiredBy?: readonly string[];
|
|
79
|
+
alias?: readonly string[];
|
|
80
|
+
};
|
|
81
|
+
/** `systemctl start`/`stop` the GENERATED `<name>.service`. @default true. No `enabled` prop —
|
|
82
|
+
* see container-generator.ts's header for why boot-time enablement is not a systemctl call here. */
|
|
83
|
+
started?: boolean;
|
|
84
|
+
/** Same contract as Systemd.Unit's `restartOn`: digests of things read outside the `.container` file. */
|
|
85
|
+
restartOn?: readonly string[];
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type ContainerAttributes = {
|
|
89
|
+
name: string;
|
|
90
|
+
/** The GENERATED unit's name: `<name>.service`. Never `.container` — systemctl never sees that. */
|
|
91
|
+
serviceName: string;
|
|
92
|
+
containerPath: string;
|
|
93
|
+
/** SHA-256 of the `.container` file this resource wrote. */
|
|
94
|
+
containerSha256: string;
|
|
95
|
+
configSha256: string;
|
|
96
|
+
loadState: string;
|
|
97
|
+
activeState: string;
|
|
98
|
+
unitFileState?: string;
|
|
99
|
+
/** `systemctl show`'s `SourcePath=` on the generated unit — measured on CT100 2026-09-24 to be
|
|
100
|
+
* the `.container` file's own path; used to prove the generated unit is really ours. */
|
|
101
|
+
sourcePath?: string;
|
|
102
|
+
active: boolean;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Podman's OWN rootful search path, most specific first — `podman-systemd.unit(5)`, "Podman Unit
|
|
107
|
+
* Search Path", Podman 5.4, read 2026-09-24: "Quadlet files for the root user can be placed in the
|
|
108
|
+
* following directories ordered in precedence" (`/run` over `/etc` over `/usr/share`).
|
|
109
|
+
* ★ CT100's `caddy.container` lives in the middle one — measured live, `cat` over `ssh ct100`,
|
|
110
|
+
* 2026-09-24: `/etc/containers/systemd/caddy.container`.
|
|
111
|
+
* ⛔ A directory outside this list is invisible to Quadlet: the generator only reads these three, so
|
|
112
|
+
* a file elsewhere is a `.container` file with no `.service` ever generated from it — silently,
|
|
113
|
+
* unlike `Systemd.Unit`'s directory check, which at least writes a unit systemd could load if only
|
|
114
|
+
* it were enabled. See `containerProblems`.
|
|
115
|
+
*/
|
|
116
|
+
export const QUADLET_SEARCH_DIRECTORIES = [
|
|
117
|
+
'/run/containers/systemd',
|
|
118
|
+
'/etc/containers/systemd',
|
|
119
|
+
'/usr/share/containers/systemd',
|
|
120
|
+
] as const;
|
|
121
|
+
|
|
122
|
+
export const QUADLET_DEFAULT_DIRECTORY = '/etc/containers/systemd';
|
|
123
|
+
|
|
124
|
+
/** ★ A conservative subset of what Quadlet itself accepts as a unit name: no template `@`, no `/`. */
|
|
125
|
+
export const NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
126
|
+
|
|
127
|
+
export const containerPathFor = (props: Pick<ContainerProps, 'name' | 'directory'>): string =>
|
|
128
|
+
`${props.directory ?? QUADLET_DEFAULT_DIRECTORY}/${props.name}.container`;
|
|
129
|
+
|
|
130
|
+
/** The unit Quadlet generates FROM the `.container` file — systemctl only ever sees this name. */
|
|
131
|
+
export const serviceNameFor = (props: Pick<ContainerProps, 'name'>): string =>
|
|
132
|
+
`${props.name}.service`;
|
|
133
|
+
|
|
134
|
+
const unitLines = (props: ContainerProps): UnitLine[] => [
|
|
135
|
+
...(props.unit?.description === undefined
|
|
136
|
+
? []
|
|
137
|
+
: [['Description', props.unit.description] as UnitLine]),
|
|
138
|
+
...(props.unit?.documentation === undefined
|
|
139
|
+
? []
|
|
140
|
+
: [['Documentation', props.unit.documentation] as UnitLine]),
|
|
141
|
+
...(props.unit?.lines ?? []),
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
const containerLines = (c: ContainerSection): UnitLine[] => [
|
|
145
|
+
['Image', c.image],
|
|
146
|
+
...(c.containerName === undefined ? [] : [['ContainerName', c.containerName] as UnitLine]),
|
|
147
|
+
...(c.network === undefined ? [] : [['Network', c.network] as UnitLine]),
|
|
148
|
+
...(c.user === undefined ? [] : [['User', c.user] as UnitLine]),
|
|
149
|
+
...(c.exec === undefined ? [] : [['Exec', c.exec] as UnitLine]),
|
|
150
|
+
...(c.autoUpdate === undefined ? [] : [['AutoUpdate', c.autoUpdate] as UnitLine]),
|
|
151
|
+
...(c.environmentFile ?? []).map((file): UnitLine => ['EnvironmentFile', file]),
|
|
152
|
+
// ★ Sorted by key: a stable render for a plain object whose own key order JS does not promise.
|
|
153
|
+
...Object.keys(c.environment ?? {})
|
|
154
|
+
.sort()
|
|
155
|
+
.map((key): UnitLine => [
|
|
156
|
+
'Environment',
|
|
157
|
+
`${key}=${(c.environment as Record<string, string>)[key]}`,
|
|
158
|
+
]),
|
|
159
|
+
...(c.volume ?? []).map((volume): UnitLine => ['Volume', volume]),
|
|
160
|
+
...(c.publishPort ?? []).map((port): UnitLine => ['PublishPort', port]),
|
|
161
|
+
...(c.podmanArgs ?? []).map((arg): UnitLine => ['PodmanArgs', arg]),
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
const serviceLines = (props: ContainerProps): UnitLine[] => [
|
|
165
|
+
...(props.service?.restart === undefined ? [] : [['Restart', props.service.restart] as UnitLine]),
|
|
166
|
+
...(props.service?.restartSec === undefined
|
|
167
|
+
? []
|
|
168
|
+
: [['RestartSec', props.service.restartSec] as UnitLine]),
|
|
169
|
+
...(props.service?.lines ?? []),
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
const installLines = (props: ContainerProps): UnitLine[] => [
|
|
173
|
+
...(props.install?.wantedBy ?? []).map((target): UnitLine => ['WantedBy', target]),
|
|
174
|
+
...(props.install?.requiredBy ?? []).map((target): UnitLine => ['RequiredBy', target]),
|
|
175
|
+
...(props.install?.alias ?? []).map((name): UnitLine => ['Alias', name]),
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
/** The whole `.container` file. ★ Reuses unit-form.ts's `renderUnit` — same `[Section]` INI shape. */
|
|
179
|
+
export const renderContainerFile = (props: ContainerProps): string => {
|
|
180
|
+
const sections: UnitSection[] = [
|
|
181
|
+
{ name: 'Unit', lines: unitLines(props) },
|
|
182
|
+
{ name: 'Container', lines: containerLines(props.container) },
|
|
183
|
+
{ name: 'Service', lines: serviceLines(props) },
|
|
184
|
+
{ name: 'Install', lines: installLines(props) },
|
|
185
|
+
];
|
|
186
|
+
return renderUnit(sections.filter((section) => section.lines.length > 0));
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export const refuseContainer = (name: string, message: string): Error =>
|
|
190
|
+
new Error(`Podman.Container ${name}: ${message}`);
|
|
191
|
+
|
|
192
|
+
/** Every refusal at once, so one plan shows the whole list — mirrors `unitProblems`'s shape. */
|
|
193
|
+
export const containerProblems = (props: ContainerProps): string[] => {
|
|
194
|
+
const found: string[] = [];
|
|
195
|
+
if (!NAME.test(props.name)) {
|
|
196
|
+
found.push(
|
|
197
|
+
`name must match ${String(NAME)}, with no '.container' suffix; got ${JSON.stringify(props.name)}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
const directory = props.directory ?? QUADLET_DEFAULT_DIRECTORY;
|
|
201
|
+
if (!directory.startsWith('/') || directory.endsWith('/') || directory.includes('/..')) {
|
|
202
|
+
found.push('directory must be an absolute, normalised path with no trailing slash');
|
|
203
|
+
} else if (
|
|
204
|
+
!QUADLET_SEARCH_DIRECTORIES.includes(directory as (typeof QUADLET_SEARCH_DIRECTORIES)[number])
|
|
205
|
+
) {
|
|
206
|
+
found.push(
|
|
207
|
+
`directory must be one of Quadlet's own rootful search path (${QUADLET_SEARCH_DIRECTORIES.join(', ')}); ` +
|
|
208
|
+
`${directory} is never read by the generator (podman-systemd.unit(5), Podman 5.4).`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
if (props.container.image.trim() === '') found.push('container.image is required');
|
|
212
|
+
if (props.container.network === 'host' && (props.container.publishPort?.length ?? 0) > 0) {
|
|
213
|
+
found.push(
|
|
214
|
+
"container.publishPort has no effect with container.network 'host': the container already " +
|
|
215
|
+
'binds the host’s ports directly. Drop publishPort, or use a different network.',
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
found.push(
|
|
219
|
+
...secretLikeEnvironment(props.container.environment ?? {}).map(
|
|
220
|
+
(problem) => `container.${problem}`,
|
|
221
|
+
),
|
|
222
|
+
);
|
|
223
|
+
found.push(
|
|
224
|
+
...podmanArgsProblems(props.container.podmanArgs ?? []).map(
|
|
225
|
+
(problem) => `container.${problem}`,
|
|
226
|
+
),
|
|
227
|
+
);
|
|
228
|
+
// ★ Every OTHER rendered line — `Exec=`/`PodmanArgs=` and the `unit`/`service`/`install` escape
|
|
229
|
+
// hatches — checked too, so a secret cannot reach the file through a field this typed check does
|
|
230
|
+
// not otherwise inspect. `container.environment`'s OWN lines are excluded: `secretLikeEnvironment`
|
|
231
|
+
// above already checked them, with a better error message (the actual env var name).
|
|
232
|
+
found.push(
|
|
233
|
+
...secretLikeLines([
|
|
234
|
+
...unitLines(props),
|
|
235
|
+
...containerLines(props.container).filter(([key]) => key !== 'Environment'),
|
|
236
|
+
...serviceLines(props),
|
|
237
|
+
...installLines(props),
|
|
238
|
+
]),
|
|
239
|
+
);
|
|
240
|
+
if (renderContainerFile(props).includes('\u0000'))
|
|
241
|
+
found.push('the rendered .container file contains NUL');
|
|
242
|
+
return found;
|
|
243
|
+
};
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two things container-generator.ts's header stakes the whole design on:
|
|
3
|
+
*
|
|
4
|
+
* 1. A generator failure never reads as "the resource is absent" — it is a typed, loud
|
|
5
|
+
* `QuadletGeneratorError`, and an UPDATE that fails restores the last-known-good `.container`
|
|
6
|
+
* file and reloads again, so a container that was already running never loses its systemd unit.
|
|
7
|
+
* 2. `enable`/`disable` is never even ATTEMPTED on the generated unit — the fake fails the call
|
|
8
|
+
* (fake-quadlet.ts), so a regression that reintroduced one would fail loudly here, not silently
|
|
9
|
+
* no-op the way a plain assertion-of-absence would.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
import { FAIL_GENERATOR_MARKER, fakeQuadletHost } from './fake-quadlet.ts';
|
|
13
|
+
import { type ContainerProps, digestOf, renderContainerFile } from './container-form.ts';
|
|
14
|
+
import { deleteContainer, reconcileContainer } from './container-lifecycle.ts';
|
|
15
|
+
import { QuadletGeneratorError, verifyGenerated } from './container-generator.ts';
|
|
16
|
+
|
|
17
|
+
const NAME = 'hf-example';
|
|
18
|
+
const PATH = `/etc/containers/systemd/${NAME}.container`;
|
|
19
|
+
const SERVICE = `${NAME}.service`;
|
|
20
|
+
|
|
21
|
+
const props = (image: string): ContainerProps => ({
|
|
22
|
+
container: { image, network: 'host' },
|
|
23
|
+
install: { wantedBy: ['multi-user.target'] },
|
|
24
|
+
name: NAME,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const broken: ContainerProps = {
|
|
28
|
+
...props('example/one:1'),
|
|
29
|
+
service: { lines: [['X-Test-Fail-Generator', '1']] },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const verbs = (calls: string[][]) =>
|
|
33
|
+
calls.filter((call) => call[0] === 'systemctl').map((call) => call[1]);
|
|
34
|
+
|
|
35
|
+
test('the fixture marker really does render into the file', () => {
|
|
36
|
+
expect(renderContainerFile(broken)).toContain(FAIL_GENERATOR_MARKER);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('a generator failure is a typed, loud error', () => {
|
|
40
|
+
test('a CREATE the generator refuses removes the file it wrote and reloads clean', async () => {
|
|
41
|
+
const fake = fakeQuadletHost();
|
|
42
|
+
await expect(reconcileContainer(fake.runner, broken, undefined)).rejects.toThrow(
|
|
43
|
+
QuadletGeneratorError,
|
|
44
|
+
);
|
|
45
|
+
expect(fake.files.has(PATH)).toBe(false);
|
|
46
|
+
expect(verbs(fake.calls)).toEqual(['show', 'daemon-reload', 'show', 'daemon-reload']);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('an UPDATE the generator refuses restores the last-known-good file, not the broken one', async () => {
|
|
50
|
+
const fake = fakeQuadletHost();
|
|
51
|
+
const good = await reconcileContainer(fake.runner, props('example/one:1'), undefined);
|
|
52
|
+
const goodBytes = fake.files.get(PATH)?.bytes;
|
|
53
|
+
const before = fake.calls.length;
|
|
54
|
+
await expect(reconcileContainer(fake.runner, broken, good)).rejects.toThrow(
|
|
55
|
+
QuadletGeneratorError,
|
|
56
|
+
);
|
|
57
|
+
// ★ THE FILE ON DISK IS THE OLD, WORKING ONE — not the broken update.
|
|
58
|
+
expect(fake.files.get(PATH)?.bytes).toEqual(goodBytes);
|
|
59
|
+
// ★ AND THE GENERATED UNIT EXISTS AGAIN, still the container that was already running.
|
|
60
|
+
expect(fake.units.get(SERVICE)?.active).toBe(true);
|
|
61
|
+
expect(verbs(fake.calls.slice(before))).toEqual([
|
|
62
|
+
'show',
|
|
63
|
+
'daemon-reload',
|
|
64
|
+
'show',
|
|
65
|
+
'daemon-reload',
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('a file already on disk that the generator refuses is not "rolled back" onto itself', async () => {
|
|
70
|
+
// ⚠️ wrote === false: nothing NEW to restore to (container-lifecycle.ts's header explains why).
|
|
71
|
+
// A reload IS still attempted — needsVerificationReload (container-generator.ts) rightly
|
|
72
|
+
// tries once before giving up, since nothing has proven this content ever generated at all —
|
|
73
|
+
// but it finds the same failure, so still no write and still no rollback.
|
|
74
|
+
const fake = fakeQuadletHost();
|
|
75
|
+
fake.files.set(PATH, {
|
|
76
|
+
bytes: new TextEncoder().encode(renderContainerFile(broken)),
|
|
77
|
+
gid: 0,
|
|
78
|
+
kind: 'file',
|
|
79
|
+
mode: 0o644,
|
|
80
|
+
uid: 0,
|
|
81
|
+
});
|
|
82
|
+
await expect(reconcileContainer(fake.runner, broken, undefined, true)).rejects.toThrow(
|
|
83
|
+
QuadletGeneratorError,
|
|
84
|
+
);
|
|
85
|
+
expect(verbs(fake.calls)).toEqual(['show', 'daemon-reload', 'show']);
|
|
86
|
+
expect(fake.calls.some((call) => call[0] === 'write')).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('enable/disable is never attempted on a generated unit', () => {
|
|
91
|
+
test('create, update and delete never call enable or disable', async () => {
|
|
92
|
+
const fake = fakeQuadletHost();
|
|
93
|
+
const first = await reconcileContainer(fake.runner, props('example/one:1'), undefined);
|
|
94
|
+
const second = await reconcileContainer(fake.runner, props('example/two:2'), first);
|
|
95
|
+
await deleteContainer(fake.runner, second);
|
|
96
|
+
expect(verbs(fake.calls)).not.toContain('enable');
|
|
97
|
+
expect(verbs(fake.calls)).not.toContain('disable');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('the fake itself refuses enable, proving the guard is real and not just unexercised', async () => {
|
|
101
|
+
const fake = fakeQuadletHost();
|
|
102
|
+
const result = await fake.runner.exec(['systemctl', 'enable', '--', SERVICE]);
|
|
103
|
+
expect(result.exitCode).not.toBe(0);
|
|
104
|
+
expect(result.stderr).toMatch(/transient or was generated/);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('an interrupted apply that crashed before daemon-reload', () => {
|
|
109
|
+
test('the retry still reloads and restarts onto the NEW definition, not the stale one', async () => {
|
|
110
|
+
const fake = fakeQuadletHost();
|
|
111
|
+
const oldProps = props('example/old:1');
|
|
112
|
+
const newProps = props('example/new:2');
|
|
113
|
+
const oldOutput = await reconcileContainer(fake.runner, oldProps, undefined);
|
|
114
|
+
// ★ Simulate apply 1: its write landed, but it crashed before `daemon-reload` ran, so the
|
|
115
|
+
// generated unit is still the OLD one, and Alchemy's state is still `oldOutput` — a reconcile
|
|
116
|
+
// that never returns never gets its attributes persisted.
|
|
117
|
+
fake.files.set(PATH, {
|
|
118
|
+
bytes: new TextEncoder().encode(renderContainerFile(newProps)),
|
|
119
|
+
gid: 0,
|
|
120
|
+
kind: 'file',
|
|
121
|
+
mode: 0o644,
|
|
122
|
+
uid: 0,
|
|
123
|
+
});
|
|
124
|
+
const before = fake.calls.length;
|
|
125
|
+
const retried = await reconcileContainer(fake.runner, newProps, oldOutput);
|
|
126
|
+
const after = verbs(fake.calls.slice(before));
|
|
127
|
+
// ⛔ THE BUG THIS PROVES FIXED: without the `stale` check, `wrote` is false (the file already
|
|
128
|
+
// holds the new content) and systemd's own `NeedDaemonReload` is also false (the GENERATED
|
|
129
|
+
// unit hasn't changed yet), so nothing would ever reload — the container would restart, if at
|
|
130
|
+
// all, onto its OLD definition while state silently claimed the new one had taken effect.
|
|
131
|
+
expect(after).toContain('daemon-reload');
|
|
132
|
+
expect(after).toContain('restart');
|
|
133
|
+
const newDigest = digestOf(renderContainerFile(newProps));
|
|
134
|
+
expect(retried.containerSha256).toBe(newDigest);
|
|
135
|
+
// ★ Not just a claim in state: the fake's OWN generated-unit record is built from the NEW file.
|
|
136
|
+
expect(fake.units.get(SERVICE)?.loadedSha).toBe(newDigest);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('an interrupted CREATE that crashed before daemon-reload', () => {
|
|
141
|
+
test('the retry still reloads, verifies and starts — rather than getting stuck forever', async () => {
|
|
142
|
+
const fake = fakeQuadletHost();
|
|
143
|
+
const newProps = props('example/new:1');
|
|
144
|
+
// ★ Simulate apply 1: its write landed (the file is on disk with the desired content already),
|
|
145
|
+
// but it crashed before `daemon-reload` ran — no generated unit exists yet, and a create that
|
|
146
|
+
// never returns never gets a state row at all, so this retry starts from `output: undefined`,
|
|
147
|
+
// the same as Alchemy's own "Cannot resume creating … Re-run with --adopt" (`adopt: true`).
|
|
148
|
+
fake.files.set(PATH, {
|
|
149
|
+
bytes: new TextEncoder().encode(renderContainerFile(newProps)),
|
|
150
|
+
gid: 0,
|
|
151
|
+
kind: 'file',
|
|
152
|
+
mode: 0o644,
|
|
153
|
+
uid: 0,
|
|
154
|
+
});
|
|
155
|
+
const before = fake.calls.length;
|
|
156
|
+
const retried = await reconcileContainer(fake.runner, newProps, undefined, true);
|
|
157
|
+
const after = verbs(fake.calls.slice(before));
|
|
158
|
+
// ⛔ THE BUG THIS PROVES FIXED: without `needsVerificationReload`, `wrote` is false (the file
|
|
159
|
+
// already holds the new content) and there is no PRIOR state for `stale` to disagree with —
|
|
160
|
+
// so nothing would ever reload, and `verifyGenerated` would throw on the still-missing
|
|
161
|
+
// generated unit on every retry, FOREVER: loud, but stuck, since nothing a plain retry does
|
|
162
|
+
// ever changes any of those three signals.
|
|
163
|
+
expect(after).toContain('daemon-reload');
|
|
164
|
+
expect(after).toContain('start');
|
|
165
|
+
const newDigest = digestOf(renderContainerFile(newProps));
|
|
166
|
+
expect(retried.containerSha256).toBe(newDigest);
|
|
167
|
+
expect(fake.units.get(SERVICE)?.loadedSha).toBe(newDigest);
|
|
168
|
+
expect(fake.units.get(SERVICE)?.active).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('verifyGenerated, directly', () => {
|
|
173
|
+
test('a missing generated unit is a QuadletGeneratorError naming the debug commands', () => {
|
|
174
|
+
expect(() =>
|
|
175
|
+
verifyGenerated(NAME, PATH, SERVICE, {
|
|
176
|
+
activeState: 'inactive',
|
|
177
|
+
known: false,
|
|
178
|
+
loadState: 'not-found',
|
|
179
|
+
needDaemonReload: false,
|
|
180
|
+
}),
|
|
181
|
+
).toThrow(/podman-system-generator --dryrun/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('a SourcePath belonging to another file is a takeover, not a success', () => {
|
|
185
|
+
expect(() =>
|
|
186
|
+
verifyGenerated(NAME, PATH, SERVICE, {
|
|
187
|
+
activeState: 'active',
|
|
188
|
+
known: true,
|
|
189
|
+
loadState: 'loaded',
|
|
190
|
+
needDaemonReload: false,
|
|
191
|
+
sourcePath: '/usr/share/containers/systemd/hf-example.container',
|
|
192
|
+
}),
|
|
193
|
+
).toThrow(/earlier in Quadlet/);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quadlet is a systemd GENERATOR (`systemd.generator(7)`, tag `v257`, `man/systemd.generator.xml`,
|
|
3
|
+
* read 2026-09-24), not a unit reader — this is the one fact the whole lifecycle is built around.
|
|
4
|
+
*
|
|
5
|
+
* ★ "Generator output is supposed to last only until the next `daemon-reload`... Units written by
|
|
6
|
+
* generators are removed when the configuration is reloaded" (same doc). `daemon-reload` DELETES
|
|
7
|
+
* `/run/systemd/generator` wholesale and reruns every generator from the `.container` files
|
|
8
|
+
* currently on disk. So a `.container` file the generator refuses produces NO generated unit —
|
|
9
|
+
* even one that generated fine a moment ago, if `daemon-reload` runs again over a now-broken file.
|
|
10
|
+
* That is why `container-lifecycle.ts` restores the LAST-KNOWN-GOOD file and reloads again on a
|
|
11
|
+
* failed update, rather than leaving the bad file in place the way `Systemd.Unit` leaves a bad
|
|
12
|
+
* update for the next deploy to retry (unit-lifecycle.ts): here, "the next deploy" might be a
|
|
13
|
+
* reboot, and by then the container this resource already promised running has no systemd unit
|
|
14
|
+
* at all, not merely a stale one.
|
|
15
|
+
* ⛔ THE GENERATED UNIT CANNOT BE `systemctl enable`D — `podman-systemd.unit(5)`, Podman 5.4, "Podman
|
|
16
|
+
* unit files": "The services created by Podman are considered transient by systemd... it is not
|
|
17
|
+
* possible to 'systemctl enable' them... To compensate... the generator manually applies the
|
|
18
|
+
* [Install] section... during generation, in the same way `systemctl enable` does when run
|
|
19
|
+
* later." MEASURED on CT100 2026-09-24 (`ssh ct100`, read-only): `caddy.container` declares
|
|
20
|
+
* `[Install] WantedBy=multi-user.target` and nothing ever ran `systemctl enable caddy`; yet
|
|
21
|
+
* `systemctl is-enabled caddy.service` prints `generated`, `UnitFileState=generated`, and
|
|
22
|
+
* `/run/systemd/generator/multi-user.target.wants/caddy.service` is a symlink the GENERATOR put
|
|
23
|
+
* there. So `Podman.Container` has no `enabled` prop and never calls `enableUnit`/`disableUnit`
|
|
24
|
+
* (systemctl.ts) — declaring `install.wantedBy` IS the enablement, applied by the generator on
|
|
25
|
+
* every `daemon-reload`, exactly the way `Systemd.Unit`'s enable/disable calls are applied by
|
|
26
|
+
* this family's own `settle` step. That is the one place this family's "enable logic" differs.
|
|
27
|
+
* ★ SourcePath TIES THE GENERATED UNIT BACK TO OUR FILE. MEASURED on CT100 2026-09-24:
|
|
28
|
+
* `systemctl show caddy.service -p SourcePath` -> `/etc/containers/systemd/caddy.container`.
|
|
29
|
+
* `verifyGenerated` below uses it the way `unit-preflight.ts`'s `assertUnclaimed` uses a file
|
|
30
|
+
* digest: a generated unit whose SourcePath is NOT our path belongs to a same-named `.container`
|
|
31
|
+
* file earlier in `QUADLET_SEARCH_DIRECTORIES` (container-form.ts) — a takeover, not a success.
|
|
32
|
+
* ⚠️ REASONED, NOT MEASURED — THE ONE GAP THIS VERIFICATION HAS: whether an ALREADY-ACTIVE unit's
|
|
33
|
+
* `LoadState` stays `loaded` (systemd does not garbage-collect a referenced/active unit object
|
|
34
|
+
* merely because a reload failed to reproduce it) even when the UPDATED `.container` file the
|
|
35
|
+
* generator just refused would, on its own, generate nothing. Measuring this needs writing a
|
|
36
|
+
* broken file to a live host, which read-only access forbids. If that is how a live host
|
|
37
|
+
* behaves, `LoadState`/`SourcePath` alone would not catch a failed update to a container that
|
|
38
|
+
* was already running — only a CREATE, or an update to an already-INACTIVE one, is verified with
|
|
39
|
+
* full confidence by this check alone. Restoring the last-known-good file on ANY verification
|
|
40
|
+
* failure (container-lifecycle.ts) does not depend on this gap: it always leaves the host at the
|
|
41
|
+
* file that is known to generate, closing the risk window even where detection is uncertain.
|
|
42
|
+
* ⛔ `needsVerificationReload` (below) is what stops a NEW resource from getting stuck — found on
|
|
43
|
+
* adversarial review. `container-lifecycle.ts`'s `needsReload` used to be `wrote || stale ||
|
|
44
|
+
* preStatus.needDaemonReload`; for an INTERRUPTED CREATE (the write landed, the apply crashed
|
|
45
|
+
* before `daemon-reload` ran) the retry sees `wrote: false` (the file already holds `desired`)
|
|
46
|
+
* and `stale: false` (there is no PRIOR state to disagree with — this is a create), so nothing
|
|
47
|
+
* ever reloaded and `verifyGenerated` threw on the still-missing generated unit, forever: loud,
|
|
48
|
+
* but stuck, since nothing about a plain retry ever changed. The same holds for adopting a file
|
|
49
|
+
* whose generated unit is missing or does not match. `needsVerificationReload` shares
|
|
50
|
+
* `generatedMatches` with `verifyGenerated` itself, so the reload gate and the verification it
|
|
51
|
+
* is gating can never disagree about what "already proven" means.
|
|
52
|
+
*/
|
|
53
|
+
import type { UnitStatus } from './systemctl.ts';
|
|
54
|
+
|
|
55
|
+
/** MEASURED on CT100 2026-09-24: `systemctl show caddy.service -p FragmentPath`. */
|
|
56
|
+
export const GENERATED_UNIT_DIRECTORY = '/run/systemd/generator';
|
|
57
|
+
|
|
58
|
+
export class QuadletGeneratorError extends Error {
|
|
59
|
+
constructor(name: string, detail: string) {
|
|
60
|
+
super(`Podman.Container ${name}: ${detail}`);
|
|
61
|
+
this.name = 'QuadletGeneratorError';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Whether `status` already proves a generated unit that came from `containerPath` — the one
|
|
66
|
+
* question both `verifyGenerated` (throws when false) and `needsVerificationReload` (reloads to
|
|
67
|
+
* try to make it true) ask. */
|
|
68
|
+
const generatedMatches = (containerPath: string, status: UnitStatus): boolean =>
|
|
69
|
+
status.known &&
|
|
70
|
+
status.loadState === 'loaded' &&
|
|
71
|
+
(status.sourcePath === undefined || status.sourcePath === containerPath);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether `container-lifecycle.ts` must `daemon-reload` before trusting `status` as proof of
|
|
75
|
+
* anything — true whenever `status` does not already show a matching generation. Called on the
|
|
76
|
+
* PRE-write status, so it costs nothing when `wrote`/`stale`/`NeedDaemonReload` already force a
|
|
77
|
+
* reload; it earns its keep exactly when none of them do but nothing has actually been proven yet
|
|
78
|
+
* (a fresh create or adopt with no prior state to compare against).
|
|
79
|
+
*/
|
|
80
|
+
export const needsVerificationReload = (containerPath: string, status: UnitStatus): boolean =>
|
|
81
|
+
!generatedMatches(containerPath, status);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Throws a typed, loud `QuadletGeneratorError` — never lets a generator failure read as "the
|
|
85
|
+
* resource is simply absent", which is what a plain `LoadState=not-found` would otherwise look
|
|
86
|
+
* like to a caller that only knows the `Systemd.Unit` shape (a unit that has genuinely never been
|
|
87
|
+
* declared also reads `not-found`).
|
|
88
|
+
*/
|
|
89
|
+
export const verifyGenerated = (
|
|
90
|
+
name: string,
|
|
91
|
+
containerPath: string,
|
|
92
|
+
serviceName: string,
|
|
93
|
+
status: UnitStatus,
|
|
94
|
+
): void => {
|
|
95
|
+
if (!status.known || status.loadState !== 'loaded') {
|
|
96
|
+
throw new QuadletGeneratorError(
|
|
97
|
+
name,
|
|
98
|
+
`${serviceName} does not exist after daemon-reload (LoadState=${status.loadState}). The ` +
|
|
99
|
+
'.container file was written and reloaded, so Quadlet’s generator refused it — a bad ' +
|
|
100
|
+
'key, a bad value, or an interaction this validation does not check. Debug on the host ' +
|
|
101
|
+
"with '/usr/lib/systemd/system-generators/podman-system-generator --dryrun' or " +
|
|
102
|
+
`'systemd-analyze --generators=true verify ${serviceName}' (podman-systemd.unit(5), Podman 5.4).`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (status.sourcePath !== undefined && status.sourcePath !== containerPath) {
|
|
106
|
+
throw new QuadletGeneratorError(
|
|
107
|
+
name,
|
|
108
|
+
`${serviceName}'s SourcePath is ${status.sourcePath}, not ${containerPath} — a same-named ` +
|
|
109
|
+
'.container file earlier in Quadlet’s search path is generating this unit instead of ours ' +
|
|
110
|
+
'(podman-systemd.unit(5) “Podman Unit Search Path”, Podman 5.4).',
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
};
|