@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
@@ -0,0 +1,171 @@
1
+ /** QEMU engine tests over a stub fetch. No live host is contacted. */
2
+ import { describe, expect, test } from 'bun:test';
3
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
4
+ import * as Effect from 'effect/Effect';
5
+ import * as Layer from 'effect/Layer';
6
+ import { engineOver } from '../verify/fake-engine.ts';
7
+ import { FAKE_TARGET, type FakePve, fakePve, withoutBao } from './fake-pve.ts';
8
+ import { ProxmoxVm, ProxmoxVmProvider } from './qemu.ts';
9
+
10
+ const NODE = 'pve1';
11
+ const VMID = 150;
12
+ const props = { target: FAKE_TARGET, node: NODE, vmid: VMID };
13
+ const digest = 'a'.repeat(40);
14
+ const live = { digest, name: 'vm150', memory: '512', cores: 1, sockets: 1, onboot: 0 };
15
+ const missing = () =>
16
+ Response.json(
17
+ {
18
+ data: null,
19
+ message: `Configuration file 'nodes/${NODE}/qemu-server/${String(VMID)}.conf' does not exist\n`,
20
+ },
21
+ { status: 500 },
22
+ );
23
+ const UPID = `UPID:${NODE}:00000001:0:0:qmcreate:${String(VMID)}:test@pve!fake:`;
24
+
25
+ const engine = (fake: FakePve) =>
26
+ engineOver(ProxmoxVmProvider().pipe(Layer.provideMerge(fake.layer)));
27
+
28
+ const isIndex = (call: { method: string; path: string }) =>
29
+ call.method === 'GET' && call.path.startsWith('cluster/resources');
30
+
31
+ describe('Proxmox.Vm distilled transport', () => {
32
+ test('adoption and a second plan write nothing', async () => {
33
+ const fake = fakePve((call) => (call.method === 'GET' ? live : null));
34
+ await withoutBao(async () => {
35
+ const stack = engine(fake);
36
+ expect(await stack.deploy(ProxmoxVm('row', props))).toEqual({ row: 'adopted' });
37
+ expect((await stack.verify(ProxmoxVm('row', props), { all: true })).rows).toEqual([
38
+ expect.objectContaining({ diff: 'noop', ok: true }),
39
+ ]);
40
+ });
41
+ expect(fake.writes()).toEqual([]);
42
+ });
43
+
44
+ test('absence creates on the collection route, waits, then settles', async () => {
45
+ let exists = false;
46
+ const fake = fakePve((call) => {
47
+ if (call.path.includes('/tasks/') && call.path.endsWith('/status')) {
48
+ return { status: 'stopped', exitstatus: 'OK' };
49
+ }
50
+ if (isIndex(call)) return [];
51
+ if (call.method === 'GET') return exists ? live : missing();
52
+ exists = true;
53
+ return UPID;
54
+ });
55
+ await withoutBao(async () => {
56
+ const stack = engine(fake);
57
+ await stack.deploy(
58
+ ProxmoxVm('row', { ...props, net0: 'virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0' }),
59
+ );
60
+ expect((await stack.verify(ProxmoxVm('row', props), { all: true })).rows[0]).toMatchObject({
61
+ diff: 'noop',
62
+ });
63
+ });
64
+ expect(fake.writes()).toEqual([`POST nodes/${NODE}/qemu`]);
65
+ expect(fake.calls.find((call) => call.method === 'POST')?.form['net0']).toBe(
66
+ 'virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0',
67
+ );
68
+ expect(fake.calls.some((call) => call.path.includes('/tasks/'))).toBe(true);
69
+ });
70
+
71
+ test('a transitional task status keeps polling instead of aborting the create', async () => {
72
+ // ⚠️ `GetNodeTaskStatusResponseStatus` types `status` as the closed union
73
+ // "running" | "stopped", but the runtime validator is `S.String` (distilled-proxmox's
74
+ // nodes.ts) -- a status word this endpoint's contract was never proven to exclude must
75
+ // keep polling rather than abort the whole create, since only "stopped" ends the task
76
+ // (2026-09-25 adversarial review; matches lxc-task.ts's own regression test).
77
+ let exists = false;
78
+ let polls = 0;
79
+ const fake = fakePve((call) => {
80
+ if (call.path.includes('/tasks/') && call.path.endsWith('/status')) {
81
+ polls++;
82
+ return polls === 1 ? { status: 'starting' } : { status: 'stopped', exitstatus: 'OK' };
83
+ }
84
+ if (isIndex(call)) return [];
85
+ if (call.method === 'GET') return exists ? live : missing();
86
+ exists = true;
87
+ return UPID;
88
+ });
89
+ await withoutBao(async () => {
90
+ const stack = engine(fake);
91
+ await stack.deploy(ProxmoxVm('row', props));
92
+ expect((await stack.verify(ProxmoxVm('row', props), { all: true })).rows[0]).toMatchObject({
93
+ diff: 'noop',
94
+ });
95
+ });
96
+ expect(polls).toBe(2);
97
+ expect(fake.writes()).toEqual([`POST nodes/${NODE}/qemu`]);
98
+ });
99
+
100
+ test('a vmid held by a container is not created', async () => {
101
+ const fake = fakePve((call) => {
102
+ if (isIndex(call)) {
103
+ return [{ id: 'lxc/150', type: 'lxc', vmid: 150, node: 'pve2' }];
104
+ }
105
+ if (call.method === 'GET') return missing();
106
+ return UPID;
107
+ });
108
+ await withoutBao(async () => {
109
+ await expect(engine(fake).deploy(ProxmoxVm('row', props))).rejects.toThrow(
110
+ /container|lxc|pve2/,
111
+ );
112
+ });
113
+ expect(fake.writes()).toEqual([]);
114
+ });
115
+
116
+ test('drift updates the config route and the next plan is a no-op', async () => {
117
+ let current: unknown = { ...live, memory: '256' };
118
+ const fake = fakePve((call) => {
119
+ if (call.method === 'GET') return current;
120
+ current = live;
121
+ return null;
122
+ });
123
+ await withoutBao(async () => {
124
+ const stack = engine(fake);
125
+ expect((await stack.verify(ProxmoxVm('row', props), { all: true })).rows[0]).toMatchObject({
126
+ diff: 'update',
127
+ });
128
+ await stack.deploy(ProxmoxVm('row', props));
129
+ expect((await stack.verify(ProxmoxVm('row', props), { all: true })).rows[0]).toMatchObject({
130
+ diff: 'noop',
131
+ });
132
+ });
133
+ expect(fake.writes()).toEqual([`PUT nodes/${NODE}/qemu/${String(VMID)}/config`]);
134
+ });
135
+
136
+ test('unrelated read failures and a digest-less body do not write', async () => {
137
+ for (const body of [
138
+ Response.json({ data: null, message: 'unrelated failure' }, { status: 500 }),
139
+ Response.json({ data: null, message: 'permission denied' }, { status: 403 }),
140
+ { name: 'vm150' },
141
+ ]) {
142
+ const fake = fakePve(() => body);
143
+ await withoutBao(async () => {
144
+ await expect(engine(fake).deploy(ProxmoxVm('row', props))).rejects.toBeDefined();
145
+ });
146
+ expect(fake.writes()).toEqual([]);
147
+ }
148
+ });
149
+
150
+ test('destroy deletes the guest route and waits for its task', async () => {
151
+ let exists = true;
152
+ const fake = fakePve((call) => {
153
+ if (call.path.includes('/tasks/') && call.path.endsWith('/status')) {
154
+ return { status: 'stopped', exitstatus: 'OK' };
155
+ }
156
+ if (call.method === 'GET') return exists ? live : missing();
157
+ if (call.method === 'DELETE') {
158
+ exists = false;
159
+ return UPID;
160
+ }
161
+ return null;
162
+ });
163
+ await withoutBao(async () => {
164
+ const stack = engine(fake);
165
+ await stack.deploy(ProxmoxVm('row', props).pipe(RemovalPolicy.destroy()));
166
+ await stack.deploy(Effect.void);
167
+ });
168
+ expect(fake.writes()).toEqual([`DELETE nodes/${NODE}/qemu/${String(VMID)}`]);
169
+ expect(fake.writes().some((line) => line.includes('/config'))).toBe(false);
170
+ });
171
+ });
@@ -0,0 +1,134 @@
1
+ /**
2
+ * QEMU lifecycle on named operations.
3
+ * ⛔ GET and PUT use `/nodes/{node}/qemu/{vmid}/config`. DELETE uses `/nodes/{node}/qemu/{vmid}`.
4
+ * qemu-server Qemu.pm names that second route `destroy_vm`. Sending DELETE to the config
5
+ * route does not destroy the guest.
6
+ */
7
+ import { isResolved } from 'alchemy/Diff';
8
+ import type { Input } from 'alchemy/Input';
9
+ import * as nodes from '@distilled.cloud/proxmox/nodes';
10
+ import * as Effect from 'effect/Effect';
11
+ import { runPve } from './distilled-pve.ts';
12
+ import { createForm, shape } from './qemu-form.ts';
13
+ import { QemuRefusedError } from './qemu-errors.ts';
14
+ import { readVm } from './qemu-read.ts';
15
+ import { qemuTask } from './qemu-task.ts';
16
+ import type { VmAttributes, VmProps } from './qemu.ts';
17
+ import type { PveSpec } from './resource-spec.ts';
18
+ import { specGuards } from './resource-guard.ts';
19
+ import { formToSend } from './update-guard.ts';
20
+ import { int } from './values.ts';
21
+
22
+ const CREATE_POLLS = 180;
23
+ const DELETE_POLLS = 60;
24
+
25
+ const attributesOf = (
26
+ live: {
27
+ cores?: unknown;
28
+ memory?: unknown;
29
+ name?: unknown;
30
+ onboot?: unknown;
31
+ sockets?: unknown;
32
+ },
33
+ props: VmProps,
34
+ ): VmAttributes => ({
35
+ cores: int(live.cores, 1),
36
+ memory: int(live.memory, 512),
37
+ name: typeof live.name === 'string' ? live.name : '',
38
+ node: props.node,
39
+ onboot: live.onboot === 1 || live.onboot === true,
40
+ sockets: int(live.sockets, 1),
41
+ vmid: props.vmid,
42
+ });
43
+
44
+ const matches = (attributes: VmAttributes, props: VmProps) =>
45
+ attributes.name === (props.name ?? `vm${String(props.vmid)}`) &&
46
+ attributes.memory === (props.memory ?? 512) &&
47
+ attributes.cores === (props.cores ?? 1) &&
48
+ attributes.sockets === (props.sockets ?? 1) &&
49
+ attributes.onboot === (props.onboot === true);
50
+
51
+ const spec = {
52
+ attributes: attributesOf,
53
+ collection: (props: VmProps) => `nodes/${props.node}/qemu`,
54
+ createForm,
55
+ endpoint: {
56
+ create: 'pve:POST /nodes/{node}/qemu',
57
+ update: 'pve:PUT /nodes/{node}/qemu/{vmid}/config',
58
+ },
59
+ matches,
60
+ path: (props: VmProps) => `nodes/${props.node}/qemu/${String(props.vmid)}/config`,
61
+ updateForm: shape,
62
+ } satisfies PveSpec<VmProps, VmAttributes>;
63
+
64
+ const { guardCreate, guardUpdate } = specGuards(spec);
65
+
66
+ export const readQemu = (props: VmProps) =>
67
+ readVm(props).pipe(
68
+ Effect.map((live) => (live === undefined ? undefined : attributesOf(live, props))),
69
+ );
70
+
71
+ /** No purge, no skiplock, no destroy-unreferenced-disks. A missing config is already gone. */
72
+ export const deleteQemu = (props: VmProps) =>
73
+ qemuTask(
74
+ props,
75
+ nodes.deleteNodeQemu({ node: props.node, vmid: String(props.vmid) }),
76
+ `destroy VM ${String(props.vmid)}`,
77
+ DELETE_POLLS,
78
+ ).pipe(Effect.catchTag('QemuConfigNotFound', () => Effect.void));
79
+
80
+ export const qemuHandlers = {
81
+ list: () => Effect.succeed([]),
82
+ read: ({ olds }: { olds: VmProps }) => readQemu(olds),
83
+ diff: Effect.fn(function* ({
84
+ news,
85
+ output,
86
+ }: {
87
+ news: Input<VmProps>;
88
+ output: VmAttributes | undefined;
89
+ }) {
90
+ if (!isResolved(news)) return undefined;
91
+ yield* guardCreate(news, output === undefined);
92
+ yield* guardUpdate(news);
93
+ if (output === undefined) return undefined;
94
+ const live = yield* readQemu(news);
95
+ if (live === undefined) {
96
+ yield* guardCreate(news, true);
97
+ return { action: 'update' } as const;
98
+ }
99
+ return { action: spec.matches(live, news) ? 'noop' : 'update' } as const;
100
+ }),
101
+ reconcile: Effect.fn(function* ({ news }: { news: VmProps }) {
102
+ const live = yield* readQemu(news);
103
+ yield* guardCreate(news, live === undefined);
104
+ yield* guardUpdate(news);
105
+ if (live === undefined) {
106
+ yield* qemuTask(
107
+ news,
108
+ nodes.createNodeQemu({ ...createForm(news), node: news.node }),
109
+ `create VM ${String(news.vmid)}`,
110
+ CREATE_POLLS,
111
+ );
112
+ } else {
113
+ const form = formToSend(spec.matches, live, news, spec.updateForm(news));
114
+ if (form !== undefined) {
115
+ yield* runPve(
116
+ news.target,
117
+ 'provision',
118
+ true,
119
+ nodes.putNodeQemuConfig({ ...form, node: news.node, vmid: String(news.vmid) }),
120
+ );
121
+ }
122
+ }
123
+ const after = yield* readQemu(news);
124
+ if (after === undefined) {
125
+ return yield* Effect.fail(
126
+ new QemuRefusedError(
127
+ `${spec.path(news)}: write returned success but the VM is still absent`,
128
+ ),
129
+ );
130
+ }
131
+ return after;
132
+ }),
133
+ delete: ({ olds }: { olds: VmProps }) => deleteQemu(olds),
134
+ };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * One QEMU config read, then a cluster-wide vmid check when that file is absent.
3
+ * ⛔ QemuConfigNotFound means the file is missing on THIS node. AbstractConfig raises it
4
+ * before any other guest type is considered. A vmid is shared with containers, so absence
5
+ * here is not permission to POST until the same credential's guest index agrees.
6
+ */
7
+ import * as cluster from '@distilled.cloud/proxmox/cluster';
8
+ import * as nodes from '@distilled.cloud/proxmox/nodes';
9
+ import * as Effect from 'effect/Effect';
10
+ import { runPveWith } from './distilled-pve.ts';
11
+ import { leased } from './lease-cache.ts';
12
+ import { QemuRefusedError } from './qemu-errors.ts';
13
+ import type { VmProps } from './qemu.ts';
14
+ import { text } from './values.ts';
15
+
16
+ const guestTypes = ['lxc', 'qemu'] as const;
17
+
18
+ export const readVm = (props: VmProps) =>
19
+ Effect.gen(function* () {
20
+ const credential = yield* leased(props.target, 'read');
21
+ const answer = yield* runPveWith(
22
+ props.target,
23
+ credential,
24
+ false,
25
+ nodes.getNodeQemuConfig({ node: props.node, vmid: String(props.vmid) }),
26
+ ).pipe(
27
+ Effect.map((data) => ({ data, error: undefined })),
28
+ Effect.catchTag('QemuConfigNotFound', (error) => Effect.succeed({ data: undefined, error })),
29
+ );
30
+ if (answer.error === undefined) {
31
+ const live = answer.data;
32
+ // ⛔ The vendor requires digest. Null or an empty object is not absence.
33
+ if (typeof live.digest !== 'string' || live.digest === '') {
34
+ return yield* Effect.fail(
35
+ new QemuRefusedError(
36
+ `VM ${String(props.vmid)}: config read did not return a digest-bearing config.`,
37
+ ),
38
+ );
39
+ }
40
+ return live;
41
+ }
42
+ const rows = yield* runPveWith(
43
+ props.target,
44
+ credential,
45
+ false,
46
+ cluster.listClusterResources({ type: 'vm' }),
47
+ );
48
+ if (
49
+ !Array.isArray(rows) ||
50
+ rows.some(
51
+ (row) =>
52
+ !Number.isSafeInteger(Number(row?.vmid)) ||
53
+ Number(row?.vmid) <= 0 ||
54
+ typeof row?.node !== 'string' ||
55
+ row.node === '' ||
56
+ !guestTypes.includes(row.type as (typeof guestTypes)[number]),
57
+ )
58
+ ) {
59
+ return yield* Effect.fail(
60
+ new QemuRefusedError('The cluster guest index did not identify its rows.'),
61
+ );
62
+ }
63
+ const found = rows.find((row) => Number(row.vmid) === props.vmid);
64
+ if (found === undefined) return undefined;
65
+ if (found.node === props.node && found.type === 'qemu') return yield* Effect.fail(answer.error);
66
+ return yield* Effect.fail(
67
+ new QemuRefusedError(
68
+ `vmid ${String(props.vmid)} is a ${text(found.type, 'guest')} on ` +
69
+ `${text(found.node, '?')}, not a VM on ${props.node}. A migration is not an ` +
70
+ 'update and vmids are cluster-wide: declare the node it is on, or pick a free vmid.',
71
+ ),
72
+ );
73
+ });
@@ -0,0 +1,64 @@
1
+ /** Bounded QEMU task observation on the credential that started the worker. */
2
+ import * as nodes from '@distilled.cloud/proxmox/nodes';
3
+ import type { ProxmoxOpContext } from '@distilled.cloud/proxmox/Protocol';
4
+ import * as Effect from 'effect/Effect';
5
+ import { runPveWith } from './distilled-pve.ts';
6
+ import { mint } from './mint.ts';
7
+ import { QemuRefusedError } from './qemu-errors.ts';
8
+ import type { VmProps } from './qemu.ts';
9
+ import { text } from './values.ts';
10
+
11
+ /**
12
+ * ⛔ Fresh provision mint, then the same lease for every poll. A timed-out poll is uncertainty,
13
+ * never a reason to send the write again.
14
+ */
15
+ export const qemuTask = <E>(
16
+ props: VmProps,
17
+ operation: Effect.Effect<string, E, ProxmoxOpContext>,
18
+ what: string,
19
+ polls: number,
20
+ ) =>
21
+ Effect.gen(function* () {
22
+ const credential = yield* mint(props.target, 'provision');
23
+ const upid = text(yield* runPveWith(props.target, credential, true, operation));
24
+ if (upid === '') {
25
+ return yield* Effect.fail(
26
+ new QemuRefusedError(
27
+ `${what}: PVE returned no task id, so the write cannot be confirmed and is not claimed.`,
28
+ ),
29
+ );
30
+ }
31
+ let exit = 'still running when this provider stopped waiting';
32
+ for (let attempt = 0; attempt < polls; attempt++) {
33
+ const status = yield* runPveWith(
34
+ props.target,
35
+ credential,
36
+ false,
37
+ nodes.getNodeTaskStatus({ node: props.node, upid }),
38
+ ).pipe(Effect.orElseSucceed(() => undefined));
39
+ // ⚠️ "COULD NOT BE READ" IS A MISSING ANSWER OR A MISSING `status` FIELD, NOT AN UNEXPECTED
40
+ // ONE. `GetNodeTaskStatusResponseStatus` types `status` as the closed union
41
+ // `"running" | "stopped"`, but the runtime validator is `S.String` (measured in
42
+ // distilled-proxmox's nodes.ts), so a well-formed answer with a status word this
43
+ // endpoint's contract was never proven to exclude (something other than exactly
44
+ // "running") must keep polling rather than abort, since only "stopped" ends the task
45
+ // (2026-09-25 adversarial review; matches lxc-task.ts and network-apply-read.ts's
46
+ // awaitTask).
47
+ if (status === undefined || status.status === undefined) {
48
+ exit = 'the task could not be read';
49
+ break;
50
+ }
51
+ if (status.status === 'stopped') {
52
+ exit = text(status.exitstatus, 'stopped with no exitstatus');
53
+ break;
54
+ }
55
+ yield* Effect.sleep('1 second');
56
+ }
57
+ if (exit !== 'OK') {
58
+ return yield* Effect.fail(
59
+ new QemuRefusedError(
60
+ `${what}: task ${upid} ended "${exit}". Read the task log before running the deploy again.`,
61
+ ),
62
+ );
63
+ }
64
+ });
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * `Proxmox.Vm` — a QEMU virtual machine, declared.
3
3
  *
4
- * ⚠️ THE SAME FOUR OPERATIONS AS AN LXC OVER A DIFFERENT PATH, which is the point of the factory:
5
- * `nodes/{node}/qemu` to create, `nodes/{node}/qemu/{vmid}/config` to read and update. A VM and a
6
- * container differ in what they carry, not in how they are reconciled.
4
+ * ⚠️ CREATE is `POST /nodes/{node}/qemu`. GET and PUT stay on `.../qemu/{vmid}/config`.
5
+ * ⛔ DELETE is `DELETE /nodes/{node}/qemu/{vmid}` (`destroy_vm` in qemu-server Qemu.pm). The
6
+ * config route is not a destroy. A missing config file is `QemuConfigNotFound` only.
7
7
  *
8
8
  * ⛔ `vmid` IS CLUSTER-WIDE AND SHARED WITH CONTAINERS. A VM and an LXC cannot hold the same id, so
9
9
  * these two resources compete for one number space. Declaring both with vmid 101 is not a
@@ -17,8 +17,8 @@
17
17
  import { Resource } from 'alchemy';
18
18
  import * as Provider from 'alchemy/Provider';
19
19
  import * as Effect from 'effect/Effect';
20
- import { type PveRequirements, type WithTarget, pveHandlers } from './resource.ts';
21
- import { num } from './values.ts';
20
+ import { qemuHandlers } from './qemu-lifecycle.ts';
21
+ import type { PveRequirements, WithTarget } from './resource.ts';
22
22
 
23
23
  export interface VmProps extends WithTarget {
24
24
  node: string;
@@ -54,44 +54,11 @@ export interface ProxmoxVm extends Resource<
54
54
 
55
55
  export const ProxmoxVm = Resource<ProxmoxVm>('Proxmox.Vm');
56
56
 
57
- /** ★ Exported so the constraint proof can run the REAL create form, not a retyped copy of it. */
58
- export const createForm = (props: VmProps) => ({ ...shape(props), vmid: String(props.vmid) });
59
-
60
- const shape = (props: VmProps) => ({
61
- cores: String(props.cores ?? 1),
62
- memory: String(props.memory ?? 512),
63
- name: props.name ?? `vm${String(props.vmid)}`,
64
- onboot: props.onboot === true ? '1' : '0',
65
- sockets: String(props.sockets ?? 1),
66
- ...(props.net0 === undefined ? {} : { net0: props.net0 }),
67
- });
68
-
69
- const handlers = pveHandlers<VmProps, VmAttributes>({
70
- attributes: (live, props) => ({
71
- cores: num(live['cores'], 1),
72
- memory: num(live['memory'], 512),
73
- name: typeof live['name'] === 'string' ? live['name'] : '',
74
- node: props.node,
75
- onboot: live['onboot'] === 1 || live['onboot'] === true,
76
- sockets: num(live['sockets'], 1),
77
- vmid: props.vmid,
78
- }),
79
- collection: (props) => `nodes/${props.node}/qemu`,
80
- createForm,
81
- /** The vendor rules both forms are checked against at plan time — resource-spec.ts. */
82
- endpoint: {
83
- create: 'pve:POST /nodes/{node}/qemu',
84
- update: 'pve:PUT /nodes/{node}/qemu/{vmid}/config',
85
- },
86
- matches: (attributes, props) =>
87
- attributes.name === (props.name ?? `vm${String(props.vmid)}`) &&
88
- attributes.memory === (props.memory ?? 512) &&
89
- attributes.cores === (props.cores ?? 1) &&
90
- attributes.sockets === (props.sockets ?? 1) &&
91
- attributes.onboot === (props.onboot === true),
92
- path: (props) => `nodes/${props.node}/qemu/${String(props.vmid)}/config`,
93
- updateForm: shape,
94
- });
57
+ export { createForm } from './qemu-form.ts';
95
58
 
59
+ /**
60
+ * ⛔ Destroy uses deleteNodeQemu (`DELETE /nodes/{node}/qemu/{vmid}`), not the config path.
61
+ * The old factory sent both reads and deletes to `.../config`.
62
+ */
96
63
  export const ProxmoxVmProvider = () =>
97
- Provider.effect(ProxmoxVm, Effect.succeed(ProxmoxVm.Provider.of(handlers)));
64
+ Provider.effect(ProxmoxVm, Effect.succeed(ProxmoxVm.Provider.of(qemuHandlers)));
@@ -0,0 +1,71 @@
1
+ /** Source-backed vendor responses; no live host or credential is used by these tests. */
2
+ import * as Layer from 'effect/Layer';
3
+ import { engineOver } from '../verify/fake-engine.ts';
4
+ import { FAKE_TARGET } from './fake-pve.ts';
5
+ import type { CoreCase } from './live-core-fixtures.ts';
6
+ import { ProxmoxReplicationJob, ProxmoxReplicationJobProvider } from './replication-job.ts';
7
+ import { deleteReplicationJob, readReplicationJob } from './replication-job-lifecycle.ts';
8
+ import { ProxmoxFirewallAlias, ProxmoxFirewallAliasProvider } from './firewall-alias.ts';
9
+ import { deleteFirewallAlias, readFirewallAlias } from './firewall-alias-lifecycle.ts';
10
+
11
+ export const replicationProps = { target: FAKE_TARGET, guest: 900, jobnum: 0, targetNode: 'pve-b' };
12
+ export const aliasProps = {
13
+ target: FAKE_TARGET,
14
+ name: 'HomeLan',
15
+ cidr: '192.0.2.1/32',
16
+ comment: ' 0 ',
17
+ };
18
+ export const replicationLive = {
19
+ id: '900-0',
20
+ guest: 900,
21
+ jobnum: 0,
22
+ target: 'pve-b',
23
+ type: 'local',
24
+ source: 'pve-a',
25
+ };
26
+ export const aliasLive = { name: 'homelan', cidr: '192.0.2.1', ipversion: 4 };
27
+ export const replicationMissing = () =>
28
+ Response.json({ data: null, message: "no such replication job '900-0'\n" }, { status: 500 });
29
+ export const aliasMissing = () =>
30
+ Response.json(
31
+ { data: null, message: 'Parameter verification failed.\n', errors: { name: 'no such alias' } },
32
+ { status: 400 },
33
+ );
34
+
35
+ export const replicationFirewallCases: CoreCase[] = [
36
+ {
37
+ name: 'ReplicationJob',
38
+ path: 'cluster/replication/900-0',
39
+ createPath: 'cluster/replication',
40
+ declare: () => ProxmoxReplicationJob('row', replicationProps),
41
+ engine: (fake) =>
42
+ engineOver(ProxmoxReplicationJobProvider().pipe(Layer.provideMerge(fake.layer))),
43
+ read: () => readReplicationJob(replicationProps),
44
+ remove: () => deleteReplicationJob(replicationProps),
45
+ live: replicationLive,
46
+ drift: { ...replicationLive, remove_job: 'full' },
47
+ missing: replicationMissing,
48
+ createForm: {
49
+ id: '900-0',
50
+ type: 'local',
51
+ target: 'pve-b',
52
+ comment: '',
53
+ disable: '0',
54
+ schedule: '*/15',
55
+ },
56
+ },
57
+ {
58
+ name: 'FirewallAlias',
59
+ path: 'cluster/firewall/aliases/HomeLan',
60
+ createPath: 'cluster/firewall/aliases',
61
+ declare: () => ProxmoxFirewallAlias('row', aliasProps),
62
+ engine: (fake) =>
63
+ engineOver(ProxmoxFirewallAliasProvider().pipe(Layer.provideMerge(fake.layer))),
64
+ read: () => readFirewallAlias(aliasProps),
65
+ remove: () => deleteFirewallAlias(aliasProps),
66
+ live: aliasLive,
67
+ drift: { ...aliasLive, comment: 'old' },
68
+ missing: aliasMissing,
69
+ createForm: { name: 'HomeLan', cidr: '192.0.2.1' },
70
+ },
71
+ ];