@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,171 @@
1
+ /**
2
+ * The secret refusal the task asks to be proven, not merely implemented: no secret can appear in
3
+ * the RENDERED file or in Alchemy STATE. `secretLikeEnvironment` is the detector; this file also
4
+ * proves the whole path is wired — `containerProblems` calls it, and `reconcileContainer` refuses
5
+ * BEFORE any write, so a secret-shaped `Environment=` never reaches the fake filesystem (which is
6
+ * what a real deploy's `writeFileAtomic` and Alchemy's state row both stand in for here).
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { podmanArgsProblems, secretLikeEnvironment, secretLikeLines } from './container-secrets.ts';
10
+ import { type ContainerProps, containerProblems, renderContainerFile } from './container-form.ts';
11
+ import { fakeQuadletHost } from './fake-quadlet.ts';
12
+ import { reconcileContainer } from './container-lifecycle.ts';
13
+
14
+ describe('secretLikeEnvironment', () => {
15
+ test('a key name that says what it holds is refused', () => {
16
+ const found = secretLikeEnvironment({
17
+ API_TOKEN: 'x',
18
+ DB_PASSWORD: 'y',
19
+ GRAFANA_SECRET_KEY: 'z',
20
+ });
21
+ expect(found).toHaveLength(3);
22
+ expect(found.join(' ')).toMatch(/looks like a secret by its name/);
23
+ });
24
+
25
+ test('an ordinary config key is left alone', () => {
26
+ expect(
27
+ secretLikeEnvironment({ CADDY_CONFIG: '/etc/caddy/Caddyfile', GF_SERVER_DOMAIN: 'x' }),
28
+ ).toEqual([]);
29
+ });
30
+
31
+ test('a value shaped like a real vendor token is refused even under an innocuous key name', () => {
32
+ const found = secretLikeEnvironment({ EXTRA_ARGS: 'ghp_abcdefghijklmnopqrstuvwxyz0123456789' });
33
+ expect(found.join(' ')).toMatch(/looks like a credential/);
34
+ });
35
+
36
+ test('a bearer/basic auth header value is refused', () => {
37
+ expect(secretLikeEnvironment({ HEADER: 'Bearer sometoken' })).toHaveLength(1);
38
+ });
39
+ });
40
+
41
+ describe('a secret-shaped Environment= is refused before anything touches the host', () => {
42
+ const NAME = 'hf-example';
43
+
44
+ const withSecret: ContainerProps = {
45
+ container: {
46
+ environment: { GF_SECURITY_ADMIN_PASSWORD: 'hunter2' },
47
+ image: 'grafana/grafana:12',
48
+ },
49
+ name: NAME,
50
+ };
51
+
52
+ test('containerProblems names the offending key', () => {
53
+ expect(containerProblems(withSecret).join(' ')).toMatch(/GF_SECURITY_ADMIN_PASSWORD/);
54
+ });
55
+
56
+ test('reconcile refuses and writes nothing — not the file, not a generated unit', async () => {
57
+ const fake = fakeQuadletHost();
58
+ await expect(reconcileContainer(fake.runner, withSecret, undefined)).rejects.toThrow(
59
+ /GF_SECURITY_ADMIN_PASSWORD/,
60
+ );
61
+ expect(fake.calls).toHaveLength(0);
62
+ expect(fake.files.has(`/etc/containers/systemd/${NAME}.container`)).toBe(false);
63
+ });
64
+
65
+ test('the renderer alone does not filter secrets — validation is the only backstop', () => {
66
+ // ★ `renderContainerFile` is a pure formatter; it does not know what a secret looks like.
67
+ // `containerProblems`/`assertValid` do, and the previous test proves `reconcileContainer`
68
+ // calls one before any write. This is why that ordering is load-bearing: called on its own,
69
+ // the renderer WOULD put the secret in the file — there is no second, independent filter.
70
+ expect(renderContainerFile(withSecret)).toContain('hunter2');
71
+ });
72
+ });
73
+
74
+ // ⛔ FOUND ON ADVERSARIAL REVIEW: `container.environment` was the only field checked. `exec`,
75
+ // `podmanArgs` and the `unit.lines`/`service.lines` escape hatches all render into the same
76
+ // 0644 file and the same state row, unchecked. These prove the fix closes every one of them.
77
+ describe('secretLikeLines — every OTHER rendered field', () => {
78
+ test('a value shaped like a vendor token is refused, wherever it sits in the line', () => {
79
+ const found = secretLikeLines([
80
+ ['Exec', 'sh -c "curl -H \'X: ghp_abcdefghijklmnopqrstuvwxyz0123\'"'],
81
+ ]);
82
+ expect(found.join(' ')).toMatch(/looks like it carries a credential/);
83
+ });
84
+
85
+ test('an embedded KEY=VALUE whose key looks secret is refused even with no known token shape', () => {
86
+ const found = secretLikeLines([['PodmanArgs', 'GITHUB_TOKEN=some-opaque-value']]);
87
+ expect(found.join(' ')).toMatch(/embeds a KEY=VALUE pair whose key looks like a secret/);
88
+ });
89
+
90
+ test('a raw systemd Environment= line is caught even though it never touches the typed map', () => {
91
+ const found = secretLikeLines([['Environment', 'DB_PASSWORD=hunter2']]);
92
+ expect(found).toHaveLength(1);
93
+ });
94
+
95
+ test('an ordinary line is left alone', () => {
96
+ expect(
97
+ secretLikeLines([
98
+ ['Description', 'the shared postgres'],
99
+ ['WantedBy', 'multi-user.target'],
100
+ ]),
101
+ ).toEqual([]);
102
+ });
103
+ });
104
+
105
+ describe('podmanArgsProblems — an env flag is refused outright', () => {
106
+ test('-e and --env, split or `=`-joined or glued, are all caught', () => {
107
+ for (const args of [
108
+ ['-e', 'FOO=bar'],
109
+ ['--env', 'FOO=bar'],
110
+ ['--env=FOO=bar'],
111
+ ['-eFOO=bar'],
112
+ ]) {
113
+ expect(podmanArgsProblems(args).length).toBeGreaterThan(0);
114
+ }
115
+ });
116
+
117
+ test('an unrelated arg is left alone', () => {
118
+ expect(podmanArgsProblems(['--pull=never', '--cgroups=split'])).toEqual([]);
119
+ });
120
+ });
121
+
122
+ describe('containerProblems catches a secret smuggled through every free-text field', () => {
123
+ const base = { container: { image: 'grafana/grafana:12', network: 'host' }, name: 'hf-example' };
124
+
125
+ test('exec', () => {
126
+ const problems = containerProblems({
127
+ ...base,
128
+ container: { ...base.container, exec: 'sh -c "export API_TOKEN=hunter2 && run"' },
129
+ });
130
+ expect(problems.join(' ')).toMatch(/embeds a KEY=VALUE pair/);
131
+ });
132
+
133
+ test('podmanArgs — the outright -e/--env refusal fires before the shape check even runs', () => {
134
+ const problems = containerProblems({
135
+ ...base,
136
+ container: {
137
+ ...base.container,
138
+ podmanArgs: ['-e', 'GITHUB_TOKEN=ghp_abcdefghijklmnopqrstuvwxyz0123'],
139
+ },
140
+ });
141
+ expect(problems.join(' ')).toMatch(/sets a container environment variable outside/);
142
+ });
143
+
144
+ test('unit.lines', () => {
145
+ const problems = containerProblems({
146
+ ...base,
147
+ unit: { lines: [['X-Note', 'token: ghp_abcdefghijklmnopqrstuvwxyz0123']] },
148
+ });
149
+ expect(problems.join(' ')).toMatch(/looks like it carries a credential/);
150
+ });
151
+
152
+ test('service.lines — a raw systemd Environment=, bypassing the typed map entirely', () => {
153
+ const problems = containerProblems({
154
+ ...base,
155
+ service: { lines: [['Environment', 'CLIENT_SECRET=abc123']] },
156
+ });
157
+ expect(problems.join(' ')).toMatch(/embeds a KEY=VALUE pair/);
158
+ });
159
+
160
+ test('reconcile refuses a podmanArgs secret before anything touches the host', async () => {
161
+ const fake = fakeQuadletHost();
162
+ const withArgSecret: ContainerProps = {
163
+ ...base,
164
+ container: { ...base.container, podmanArgs: ['-e', 'DB_PASSWORD=hunter2'] },
165
+ };
166
+ await expect(reconcileContainer(fake.runner, withArgSecret, undefined)).rejects.toThrow(
167
+ /environment variable outside/,
168
+ );
169
+ expect(fake.calls).toHaveLength(0);
170
+ });
171
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * A best-effort refusal of an inline secret ANYWHERE in a rendered `.container` file — checked
3
+ * from `containerProblems` (container-form.ts), so it runs at plan AND apply time, before anything
4
+ * is written.
5
+ *
6
+ * ⛔ THIS IS NOT A SECRET SCANNER. It cannot prove a value is safe, only refuse the OBVIOUS cases: a
7
+ * key whose name says what it holds, or a value shaped like a real vendor token. The actual
8
+ * contract is `EnvironmentFile=` (container-form.ts's header): a secret belongs in a host file an
9
+ * out-of-band renderer (openbao-agent) maintains, never in a prop, because the WHOLE file is
10
+ * rendered into a 0644 file (`unit-form.ts` `UNIT_WRITE`) AND persisted unencrypted in Alchemy
11
+ * state — the same two reasons `Systemd.Unit`'s `content` is never secret. A determined caller can
12
+ * still name a variable `CFG` and put a token in it; this check is a guardrail against the common
13
+ * mistake, not a boundary a hostile caller cannot cross.
14
+ * ★ KEY-NAME FIRST, VALUE SECOND. The name check catches the overwhelming majority of real
15
+ * mistakes (copying a `docker run -e SECRET=...` line verbatim) and has no false-negative risk
16
+ * from an unfamiliar token format; the value check is defense in depth for a handful of
17
+ * widely-recognised vendor prefixes, not an attempt at general entropy detection — an entropy
18
+ * check has enough false positives on ordinary config (long image digests, base64 Caddyfile
19
+ * snippets) that it would train callers to ignore the refusal.
20
+ * ⛔ CHECKED EVERYWHERE, NOT ONLY `container.environment` — found on adversarial review.
21
+ * `container.environment` is the field most likely to carry one BY ACCIDENT, and gets the
22
+ * precise per-key `secretLikeEnvironment` check below with the best error message. But every
23
+ * other free-text field renders into the SAME 0644 file and the SAME state row: `exec`,
24
+ * `podmanArgs` (each entry rendered verbatim), and the escape-hatch `unit.lines`/`service.lines`
25
+ * — a `service.lines` entry can spell a native systemd `Environment=` directly, which never goes
26
+ * through the typed `container.environment` map at all. `secretLikeLines` below runs the same two
27
+ * heuristics — value-shape, and an embedded `KEY=VALUE` whose KEY looks like a secret — over every
28
+ * OTHER rendered line, and `container-form.ts`'s `containerProblems` calls both, so nothing
29
+ * rendered escapes either check.
30
+ */
31
+
32
+ /** Case-insensitive: matches the key APPEARING to name a credential, wherever it sits in the name. */
33
+ const SECRET_KEY_NAME =
34
+ /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|API_?KEY|CREDENTIAL|ACCESS_KEY|CLIENT_SECRET|AUTH_?KEY|DSN)/i;
35
+
36
+ /**
37
+ * A handful of widely-recognised credential value shapes: Bearer/Basic auth headers, common vendor
38
+ * token prefixes (GitHub `ghp_`/`gho_`, GitLab `glpat-`, Slack `xoxb-`/`xoxp-`/…, AWS `AKIA`), and a
39
+ * JWT's `eyJ` base64url header. ⚠️ Not exhaustive — see file header.
40
+ */
41
+ const SECRET_VALUE_PREFIX = /^(Bearer\s|Basic\s|sk-|ghp_|gho_|glpat-|xox[baprs]-|AKIA|eyJ)/;
42
+ /** The same shapes, but matched ANYWHERE in a longer string — a `podmanArgs`/`exec` line is a whole
43
+ * command or arg list, not a bare value, so the token can sit mid-string (`... --env=ghp_xxx`). */
44
+ const SECRET_VALUE_ANYWHERE = new RegExp(SECRET_VALUE_PREFIX.source.replace(/^\^/, ''));
45
+
46
+ /** A `KEY=VALUE`-shaped substring whose KEY looks like a secret name, wherever it sits in a line —
47
+ * catches `-e GITHUB_TOKEN=ghp_xxx` or a raw `service.lines` `Environment=SECRET=xxx`. */
48
+ const EMBEDDED_SECRET_ASSIGNMENT = new RegExp(
49
+ `[A-Za-z0-9_]*(?:${SECRET_KEY_NAME.source})[A-Za-z0-9_]*\\s*=\\s*\\S+`,
50
+ 'i',
51
+ );
52
+
53
+ /** `-e`/`--env` (and their `=`-joined and glued forms) in `PodmanArgs=`: refused OUTRIGHT, not only
54
+ * when the value looks secret — this resource already has a dedicated, secret-checked way to set a
55
+ * container env var (`container.environment`/`container.environmentFile`), so a raw `PodmanArgs=`
56
+ * env flag is always the wrong tool here, whatever it carries. */
57
+ const ENV_FLAG = /^(?:-e|--env)(?:=|$|[A-Za-z_])/;
58
+
59
+ export const secretLikeEnvironment = (environment: Readonly<Record<string, string>>): string[] => {
60
+ const found: string[] = [];
61
+ for (const [key, value] of Object.entries(environment)) {
62
+ if (SECRET_KEY_NAME.test(key)) {
63
+ found.push(
64
+ `environment key ${JSON.stringify(key)} looks like a secret by its name. Environment= is ` +
65
+ 'rendered into a world-readable file and stored in Alchemy state; use environmentFile ' +
66
+ 'instead, pointing at a host path a secret renderer maintains.',
67
+ );
68
+ } else if (SECRET_VALUE_PREFIX.test(value)) {
69
+ found.push(
70
+ `environment key ${JSON.stringify(key)}'s value looks like a credential. Environment= is ` +
71
+ 'rendered into a world-readable file and stored in Alchemy state; use environmentFile ' +
72
+ 'instead, pointing at a host path a secret renderer maintains.',
73
+ );
74
+ }
75
+ }
76
+ return found;
77
+ };
78
+
79
+ /** The same two heuristics as `secretLikeEnvironment`, over any OTHER rendered `[key, value]` line
80
+ * (`Exec=`, `PodmanArgs=`, and the `unit.lines`/`service.lines` escape hatches) — see file header. */
81
+ export const secretLikeLines = (
82
+ lines: readonly (readonly [key: string, value: string])[],
83
+ ): string[] => {
84
+ const found: string[] = [];
85
+ for (const [key, value] of lines) {
86
+ if (SECRET_VALUE_ANYWHERE.test(value)) {
87
+ found.push(
88
+ `${key}=${JSON.stringify(value)} looks like it carries a credential by its value. This ` +
89
+ 'line is rendered into a world-readable file and stored in Alchemy state; use ' +
90
+ 'container.environmentFile instead, pointing at a host path a secret renderer maintains.',
91
+ );
92
+ } else if (EMBEDDED_SECRET_ASSIGNMENT.test(value)) {
93
+ found.push(
94
+ `${key}=${JSON.stringify(value)} embeds a KEY=VALUE pair whose key looks like a secret ` +
95
+ 'name. This line is rendered into a world-readable file and stored in Alchemy state; use ' +
96
+ 'container.environment (checked for secrets) or container.environmentFile instead.',
97
+ );
98
+ }
99
+ }
100
+ return found;
101
+ };
102
+
103
+ /** `PodmanArgs=` entries that try to set a container env var outright — see `ENV_FLAG`'s comment. */
104
+ export const podmanArgsProblems = (args: readonly string[]): string[] =>
105
+ args
106
+ .filter((arg) => ENV_FLAG.test(arg))
107
+ .map(
108
+ (arg) =>
109
+ `podmanArgs entry ${JSON.stringify(arg)} sets a container environment variable outside ` +
110
+ 'the typed props, which this family never checks for a secret. Use container.environment ' +
111
+ '(checked) or container.environmentFile (for anything secret) instead.',
112
+ );
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The step that decides whether the GENERATED `<name>.service` actually moves: running state and —
3
+ * only for a real change — a restart. Split out for the same reason unit-settle.ts is split from
4
+ * unit-lifecycle.ts: it is the restart contract, and it should be readable on its own.
5
+ *
6
+ * ⛔ THE RESTART CONDITION IS THE WHOLE CONTRACT, same as `Systemd.Unit`'s (unit-settle.ts):
7
+ * `changed` comes from the caller (container-lifecycle.ts `reconcileContainer`), `configChanged`
8
+ * is a digest the DECLARATION itself listed. Nothing else restarts a container.
9
+ * ★ NO ENABLE/DISABLE HERE — the one place this file is SHORTER than unit-settle.ts, not longer.
10
+ * Quadlet's generator applies `[Install]` itself on every `daemon-reload` (container-generator.ts
11
+ * header); calling `systemctl enable`/`disable` on a generated unit does not work at all (same
12
+ * header, doc-cited). So there is no enablement branch to settle — only started/stopped and the
13
+ * restart-on-change rule.
14
+ */
15
+ import type { HostRunner } from '../launchd/runner.ts';
16
+ import { isUnitRunning } from './unit-form.ts';
17
+ import {
18
+ type UnitStatus,
19
+ daemonReload,
20
+ restartUnit,
21
+ showUnit,
22
+ startUnit,
23
+ stopUnit,
24
+ } from './systemctl.ts';
25
+ import {
26
+ type ContainerAttributes,
27
+ type ContainerProps,
28
+ refuseContainer,
29
+ } from './container-form.ts';
30
+
31
+ export const attributesOf = (
32
+ props: ContainerProps,
33
+ configSha256: string,
34
+ containerSha256: string,
35
+ containerPath: string,
36
+ serviceName: string,
37
+ status: UnitStatus,
38
+ ): ContainerAttributes => ({
39
+ active: status.activeState === 'active' || status.activeState === 'activating',
40
+ activeState: status.activeState,
41
+ configSha256,
42
+ containerPath,
43
+ containerSha256,
44
+ loadState: status.loadState,
45
+ name: props.name,
46
+ serviceName,
47
+ ...(status.unitFileState === undefined ? {} : { unitFileState: status.unitFileState }),
48
+ ...(status.sourcePath === undefined ? {} : { sourcePath: status.sourcePath }),
49
+ });
50
+
51
+ export type Settle = {
52
+ readonly before: Uint8Array | undefined;
53
+ /** The `.container` file or systemd's view of it changed in a way that earns a restart. */
54
+ readonly changed: boolean;
55
+ readonly configChanged: boolean;
56
+ readonly desired: string;
57
+ readonly path: string;
58
+ readonly serviceName: string;
59
+ readonly status: UnitStatus;
60
+ };
61
+
62
+ /**
63
+ * Bring running state to the declaration, restarting ONLY for a real change. No enable/disable —
64
+ * see file header.
65
+ */
66
+ export const settle = async (
67
+ runner: HostRunner,
68
+ props: ContainerProps,
69
+ configSha256: string,
70
+ step: Settle,
71
+ ): Promise<ContainerAttributes> => {
72
+ const wantStarted = props.started !== false;
73
+ const running = isUnitRunning(step.status.activeState, step.status.subState, wantStarted);
74
+ const undo = async (detail: string): Promise<never> => {
75
+ // ⚠️ A create that never came up leaves nothing behind; an update keeps the new file — the
76
+ // generator-verification step already ran (container-lifecycle.ts) by the time settle is
77
+ // called, so the unit DOES exist here; only the start/restart itself failed.
78
+ if (step.before === undefined) {
79
+ await runner.removeFile(step.path).catch(() => undefined);
80
+ await daemonReload(runner).catch(() => undefined);
81
+ }
82
+ throw refuseContainer(props.name, `${detail} The container's unit is NOT running.`);
83
+ };
84
+ try {
85
+ if (!wantStarted) {
86
+ if (running) await stopUnit(runner, step.serviceName);
87
+ } else if (!running) await startUnit(runner, step.serviceName);
88
+ else if (step.changed || step.configChanged) await restartUnit(runner, step.serviceName);
89
+ } catch (cause) {
90
+ return undo(cause instanceof Error ? cause.message : String(cause));
91
+ }
92
+ const after = await showUnit(runner, step.serviceName);
93
+ if (wantStarted && !isUnitRunning(after.activeState, after.subState, wantStarted)) {
94
+ return undo(`systemctl reported success but the unit is ${after.activeState}.`);
95
+ }
96
+ return attributesOf(props, configSha256, step.desired, step.path, step.serviceName, after);
97
+ };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `Podman.Container` — one Quadlet `.container` file, declared, plus the systemd unit Podman's own
3
+ * generator turns it into.
4
+ *
5
+ * - create / update — render, write the `.container` file atomically, `daemon-reload`, VERIFY the
6
+ * generator actually produced `<name>.service` (container-generator.ts), then start or RESTART
7
+ * the generated unit only when something changed (container-lifecycle.ts, container-settle.ts).
8
+ * - read — the `.container` file's SHA-256 plus `systemctl show <name>.service`.
9
+ * - diff — the rendered digest against stored and on-disk, running state against the declaration,
10
+ * and systemd's own `NeedDaemonReload`.
11
+ * - replace — only when the file's name or directory changes, delete-first.
12
+ * - delete — stop the generated unit, remove the `.container` file, reload.
13
+ *
14
+ * ⛔ A DEPLOY NEVER MASS-RESTARTS — see container-lifecycle.ts's header.
15
+ * ⛔ THE `.container` FILE IS NEVER A SECRET, and `[Container] Environment=` is refused if it looks
16
+ * like one — see container-form.ts and container-secrets.ts.
17
+ * ⛔ THE GENERATED UNIT IS NEVER `systemctl enable`D — see container-generator.ts's header for why,
18
+ * measured and doc-cited, and what stands in for it (`install.wantedBy`).
19
+ * ⚠️ NOTHING IS ADOPTED WITHOUT `--adopt` — same contract as every family here.
20
+ */
21
+ import { Resource } from 'alchemy';
22
+ import * as Provider from 'alchemy/Provider';
23
+ import * as Effect from 'effect/Effect';
24
+ import { HostRunnerService } from '../launchd/runner.ts';
25
+ import type { ContainerAttributes, ContainerProps } from './container-form.ts';
26
+ import { deleteHandler, diffHandler, readHandler, reconcileHandler } from './container-handlers.ts';
27
+
28
+ export type {
29
+ ContainerAttributes as PodmanContainerAttributes,
30
+ ContainerProps as PodmanContainerProps,
31
+ ContainerSection,
32
+ UnitLine as PodmanUnitLine,
33
+ } from './container-form.ts';
34
+
35
+ export interface PodmanContainer extends Resource<
36
+ 'Podman.Container',
37
+ ContainerProps,
38
+ ContainerAttributes
39
+ > {}
40
+
41
+ export const PodmanContainer = Resource<PodmanContainer>('Podman.Container');
42
+
43
+ export const PodmanContainerProvider = () =>
44
+ Provider.effect(
45
+ PodmanContainer,
46
+ Effect.gen(function* () {
47
+ const runner = yield* HostRunnerService;
48
+ return PodmanContainer.Provider.of({
49
+ /**
50
+ * ⛔ Same rule as `Systemd.Unit`'s `list` (unit.ts): systemd's unit list is every vendor,
51
+ * package and hand-written unit on the host, not what this stack owns.
52
+ */
53
+ list: () => Effect.succeed([]),
54
+ read: ({ olds, output }) => readHandler(runner, olds, output),
55
+ diff: ({ instanceId, news, output }) => diffHandler(runner, instanceId, news, output),
56
+ reconcile: (args) => reconcileHandler(runner, args),
57
+ delete: ({ output }) => deleteHandler(runner, output),
58
+ });
59
+ }),
60
+ );
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The Quadlet half of the fake Linux host, layered on top of `fake-linux-host.ts`: a `daemon-reload`
3
+ * that turns `.container` files into generated units the way `podman-system-generator` does, and a
4
+ * `systemctl enable`/`disable` that FAILS the way it does on a live host.
5
+ *
6
+ * ⛔ TEST-ONLY, and not on the barrel — same rule as `fake-linux-host.ts`.
7
+ * ★ WHAT IT MODELS, each cited in container-generator.ts's header: `daemon-reload` deletes and
8
+ * regenerates ALL generator output, so a `.container` file the (fake) generator refuses has no
9
+ * generated unit afterwards, even if one existed a moment ago; a generated unit carries
10
+ * `SourcePath=` back to its `.container` file; `enable`/`disable` on a generated unit fails.
11
+ * ⚠️ WHAT IT DOES NOT MODEL: `UnitFileState` still reads `enabled`/`disabled` from the shared
12
+ * `fake-systemd.ts`, not the real `generated`. Nothing in `container-lifecycle.ts` branches on
13
+ * that field — only `LoadState`, `ActiveState`/`SubState` and `SourcePath` drive any decision —
14
+ * so a fresh string there would cost a fork of `fake-systemd.ts` for no behaviour under test.
15
+ */
16
+ import { sha256Hex } from '../launchd/job-form.ts';
17
+ import { type FakeLinuxOptions, fakeLinuxHost } from './fake-linux-host.ts';
18
+ import { GENERATED_UNIT_DIRECTORY } from './container-generator.ts';
19
+ import { QUADLET_SEARCH_DIRECTORIES } from './container-form.ts';
20
+ import type { ExecResult, HostRunner } from '../launchd/runner.ts';
21
+
22
+ /** A line no real Quadlet key ever spells, so a test can force the (fake) generator to refuse a file. */
23
+ export const FAIL_GENERATOR_MARKER = 'X-Test-Fail-Generator=1';
24
+
25
+ const digest = (text: string) => sha256Hex(new TextEncoder().encode(text));
26
+ const decode = (bytes: Uint8Array | undefined) =>
27
+ bytes === undefined ? undefined : new TextDecoder().decode(bytes);
28
+ const fail = (exitCode: number, stderr: string): ExecResult => ({ exitCode, stderr, stdout: '' });
29
+
30
+ export const fakeQuadletHost = (options: FakeLinuxOptions = {}) => {
31
+ const base = fakeLinuxHost({
32
+ dirs: { '/etc/systemd/system': 0, [QUADLET_SEARCH_DIRECTORIES[1]]: 0, ...options.dirs },
33
+ ...options,
34
+ });
35
+ /** service name -> the `.container` path the (fake) generator last produced it from. */
36
+ const sourcePaths = new Map<string, string>();
37
+
38
+ const isQuadletFile = (path: string) =>
39
+ path.endsWith('.container') &&
40
+ QUADLET_SEARCH_DIRECTORIES.some((dir) => path.startsWith(`${dir}/`));
41
+
42
+ const serviceFor = (containerPath: string) =>
43
+ `${containerPath.slice(containerPath.lastIndexOf('/') + 1).replace(/\.container$/, '')}.service`;
44
+
45
+ /**
46
+ * ★ MEASURED (systemd.generator(7)): reload wipes and reruns generators from what is on disk
47
+ * now. So a stale generated file at `GENERATED_UNIT_DIRECTORY` is removed HERE for every
48
+ * service this reload does not (re)confirm — otherwise `fake-systemd.ts`'s own "a file
49
+ * appeared" sweep would find that stale file still sitting there and resurrect the unit from
50
+ * it, which is exactly backwards: real `daemon-reload` deletes it first.
51
+ * ⚠️ REASONED, NOT MEASURED: what happens to an ALREADY-ACTIVE unit's `LoadState` when the file
52
+ * that generated it is wiped and the replacement fails — see container-generator.ts's header.
53
+ * This fake takes the CONSERVATIVE reading (the stale definition stops being `loaded`), which
54
+ * is the safer direction to be wrong in for a test suite: it exercises the refusal path this
55
+ * family relies on rather than silently passing a scenario a real host might handle more
56
+ * leniently through unit garbage-collection.
57
+ */
58
+ const regenerate = () => {
59
+ const seen = new Set<string>();
60
+ for (const [path, entry] of base.files) {
61
+ if (!isQuadletFile(path)) continue;
62
+ const service = serviceFor(path);
63
+ seen.add(service);
64
+ const text = decode(entry.bytes) ?? '';
65
+ const generatedPath = `${GENERATED_UNIT_DIRECTORY}/${service}`;
66
+ const existing = base.units.get(service);
67
+ if (text.includes(FAIL_GENERATOR_MARKER)) {
68
+ sourcePaths.delete(service);
69
+ base.files.delete(generatedPath);
70
+ // ★ Wiped, not merely stale: a unit that is not currently active stops being known at all,
71
+ // same rule `fake-systemd.ts`'s own sweep already applies when a unit file disappears.
72
+ if (existing === undefined) continue;
73
+ if (existing.active) existing.loadedSha = undefined;
74
+ else base.units.delete(service);
75
+ continue;
76
+ }
77
+ sourcePaths.set(service, path);
78
+ base.placeUnit(generatedPath, text, {
79
+ active: existing?.active ?? false,
80
+ enabled: false,
81
+ loadedSha: digest(text),
82
+ ...(existing?.activeState === undefined ? {} : { activeState: existing.activeState }),
83
+ ...(existing?.subState === undefined ? {} : { subState: existing.subState }),
84
+ });
85
+ }
86
+ // A generated unit whose `.container` file is gone stops being generated — remove the stale
87
+ // generated file (same reasoning as above) and, once it is not running, the unit itself.
88
+ for (const service of sourcePaths.keys()) {
89
+ if (seen.has(service)) continue;
90
+ sourcePaths.delete(service);
91
+ base.files.delete(`${GENERATED_UNIT_DIRECTORY}/${service}`);
92
+ const existing = base.units.get(service);
93
+ if (existing === undefined) continue;
94
+ if (existing.active) existing.loadedSha = undefined;
95
+ else base.units.delete(service);
96
+ }
97
+ };
98
+
99
+ const runner: HostRunner = {
100
+ ...base.runner,
101
+ exec: async (argv) => {
102
+ const verb = argv[1];
103
+ if (argv[0] === 'systemctl' && (verb === 'enable' || verb === 'disable')) {
104
+ // ⛔ MEASURED-DOC (podman-systemd.unit(5)): a generated unit is transient; enable/disable
105
+ // fails. Logged via `base.calls` so a test can assert it was never even ATTEMPTED.
106
+ base.calls.push([...argv]);
107
+ return fail(
108
+ 1,
109
+ `Failed to ${verb} unit: unit ${argv.at(-1)} is transient or was generated by a ` +
110
+ 'generator, and cannot be enabled/disabled (podman-systemd.unit(5)).',
111
+ );
112
+ }
113
+ if (argv[0] === 'systemctl' && verb === 'daemon-reload') regenerate();
114
+ const result = await base.runner.exec(argv);
115
+ if (argv[0] === 'systemctl' && verb === 'show') {
116
+ const name = argv.at(-1) ?? '';
117
+ return {
118
+ ...result,
119
+ stdout: `${result.stdout}\nSourcePath=${sourcePaths.get(name) ?? ''}\n`,
120
+ };
121
+ }
122
+ return result;
123
+ },
124
+ };
125
+
126
+ return { ...base, runner };
127
+ };
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Linux host providers for Alchemy — a host's directories, its files (whole, or one managed block
3
- * inside somebody else's file) and its systemd units, over the SAME HostRunner seam the launchd
4
- * subpath drives a Mac through.
3
+ * inside somebody else's file), its systemd units and its Podman Quadlet containers, over the SAME
4
+ * HostRunner seam the launchd subpath drives a Mac through.
5
5
  *
6
6
  * ⛔ THIS BARREL IS THE PUBLIC API, AND IT IS DELIBERATELY SMALLER THAN THE DIRECTORY. The lifecycle
7
7
  * files, the systemctl parsers and the fake host are internals; an `export *` would publish them
8
8
  * as API and make the next refactor a breaking change.
9
9
  * ⛔ A DEPLOY NEVER MASS-RESTARTS: a unit is restarted only when its own file changed, or a digest
10
- * the declaration listed changed. See docs/linux-host.md.
10
+ * the declaration listed changed. See docs/linux-host.md and docs/quadlet-container.md.
11
11
  * ★ THE SEAM IS SHARED, NOT COPIED. `HostRunner` is re-exported here so a Linux stack never has to
12
12
  * import from the launchd subpath to type its own runner.
13
13
  */
@@ -32,3 +32,11 @@ export { HostRunnerService, canActAsRoot, hostRunnerLayer } from '../launchd/run
32
32
  export type { SystemdUnitAttributes, SystemdUnitProps, UnitSection } from './unit-form.ts';
33
33
  export { DEFAULT_UNIT_DIRECTORY, renderUnit } from './unit-form.ts';
34
34
  export { SystemdTimer, SystemdTimerProvider, SystemdUnit, SystemdUnitProvider } from './unit.ts';
35
+ export type {
36
+ ContainerSection,
37
+ PodmanContainerAttributes,
38
+ PodmanContainerProps,
39
+ PodmanUnitLine,
40
+ } from './container.ts';
41
+ export { QUADLET_DEFAULT_DIRECTORY, QUADLET_SEARCH_DIRECTORIES } from './container-form.ts';
42
+ export { PodmanContainer, PodmanContainerProvider } from './container.ts';
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import * as Layer from 'effect/Layer';
15
15
  import { type HostRunner, hostRunnerLayer } from '../launchd/runner.ts';
16
+ import { PodmanContainerProvider } from './container.ts';
16
17
  import { HostDirectoryProvider } from './directory.ts';
17
18
  import { RemoteFileProvider } from './remote-file.ts';
18
19
  import { SystemdTimerProvider, SystemdUnitProvider } from './unit.ts';
@@ -20,6 +21,7 @@ import { SystemdTimerProvider, SystemdUnitProvider } from './unit.ts';
20
21
  export const linuxProviders = (runner: HostRunner) =>
21
22
  Layer.mergeAll(
22
23
  HostDirectoryProvider(),
24
+ PodmanContainerProvider(),
23
25
  RemoteFileProvider(),
24
26
  SystemdTimerProvider(),
25
27
  SystemdUnitProvider(),
@@ -26,6 +26,10 @@
26
26
  * the state is read back with `show` afterwards rather than assumed.
27
27
  * ⛔ NEVER PUT `show` STDOUT WITH NO PROPERTY FILTER IN AN ERROR: the unfiltered property set
28
28
  * carries the unit's whole environment block.
29
+ * ★ `SourcePath` ADDED 2026-09-24 for `Podman.Container` (container-generator.ts): a systemd
30
+ * GENERATOR's output unit carries the source file it was generated from — MEASURED on CT100,
31
+ * `systemctl show caddy.service -p SourcePath` -> `/etc/containers/systemd/caddy.container`. An
32
+ * ordinary hand-written unit's `SourcePath` is empty, so this is additive for `Systemd.Unit`.
29
33
  */
30
34
  import type { HostRunner } from '../launchd/runner.ts';
31
35
 
@@ -39,6 +43,7 @@ export const SHOW_PROPERTIES = [
39
43
  'SubState',
40
44
  'UnitFileState',
41
45
  'FragmentPath',
46
+ 'SourcePath',
42
47
  'NeedDaemonReload',
43
48
  ] as const;
44
49
 
@@ -60,6 +65,8 @@ export type UnitStatus = {
60
65
  readonly unitFileState?: string;
61
66
  /** The unit file systemd would read. Empty when there is none. */
62
67
  readonly fragmentPath?: string;
68
+ /** A GENERATOR's source file, e.g. Quadlet's `.container` file. Empty for a hand-written unit. */
69
+ readonly sourcePath?: string;
63
70
  /** systemd's own view that its on-disk unit files are newer than what it has loaded. */
64
71
  readonly needDaemonReload: boolean;
65
72
  };
@@ -81,6 +88,7 @@ export const statusOf = (stdout: string): UnitStatus => {
81
88
  const loadState = fields.get('LoadState') ?? 'not-found';
82
89
  const unitFileState = fields.get('UnitFileState') ?? '';
83
90
  const fragmentPath = fields.get('FragmentPath') ?? '';
91
+ const sourcePath = fields.get('SourcePath') ?? '';
84
92
  const subState = fields.get('SubState') ?? '';
85
93
  return {
86
94
  activeState: fields.get('ActiveState') ?? 'inactive',
@@ -92,6 +100,7 @@ export const statusOf = (stdout: string): UnitStatus => {
92
100
  ...(subState === '' ? {} : { subState }),
93
101
  ...(unitFileState === '' ? {} : { unitFileState }),
94
102
  ...(fragmentPath === '' ? {} : { fragmentPath }),
103
+ ...(sourcePath === '' ? {} : { sourcePath }),
95
104
  };
96
105
  };
97
106