@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.
Files changed (166) hide show
  1. package/README.md +44 -7
  2. package/dist/grafana/index.d.ts +6 -5
  3. package/dist/grafana/index.d.ts.map +1 -1
  4. package/dist/linux/container-form.d.ts +129 -0
  5. package/dist/linux/container-form.d.ts.map +1 -0
  6. package/dist/linux/container-generator.d.ts +74 -0
  7. package/dist/linux/container-generator.d.ts.map +1 -0
  8. package/dist/linux/container-handlers.d.ts +15 -0
  9. package/dist/linux/container-handlers.d.ts.map +1 -0
  10. package/dist/linux/container-lifecycle.d.ts +37 -0
  11. package/dist/linux/container-lifecycle.d.ts.map +1 -0
  12. package/dist/linux/container-preflight.d.ts +34 -0
  13. package/dist/linux/container-preflight.d.ts.map +1 -0
  14. package/dist/linux/container-secrets.d.ts +37 -0
  15. package/dist/linux/container-secrets.d.ts.map +1 -0
  16. package/dist/linux/container-settle.d.ts +34 -0
  17. package/dist/linux/container-settle.d.ts.map +1 -0
  18. package/dist/linux/container.d.ts +30 -0
  19. package/dist/linux/container.d.ts.map +1 -0
  20. package/dist/linux/fake-quadlet.d.ts +25 -0
  21. package/dist/linux/fake-quadlet.d.ts.map +1 -0
  22. package/dist/linux/index.d.ts +6 -3
  23. package/dist/linux/index.d.ts.map +1 -1
  24. package/dist/linux/index.js +641 -268
  25. package/dist/linux/index.js.map +15 -7
  26. package/dist/linux/providers.d.ts +1 -1
  27. package/dist/linux/providers.d.ts.map +1 -1
  28. package/dist/linux/systemctl.d.ts +7 -1
  29. package/dist/linux/systemctl.d.ts.map +1 -1
  30. package/dist/opnsense/alias-form.d.ts +29 -0
  31. package/dist/opnsense/alias-form.d.ts.map +1 -0
  32. package/dist/opnsense/alias.d.ts +81 -0
  33. package/dist/opnsense/alias.d.ts.map +1 -0
  34. package/dist/opnsense/category-form.d.ts +10 -0
  35. package/dist/opnsense/category-form.d.ts.map +1 -0
  36. package/dist/opnsense/category.d.ts +58 -0
  37. package/dist/opnsense/category.d.ts.map +1 -0
  38. package/dist/opnsense/credentials.d.ts +36 -0
  39. package/dist/opnsense/credentials.d.ts.map +1 -0
  40. package/dist/opnsense/fake-opnsense.d.ts +51 -0
  41. package/dist/opnsense/fake-opnsense.d.ts.map +1 -0
  42. package/dist/opnsense/group-form.d.ts +11 -0
  43. package/dist/opnsense/group-form.d.ts.map +1 -0
  44. package/dist/opnsense/group.d.ts +62 -0
  45. package/dist/opnsense/group.d.ts.map +1 -0
  46. package/dist/opnsense/index.d.ts +16 -0
  47. package/dist/opnsense/index.d.ts.map +1 -0
  48. package/dist/opnsense/index.js +211 -0
  49. package/dist/opnsense/index.js.map +20 -0
  50. package/dist/opnsense/policy.d.ts +20 -0
  51. package/dist/opnsense/policy.d.ts.map +1 -0
  52. package/dist/opnsense/providers.d.ts +13 -0
  53. package/dist/opnsense/providers.d.ts.map +1 -0
  54. package/dist/opnsense/resource.d.ts +74 -0
  55. package/dist/opnsense/resource.d.ts.map +1 -0
  56. package/dist/opnsense/wire.d.ts +39 -0
  57. package/dist/opnsense/wire.d.ts.map +1 -0
  58. package/dist/proxmox/acl.d.ts.map +1 -1
  59. package/dist/proxmox/api-token.d.ts.map +1 -1
  60. package/dist/proxmox/group-wire.d.ts +44 -5
  61. package/dist/proxmox/group-wire.d.ts.map +1 -1
  62. package/dist/proxmox/group.d.ts.map +1 -1
  63. package/dist/proxmox/index.js +326 -264
  64. package/dist/proxmox/index.js.map +13 -11
  65. package/dist/proxmox/role.d.ts.map +1 -1
  66. package/dist/proxmox/storage-form.d.ts +54 -0
  67. package/dist/proxmox/storage-form.d.ts.map +1 -0
  68. package/dist/proxmox/storage-wire.d.ts +56 -0
  69. package/dist/proxmox/storage-wire.d.ts.map +1 -0
  70. package/dist/proxmox/storage.d.ts +55 -60
  71. package/dist/proxmox/storage.d.ts.map +1 -1
  72. package/dist/proxmox/user-wire.d.ts +34 -5
  73. package/dist/proxmox/user-wire.d.ts.map +1 -1
  74. package/dist/proxmox/user.d.ts +6 -6
  75. package/dist/proxmox/user.d.ts.map +1 -1
  76. package/dist/unifi/fake-unifi.d.ts +48 -0
  77. package/dist/unifi/fake-unifi.d.ts.map +1 -0
  78. package/dist/unifi/firewall-zone-form.d.ts +40 -0
  79. package/dist/unifi/firewall-zone-form.d.ts.map +1 -0
  80. package/dist/unifi/firewall-zone.d.ts +50 -0
  81. package/dist/unifi/firewall-zone.d.ts.map +1 -0
  82. package/dist/unifi/index.d.ts +13 -0
  83. package/dist/unifi/index.d.ts.map +1 -0
  84. package/dist/unifi/index.js +190 -0
  85. package/dist/unifi/index.js.map +16 -0
  86. package/dist/unifi/network-form.d.ts +67 -0
  87. package/dist/unifi/network-form.d.ts.map +1 -0
  88. package/dist/unifi/network.d.ts +67 -0
  89. package/dist/unifi/network.d.ts.map +1 -0
  90. package/dist/unifi/policy.d.ts +20 -0
  91. package/dist/unifi/policy.d.ts.map +1 -0
  92. package/dist/unifi/providers.d.ts +13 -0
  93. package/dist/unifi/providers.d.ts.map +1 -0
  94. package/dist/unifi/resource.d.ts +64 -0
  95. package/dist/unifi/resource.d.ts.map +1 -0
  96. package/dist/verify/fake-engine.d.ts +14 -0
  97. package/dist/verify/fake-engine.d.ts.map +1 -1
  98. package/docs/grafana.md +47 -17
  99. package/docs/opnsense.md +146 -0
  100. package/docs/provider-standard.md +1 -1
  101. package/docs/quadlet-container.md +132 -0
  102. package/docs/unifi.md +93 -0
  103. package/docs/upstream-conformance.md +14 -4
  104. package/package.json +13 -3
  105. package/src/grafana/index.ts +6 -5
  106. package/src/linux/container-fixture.test.ts +92 -0
  107. package/src/linux/container-form.ts +243 -0
  108. package/src/linux/container-generator.test.ts +195 -0
  109. package/src/linux/container-generator.ts +113 -0
  110. package/src/linux/container-handlers.ts +73 -0
  111. package/src/linux/container-lifecycle.test.ts +209 -0
  112. package/src/linux/container-lifecycle.ts +217 -0
  113. package/src/linux/container-preflight.ts +97 -0
  114. package/src/linux/container-secrets.test.ts +171 -0
  115. package/src/linux/container-secrets.ts +112 -0
  116. package/src/linux/container-settle.ts +97 -0
  117. package/src/linux/container.ts +60 -0
  118. package/src/linux/fake-quadlet.ts +127 -0
  119. package/src/linux/index.ts +11 -3
  120. package/src/linux/providers.ts +2 -0
  121. package/src/linux/systemctl.ts +9 -0
  122. package/src/opnsense/alias-form.test.ts +89 -0
  123. package/src/opnsense/alias-form.ts +53 -0
  124. package/src/opnsense/alias.test.ts +203 -0
  125. package/src/opnsense/alias.ts +117 -0
  126. package/src/opnsense/category-form.test.ts +54 -0
  127. package/src/opnsense/category-form.ts +20 -0
  128. package/src/opnsense/category.test.ts +138 -0
  129. package/src/opnsense/category.ts +82 -0
  130. package/src/opnsense/credentials.ts +41 -0
  131. package/src/opnsense/fake-opnsense.ts +79 -0
  132. package/src/opnsense/group-form.test.ts +75 -0
  133. package/src/opnsense/group-form.ts +25 -0
  134. package/src/opnsense/group.test.ts +141 -0
  135. package/src/opnsense/group.ts +78 -0
  136. package/src/opnsense/index.ts +45 -0
  137. package/src/opnsense/policy.ts +49 -0
  138. package/src/opnsense/providers.ts +21 -0
  139. package/src/opnsense/resource.ts +146 -0
  140. package/src/opnsense/wire.ts +56 -0
  141. package/src/opnsense/write-refusal.test.ts +148 -0
  142. package/src/proxmox/acl.ts +10 -4
  143. package/src/proxmox/api-token.ts +20 -12
  144. package/src/proxmox/group-wire.ts +62 -8
  145. package/src/proxmox/group.ts +23 -35
  146. package/src/proxmox/read-failure-propagation.test.ts +183 -0
  147. package/src/proxmox/role.ts +9 -2
  148. package/src/proxmox/storage-form.ts +144 -0
  149. package/src/proxmox/storage-read-failure.test.ts +214 -0
  150. package/src/proxmox/storage-wire.ts +124 -0
  151. package/src/proxmox/storage.ts +165 -175
  152. package/src/proxmox/user-wire.ts +44 -8
  153. package/src/proxmox/user.ts +29 -27
  154. package/src/unifi/fake-unifi.ts +60 -0
  155. package/src/unifi/firewall-zone-form.ts +65 -0
  156. package/src/unifi/firewall-zone.test.ts +88 -0
  157. package/src/unifi/firewall-zone.ts +66 -0
  158. package/src/unifi/index.ts +26 -0
  159. package/src/unifi/network-form.ts +207 -0
  160. package/src/unifi/network.test.ts +209 -0
  161. package/src/unifi/network.ts +83 -0
  162. package/src/unifi/policy.ts +37 -0
  163. package/src/unifi/providers.ts +15 -0
  164. package/src/unifi/resource.test.ts +133 -0
  165. package/src/unifi/resource.ts +131 -0
  166. 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
+ };