@homeflare/alchemy 0.37.8 → 0.39.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 (183) hide show
  1. package/README.md +22 -0
  2. package/dist/caddy/admin.d.ts +42 -8
  3. package/dist/caddy/admin.d.ts.map +1 -1
  4. package/dist/caddy/config.d.ts +9 -3
  5. package/dist/caddy/config.d.ts.map +1 -1
  6. package/dist/caddy/fake-caddy.d.ts +5 -0
  7. package/dist/caddy/fake-caddy.d.ts.map +1 -1
  8. package/dist/caddy/index.js +10 -6
  9. package/dist/caddy/index.js.map +5 -5
  10. package/dist/caddy/providers.d.ts +19 -12
  11. package/dist/caddy/providers.d.ts.map +1 -1
  12. package/dist/forgejo/index.js +4 -2
  13. package/dist/forgejo/index.js.map +3 -3
  14. package/dist/forgejo/team-member.d.ts +25 -0
  15. package/dist/forgejo/team-member.d.ts.map +1 -1
  16. package/dist/proxmox/generated/constraints/index.d.ts +1 -1
  17. package/dist/proxmox/generated/constraints/index.d.ts.map +1 -1
  18. package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
  19. package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
  20. package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
  21. package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
  22. package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
  23. package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
  24. package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
  25. package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
  26. package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
  27. package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
  28. package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
  29. package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
  30. package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
  31. package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
  32. package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts +23 -0
  33. package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts.map +1 -0
  34. package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
  35. package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
  36. package/dist/proxmox/index.d.ts +14 -3
  37. package/dist/proxmox/index.d.ts.map +1 -1
  38. package/dist/proxmox/index.js +565 -133
  39. package/dist/proxmox/index.js.map +33 -23
  40. package/dist/proxmox/qemu-form.d.ts +27 -18
  41. package/dist/proxmox/qemu-form.d.ts.map +1 -1
  42. package/dist/proxmox/qemu-judge.d.ts +30 -0
  43. package/dist/proxmox/qemu-judge.d.ts.map +1 -0
  44. package/dist/proxmox/qemu-lifecycle.d.ts +16 -2
  45. package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -1
  46. package/dist/proxmox/qemu-net.d.ts +9 -0
  47. package/dist/proxmox/qemu-net.d.ts.map +1 -0
  48. package/dist/proxmox/qemu-props.d.ts +112 -0
  49. package/dist/proxmox/qemu-props.d.ts.map +1 -0
  50. package/dist/proxmox/qemu-volume.d.ts +17 -0
  51. package/dist/proxmox/qemu-volume.d.ts.map +1 -0
  52. package/dist/proxmox/qemu.d.ts +14 -27
  53. package/dist/proxmox/qemu.d.ts.map +1 -1
  54. package/dist/proxmox/storage-download-errors.d.ts +5 -0
  55. package/dist/proxmox/storage-download-errors.d.ts.map +1 -0
  56. package/dist/proxmox/storage-download-identity.d.ts +27 -0
  57. package/dist/proxmox/storage-download-identity.d.ts.map +1 -0
  58. package/dist/proxmox/storage-download-lifecycle.d.ts +97 -0
  59. package/dist/proxmox/storage-download-lifecycle.d.ts.map +1 -0
  60. package/dist/proxmox/storage-download-props.d.ts +36 -0
  61. package/dist/proxmox/storage-download-props.d.ts.map +1 -0
  62. package/dist/proxmox/storage-download-task.d.ts +17 -0
  63. package/dist/proxmox/storage-download-task.d.ts.map +1 -0
  64. package/dist/proxmox/storage-download.d.ts +44 -0
  65. package/dist/proxmox/storage-download.d.ts.map +1 -0
  66. package/dist/telemetry/fake-collector.d.ts +36 -0
  67. package/dist/telemetry/fake-collector.d.ts.map +1 -0
  68. package/dist/telemetry/index.d.ts +11 -0
  69. package/dist/telemetry/index.d.ts.map +1 -0
  70. package/dist/telemetry/index.js +223 -0
  71. package/dist/telemetry/index.js.map +12 -0
  72. package/dist/telemetry/layer.d.ts +86 -0
  73. package/dist/telemetry/layer.d.ts.map +1 -0
  74. package/dist/telemetry/redact-serialization.d.ts +21 -0
  75. package/dist/telemetry/redact-serialization.d.ts.map +1 -0
  76. package/dist/telemetry/redact.d.ts +46 -0
  77. package/dist/telemetry/redact.d.ts.map +1 -0
  78. package/dist/unifi/drift.d.ts +27 -0
  79. package/dist/unifi/drift.d.ts.map +1 -0
  80. package/dist/unifi/fake-unifi.d.ts +5 -1
  81. package/dist/unifi/fake-unifi.d.ts.map +1 -1
  82. package/dist/unifi/firewall-zone-form.d.ts +6 -0
  83. package/dist/unifi/firewall-zone-form.d.ts.map +1 -1
  84. package/dist/unifi/firewall-zone.d.ts +1 -1
  85. package/dist/unifi/firewall-zone.d.ts.map +1 -1
  86. package/dist/unifi/index.d.ts +4 -3
  87. package/dist/unifi/index.d.ts.map +1 -1
  88. package/dist/unifi/index.js +138 -58
  89. package/dist/unifi/index.js.map +10 -8
  90. package/dist/unifi/network-drift.d.ts +26 -0
  91. package/dist/unifi/network-drift.d.ts.map +1 -0
  92. package/dist/unifi/network-form.d.ts +40 -7
  93. package/dist/unifi/network-form.d.ts.map +1 -1
  94. package/dist/unifi/network.d.ts +1 -0
  95. package/dist/unifi/network.d.ts.map +1 -1
  96. package/dist/unifi/paginate.d.ts +36 -0
  97. package/dist/unifi/paginate.d.ts.map +1 -0
  98. package/dist/unifi/resource.d.ts +36 -1
  99. package/dist/unifi/resource.d.ts.map +1 -1
  100. package/docs/telemetry-spike.md +94 -0
  101. package/docs/telemetry.md +109 -0
  102. package/docs/unifi.md +56 -0
  103. package/package.json +6 -2
  104. package/src/caddy/admin-transport-scope.test.ts +127 -0
  105. package/src/caddy/admin.ts +45 -9
  106. package/src/caddy/config-adopt-provider.test.ts +2 -2
  107. package/src/caddy/config-format-warning.test.ts +9 -3
  108. package/src/caddy/config.ts +20 -4
  109. package/src/caddy/fake-caddy.ts +10 -1
  110. package/src/caddy/providers.test.ts +3 -2
  111. package/src/caddy/providers.ts +18 -11
  112. package/src/forgejo/team-member.test.ts +14 -0
  113. package/src/forgejo/team-member.ts +28 -1
  114. package/src/proxmox/constraints-required.test.ts +1 -0
  115. package/src/proxmox/generated/constraints/index.ts +3 -1
  116. package/src/proxmox/generated/constraints/pve-access.ts +1 -1
  117. package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
  118. package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
  119. package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
  120. package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
  121. package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
  122. package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
  123. package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
  124. package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
  125. package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
  126. package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
  127. package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
  128. package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
  129. package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
  130. package/src/proxmox/generated/constraints/pve-nodes-storage.ts +30 -0
  131. package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
  132. package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
  133. package/src/proxmox/index.test.ts +11 -6
  134. package/src/proxmox/index.ts +19 -3
  135. package/src/proxmox/qemu-form.ts +49 -13
  136. package/src/proxmox/qemu-identity.test.ts +30 -0
  137. package/src/proxmox/qemu-judge.test.ts +65 -0
  138. package/src/proxmox/qemu-judge.ts +84 -0
  139. package/src/proxmox/qemu-lifecycle.test.ts +78 -19
  140. package/src/proxmox/qemu-lifecycle.ts +131 -32
  141. package/src/proxmox/qemu-net.test.ts +48 -0
  142. package/src/proxmox/qemu-net.ts +78 -0
  143. package/src/proxmox/qemu-ownership.test.ts +44 -0
  144. package/src/proxmox/qemu-props.test.ts +87 -0
  145. package/src/proxmox/qemu-props.ts +157 -0
  146. package/src/proxmox/qemu-volume.test.ts +80 -0
  147. package/src/proxmox/qemu-volume.ts +121 -0
  148. package/src/proxmox/qemu.ts +15 -29
  149. package/src/proxmox/storage-download-errors.ts +7 -0
  150. package/src/proxmox/storage-download-identity.test.ts +54 -0
  151. package/src/proxmox/storage-download-identity.ts +50 -0
  152. package/src/proxmox/storage-download-lifecycle.ts +226 -0
  153. package/src/proxmox/storage-download-ownership.test.ts +50 -0
  154. package/src/proxmox/storage-download-props.ts +38 -0
  155. package/src/proxmox/storage-download-task.ts +72 -0
  156. package/src/proxmox/storage-download.test.ts +216 -0
  157. package/src/proxmox/storage-download.ts +58 -0
  158. package/src/telemetry/fake-collector.ts +59 -0
  159. package/src/telemetry/http-client-sealing.test.ts +82 -0
  160. package/src/telemetry/index.ts +10 -0
  161. package/src/telemetry/layer.test.ts +204 -0
  162. package/src/telemetry/layer.ts +152 -0
  163. package/src/telemetry/redact-serialization.ts +178 -0
  164. package/src/telemetry/redact.test.ts +124 -0
  165. package/src/telemetry/redact.ts +147 -0
  166. package/src/unifi/drift.test.ts +78 -0
  167. package/src/unifi/drift.ts +56 -0
  168. package/src/unifi/errors-and-secrets.test.ts +127 -0
  169. package/src/unifi/fake-unifi.ts +5 -1
  170. package/src/unifi/firewall-zone-form.ts +31 -2
  171. package/src/unifi/firewall-zone.test.ts +21 -1
  172. package/src/unifi/firewall-zone.ts +1 -1
  173. package/src/unifi/get-only-guard.test.ts +165 -0
  174. package/src/unifi/index.ts +15 -1
  175. package/src/unifi/network-drift.test.ts +207 -0
  176. package/src/unifi/network-drift.ts +80 -0
  177. package/src/unifi/network-form.ts +11 -39
  178. package/src/unifi/network.test.ts +7 -82
  179. package/src/unifi/network.ts +6 -6
  180. package/src/unifi/paginate.test.ts +228 -0
  181. package/src/unifi/paginate.ts +129 -0
  182. package/src/unifi/resource.ts +73 -3
  183. package/src/unifi/write-op-reference.test.ts +236 -0
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Pure unit tests for `judgeDisk` — the red-team C1 fix on PR 297. No network, no fake PVE.
3
+ * ⛔ WHAT THESE PIN: a "fresh" declaration (new-disk GiB, or `storage:cloudinit`) never re-drifts
4
+ * against an existing live volume in the same slot; a genuine volume/storage swap is refused,
5
+ * never written; a size change is refused (no resize call exists here); an option-only change is
6
+ * written on the LIVE volume id and LIVE size.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { judgeDisk, parseDisk } from './qemu-volume.ts';
10
+
11
+ describe('a "fresh" declaration matches any existing volume in its slot', () => {
12
+ test('new-disk GiB spelling: the measured C1 regression', () => {
13
+ expect(judgeDisk('scsi0', 'cephtb4:32', 'cephtb4:vm-101-disk-0,size=32G')).toEqual({});
14
+ });
15
+
16
+ test('cloud-init spelling, QEMU-only: no LXC equivalent', () => {
17
+ expect(judgeDisk('ide2', 'cephtb4:cloudinit', 'cephtb4:vm-101-cloudinit,media=cdrom')).toEqual(
18
+ {},
19
+ );
20
+ });
21
+ });
22
+
23
+ describe('an existing-volume declaration', () => {
24
+ const live = 'cephtb4:vm-101-disk-0,size=32G,ssd=1';
25
+
26
+ test('matching options is a noop', () => {
27
+ expect(judgeDisk('scsi0', live, live)).toEqual({});
28
+ });
29
+
30
+ test('a different storage is refused, never written', () => {
31
+ const verdict = judgeDisk('scsi0', 'other:vm-101-disk-0,size=32G,ssd=1', live);
32
+ expect(verdict.refuse).toMatch(/the volume is on cephtb4, declared on other/);
33
+ expect(verdict.put).toBeUndefined();
34
+ });
35
+
36
+ test('a different volume on the same storage is refused: it would detach the live one', () => {
37
+ const verdict = judgeDisk('scsi0', 'cephtb4:vm-101-disk-9,size=32G', live);
38
+ expect(verdict.refuse).toMatch(/detach the live volume to unusedN/);
39
+ });
40
+
41
+ test('a declared size difference is refused: this resource does not resize', () => {
42
+ const verdict = judgeDisk('scsi0', 'cephtb4:vm-101-disk-0,size=64G,ssd=1', live);
43
+ expect(verdict.refuse).toMatch(/does not resize a disk/);
44
+ });
45
+
46
+ test('an option-only change writes the LIVE volume id and LIVE size, not the declared ones', () => {
47
+ const verdict = judgeDisk('scsi0', 'cephtb4:vm-101-disk-0,size=32G,ssd=0', live);
48
+ expect(verdict.put).toBe('cephtb4:vm-101-disk-0,ssd=0,size=32G');
49
+ });
50
+
51
+ test('the cloud-init slot has no size to carry: the PUT omits it rather than writing size=', () => {
52
+ const cloudinitLive = 'cephtb4:vm-101-cloudinit,media=cdrom';
53
+ const verdict = judgeDisk(
54
+ 'ide2',
55
+ 'cephtb4:vm-101-cloudinit,media=cdrom,readonly=1',
56
+ cloudinitLive,
57
+ );
58
+ expect(verdict.put).toBe('cephtb4:vm-101-cloudinit,media=cdrom,readonly=1');
59
+ });
60
+ });
61
+
62
+ describe('parseDisk', () => {
63
+ test('a bare new-disk spelling has no volname and no size stored as an option', () => {
64
+ expect(parseDisk('cephtb4:32')).toEqual({
65
+ kind: 'fresh',
66
+ options: new Map(),
67
+ size: undefined,
68
+ source: 'cephtb4',
69
+ volname: '',
70
+ });
71
+ });
72
+
73
+ test('an existing volume splits size out of the options map', () => {
74
+ const disk = parseDisk('cephtb4:vm-101-disk-0,size=32G,ssd=1');
75
+ expect(disk.kind).toBe('volume');
76
+ expect(disk.volname).toBe('vm-101-disk-0');
77
+ expect(disk.size).toBe('32G');
78
+ expect(disk.options).toEqual(new Map([['ssd', '1']]));
79
+ });
80
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * `scsiN` and `ideN`: what a declared disk may change on a live VM, and how.
3
+ *
4
+ * ★ PORTED FROM lxc-volume.ts (2026-09-26, red-team C1 on PR 297): pve-qemu-server shares the same
5
+ * "new-disk spelling" rule pve-container has. `storage:GiB` in a PUT means "allocate a fresh
6
+ * volume" — so a declared `cephtb4:32` that already has a live `cephtb4:vm-101-disk-0,size=32G`
7
+ * in that slot must compare EQUAL, never be re-sent. Measured by the builder's red team: resending
8
+ * it allocated a second volume every deploy and parked the guest's real disk at `unusedN` (or left
9
+ * the swap pending on a running boot disk until the next reboot).
10
+ * ⚠️ `ideN` HAS A SECOND NEW-VOLUME SPELLING QEMU DOES NOT SHARE WITH LXC: `<storage>:cloudinit`
11
+ * creates a fresh cloud-init seed drive; the live slot then reads back
12
+ * `<storage>:vm-<vmid>-cloudinit,media=cdrom`. Both spellings compare equal to an existing volume
13
+ * already in the slot, for the same reason `storage:GiB` does.
14
+ * ⛔ NO RESIZE IS MADE HERE. lxc-volume.ts drives a second endpoint (`PUT .../resize`) this resource
15
+ * does not call — Talos's disks are declared once at their create size. A size difference between
16
+ * two already-adopted volume declarations is REFUSED with the `qm resize` to run by hand, never
17
+ * guessed at or silently written.
18
+ * ⚠️ OPTIONS BEYOND `size` ARE COMPARED AS A WHOLE, NOT KEY BY KEY. Unlike lxc-volume.ts, this file
19
+ * does not model pve-qemu-server's per-option defaults (`ssd`, `discard`, `iothread`, …) — that
20
+ * table is unverified here. So an option difference is real drift and gets written, exactly as the
21
+ * whole-string comparison this replaces already did; only the two "new volume" spellings and the
22
+ * live volume id/size are special-cased, which is the minimum that fixes the measured bug.
23
+ */
24
+ import { sameMap } from './lxc-wire.ts';
25
+
26
+ /** PVE's own new-disk spelling: `storage:SIZE`, SIZE in GiB, fractional allowed. */
27
+ const NEW_DISK = /^([^:\s]+):(\d+(?:\.\d+)?)$/;
28
+ /** The `ideN` cloud-init-drive spelling — QEMU only, no GiB, no LXC equivalent. */
29
+ const NEW_CLOUDINIT = /^([^:\s]+):cloudinit$/;
30
+
31
+ export type Disk = {
32
+ /** `fresh`: either new-disk spelling above. `volume`: an existing volume, `storage:volname,...`. */
33
+ readonly kind: 'fresh' | 'volume';
34
+ readonly source: string;
35
+ readonly volname: string;
36
+ readonly size: string | undefined;
37
+ readonly options: Map<string, string>;
38
+ };
39
+
40
+ /** `storage:volname,k=v,...` (or one of the two "fresh" spellings) into its comparable parts. */
41
+ export const parseDisk = (text: string): Disk => {
42
+ const fresh = NEW_DISK.exec(text) ?? NEW_CLOUDINIT.exec(text);
43
+ if (fresh !== null) {
44
+ return {
45
+ kind: 'fresh',
46
+ options: new Map(),
47
+ size: undefined,
48
+ source: fresh[1] ?? '',
49
+ volname: '',
50
+ };
51
+ }
52
+ const [first, ...rest] = text.split(',');
53
+ const at = (first ?? '').indexOf(':');
54
+ const source = at === -1 ? (first ?? '') : (first ?? '').slice(0, at);
55
+ const volname = at === -1 ? '' : (first ?? '').slice(at + 1);
56
+ const options = new Map(
57
+ rest.map((part): [string, string] => {
58
+ const eq = part.indexOf('=');
59
+ return eq === -1 ? [part, ''] : [part.slice(0, eq), part.slice(eq + 1)];
60
+ }),
61
+ );
62
+ const size = options.get('size');
63
+ options.delete('size');
64
+ return { kind: 'volume', options, size, source, volname };
65
+ };
66
+
67
+ export type DiskVerdict = {
68
+ readonly refuse?: string;
69
+ /** The value to PUT, built on the LIVE volume id and LIVE size — never the declared spelling. */
70
+ readonly put?: string;
71
+ };
72
+
73
+ /**
74
+ * A map back to `key=value,...` — mirrors lxc-wire.ts's `printMap`.
75
+ * ⚠️ `size` OMITTED WHEN THE LIVE SLOT HAS NONE (the cloud-init drive: no GiB figure to carry).
76
+ * Writing `size=` empty is not a value PVE's own parser accepts.
77
+ */
78
+ const printOptions = (
79
+ source: string,
80
+ volname: string,
81
+ options: Map<string, string>,
82
+ size: string | undefined,
83
+ ) =>
84
+ [
85
+ `${source}:${volname}`,
86
+ ...[...options].map(([k, v]) => `${k}=${v}`),
87
+ ...(size === undefined ? [] : [`size=${size}`]),
88
+ ].join(',');
89
+
90
+ export const judgeDisk = (key: string, declared: string, live: string): DiskVerdict => {
91
+ const want = parseDisk(declared);
92
+ const have = parseDisk(live);
93
+ if (want.source !== have.source) {
94
+ return {
95
+ refuse:
96
+ `${key}: the volume is on ${have.source}, declared on ${want.source}. Moving a disk ` +
97
+ 'between storages is not an update this resource makes -- move it by hand, then declare it.',
98
+ };
99
+ }
100
+ // ⛔ A "fresh" declaration always matches an existing volume already in this slot: PVE holds one
101
+ // there, and re-sending storage:GiB / storage:cloudinit would allocate a SECOND volume and park
102
+ // the live one at unusedN (the header's ⛔, ported from lxc-volume.ts's own).
103
+ if (want.kind === 'fresh') return {};
104
+ if (want.volname !== have.volname) {
105
+ return {
106
+ refuse:
107
+ `${key}: declared volume ${want.volname} is not the live ${have.volname}. Writing it ` +
108
+ 'would detach the live volume to unusedN; attach or swap volumes by hand.',
109
+ };
110
+ }
111
+ if (want.size !== undefined && have.size !== undefined && want.size !== have.size) {
112
+ return {
113
+ refuse:
114
+ `${key}: declared size ${want.size} differs from the live ${have.size}. This resource does ` +
115
+ 'not resize a disk -- grow it by hand (`qm resize`) and declare the size it then reports.',
116
+ };
117
+ }
118
+ if (sameMap(want.options, have.options)) return {};
119
+ // ⚠️ THE LIVE SIZE, NOT THE DECLARED ONE — see the header's ⛔ on why no resize happens here.
120
+ return { put: printOptions(have.source, have.volname, want.options, have.size) };
121
+ };
@@ -1,5 +1,7 @@
1
1
  /**
2
- * `Proxmox.Vm` — a QEMU virtual machine, declared.
2
+ * `Proxmox.Vm` — a QEMU virtual machine, declared. Exported (not just Provider-only) since
3
+ * 2026-09-26 for Talos: see qemu-props.ts for what a declaration may say and why `cipassword`
4
+ * and `machine` may never be one of them.
3
5
  *
4
6
  * ⚠️ CREATE is `POST /nodes/{node}/qemu`. GET and PUT stay on `.../qemu/{vmid}/config`.
5
7
  * ⛔ DELETE is `DELETE /nodes/{node}/qemu/{vmid}` (`destroy_vm` in qemu-server Qemu.pm). The
@@ -10,39 +12,22 @@
10
12
  * collision Alchemy can see -- they are different resource types -- and PVE refuses the second
11
13
  * with "already exists". Pick ids per cluster, not per kind.
12
14
  *
13
- * ⚠️ POWER STATE IS REPORTED, NEVER DECLARED. `status` is an attribute so a plan can show it; there
14
- * is no `running` prop. Starting and stopping a VM from a plan would make a deploy a maintenance
15
- * window, and the estate has one of those already.
15
+ * ⛔ THIS RESOURCE NEVER MIGRATES. `qemu-read.ts` refuses the plan outright when the declared vmid
16
+ * is found running on a DIFFERENT node than the one declared ("A migration is not an update") --
17
+ * unchanged by the 2026-09-26 export, and exactly what keeps a node-pinned Talos VM pinned.
18
+ *
19
+ * ⚠️ POWER STATE IS NEITHER DECLARED NOR REPORTED. There is no `running` prop and `VmAttributes`
20
+ * carries no `status` field (qemu-props.ts) — starting and stopping a VM from a plan would make a
21
+ * deploy a maintenance window, and the estate has one of those already.
16
22
  */
17
23
  import { Resource } from 'alchemy';
18
24
  import * as Provider from 'alchemy/Provider';
19
25
  import * as Effect from 'effect/Effect';
20
26
  import { qemuHandlers } from './qemu-lifecycle.ts';
21
- import type { PveRequirements, WithTarget } from './resource.ts';
22
-
23
- export interface VmProps extends WithTarget {
24
- node: string;
25
- /** ⛔ Cluster-wide, and shared with LXC. See the ⛔ above. */
26
- vmid: number;
27
- name?: string;
28
- /** MiB. */
29
- memory?: number;
30
- cores?: number;
31
- sockets?: number;
32
- /** e.g. `virtio=<mac>,bridge=vmbr0`. */
33
- net0?: string;
34
- onboot?: boolean;
35
- }
27
+ import type { PveRequirements } from './resource.ts';
28
+ import type { VmAttributes, VmProps } from './qemu-props.ts';
36
29
 
37
- export interface VmAttributes {
38
- vmid: number;
39
- node: string;
40
- name: string;
41
- memory: number;
42
- cores: number;
43
- sockets: number;
44
- onboot: boolean;
45
- }
30
+ export type { VmAttributes, VmProps } from './qemu-props.ts';
46
31
 
47
32
  export interface ProxmoxVm extends Resource<
48
33
  'Proxmox.Vm',
@@ -52,7 +37,8 @@ export interface ProxmoxVm extends Resource<
52
37
  PveRequirements
53
38
  > {}
54
39
 
55
- export const ProxmoxVm = Resource<ProxmoxVm>('Proxmox.Vm');
40
+ /** ★ `retain` by default — a VM's disks cannot be rebuilt from a line. See resource.ts / lxc.ts. */
41
+ export const ProxmoxVm = Resource<ProxmoxVm>('Proxmox.Vm', { defaultRemovalPolicy: 'retain' });
56
42
 
57
43
  export { createForm } from './qemu-form.ts';
58
44
 
@@ -0,0 +1,7 @@
1
+ /** A `Proxmox.StorageDownload` write or observation this provider will not turn into a claim of success. */
2
+ export class StorageDownloadRefusedError extends Error {
3
+ constructor(message: string) {
4
+ super(message);
5
+ this.name = 'StorageDownloadRefusedError';
6
+ }
7
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Pure unit tests for `judgeIdentity` — red-team I1/I2 on PR 297. No network, no fake PVE.
3
+ * ⛔ WHAT THESE PIN: a changed `filename`/`storage` is a `replace`, never a silent `update` that
4
+ * orphans the old file; a changed pin (`checksum`/`url`/…) on the SAME `filename` is named for a
5
+ * refusal, never treated as a matching declaration; nothing changing is `same`.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import { FAKE_TARGET } from './fake-pve.ts';
9
+ import { judgeIdentity } from './storage-download-identity.ts';
10
+ import type { StorageDownloadProps } from './storage-download-props.ts';
11
+
12
+ const base: StorageDownloadProps = {
13
+ checksum: 'a'.repeat(64),
14
+ checksumAlgorithm: 'sha256',
15
+ filename: 'talos-v1.13.0-metal-amd64.qcow2',
16
+ node: 'pve1',
17
+ storage: 'cephfs-tb4',
18
+ target: FAKE_TARGET,
19
+ url: 'https://images.example.test/talos.qcow2',
20
+ };
21
+
22
+ describe('judgeIdentity', () => {
23
+ test('no prior declaration: always "same" -- a fresh create has nothing to compare', () => {
24
+ expect(judgeIdentity(base, undefined)).toEqual({ kind: 'same' });
25
+ });
26
+
27
+ test('nothing changed: "same"', () => {
28
+ expect(judgeIdentity(base, { ...base })).toEqual({ kind: 'same' });
29
+ });
30
+
31
+ test('a changed filename is a "replace": the measured I1 regression (silent orphan)', () => {
32
+ expect(judgeIdentity({ ...base, filename: 'talos-v1.14.0-metal-amd64.qcow2' }, base)).toEqual({
33
+ kind: 'replace',
34
+ });
35
+ });
36
+
37
+ test('a changed storage is a "replace" too', () => {
38
+ expect(judgeIdentity({ ...base, storage: 'cephtb4' }, base)).toEqual({ kind: 'replace' });
39
+ });
40
+
41
+ test('a changed checksum on the same filename is "pin-changed": the measured I2 regression', () => {
42
+ const verdict = judgeIdentity({ ...base, checksum: 'b'.repeat(64) }, base);
43
+ expect(verdict.kind).toBe('pin-changed');
44
+ expect((verdict as { refuse: string }).refuse).toMatch(/checksum changed but filename did not/);
45
+ });
46
+
47
+ test('a changed url on the same filename is "pin-changed" too', () => {
48
+ const verdict = judgeIdentity(
49
+ { ...base, url: 'https://images.example.test/other.qcow2' },
50
+ base,
51
+ );
52
+ expect(verdict.kind).toBe('pin-changed');
53
+ });
54
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * What makes a `Proxmox.StorageDownload` a DIFFERENT file, and what a changed pin on the SAME
3
+ * file means. (2026-09-26, red-team I1/I2 on PR 297.)
4
+ *
5
+ * ★ `filename` (WITH `storage`) IS THE IDENTITY. PVE does not record what a volume was downloaded
6
+ * from — `GET .../content` reports only `format`/`size`/`volid` (storage-download-props.ts) — so
7
+ * once the file exists there is nothing left to compare a new `url`/`checksum` against except the
8
+ * PREVIOUS DECLARATION. `storage-download-lifecycle.ts`'s `diff` asks `judgeIdentity` before
9
+ * touching the file; a genuine identity change plans `replace` (delete the old file, download the
10
+ * new one under its own name) rather than the silent `update` that used to orphan the old file
11
+ * forever, and a changed pin on the SAME filename refuses rather than silently planning `noop`.
12
+ */
13
+ import type { StorageDownloadProps } from './storage-download-props.ts';
14
+
15
+ /** The fields PVE never stores back, so a change in them is only visible against `olds`. */
16
+ const PINNED: readonly (keyof StorageDownloadProps)[] = [
17
+ 'checksum',
18
+ 'checksumAlgorithm',
19
+ 'compression',
20
+ 'url',
21
+ 'verifyCertificates',
22
+ ];
23
+
24
+ export type IdentityVerdict =
25
+ | { readonly kind: 'same' }
26
+ | { readonly kind: 'replace' }
27
+ | { readonly kind: 'pin-changed'; readonly refuse: string };
28
+
29
+ /**
30
+ * `olds` is undefined for a resource with no prior declaration (a fresh create, or the recovery
31
+ * read for an interrupted one) — neither case has a pin to compare, so both are `same`.
32
+ */
33
+ export const judgeIdentity = (
34
+ news: StorageDownloadProps,
35
+ olds: StorageDownloadProps | undefined,
36
+ ): IdentityVerdict => {
37
+ if (olds === undefined) return { kind: 'same' };
38
+ if (news.filename !== olds.filename || news.storage !== olds.storage) {
39
+ return { kind: 'replace' };
40
+ }
41
+ const changed = PINNED.filter((key) => news[key] !== olds[key]);
42
+ if (changed.length === 0) return { kind: 'same' };
43
+ return {
44
+ kind: 'pin-changed',
45
+ refuse:
46
+ `${news.filename}: ${changed.join(', ')} changed but filename did not. filename is the ` +
47
+ 'identity -- PVE does not record what a volume was downloaded from, so this declaration ' +
48
+ 'cannot be verified or rewritten in place. Change filename to force a new download.',
49
+ };
50
+ };
@@ -0,0 +1,226 @@
1
+ /**
2
+ * StorageDownload lifecycle. Read finds a content row by filename; create downloads and polls;
3
+ * delete is idempotent — a volume already gone is success, not a refusal.
4
+ *
5
+ * ⛔ 2026-09-26, red-team C2/I1/I2/I3 on PR 297: `read` and `reconcile` now go through
6
+ * `ownedRead`/`refuseTakeover` (C2) so a pre-existing file is never silently adopted — and then
7
+ * deleted, since this family is not retain-by-default; `diff` asks `storage-download-identity.ts`
8
+ * (I1/I2) before touching the file; the content list read uses the `read` role, not `provision`
9
+ * (I3, `credentials.ts`: "read for read/diff, provision for reconcile/delete").
10
+ */
11
+ import { isResolved } from 'alchemy/Diff';
12
+ import type { Input } from 'alchemy/Input';
13
+ import * as nodes from '@distilled.cloud/proxmox/nodes';
14
+ import * as Effect from 'effect/Effect';
15
+ import { refuseTakeover } from '../ownership/adopt.ts';
16
+ import { ownedRead } from '../ownership/probe.ts';
17
+ import { formViolations } from './constraint-guard.ts';
18
+ import { runPve } from './distilled-pve.ts';
19
+ import { judgeIdentity } from './storage-download-identity.ts';
20
+ import { StorageDownloadRefusedError } from './storage-download-errors.ts';
21
+ import type { StorageDownloadAttributes, StorageDownloadProps } from './storage-download-props.ts';
22
+ import { storageDownloadTask } from './storage-download-task.ts';
23
+ import { int, text } from './values.ts';
24
+
25
+ const CREATE_POLLS = 600; // an image download can run far longer than a config write
26
+ const DELETE_POLLS = 60;
27
+
28
+ /** ⚠️ Named as a literal so `codegen/constraints.ts`'s text scan tables it (that file's own header). */
29
+ const DOWNLOAD_ENDPOINT = 'pve:POST /nodes/{node}/storage/{storage}/download-url';
30
+
31
+ /**
32
+ * The wire form, PVE-spelled (`checksum-algorithm`, `verify-certificates`) — what the generated
33
+ * vendor table keys by, and what `formViolations` checks. ⚠️ NOT re-spelled for the SDK call:
34
+ * `nodes.downloadNodesStorageUrl` never declares these two hyphenated names as typed properties
35
+ * either (its schema names them `checksum_algorithm`/`verify_certificates`), so passing this
36
+ * object straight through relies on protocol-http.ts's own documented "unknown keys pass through
37
+ * as body fields" rule — the same spec-drift path qemu-props.ts leans on for `scsi0`/`net0`, and
38
+ * unlike storage-wire.ts's `underscored()` this family has no reason to translate: the schema's
39
+ * OWN property names would just be a second spelling of the same two fields.
40
+ */
41
+ export const downloadForm = (props: StorageDownloadProps) => ({
42
+ checksum: props.checksum,
43
+ 'checksum-algorithm': props.checksumAlgorithm,
44
+ ...(props.compression === undefined ? {} : { compression: props.compression }),
45
+ content: 'import',
46
+ filename: props.filename,
47
+ node: props.node,
48
+ storage: props.storage,
49
+ url: props.url,
50
+ ...(props.verifyCertificates === undefined
51
+ ? {}
52
+ : { 'verify-certificates': props.verifyCertificates ? '1' : '0' }),
53
+ });
54
+
55
+ /**
56
+ * Why this declaration cannot be sent, checked before every diff and every reconcile.
57
+ * ⛔ `checksum` REQUIRED, EVEN THOUGH THE TYPE ALREADY SAYS SO — a JS caller or a cast can still
58
+ * leave it `''`; this is the runtime backstop storage-download.ts's header promises.
59
+ */
60
+ export const createRefusals = (props: StorageDownloadProps): string[] => {
61
+ const refuse: string[] = [];
62
+ if (props.checksum.trim() === '') {
63
+ refuse.push(
64
+ `${props.filename}: checksum is required — an unpinned download is not a declaration.`,
65
+ );
66
+ }
67
+ return refuse.concat(
68
+ formViolations(DOWNLOAD_ENDPOINT, downloadForm(props), true).map(
69
+ (why) => `${props.filename}: ${why}`,
70
+ ),
71
+ );
72
+ };
73
+
74
+ /**
75
+ * `GET .../content?content=import`, matched by filename suffix — see storage-download-props.ts's
76
+ * `volid` doc for why a suffix match, not an assumed prefix. `content` is already server-filtered.
77
+ * ⛔ I3: THE `read` ROLE, NOT `provision` — this is a plain GET, and every caller (read/diff AND
78
+ * reconcile/delete, which mint their own `provision` credential for the actual write) may use the
79
+ * cheaper, longer-lived one, exactly as `qemu-read.ts`'s `readVm` already does for its own GET.
80
+ */
81
+ export const readDownload = (props: StorageDownloadProps) =>
82
+ Effect.gen(function* () {
83
+ const rows = yield* runPve(
84
+ props.target,
85
+ 'read',
86
+ false,
87
+ nodes.listNodeStorageContent({
88
+ content: 'import',
89
+ node: props.node,
90
+ storage: props.storage,
91
+ }),
92
+ );
93
+ if (!Array.isArray(rows)) {
94
+ return yield* Effect.fail(
95
+ new StorageDownloadRefusedError(
96
+ `${props.storage}: the content list did not come back as a list.`,
97
+ ),
98
+ );
99
+ }
100
+ const found = rows.find((row) => row.volid.endsWith(`/${props.filename}`));
101
+ if (found === undefined) return undefined;
102
+ return {
103
+ filename: props.filename,
104
+ format: text(found.format, 'raw'),
105
+ node: props.node,
106
+ size: int(found.size, 0),
107
+ storage: props.storage,
108
+ volid: text(found.volid, ''),
109
+ } satisfies StorageDownloadAttributes;
110
+ });
111
+
112
+ /** ⛔ IDEMPOTENT DELETE (distilled doctrine): a volume already gone is success, never a refusal. */
113
+ export const destroyDownload = (props: StorageDownloadProps) =>
114
+ Effect.gen(function* () {
115
+ const live = yield* readDownload(props);
116
+ if (live === undefined) return;
117
+ yield* storageDownloadTask(
118
+ props,
119
+ nodes.deleteNodeStorageContent({
120
+ node: props.node,
121
+ storage: props.storage,
122
+ volume: live.volid,
123
+ }),
124
+ `delete ${live.volid}`,
125
+ DELETE_POLLS,
126
+ );
127
+ });
128
+
129
+ export const storageDownloadHandlers = {
130
+ list: () => Effect.succeed([]),
131
+ /**
132
+ * ⛔ C2: `Unowned` unless state already vouches for it. With no attributes this is the adoption
133
+ * probe or the recovery read for an interrupted download; `filename` (with `storage`) is the
134
+ * whole identity this family has (storage-download-identity.ts), so once the row's own instance
135
+ * is proven to hold the WHOLE declaration (`ownership/rows.ts`, inside `ownedRead`), a matching
136
+ * file is ours -- there is no further per-key drift to prove, unlike a config-bearing resource.
137
+ */
138
+ read: Effect.fn(function* ({
139
+ fqn,
140
+ instanceId,
141
+ olds,
142
+ output,
143
+ }: {
144
+ fqn: string;
145
+ instanceId: string;
146
+ olds: StorageDownloadProps;
147
+ output: StorageDownloadAttributes | undefined;
148
+ }) {
149
+ const found = yield* readDownload(olds);
150
+ return yield* ownedRead({ fqn, instanceId, output }, found, Effect.succeed(true));
151
+ }),
152
+ diff: Effect.fn(function* ({
153
+ news,
154
+ olds,
155
+ output,
156
+ }: {
157
+ news: Input<StorageDownloadProps>;
158
+ olds: StorageDownloadProps;
159
+ output: StorageDownloadAttributes | undefined;
160
+ }) {
161
+ if (!isResolved(news)) return undefined;
162
+ const refused = createRefusals(news);
163
+ if (refused.length > 0) {
164
+ return yield* Effect.fail(new StorageDownloadRefusedError(refused.join('\n')));
165
+ }
166
+ if (output === undefined) return undefined;
167
+ // ⛔ I1: a changed filename/storage is a DIFFERENT file -- plan a replace (delete the old one,
168
+ // download the new one under its own name) rather than the `update` that used to leave the
169
+ // old file orphaned forever with no state pointing at it.
170
+ const identity = judgeIdentity(news, olds);
171
+ if (identity.kind === 'replace') return { action: 'replace' } as const;
172
+ const live = yield* readDownload(news);
173
+ // ⛔ I2: a changed url/checksum/etc on the SAME filename is refused, never silently `noop` --
174
+ // PVE does not record what a volume was downloaded from, so this declaration cannot be
175
+ // verified or rewritten in place. Only when the file is actually there: if it already vanished
176
+ // out of band, the pin change is simply what the next download uses.
177
+ if (identity.kind === 'pin-changed' && live !== undefined) {
178
+ return yield* Effect.fail(new StorageDownloadRefusedError(identity.refuse));
179
+ }
180
+ return { action: live === undefined ? 'update' : 'noop' } as const;
181
+ }),
182
+ reconcile: Effect.fn(function* ({
183
+ fqn,
184
+ instanceId,
185
+ news,
186
+ output,
187
+ }: {
188
+ fqn: string;
189
+ instanceId: string;
190
+ news: StorageDownloadProps;
191
+ output: StorageDownloadAttributes | undefined;
192
+ }) {
193
+ const refused = createRefusals(news);
194
+ if (refused.length > 0) {
195
+ return yield* Effect.fail(new StorageDownloadRefusedError(refused.join('\n')));
196
+ }
197
+ const live = yield* readDownload(news);
198
+ if (live === undefined) {
199
+ yield* storageDownloadTask(
200
+ news,
201
+ nodes.downloadNodesStorageUrl(downloadForm(news)),
202
+ `download ${news.filename} onto ${news.storage}`,
203
+ CREATE_POLLS,
204
+ );
205
+ } else {
206
+ // ⛔ C2: a file already at this filename that this stack holds no state for is refused unless
207
+ // adoption is on. Without this, dropping the declaration later DELETES a file this stack
208
+ // never created — this family is not retain-by-default (storage-download.ts's header).
209
+ yield* refuseTakeover(
210
+ { fqn, instanceId, output },
211
+ `Proxmox.StorageDownload ${news.filename} on ${news.storage}`,
212
+ );
213
+ }
214
+ const after = yield* readDownload(news);
215
+ if (after === undefined) {
216
+ return yield* Effect.fail(
217
+ new StorageDownloadRefusedError(
218
+ `${news.filename}: the write returned no error but ${news.storage} still has no ` +
219
+ 'matching content — PVE may have normalised the filename; read it back by hand.',
220
+ ),
221
+ );
222
+ }
223
+ return after;
224
+ }),
225
+ delete: ({ olds }: { olds: StorageDownloadProps }) => destroyDownload(olds),
226
+ };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Whose file it is — docs/ownership.md applied to `Proxmox.StorageDownload` (red-team C2 on PR
3
+ * 297), through the real engine over a stub fetch (fake-pve.ts).
4
+ *
5
+ * ⛔ WHAT THIS PINS: a pre-existing file at the declared filename, that this stack holds no state
6
+ * for, is never adopted silently. The measured regression was worse than a pointless write-back:
7
+ * this family is not retain-by-default, so a silent adoption followed by dropping the declaration
8
+ * later DELETES a file this stack never created. `adopt(false)` wins over the test harness's own
9
+ * forced `AdoptPolicy: true` (fake-engine.ts), same as qemu-ownership.test.ts.
10
+ */
11
+ import { describe, expect, test } from 'bun:test';
12
+ import { adopt } from 'alchemy/AdoptPolicy';
13
+ import * as Layer from 'effect/Layer';
14
+ import { engineOver } from '../verify/fake-engine.ts';
15
+ import { FAKE_TARGET, fakePve, withoutBao } from './fake-pve.ts';
16
+ import type { StorageDownloadProps } from './storage-download-props.ts';
17
+ import { ProxmoxStorageDownload, ProxmoxStorageDownloadProvider } from './storage-download.ts';
18
+
19
+ const NODE = 'pve1';
20
+ const STORAGE = 'cephfs-tb4';
21
+ const FILENAME = 'talos-v1.13.0-metal-amd64.qcow2';
22
+ const row = { format: 'qcow2', size: 1, volid: `${STORAGE}:import/${FILENAME}` };
23
+ const declare = (): StorageDownloadProps => ({
24
+ checksum: 'a'.repeat(64),
25
+ checksumAlgorithm: 'sha256',
26
+ filename: FILENAME,
27
+ node: NODE,
28
+ storage: STORAGE,
29
+ target: FAKE_TARGET,
30
+ url: 'https://images.example.test/talos.qcow2',
31
+ });
32
+
33
+ const isList = (call: { method: string; path: string }) =>
34
+ call.method === 'GET' && call.path.includes('/content?');
35
+
36
+ const engine = (fake: ReturnType<typeof fakePve>) =>
37
+ engineOver(ProxmoxStorageDownloadProvider().pipe(Layer.provideMerge(fake.layer)));
38
+
39
+ describe('Proxmox.StorageDownload ownership', () => {
40
+ test('adopt(false): a pre-existing file is refused, and nothing is downloaded or deleted', async () => {
41
+ const fake = fakePve((call) => (isList(call) ? [row] : null));
42
+ await withoutBao(async () => {
43
+ const declared = ProxmoxStorageDownload('image', declare()).pipe(adopt(false));
44
+ await expect(engine(fake).deploy(declared)).rejects.toThrow(
45
+ /adopt|exists, and this stack holds no state/i,
46
+ );
47
+ });
48
+ expect(fake.writes()).toEqual([]);
49
+ });
50
+ });