@homeflare/alchemy 0.37.6 → 0.37.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/dist/linux/container-form.d.ts +1 -1
  2. package/dist/linux/container-generator.d.ts +28 -0
  3. package/dist/linux/container-generator.d.ts.map +1 -1
  4. package/dist/linux/container-lifecycle.d.ts +18 -1
  5. package/dist/linux/container-lifecycle.d.ts.map +1 -1
  6. package/dist/linux/container-preflight.d.ts +34 -0
  7. package/dist/linux/container-preflight.d.ts.map +1 -1
  8. package/dist/linux/container-settle.d.ts.map +1 -1
  9. package/dist/linux/fake-sudo.d.ts +14 -2
  10. package/dist/linux/fake-sudo.d.ts.map +1 -1
  11. package/dist/linux/index.js +76 -29
  12. package/dist/linux/index.js.map +11 -10
  13. package/dist/linux/sudo-exec.d.ts +16 -0
  14. package/dist/linux/sudo-exec.d.ts.map +1 -1
  15. package/dist/linux/unit-form.d.ts.map +1 -1
  16. package/dist/linux/unit-text.d.ts +35 -0
  17. package/dist/linux/unit-text.d.ts.map +1 -0
  18. package/dist/proxmox/firewall-alias-config.d.ts +36 -0
  19. package/dist/proxmox/firewall-alias-config.d.ts.map +1 -0
  20. package/dist/proxmox/firewall-alias-form.d.ts +3 -1
  21. package/dist/proxmox/firewall-alias-form.d.ts.map +1 -1
  22. package/dist/proxmox/firewall-alias-lifecycle.d.ts +43 -0
  23. package/dist/proxmox/firewall-alias-lifecycle.d.ts.map +1 -0
  24. package/dist/proxmox/firewall-alias.d.ts +2 -8
  25. package/dist/proxmox/firewall-alias.d.ts.map +1 -1
  26. package/dist/proxmox/index.js +795 -526
  27. package/dist/proxmox/index.js.map +23 -10
  28. package/dist/proxmox/lxc-errors.d.ts +12 -0
  29. package/dist/proxmox/lxc-errors.d.ts.map +1 -0
  30. package/dist/proxmox/lxc-lifecycle.d.ts +9 -31
  31. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -1
  32. package/dist/proxmox/lxc-read.d.ts +4 -0
  33. package/dist/proxmox/lxc-read.d.ts.map +1 -0
  34. package/dist/proxmox/lxc-task.d.ts +15 -0
  35. package/dist/proxmox/lxc-task.d.ts.map +1 -0
  36. package/dist/proxmox/network-apply-read.d.ts +7 -14
  37. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  38. package/dist/proxmox/network-apply-run.d.ts +7 -0
  39. package/dist/proxmox/network-apply-run.d.ts.map +1 -0
  40. package/dist/proxmox/network-apply.d.ts +2 -3
  41. package/dist/proxmox/network-apply.d.ts.map +1 -1
  42. package/dist/proxmox/qemu-errors.d.ts +5 -0
  43. package/dist/proxmox/qemu-errors.d.ts.map +1 -0
  44. package/dist/proxmox/qemu-form.d.ts +22 -0
  45. package/dist/proxmox/qemu-form.d.ts.map +1 -0
  46. package/dist/proxmox/qemu-lifecycle.d.ts +26 -0
  47. package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -0
  48. package/dist/proxmox/qemu-read.d.ts +5 -0
  49. package/dist/proxmox/qemu-read.d.ts.map +1 -0
  50. package/dist/proxmox/qemu-task.d.ts +9 -0
  51. package/dist/proxmox/qemu-task.d.ts.map +1 -0
  52. package/dist/proxmox/qemu.d.ts +9 -14
  53. package/dist/proxmox/qemu.d.ts.map +1 -1
  54. package/dist/proxmox/replication-firewall-fixtures.d.ts +30 -0
  55. package/dist/proxmox/replication-firewall-fixtures.d.ts.map +1 -0
  56. package/dist/proxmox/replication-job-config.d.ts +56 -0
  57. package/dist/proxmox/replication-job-config.d.ts.map +1 -0
  58. package/dist/proxmox/replication-job-lifecycle.d.ts +62 -0
  59. package/dist/proxmox/replication-job-lifecycle.d.ts.map +1 -0
  60. package/dist/proxmox/replication-job.d.ts +5 -6
  61. package/dist/proxmox/replication-job.d.ts.map +1 -1
  62. package/dist/release/archive.d.ts.map +1 -1
  63. package/dist/release/index.js +3 -2
  64. package/dist/release/index.js.map +3 -3
  65. package/docs/linux-sudo.md +18 -7
  66. package/docs/proxmox-lxc-distilled.md +56 -0
  67. package/docs/proxmox-replication-firewall-distilled.md +53 -0
  68. package/docs/proxmox-sdk-migration.md +23 -18
  69. package/package.json +2 -2
  70. package/src/linux/container-form.ts +1 -1
  71. package/src/linux/container-generator.test.ts +33 -0
  72. package/src/linux/container-generator.ts +72 -0
  73. package/src/linux/container-lifecycle.test.ts +6 -3
  74. package/src/linux/container-lifecycle.ts +23 -2
  75. package/src/linux/container-preflight.ts +47 -0
  76. package/src/linux/container-settle.ts +12 -1
  77. package/src/linux/container-shadow.test.ts +131 -0
  78. package/src/linux/fake-sudo.ts +21 -5
  79. package/src/linux/sudo-exec.ts +60 -10
  80. package/src/linux/sudo-lifecycle.test.ts +64 -3
  81. package/src/linux/unit-form.ts +4 -18
  82. package/src/linux/unit-text.test.ts +67 -0
  83. package/src/linux/unit-text.ts +58 -0
  84. package/src/proxmox/firewall-alias-config.ts +39 -0
  85. package/src/proxmox/firewall-alias-form.ts +1 -1
  86. package/src/proxmox/firewall-alias-lifecycle.ts +100 -0
  87. package/src/proxmox/firewall-alias.ts +4 -67
  88. package/src/proxmox/lxc-create.test.ts +4 -1
  89. package/src/proxmox/lxc-distilled.test.ts +239 -0
  90. package/src/proxmox/lxc-errors.ts +9 -0
  91. package/src/proxmox/lxc-lifecycle.ts +31 -113
  92. package/src/proxmox/lxc-read.test.ts +29 -6
  93. package/src/proxmox/lxc-read.ts +88 -0
  94. package/src/proxmox/lxc-task.ts +71 -0
  95. package/src/proxmox/network-apply-distilled.test.ts +76 -0
  96. package/src/proxmox/network-apply-read.ts +18 -39
  97. package/src/proxmox/network-apply-run.ts +97 -0
  98. package/src/proxmox/network-apply.ts +5 -94
  99. package/src/proxmox/qemu-errors.ts +7 -0
  100. package/src/proxmox/qemu-form.ts +15 -0
  101. package/src/proxmox/qemu-lifecycle.test.ts +171 -0
  102. package/src/proxmox/qemu-lifecycle.ts +134 -0
  103. package/src/proxmox/qemu-read.ts +73 -0
  104. package/src/proxmox/qemu-task.ts +64 -0
  105. package/src/proxmox/qemu.ts +11 -44
  106. package/src/proxmox/replication-firewall-fixtures.ts +71 -0
  107. package/src/proxmox/replication-firewall-lifecycle.test.ts +109 -0
  108. package/src/proxmox/replication-firewall-wire.test.ts +173 -0
  109. package/src/proxmox/replication-job-config.ts +79 -0
  110. package/src/proxmox/replication-job-lifecycle.ts +113 -0
  111. package/src/proxmox/replication-job.ts +7 -84
  112. package/src/release/archive.ts +4 -1
@@ -1,15 +1,19 @@
1
1
  /**
2
- * `Systemd.Unit` and `Remote.File`'s own lifecycle functions, driven through sshSudoRunner over
3
- * the fake Linux host — proving the two families this runner exists for actually work end to end,
4
- * not just that the runner's own primitives do. No sudo runs anywhere but through the fake.
2
+ * `Systemd.Unit`, `Remote.File` and `Podman.Container`'s own lifecycle functions, driven through
3
+ * sshSudoRunner over the fake Linux host — proving the families this runner exists for actually
4
+ * work end to end, not just that the runner's own primitives do. No sudo runs anywhere but through
5
+ * the fake.
5
6
  */
6
7
  import { describe, expect, test } from 'bun:test';
8
+ import { fakeQuadletHost } from './fake-quadlet.ts';
7
9
  import { fakeSudoHost } from './fake-sudo.ts';
8
10
  import { SudoRefusedError } from './sudo-runner.ts';
9
11
  import { deleteFile, reconcileFile } from './remote-file-lifecycle.ts';
10
12
  import type { RemoteFileProps } from './remote-file-form.ts';
11
13
  import { deleteUnit, diffUnit, reconcileUnit } from './unit-lifecycle.ts';
12
14
  import type { SystemdUnitProps } from './unit-form.ts';
15
+ import { reconcileContainer } from './container-lifecycle.ts';
16
+ import type { ContainerProps } from './container-form.ts';
13
17
 
14
18
  const UNIT: SystemdUnitProps = {
15
19
  name: 'hf-thing.service',
@@ -124,6 +128,63 @@ describe('Systemd.Unit through sshSudoRunner', () => {
124
128
  });
125
129
  });
126
130
 
131
+ // 🔴 REGRESSION, CT100 deploy 2026-09-26 00:11Z: `Podman.Container caddy`'s generated unit's
132
+ // FragmentPath (`/run/systemd/generator/…`) was refused outright by the ownership check above,
133
+ // even though its SourcePath (`/etc/containers/systemd/caddy.container`) was this runner's own
134
+ // `.container` file, under a declared prefix. `fakeQuadletHost` in place of the plain
135
+ // `fakeLinuxHost` (via `fakeSudoHost`'s `hostFactory`) reproduces the exact CT100 shape: a
136
+ // generated unit whose FragmentPath is the generator's own output directory.
137
+ const CADDY_PREFIXES = ['/etc/containers/systemd', '/etc/systemd/system'];
138
+ const CADDY: ContainerProps = {
139
+ container: { image: 'docker.io/library/caddy:2', network: 'host' },
140
+ install: { wantedBy: ['multi-user.target'] },
141
+ name: 'caddy',
142
+ service: { restart: 'always' },
143
+ };
144
+
145
+ describe('Podman.Container through sshSudoRunner', () => {
146
+ test(
147
+ 'a full reconcile writes, reloads and starts — the generated unit’s FragmentPath under ' +
148
+ 'the generator is reachable because its SourcePath is under a declared prefix',
149
+ async () => {
150
+ const { privileged, runner } = fakeSudoHost({}, undefined, {
151
+ hostFactory: fakeQuadletHost,
152
+ prefixes: CADDY_PREFIXES,
153
+ });
154
+ const attrs = await reconcileContainer(runner, CADDY, undefined);
155
+ expect(attrs.active).toBe(true);
156
+ expect(attrs.sourcePath).toBe('/etc/containers/systemd/caddy.container');
157
+ const verbs = privileged()
158
+ .filter((c) => c[0] === '/usr/bin/systemctl')
159
+ .map((c) => c[1]);
160
+ expect(verbs).toContain('daemon-reload');
161
+ expect(verbs).toContain('start');
162
+ },
163
+ );
164
+
165
+ test('a generated unit whose SourcePath is outside every declared prefix still refuses', async () => {
166
+ const { fake, privileged, runner } = fakeSudoHost({}, undefined, {
167
+ hostFactory: fakeQuadletHost,
168
+ prefixes: CADDY_PREFIXES,
169
+ });
170
+ // A `.container` file Quadlet's OWN search path would also read (podman-systemd.unit(5)), but
171
+ // not under any prefix THIS runner declared — a foreign Quadlet source, same idea as the
172
+ // vendor `pveproxy.service` test above for a plain unit.
173
+ fake.files.set('/run/containers/systemd/other.container', {
174
+ bytes: new TextEncoder().encode('[Container]\nImage=example/other:1\n'),
175
+ gid: 0,
176
+ kind: 'file',
177
+ mode: 0o644,
178
+ uid: 0,
179
+ });
180
+ await runner.exec(['systemctl', 'daemon-reload']);
181
+ await expect(runner.exec(['systemctl', 'restart', '--', 'other.service'])).rejects.toThrow(
182
+ /SourcePath .* is not under one either/,
183
+ );
184
+ expect(privileged().some((c) => c[1] === 'restart')).toBe(false);
185
+ });
186
+ });
187
+
127
188
  const SCRIPT: RemoteFileProps = {
128
189
  content: '#!/bin/sh\necho hi\n',
129
190
  mode: 0o755,
@@ -12,8 +12,11 @@
12
12
  * ⛔ `content` IS NEVER A SECRET. Props sit unencrypted in Alchemy state and the unit file is
13
13
  * world-readable at 0644; a unit that needs a secret takes `EnvironmentFile=` pointing at a file
14
14
  * a secret renderer maintains.
15
+ * ★ THE TEXT-SHAPE CHECKS (comments/blank lines before [Section], [Install] key lookup) live in
16
+ * `unit-text.ts`, split out to keep this file under the house line cap.
15
17
  */
16
18
  import { sha256Hex } from '../launchd/job-form.ts';
19
+ import { hasInstallSection, textProblems } from './unit-text.ts';
17
20
 
18
21
  export type UnitSection = {
19
22
  /** `Unit`, `Service`, `Timer`, `Install`, … — verbatim, including its capitalisation. */
@@ -172,8 +175,6 @@ const TYPES = new Set([
172
175
  'timer',
173
176
  ]);
174
177
 
175
- const INSTALL_KEYS = new Set(['WantedBy', 'RequiredBy', 'UpsertedBy', 'Also', 'Alias']);
176
-
177
178
  /**
178
179
  * Every refusal at once, so one plan shows the whole list.
179
180
  * ⛔ THE TWO STRUCTURAL RULES, both proven by systemd's own output rather than assumed:
@@ -206,7 +207,7 @@ export const unitProblems = (props: SystemdUnitProps, expect?: string): string[]
206
207
  `${directory} is not one of them. Move it, or declare enabled: false.`,
207
208
  );
208
209
  }
209
- if (props.enabled !== false && !hasInstall(props)) {
210
+ if (props.enabled !== false && !hasInstallSection(unitText(props))) {
210
211
  found.push(
211
212
  'enabled units need an [Install] section with WantedBy/RequiredBy/Also/Alias; without one ' +
212
213
  'systemd calls the unit "static" and `systemctl enable` has nothing to link. Add one, or ' +
@@ -218,18 +219,3 @@ export const unitProblems = (props: SystemdUnitProps, expect?: string): string[]
218
219
  }
219
220
  return found;
220
221
  };
221
-
222
- const hasInstall = (props: SystemdUnitProps): boolean => {
223
- const text = unitText(props);
224
- if (!text.includes('[Install]')) return false;
225
- const after = text.slice(text.indexOf('[Install]'));
226
- return [...INSTALL_KEYS].some((key) => new RegExp(`^${key}=`, 'm').test(after));
227
- };
228
-
229
- const textProblems = (text: string): string[] => {
230
- const found: string[] = [];
231
- if (text.trim() === '') found.push('the unit file is empty');
232
- if (text.includes('\u0000')) found.push('the unit file contains NUL');
233
- if (!/^\s*\[[A-Za-z]+]/.test(text)) found.push('a unit file must begin with a [Section]');
234
- return found;
235
- };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * `unit-text.ts`'s own tests — the INI text-shape checks, on their own, separate from
3
+ * `unit-lifecycle.test.ts`'s end-to-end fake-host behaviour.
4
+ */
5
+ import { describe, expect, test } from 'bun:test';
6
+ import { hasInstallSection, textProblems } from './unit-text.ts';
7
+
8
+ // 🔴 REGRESSION, CT100 deploy 2026-09-26 00:11Z: `assertValid` refused this EXACT content — "a
9
+ // unit file must begin with a [Section]" — even though it is byte-identical to what systemd
10
+ // already loads on CT100 (sha256 verified against the live host). Leading lines copied
11
+ // verbatim from homeflare-ct100's src/openbao-agent/files/openbao-agent.service.
12
+ const CT100_OPENBAO_AGENT = [
13
+ "# CT100's openbao-agent.",
14
+ '#',
15
+ "# THIS FILE WAS RECOVERED FROM THE RUNNING BOX, NOT PUSHED TO IT. The repo's copy was STALE",
16
+ '# and would have REMOVED `Environment=BAO_NAMESPACE=homeflare` -- without which the agent',
17
+ '# authenticates into the root namespace, every template 403s, and the failure reads as a',
18
+ '# missing grant rather than a missing variable. That is the exact shape the vault policies',
19
+ '# README warns about: a deploy converging a stale copy over a working live one.',
20
+ '# So the FIRST run of deploy-ct100.sh was a --dry-run, and this is what it was for.',
21
+ '[Unit]',
22
+ 'Description=OpenBao agent',
23
+ 'After=network-online.target',
24
+ 'Wants=network-online.target',
25
+ '[Service]',
26
+ 'Environment=BAO_NAMESPACE=homeflare',
27
+ 'ExecStart=/usr/bin/bao agent -config=/etc/openbao/agent.hcl',
28
+ 'RuntimeDirectory=openbao',
29
+ 'Restart=on-failure',
30
+ 'RestartSec=10',
31
+ '[Install]',
32
+ 'WantedBy=multi-user.target',
33
+ '',
34
+ ].join('\n');
35
+
36
+ describe('textProblems', () => {
37
+ test('the CT100 openbao-agent.service fixture validates — comments may precede [Unit]', () => {
38
+ expect(textProblems(CT100_OPENBAO_AGENT)).toEqual([]);
39
+ });
40
+
41
+ test('blank lines alone may also precede the first section', () => {
42
+ expect(textProblems('\n\n[Unit]\nDescription=x\n')).toEqual([]);
43
+ });
44
+
45
+ test('a ; comment is accepted the same as a # comment', () => {
46
+ expect(textProblems('; a semicolon comment\n[Unit]\nDescription=x\n')).toEqual([]);
47
+ });
48
+
49
+ test('a genuine key=value before any section still refuses — comments are not a blanket exemption', () => {
50
+ expect(textProblems('Description=no section at all\n[Unit]\n').join(' ')).toMatch(
51
+ /must begin with a \[Section\]/,
52
+ );
53
+ });
54
+
55
+ test('an empty file and one with NUL are still refused', () => {
56
+ expect(textProblems(' \n').join(' ')).toMatch(/is empty/);
57
+ expect(textProblems('[Unit]\nDescription=a\u0000b\n').join(' ')).toMatch(/contains NUL/);
58
+ });
59
+ });
60
+
61
+ describe('hasInstallSection', () => {
62
+ test('true only once an [Install] key actually appears after the header', () => {
63
+ expect(hasInstallSection('[Unit]\n[Install]\nWantedBy=multi-user.target\n')).toBe(true);
64
+ expect(hasInstallSection('[Unit]\nDescription=x\n')).toBe(false);
65
+ expect(hasInstallSection('[Unit]\n[Install]\n')).toBe(false);
66
+ });
67
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The parts of `unit-form.ts`'s validation that inspect the unit file's TEXT rather than its
3
+ * props struct — split out so unit-form.ts stays under the file cap, and because these are one
4
+ * coherent concern: is this INI shape something systemd would actually load. Takes plain strings,
5
+ * never `SystemdUnitProps`, so it never needs to import back from unit-form.ts.
6
+ *
7
+ * ⛔ `[Install]` KEYS ARE `unit-form.ts`'s OWN LIST, NOT SYSTEMD'S FULL VOCABULARY — WantedBy,
8
+ * RequiredBy, UpsertedBy, Also, Alias, per `systemd.unit(5)` §[Install]. `container-form.ts`'s
9
+ * own header notes it deliberately does NOT reuse this set: a `.container` file's `[Install]`
10
+ * supports only Alias/WantedBy/RequiredBy (`podman-systemd.unit(5)`), narrower than this.
11
+ */
12
+
13
+ export const INSTALL_KEYS = new Set(['WantedBy', 'RequiredBy', 'UpsertedBy', 'Also', 'Alias']);
14
+
15
+ /** Whether `text` has an `[Install]` section carrying at least one key systemd actually links on. */
16
+ export const hasInstallSection = (text: string): boolean => {
17
+ if (!text.includes('[Install]')) return false;
18
+ const after = text.slice(text.indexOf('[Install]'));
19
+ return [...INSTALL_KEYS].some((key) => new RegExp(`^${key}=`, 'm').test(after));
20
+ };
21
+
22
+ /**
23
+ * The first line that is neither blank nor a comment — the line whose shape actually decides
24
+ * whether the file "begins with a [Section]", per `textProblems` below.
25
+ * ★ systemd.syntax(7): "Empty lines and lines starting with '#' or ';' are ignored", with no
26
+ * carve-out for the position before the first section — a comment header is ordinary content
27
+ * there, same as anywhere else in the file.
28
+ */
29
+ export const firstSubstantiveLine = (text: string): string | undefined =>
30
+ text
31
+ .split('\n')
32
+ .find((line) => !(line.trim() === '' || line.startsWith('#') || line.startsWith(';')));
33
+
34
+ /**
35
+ * 🔴 MEASURED FALSE, CT100 deploy 2026-09-26 00:11Z: the openbao-agent.service asset's first eight
36
+ * lines are `#`-comments recording where the file was recovered from (verbatim from the live
37
+ * host — homeflare-ct100's `src/openbao-agent/files/openbao-agent.service`), and the file is
38
+ * byte-identical to what systemd already loads there. The old check tested the WHOLE text
39
+ * against `/^\s*\[[A-Za-z]+]/` — `\s*` skips blank lines and indentation, never a `#`/`;`
40
+ * comment — so a leading comment line failed it outright. This checks only the first
41
+ * SUBSTANTIVE line, so any number of leading blank lines and comments are fine and a genuine
42
+ * `key=value` before any section — the one real error this exists to catch — still fails, since
43
+ * that line is neither blank nor a comment either.
44
+ */
45
+ export const textProblems = (text: string): string[] => {
46
+ const found: string[] = [];
47
+ if (text.trim() === '') found.push('the unit file is empty');
48
+ if (text.includes('\u0000')) found.push('the unit file contains NUL');
49
+ const first = firstSubstantiveLine(text);
50
+ // ★ `\s*` kept from the original regex — leading indentation on the SECTION line itself was
51
+ // already tolerated before this fix and stays that way; only the leading-comment shape changes.
52
+ if (first === undefined || !/^\s*\[[A-Za-z]+]/.test(first)) {
53
+ found.push(
54
+ 'a unit file must begin with a [Section] (blank lines and #/; comments before it are fine)',
55
+ );
56
+ }
57
+ return found;
58
+ };
@@ -0,0 +1,39 @@
1
+ /** Existing alias normalization and forms, independent of the named SDK transport. */
2
+ import type { FirewallAliasAttributes, FirewallAliasProps } from './firewall-alias.ts';
3
+ import type { PveSpec } from './resource-spec.ts';
4
+ import { body, cidr, comment } from './firewall-alias-form.ts';
5
+ import { int, text } from './values.ts';
6
+
7
+ export const firewallAliasSpec = {
8
+ /** The SDK read refuses malformed successful payloads before applying these normalizers. */
9
+ attributes: (live, props) => {
10
+ const address = cidr(live['cidr']);
11
+ return {
12
+ cidr: address,
13
+ comment: comment(live['comment']),
14
+ ipversion: int(live['ipversion'], 0),
15
+ /** The read checks identity; the stored spelling is preserved for display. */
16
+ name: text(live['name'], props.name),
17
+ };
18
+ },
19
+ collection: () => 'cluster/firewall/aliases',
20
+ createForm: (props) => ({ ...body(props), name: props.name }),
21
+ /**
22
+ * ⚠️ EXACTLY THE TWO FIELDS A PUT CAN PUT BACK, both sides through the same normalisers. `name`
23
+ * is identity and case-only drift (see the header); `ipversion` is derived and unwritable.
24
+ * Comparing either would report an update that no update can settle.
25
+ */
26
+ /** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
27
+ endpoint: {
28
+ create: 'pve:POST /cluster/firewall/aliases',
29
+ update: 'pve:PUT /cluster/firewall/aliases/{name}',
30
+ },
31
+ matches: (attributes, props) =>
32
+ attributes.cidr === cidr(props.cidr) && attributes.comment === comment(props.comment),
33
+ /**
34
+ * ⚠️ UNESCAPED ON PURPOSE: `pve-fw-alias` admits only `[A-Za-z][A-Za-z0-9\-\_]+`, so there is no
35
+ * character here that a URL would need to encode, and PVE lowercases the segment on arrival.
36
+ */
37
+ path: (props) => `cluster/firewall/aliases/${props.name}`,
38
+ updateForm: body,
39
+ } satisfies PveSpec<FirewallAliasProps, FirewallAliasAttributes>;
@@ -106,7 +106,7 @@ export const comment = (value: unknown) => {
106
106
  * this form would be a second, invisible identity for the object, set from a field the resource
107
107
  * does not have.
108
108
  */
109
- export const body = (props: FirewallAliasProps): Record<string, string> => {
109
+ export const body = (props: FirewallAliasProps): Record<string, string> & { cidr: string } => {
110
110
  const note = comment(props.comment);
111
111
  return { cidr: cidr(props.cidr), ...(note === '' ? {} : { comment: note }) };
112
112
  };
@@ -0,0 +1,100 @@
1
+ /** Named SDK operations; pve-firewall 6.0.5 Aliases.pm supplies exact absence and idempotent delete. */
2
+ import { isResolved } from 'alchemy/Diff';
3
+ import type { Input } from 'alchemy/Input';
4
+ import * as cluster from '@distilled.cloud/proxmox/cluster';
5
+ import { ProxmoxParseError } from '@distilled.cloud/proxmox/Errors';
6
+ import * as Effect from 'effect/Effect';
7
+ import type { FirewallAliasAttributes, FirewallAliasProps } from './firewall-alias.ts';
8
+ import { firewallAliasSpec as spec } from './firewall-alias-config.ts';
9
+ import { cidr, fold } from './firewall-alias-form.ts';
10
+ import { runPve } from './distilled-pve.ts';
11
+ import { specGuards } from './resource-guard.ts';
12
+ import { formToSend } from './update-guard.ts';
13
+
14
+ const { guardCreate, guardUpdate } = specGuards(spec);
15
+
16
+ export const readFirewallAlias = (props: FirewallAliasProps) =>
17
+ runPve(props.target, 'read', false, cluster.getClusterFirewallAlias({ name: props.name })).pipe(
18
+ Effect.flatMap((live) => {
19
+ // Vendor's GET schema is bare object; read_alias returns the stored name/cidr entry.
20
+ // ⛔ A malformed successful read is not absence and must never authorize a POST.
21
+ if (
22
+ live === null ||
23
+ typeof live !== 'object' ||
24
+ Array.isArray(live) ||
25
+ !('name' in live) ||
26
+ typeof live.name !== 'string' ||
27
+ fold(live.name) !== fold(props.name) ||
28
+ !('cidr' in live) ||
29
+ typeof live.cidr !== 'string' ||
30
+ cidr(live.cidr) === ''
31
+ ) {
32
+ return Effect.fail(
33
+ new ProxmoxParseError({
34
+ body: undefined,
35
+ cause: 'Firewall alias response does not match the requested vendor identity',
36
+ }),
37
+ );
38
+ }
39
+ return Effect.succeed(spec.attributes({ ...live }, props));
40
+ }),
41
+ Effect.catchTag('FirewallAliasNotFound', () => Effect.succeed(undefined)),
42
+ );
43
+
44
+ /** Vendor deletion silently removes a missing key. All actual SDK failures still propagate. */
45
+ export const deleteFirewallAlias = (props: FirewallAliasProps) =>
46
+ runPve(props.target, 'provision', true, cluster.deleteClusterFirewallAlias({ name: props.name }));
47
+
48
+ export const firewallAliasHandlers = {
49
+ list: () => Effect.succeed([]),
50
+ read: ({ olds }: { olds: FirewallAliasProps }) => readFirewallAlias(olds),
51
+ diff: Effect.fn(function* ({
52
+ news,
53
+ output,
54
+ }: {
55
+ news: Input<FirewallAliasProps>;
56
+ output: FirewallAliasAttributes | undefined;
57
+ }) {
58
+ if (!isResolved(news)) return undefined;
59
+ yield* guardCreate(news, output === undefined);
60
+ yield* guardUpdate(news);
61
+ if (output === undefined) return undefined;
62
+ // A case-only spelling is the same vendor key; a real rename remains create-first replacement.
63
+ if (fold(news.name) !== fold(output.name)) return { action: 'replace' } as const;
64
+ const live = yield* readFirewallAlias(news);
65
+ if (live === undefined) {
66
+ yield* guardCreate(news, true);
67
+ return { action: 'update' } as const;
68
+ }
69
+ return { action: spec.matches(live, news) ? 'noop' : 'update' } as const;
70
+ }),
71
+ reconcile: Effect.fn(function* ({ news }: { news: FirewallAliasProps }) {
72
+ const live = yield* readFirewallAlias(news);
73
+ yield* guardCreate(news, live === undefined);
74
+ yield* guardUpdate(news);
75
+ if (live === undefined) {
76
+ yield* runPve(
77
+ news.target,
78
+ 'provision',
79
+ true,
80
+ cluster.createClusterFirewallAlias(spec.createForm(news)),
81
+ );
82
+ } else {
83
+ const form = formToSend(spec.matches, live, news, spec.updateForm(news));
84
+ if (form !== undefined)
85
+ yield* runPve(
86
+ news.target,
87
+ 'provision',
88
+ true,
89
+ cluster.putClusterFirewallAlias({ ...form, name: news.name }),
90
+ );
91
+ }
92
+ const after = yield* readFirewallAlias(news);
93
+ if (after === undefined)
94
+ return yield* Effect.fail(
95
+ new Error(`${spec.path(news)}: write returned success but the resource is still absent`),
96
+ );
97
+ return after;
98
+ }),
99
+ delete: ({ olds }: { olds: FirewallAliasProps }) => deleteFirewallAlias(olds),
100
+ };
@@ -93,12 +93,10 @@
93
93
  * confirm the SECOND plan says noop before trusting this file.
94
94
  */
95
95
  import { Resource } from 'alchemy';
96
- import { isResolved } from 'alchemy/Diff';
97
96
  import * as Provider from 'alchemy/Provider';
98
97
  import * as Effect from 'effect/Effect';
99
- import { body, cidr, comment, fold } from './firewall-alias-form.ts';
100
- import { type PveRequirements, type WithTarget, pveHandlers } from './resource.ts';
101
- import { int, text } from './values.ts';
98
+ import type { PveRequirements, WithTarget } from './resource-spec.ts';
99
+ import { firewallAliasHandlers } from './firewall-alias-lifecycle.ts';
102
100
 
103
101
  export interface FirewallAliasProps extends WithTarget {
104
102
  /**
@@ -144,70 +142,9 @@ export interface ProxmoxFirewallAlias extends Resource<
144
142
 
145
143
  export const ProxmoxFirewallAlias = Resource<ProxmoxFirewallAlias>('Proxmox.FirewallAlias');
146
144
 
147
- const handlers = pveHandlers<FirewallAliasProps, FirewallAliasAttributes>({
148
- /**
149
- * ⚠️ AN ENTRY WITH NO CIDR IS NOT AN ALIAS. `read_alias` raises for a name it does not hold, so
150
- * absence normally arrives as a failed call the factory folds into `undefined`; this guard
151
- * covers the other shape — a 200 carrying something that is not an alias — rather than
152
- * recording state for an object with an empty address. The next reconcile then POSTs and PVE
153
- * answers "alias already exists", which is loud and points at the read.
154
- */
155
- attributes: (live, props) => {
156
- const address = cidr(live['cidr']);
157
- return address === ''
158
- ? undefined
159
- : {
160
- cidr: address,
161
- comment: comment(live['comment']),
162
- ipversion: int(live['ipversion'], 0),
163
- /** ⚠️ FROM THE CLUSTER, falling back to props: the stored spelling is the interesting one. */
164
- name: text(live['name'], props.name),
165
- };
166
- },
167
- collection: () => 'cluster/firewall/aliases',
168
- createForm: (props) => ({ ...body(props), name: props.name }),
169
- /**
170
- * ⚠️ EXACTLY THE TWO FIELDS A PUT CAN PUT BACK, both sides through the same normalisers. `name`
171
- * is identity and case-only drift (see the header); `ipversion` is derived and unwritable.
172
- * Comparing either would report an update that no update can settle.
173
- */
174
- /** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
175
- endpoint: {
176
- create: 'pve:POST /cluster/firewall/aliases',
177
- update: 'pve:PUT /cluster/firewall/aliases/{name}',
178
- },
179
- matches: (attributes, props) =>
180
- attributes.cidr === cidr(props.cidr) && attributes.comment === comment(props.comment),
181
- /**
182
- * ⚠️ UNESCAPED ON PURPOSE: `pve-fw-alias` admits only `[A-Za-z][A-Za-z0-9\-\_]+`, so there is no
183
- * character here that a URL would need to encode, and PVE lowercases the segment on arrival.
184
- */
185
- path: (props) => `cluster/firewall/aliases/${props.name}`,
186
- updateForm: body,
187
- });
188
-
189
- /**
190
- * ⛔ ONE HANDLER IS OVERRIDDEN AND THE REST COME FROM THE FACTORY, for the reason `acl.ts` spells
191
- * out at length: the factory answers `replace` only for an object with no update path, and this
192
- * one has a PUT. Left to delegate, a changed `name` would read absent at the new path, plan as an
193
- * update, and quietly leave the old alias behind. Everything else — the empty `list`, the read,
194
- * the read-back-guarded reconcile, the delete — is the factory's, unchanged.
195
- */
145
+ /** Named distilled operations preserve create-first identity replacement and explicit adoption. */
196
146
  export const ProxmoxFirewallAliasProvider = () =>
197
147
  Provider.effect(
198
148
  ProxmoxFirewallAlias,
199
- Effect.succeed(
200
- ProxmoxFirewallAlias.Provider.of({
201
- ...handlers,
202
- diff: Effect.fn(function* ({ news, output }) {
203
- // ⚠️ `isResolved` FIRST: at plan time `news.name` can still be an unresolved Output, and
204
- // folding a placeholder would report a replace nobody asked for — the worst possible
205
- // false positive on this family, since a replace here DELETES an alias.
206
- if (output !== undefined && isResolved(news) && fold(news.name) !== fold(output.name)) {
207
- return { action: 'replace' } as const;
208
- }
209
- return yield* handlers.diff({ news, output });
210
- }),
211
- }),
212
- ),
149
+ Effect.succeed(ProxmoxFirewallAlias.Provider.of(firewallAliasHandlers)),
213
150
  );
@@ -96,7 +96,10 @@ describe('removing a container', () => {
96
96
  `POST nodes/${NODE}/lxc`,
97
97
  `DELETE nodes/${NODE}/lxc/${String(VMID)}`,
98
98
  ]);
99
- expect(pve.seen.some((call) => /\/tasks\/UPID:.*vzdestroy/.test(call.path))).toBe(true);
99
+ // PVE APIServer/AnyEvent.pm uri_unescape runs before routing; the SDK encodes labels.
100
+ expect(
101
+ pve.seen.some((call) => /\/tasks\/UPID:.*vzdestroy/.test(decodeURIComponent(call.path))),
102
+ ).toBe(true);
100
103
  // ⛔ No `force` (stops a running guest), `purge` (drops it from HA and backup jobs) or
101
104
  // `destroy-unreferenced-disks` (deletes volumes the config does not even name).
102
105
  const del = pve.seen.find((call) => call.method === 'DELETE');