@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,73 @@
1
+ /**
2
+ * `Podman.Container`'s four provider handlers, as plain functions over a HostRunner — the same
3
+ * split as `unit-handlers.ts`, so `container.ts`'s `Provider.of` stays a thin wire-up.
4
+ */
5
+ import { Unowned } from 'alchemy/AdoptPolicy';
6
+ import type { Diff } from 'alchemy/Diff';
7
+ import { isResolved } from 'alchemy/Diff';
8
+ import type { Input } from 'alchemy/Input';
9
+ import * as Effect from 'effect/Effect';
10
+ import { lift, resolvedString } from '../launchd/host-effect.ts';
11
+ import type { HostRunner } from '../launchd/runner.ts';
12
+ import { adoptsAtApply } from '../ownership/adopt.ts';
13
+ import { noteUnfinished } from '../ownership/resume.ts';
14
+ import type { ContainerAttributes, ContainerProps } from './container-form.ts';
15
+ import {
16
+ deleteContainer,
17
+ diffContainer,
18
+ readContainer,
19
+ reconcileContainer,
20
+ } from './container-lifecycle.ts';
21
+ import { assertRenameTarget } from './container-preflight.ts';
22
+
23
+ export const readHandler = (
24
+ runner: HostRunner,
25
+ olds: ContainerProps,
26
+ output: ContainerAttributes | undefined,
27
+ ): Effect.Effect<ContainerAttributes | undefined, Error> =>
28
+ lift(async () => {
29
+ const found = await readContainer(runner, olds);
30
+ if (found === undefined) return undefined;
31
+ return output === undefined ? Unowned(found) : found;
32
+ });
33
+
34
+ export const diffHandler = (
35
+ runner: HostRunner,
36
+ instanceId: string,
37
+ news: Input<ContainerProps>,
38
+ output: ContainerAttributes | undefined,
39
+ ): Effect.Effect<Diff | undefined, Error> => {
40
+ // ★ An unfinished generation of our own: `--adopt` may resume it (ownership/resume.ts).
41
+ if (output === undefined) return noteUnfinished(instanceId);
42
+ if (isResolved(news)) return lift(() => diffContainer(runner, news, output));
43
+ /**
44
+ * ⛔ A RENAME MUST BE SEEN EVEN WHILE OTHER PROPS ARE UNRESOLVED — same reason as
45
+ * `unit-handlers.ts`'s `diffHandler`: the engine's default for a prop change is `update`, and
46
+ * an update under a new name writes the new `.container` file and leaves the old generated
47
+ * unit running with nothing in state to ever remove it.
48
+ */
49
+ const name = resolvedString(news, 'name');
50
+ if (name === undefined || name === output.name) return Effect.succeed(undefined);
51
+ return lift(async () => {
52
+ await assertRenameTarget(runner, output, name);
53
+ return { action: 'replace' as const, deleteFirst: true };
54
+ });
55
+ };
56
+
57
+ export const reconcileHandler = (
58
+ runner: HostRunner,
59
+ args: {
60
+ fqn: string;
61
+ instanceId: string;
62
+ news: ContainerProps;
63
+ output: ContainerAttributes | undefined;
64
+ },
65
+ ): Effect.Effect<ContainerAttributes, Error> =>
66
+ Effect.flatMap(adoptsAtApply(args), (adopt) =>
67
+ lift(() => reconcileContainer(runner, args.news, args.output, adopt)),
68
+ );
69
+
70
+ export const deleteHandler = (
71
+ runner: HostRunner,
72
+ output: ContainerAttributes,
73
+ ): Effect.Effect<void, Error> => lift(() => deleteContainer(runner, output));
@@ -0,0 +1,209 @@
1
+ /**
2
+ * `Podman.Container` against the fake Quadlet host — mirrors `unit-lifecycle.test.ts`'s shape and,
3
+ * like it, cares most about the NEGATIVE assertions: an adopted container that already matches is
4
+ * not restarted, a re-run restarts nothing, and a read that fails writes nothing. Generator-failure
5
+ * rollback and the never-enable guarantee are in container-generator.test.ts; secret refusal is in
6
+ * container-secrets.test.ts; the CT100 fixture is in container-fixture.test.ts.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { fakeQuadletHost } from './fake-quadlet.ts';
10
+ import { type ContainerProps, containerProblems } from './container-form.ts';
11
+ import {
12
+ deleteContainer,
13
+ diffContainer,
14
+ readContainer,
15
+ reconcileContainer,
16
+ } from './container-lifecycle.ts';
17
+
18
+ const NAME = 'hf-example';
19
+ const PATH = `/etc/containers/systemd/${NAME}.container`;
20
+ const SERVICE = `${NAME}.service`;
21
+
22
+ const props = (image: string): ContainerProps => ({
23
+ container: { image, network: 'host' },
24
+ install: { wantedBy: ['multi-user.target'] },
25
+ name: NAME,
26
+ service: { restart: 'always', restartSec: '5s' },
27
+ });
28
+
29
+ const host = () => fakeQuadletHost();
30
+ const verbs = (calls: string[][]) =>
31
+ calls.filter((call) => call[0] === 'systemctl').map((call) => call[1]);
32
+
33
+ /** `Map#get`, refused instead of `!`-asserted — a missing fixture is a test bug, not a null we hide. */
34
+ const must = <V>(map: Map<string, V>, key: string): V => {
35
+ const value = map.get(key);
36
+ if (value === undefined) throw new Error(`test fixture: nothing at ${key}`);
37
+ return value;
38
+ };
39
+
40
+ describe('create', () => {
41
+ test('writes, reloads, verifies the generator and starts — never enables', async () => {
42
+ const fake = host();
43
+ const attrs = await reconcileContainer(fake.runner, props('example/one:1'), undefined);
44
+ expect(attrs).toMatchObject({
45
+ active: true,
46
+ containerPath: PATH,
47
+ name: NAME,
48
+ serviceName: SERVICE,
49
+ });
50
+ expect(verbs(fake.calls)).toEqual(['show', 'daemon-reload', 'show', 'start', 'show']);
51
+ expect(fake.files.get(PATH)?.mode).toBe(0o644);
52
+ expect(fake.files.get(PATH)?.uid).toBe(0);
53
+ });
54
+
55
+ test('a start that fails removes the file this deploy wrote and says the unit is down', async () => {
56
+ const fake = host();
57
+ const broken = {
58
+ ...fake.runner,
59
+ exec: async (argv: readonly string[]) =>
60
+ argv[1] === 'start'
61
+ ? { exitCode: 1, stderr: 'Job failed', stdout: '' }
62
+ : fake.runner.exec(argv),
63
+ };
64
+ await expect(reconcileContainer(broken, props('example/one:1'), undefined)).rejects.toThrow(
65
+ /NOT running/,
66
+ );
67
+ expect(fake.files.has(PATH)).toBe(false);
68
+ });
69
+ });
70
+
71
+ describe('adoption', () => {
72
+ /** A fresh fake for the "adopt" half: nothing in state, the file already on disk and generated —
73
+ * exactly what a container Podman or another deploy started before this stack existed looks like. */
74
+ const adoptedHost = async () => {
75
+ const fake = host();
76
+ await reconcileContainer(fake.runner, props('example/one:1'), undefined);
77
+ const adopting = host();
78
+ adopting.files.set(PATH, must(fake.files, PATH));
79
+ await adopting.runner.exec(['systemctl', 'daemon-reload']); // the fake generator, as on a real host
80
+ must(adopting.units, SERVICE).active = true;
81
+ return adopting;
82
+ };
83
+
84
+ test('a live file that already matches is adopted without a restart', async () => {
85
+ const adopting = await adoptedHost();
86
+ const before = adopting.calls.length;
87
+ const attrs = await reconcileContainer(
88
+ adopting.runner,
89
+ props('example/one:1'),
90
+ undefined,
91
+ true,
92
+ );
93
+ expect(attrs).toMatchObject({ active: true, sourcePath: PATH });
94
+ const after = verbs(adopting.calls.slice(before));
95
+ expect(after).not.toContain('restart');
96
+ expect(after).not.toContain('daemon-reload');
97
+ });
98
+
99
+ test('and its diff is a noop, so a plan shows nothing to do', async () => {
100
+ const adopting = await adoptedHost();
101
+ const output = await reconcileContainer(
102
+ adopting.runner,
103
+ props('example/one:1'),
104
+ undefined,
105
+ true,
106
+ );
107
+ expect(await diffContainer(adopting.runner, props('example/one:1'), output)).toEqual({
108
+ action: 'noop',
109
+ });
110
+ });
111
+
112
+ test('a live file with other content is refused without --adopt', async () => {
113
+ const fake = host();
114
+ await reconcileContainer(fake.runner, props('example/one:1'), undefined);
115
+ const other = host();
116
+ other.files.set(PATH, must(fake.files, PATH));
117
+ await expect(
118
+ reconcileContainer(other.runner, props('example/two:2'), undefined),
119
+ ).rejects.toThrow(/--adopt/);
120
+ });
121
+ });
122
+
123
+ describe('restart happens exactly when something changed', () => {
124
+ test('a changed image restarts once', async () => {
125
+ const fake = host();
126
+ const output = await reconcileContainer(fake.runner, props('example/one:1'), undefined);
127
+ const before = fake.calls.length;
128
+ const next = props('example/two:2');
129
+ expect(await diffContainer(fake.runner, next, output)).toEqual({ action: 'update' });
130
+ const updated = await reconcileContainer(fake.runner, next, output);
131
+ const after = verbs(fake.calls.slice(before));
132
+ expect(after.filter((verb) => verb === 'restart')).toHaveLength(1);
133
+ expect(after).not.toContain('stop');
134
+ expect(updated.active).toBe(true);
135
+ });
136
+
137
+ test('a tracked config digest restarts the container without rewriting its file', async () => {
138
+ const fake = host();
139
+ const first = await reconcileContainer(
140
+ fake.runner,
141
+ { ...props('example/one:1'), restartOn: ['aaa'] },
142
+ undefined,
143
+ );
144
+ const next = { ...props('example/one:1'), restartOn: ['bbb'] };
145
+ expect(await diffContainer(fake.runner, next, first)).toEqual({ action: 'update' });
146
+ const before = fake.calls.length;
147
+ await reconcileContainer(fake.runner, next, first);
148
+ const after = fake.calls.slice(before);
149
+ expect(verbs(after)).toContain('restart');
150
+ expect(after.some((call) => call[0] === 'write')).toBe(false);
151
+ });
152
+
153
+ test('a re-run with nothing changed restarts nothing at all', async () => {
154
+ const fake = host();
155
+ const output = await reconcileContainer(fake.runner, props('example/one:1'), undefined);
156
+ const before = fake.calls.length;
157
+ await reconcileContainer(fake.runner, props('example/one:1'), output);
158
+ const after = verbs(fake.calls.slice(before));
159
+ expect(after).toEqual(['show', 'show']);
160
+ });
161
+
162
+ test('started: false stops a running container and does not restart it', async () => {
163
+ const fake = host();
164
+ const output = await reconcileContainer(fake.runner, props('example/one:1'), undefined);
165
+ const stopped = await reconcileContainer(
166
+ fake.runner,
167
+ { ...props('example/one:1'), started: false },
168
+ output,
169
+ );
170
+ expect(stopped.active).toBe(false);
171
+ expect(verbs(fake.calls)).toContain('stop');
172
+ expect(verbs(fake.calls)).not.toContain('restart');
173
+ });
174
+ });
175
+
176
+ describe('refusals', () => {
177
+ test('an unprivileged runner refuses before it touches anything', async () => {
178
+ const fake = fakeQuadletHost({ euid: 1001 });
179
+ await expect(
180
+ reconcileContainer(fake.runner, props('example/one:1'), undefined),
181
+ ).rejects.toThrow(/never calls sudo/);
182
+ expect(fake.calls).toHaveLength(0);
183
+ });
184
+
185
+ test('container.image is required', () => {
186
+ const bad = {
187
+ ...props('example/one:1'),
188
+ container: { ...props('example/one:1').container, image: ' ' },
189
+ };
190
+ expect(containerProblems(bad).join(' ')).toMatch(/image is required/);
191
+ });
192
+
193
+ test('a directory outside Quadlet’s search path is refused', () => {
194
+ const elsewhere = { ...props('example/one:1'), directory: '/opt/quadlet' };
195
+ expect(containerProblems(elsewhere).join(' ')).toMatch(/rootful search path/);
196
+ });
197
+ });
198
+
199
+ describe('delete', () => {
200
+ test('stops, removes the file and reloads — never disables', async () => {
201
+ const fake = host();
202
+ const output = await reconcileContainer(fake.runner, props('example/one:1'), undefined);
203
+ const before = fake.calls.length;
204
+ await deleteContainer(fake.runner, output);
205
+ expect(verbs(fake.calls.slice(before))).toEqual(['show', 'stop', 'daemon-reload']);
206
+ expect(fake.files.has(PATH)).toBe(false);
207
+ expect(await readContainer(fake.runner, props('example/one:1'))).toBeUndefined();
208
+ });
209
+ });
@@ -0,0 +1,217 @@
1
+ /**
2
+ * `Podman.Container`'s read / diff / reconcile / delete as plain async functions over a
3
+ * HostRunner — same split as `unit-lifecycle.ts`, so the whole lifecycle runs against a fake host
4
+ * (fake-quadlet.ts) with no real systemd or Podman.
5
+ *
6
+ * ⛔ WHAT A DEPLOY DOES NOT DO — same rule `Systemd.Unit` was built around (unit-lifecycle.ts
7
+ * header): it NEVER mass-restarts. A container is restarted only when THIS resource's rendered
8
+ * `.container` file changed, or a digest the declaration listed in `restartOn` changed.
9
+ * ★ THE ORDER IS WRITE, RELOAD, VERIFY THE GENERATOR, THEN START OR RESTART. `daemon-reload` is
10
+ * where Quadlet turns the `.container` file into `<name>.service` (container-generator.ts) —
11
+ * writing it changes nothing on its own, and a write that fails leaves the old file (and its
12
+ * already-generated unit) exactly as they were.
13
+ * ⛔ A GENERATOR FAILURE IS ROLLED BACK, NOT LEFT FOR THE NEXT DEPLOY. `container-generator.ts`'s
14
+ * header explains why: `daemon-reload` deletes ALL generator output before regenerating, so a
15
+ * `.container` file the generator refuses has NO generated unit at all, even if the file it
16
+ * replaced generated one fine. An UPDATE that fails verification restores the previous file and
17
+ * reloads again, so the container this resource already promised running never loses its unit; a
18
+ * CREATE that fails removes the file it wrote, same as `Systemd.Unit`'s undo.
19
+ * ⛔ NO ENABLE/DISABLE — see container-generator.ts and container-settle.ts headers.
20
+ */
21
+ import type { Diff } from 'alchemy/Diff';
22
+ import type { HostRunner } from '../launchd/runner.ts';
23
+ import { UNIT_WRITE, isUnitRunning } from './unit-form.ts';
24
+ import { daemonReload, showUnit, stopUnit } from './systemctl.ts';
25
+ import { needsVerificationReload, verifyGenerated } from './container-generator.ts';
26
+ import {
27
+ type ContainerAttributes,
28
+ type ContainerProps,
29
+ configDigest,
30
+ containerPathFor,
31
+ digestOf,
32
+ refuseContainer,
33
+ renderContainerFile,
34
+ serviceNameFor,
35
+ } from './container-form.ts';
36
+ import {
37
+ assertMayWrite,
38
+ assertReplaceable,
39
+ assertUsable,
40
+ assertValid,
41
+ } from './container-preflight.ts';
42
+ import { attributesOf, settle } from './container-settle.ts';
43
+
44
+ const encoder = new TextEncoder();
45
+ const decoder = new TextDecoder();
46
+
47
+ /** What is on the host now: the `.container` file's digest and the GENERATED unit's status. */
48
+ export const readContainer = async (
49
+ runner: HostRunner,
50
+ props: ContainerProps,
51
+ ): Promise<ContainerAttributes | undefined> => {
52
+ const path = containerPathFor(props);
53
+ const bytes = await runner.readFile(path);
54
+ const status = await showUnit(runner, serviceNameFor(props));
55
+ if (bytes === undefined && !status.known) return undefined;
56
+ const sha = bytes === undefined ? '' : digestOf(decoder.decode(bytes));
57
+ return attributesOf(
58
+ props,
59
+ configDigest(props.restartOn),
60
+ sha,
61
+ path,
62
+ serviceNameFor(props),
63
+ status,
64
+ );
65
+ };
66
+
67
+ export const diffContainer = async (
68
+ runner: HostRunner,
69
+ news: ContainerProps,
70
+ output: ContainerAttributes,
71
+ ): Promise<Diff> => {
72
+ assertValid(news);
73
+ // ★ The name or directory is the `.container` file's identity: a change is delete-first, same
74
+ // reason as `Systemd.Unit` (unit-lifecycle.ts diffUnit) — two files for one name can never
75
+ // both be the one Quadlet reads, and here also never both generate the same `.service`.
76
+ if (news.name !== output.name || containerPathFor(news) !== output.containerPath) {
77
+ await assertReplaceable(runner, news, output);
78
+ return { action: 'replace', deleteFirst: true };
79
+ }
80
+ const desired = digestOf(renderContainerFile(news));
81
+ const update = async (): Promise<Diff> => {
82
+ assertMayWrite(runner, news.name);
83
+ await runner.checkWrite?.(containerPathFor(news), UNIT_WRITE);
84
+ return { action: 'update' };
85
+ };
86
+ if (desired !== output.containerSha256) return update();
87
+ if (configDigest(news.restartOn) !== output.configSha256) return update();
88
+ const live = await readContainer(runner, news);
89
+ if (live === undefined || live.containerSha256 !== desired) return update();
90
+ const status = await showUnit(runner, serviceNameFor(news));
91
+ const wantStarted = news.started !== false;
92
+ if (isUnitRunning(status.activeState, status.subState, wantStarted) !== wantStarted)
93
+ return update();
94
+ return status.needDaemonReload ? update() : { action: 'noop' };
95
+ };
96
+
97
+ /**
98
+ * `adopt` is whether adoption is on AND this apply is a create or an unfinished generation of our
99
+ * own — same meaning as `Systemd.Unit`'s (unit-lifecycle.ts reconcileUnit).
100
+ */
101
+ export const reconcileContainer = async (
102
+ runner: HostRunner,
103
+ props: ContainerProps,
104
+ output: ContainerAttributes | undefined,
105
+ adopt = false,
106
+ ): Promise<ContainerAttributes> => {
107
+ assertValid(props);
108
+ assertMayWrite(runner, props.name);
109
+ const path = containerPathFor(props);
110
+ const serviceName = serviceNameFor(props);
111
+ const text = renderContainerFile(props);
112
+ const desired = digestOf(text);
113
+ const before = await runner.readFile(path);
114
+ const liveSha = before === undefined ? undefined : digestOf(decoder.decode(before));
115
+ const moved =
116
+ output !== undefined && (output.name !== props.name || output.containerPath !== path);
117
+ if (moved && output !== undefined) await assertReplaceable(runner, props, output);
118
+ const prior = moved ? undefined : output;
119
+ if (
120
+ prior === undefined &&
121
+ !(adopt && output === undefined) &&
122
+ liveSha !== undefined &&
123
+ liveSha !== desired
124
+ ) {
125
+ throw refuseContainer(
126
+ props.name,
127
+ `${path} is already on this host and is not this resource. Remove it, or declare it as a ` +
128
+ 'new resource and deploy with --adopt.',
129
+ );
130
+ }
131
+ if (moved && output !== undefined) await deleteContainer(runner, output);
132
+ const wrote = liveSha !== desired;
133
+ /**
134
+ * ⛔ MUST GATE THE RELOAD, NOT ONLY THE RESTART — found on adversarial review. `wrote` and
135
+ * `preStatus.needDaemonReload` both describe the GENERATED `<name>.service`'s own staleness;
136
+ * neither can see that the SOURCE `.container` file was written by an apply that then crashed
137
+ * (ssh drop, reboot) before its `daemon-reload` ran. On the retry: the file already holds
138
+ * `desired` (`wrote` is false), and systemd's own flag is unset because the generated unit —
139
+ * still built from the OLD content — hasn't changed either. Without `stale` here, no reload
140
+ * happens, `verifyGenerated` passes against that stale generation, and `settle` (below) would
141
+ * restart the container onto its OLD definition while returning `containerSha256: desired` —
142
+ * state then claims the new declaration took effect, permanently and silently, since every
143
+ * later diff compares state to itself. `stale` is exactly "the file's content no longer matches
144
+ * what THIS resource last recorded as generated", which is the one signal that catches it.
145
+ * Test: container-generator.test.ts, "an interrupted apply that crashed before daemon-reload".
146
+ */
147
+ const stale = prior !== undefined && prior.containerSha256 !== desired;
148
+ const preStatus = await showUnit(runner, serviceName);
149
+ assertUsable(props, preStatus);
150
+ /**
151
+ * ⛔ `prior === undefined` (a create, or an adopt) AND `needsVerificationReload` — found on
152
+ * adversarial review, the same bug class as `stale` above but for a resource with no prior
153
+ * state at all. An INTERRUPTED CREATE (the write landed, the apply crashed before its
154
+ * `daemon-reload` ran) retries with `wrote: false` (the file already holds `desired`) and
155
+ * `stale: false` (there is no PRIOR state to disagree with) — without this, nothing ever
156
+ * reloaded and `verifyGenerated` threw on the still-missing generated unit FOREVER: loud, but
157
+ * stuck, since a plain retry changes nothing about that. `needsVerificationReload`
158
+ * (container-generator.ts) shares its predicate with `verifyGenerated` itself, so the two can
159
+ * never disagree about what counts as already proven. Cost: one extra, idempotent reload on an
160
+ * ordinary adoption whose generation was already fine — `daemon-reload` restarts nothing, so
161
+ * the never-mass-restart property (`changed`, below) is untouched.
162
+ * Test: container-generator.test.ts, "an interrupted CREATE that crashed before daemon-reload".
163
+ */
164
+ const needsReload =
165
+ wrote ||
166
+ stale ||
167
+ preStatus.needDaemonReload ||
168
+ (prior === undefined && needsVerificationReload(path, preStatus));
169
+ if (wrote) await runner.writeFileAtomic(path, encoder.encode(text), UNIT_WRITE);
170
+ if (needsReload) await daemonReload(runner);
171
+ const status = needsReload ? await showUnit(runner, serviceName) : preStatus;
172
+ try {
173
+ verifyGenerated(props.name, path, serviceName, status);
174
+ } catch (cause) {
175
+ // ⛔ Only a WRITE this reconcile made is worth rolling back — see file header. A file already
176
+ // on disk before this reconcile ran (wrote === false) generated nothing NEW to restore to.
177
+ if (wrote) {
178
+ if (before === undefined) await runner.removeFile(path).catch(() => undefined);
179
+ else await runner.writeFileAtomic(path, before, UNIT_WRITE).catch(() => undefined);
180
+ await daemonReload(runner).catch(() => undefined);
181
+ }
182
+ throw cause;
183
+ }
184
+ const configChanged = prior !== undefined && prior.configSha256 !== configDigest(props.restartOn);
185
+ const changed = wrote || stale || preStatus.needDaemonReload;
186
+ return settle(runner, props, configDigest(props.restartOn), {
187
+ before,
188
+ changed,
189
+ configChanged,
190
+ desired,
191
+ path,
192
+ serviceName,
193
+ status,
194
+ });
195
+ };
196
+
197
+ /**
198
+ * Stop, remove the `.container` file, reload — clearing the orphaned generated unit. Idempotent.
199
+ * No `disableUnit`: never enabled through systemctl in the first place (container-settle.ts).
200
+ */
201
+ export const deleteContainer = async (
202
+ runner: HostRunner,
203
+ output: ContainerAttributes,
204
+ ): Promise<void> => {
205
+ assertMayWrite(runner, output.name);
206
+ const status = await showUnit(runner, output.serviceName);
207
+ if (status.activeState === 'active' || status.activeState === 'activating') {
208
+ await stopUnit(runner, output.serviceName);
209
+ }
210
+ const stat = await runner.stat(output.containerPath);
211
+ if (stat !== undefined) {
212
+ if (stat.kind !== 'file')
213
+ throw refuseContainer(output.name, `${output.containerPath} is a ${stat.kind}`);
214
+ await runner.removeFile(output.containerPath);
215
+ }
216
+ await daemonReload(runner);
217
+ };
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Everything `Podman.Container` checks before it touches the host — split from
3
+ * container-lifecycle.ts so a reconcile and a plan-time REPLACE run the very same checks, exactly
4
+ * the reason unit-preflight.ts exists (see that file's header; this mirrors it).
5
+ *
6
+ * ★ EVERY CHECK HERE IS READ-ONLY: validation, `systemctl show`, a file read and
7
+ * `HostRunner.checkWrite`. Nothing here writes, reloads or drives Podman.
8
+ */
9
+ import { type HostRunner, canActAsRoot } from '../launchd/runner.ts';
10
+ import { UNIT_WRITE, digestOf } from './unit-form.ts';
11
+ import { type UnitStatus, showUnit } from './systemctl.ts';
12
+ import {
13
+ type ContainerAttributes,
14
+ type ContainerProps,
15
+ containerPathFor,
16
+ containerProblems,
17
+ refuseContainer,
18
+ renderContainerFile,
19
+ serviceNameFor,
20
+ } from './container-form.ts';
21
+
22
+ const decoder = new TextDecoder();
23
+
24
+ /** Throw every refusal at once, so one plan shows the whole list. */
25
+ export const assertValid = (props: ContainerProps): void => {
26
+ const found = containerProblems(props);
27
+ if (found.length > 0) throw refuseContainer(props.name, found.join('; '));
28
+ };
29
+
30
+ /** ⛔ Writing the file and driving systemctl/Podman are root's — refuse up front, not mid-apply. */
31
+ export const assertMayWrite = (runner: HostRunner, name: string): void => {
32
+ if (canActAsRoot(runner)) return;
33
+ throw refuseContainer(
34
+ name,
35
+ 'a Quadlet .container file is root-owned and daemon-reload/systemctl need root. Point the ' +
36
+ 'runner at a destination whose ssh user is root, or provide a privileged HostRunner. This ' +
37
+ 'provider never calls sudo.',
38
+ );
39
+ };
40
+
41
+ /**
42
+ * ⛔ A MASKED GENERATED UNIT IS SOMEONE'S DECISION, NOT DRIFT — same rule as `Systemd.Unit`
43
+ * (unit-preflight.ts `assertUsable`). Masking a generated unit is done the ordinary way, by
44
+ * symlinking its name to `/dev/null` in a directory Quadlet's generator output does not win
45
+ * against (`/etc/systemd/system` outranks `/run/systemd/generator`); unmasking here would
46
+ * silently overrule that.
47
+ */
48
+ export const assertUsable = (props: Pick<ContainerProps, 'name'>, status: UnitStatus): void => {
49
+ if (status.loadState === 'masked' || status.unitFileState === 'masked') {
50
+ throw refuseContainer(
51
+ props.name,
52
+ `${serviceNameFor(props)} is masked. If that is stale, \`systemctl unmask\` it deliberately, ` +
53
+ 'then redeploy. Nothing was written.',
54
+ );
55
+ }
56
+ };
57
+
58
+ /**
59
+ * ⛔ A `.container` FILE THIS RESOURCE DOES NOT OWN IS NEVER OVERWRITTEN — same rule and same
60
+ * exemption as `unit-preflight.ts`'s `assertUnclaimed`: a file byte-identical to our render is
61
+ * this declaration's own leftover, not someone else's.
62
+ */
63
+ export const assertUnclaimed = async (runner: HostRunner, props: ContainerProps): Promise<void> => {
64
+ const path = containerPathFor(props);
65
+ const bytes = await runner.readFile(path);
66
+ const live = bytes === undefined ? undefined : digestOf(decoder.decode(bytes));
67
+ if (live !== undefined && live !== digestOf(renderContainerFile(props))) {
68
+ throw refuseContainer(
69
+ props.name,
70
+ `${path} is already on this host and is not this resource. Remove it, or declare it as a ` +
71
+ 'new resource and deploy with --adopt.',
72
+ );
73
+ }
74
+ };
75
+
76
+ /** The half of a rename check that needs only the NEW NAME — mirrors `assertRenameTarget`. */
77
+ export const assertRenameTarget = async (
78
+ runner: HostRunner,
79
+ old: ContainerAttributes,
80
+ name: string,
81
+ ): Promise<void> => {
82
+ assertMayWrite(runner, old.name);
83
+ assertMayWrite(runner, name);
84
+ assertUsable({ name }, await showUnit(runner, serviceNameFor({ name })));
85
+ };
86
+
87
+ /** A rename, checked at plan time, before the plan may promise a delete-first replace. */
88
+ export const assertReplaceable = async (
89
+ runner: HostRunner,
90
+ next: ContainerProps,
91
+ old: ContainerAttributes,
92
+ ): Promise<void> => {
93
+ assertValid(next);
94
+ await assertRenameTarget(runner, old, next.name);
95
+ await assertUnclaimed(runner, next);
96
+ await runner.checkWrite?.(containerPathFor(next), UNIT_WRITE);
97
+ };