@homeflare/alchemy 0.44.0 → 0.46.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 (130) hide show
  1. package/dist/postgres/fake-role-sql.d.ts +2 -1
  2. package/dist/postgres/fake-role-sql.d.ts.map +1 -1
  3. package/dist/postgres/fake-sql.d.ts +3 -3
  4. package/dist/postgres/fake-sql.d.ts.map +1 -1
  5. package/dist/postgres/index.d.ts +1 -1
  6. package/dist/postgres/index.d.ts.map +1 -1
  7. package/dist/postgres/index.js +117 -94
  8. package/dist/postgres/index.js.map +11 -11
  9. package/dist/postgres/role-attrs.d.ts +7 -4
  10. package/dist/postgres/role-attrs.d.ts.map +1 -1
  11. package/dist/postgres/role-diff.d.ts +2 -2
  12. package/dist/postgres/role-diff.d.ts.map +1 -1
  13. package/dist/postgres/role-errors.d.ts +13 -7
  14. package/dist/postgres/role-errors.d.ts.map +1 -1
  15. package/dist/postgres/role-membership-sql.d.ts +30 -27
  16. package/dist/postgres/role-membership-sql.d.ts.map +1 -1
  17. package/dist/postgres/role-provider.d.ts.map +1 -1
  18. package/dist/postgres/role-scram.d.ts +2 -0
  19. package/dist/postgres/role-scram.d.ts.map +1 -1
  20. package/dist/postgres/role-secrets.d.ts +8 -0
  21. package/dist/postgres/role-secrets.d.ts.map +1 -1
  22. package/dist/postgres/role-sql.d.ts.map +1 -1
  23. package/dist/postgres/role.d.ts +2 -2
  24. package/dist/postgres/role.d.ts.map +1 -1
  25. package/dist/proxmox/generated/constraints/index.d.ts +1 -1
  26. package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
  27. package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
  28. package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
  29. package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
  30. package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
  31. package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
  32. package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
  33. package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
  34. package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
  35. package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
  36. package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
  37. package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
  38. package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
  39. package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
  40. package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts.map +1 -1
  41. package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts +1 -1
  42. package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
  43. package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
  44. package/dist/proxmox/index.d.ts +1 -0
  45. package/dist/proxmox/index.d.ts.map +1 -1
  46. package/dist/proxmox/index.js +283 -130
  47. package/dist/proxmox/index.js.map +31 -27
  48. package/dist/proxmox/qemu-dns.d.ts +2 -0
  49. package/dist/proxmox/qemu-dns.d.ts.map +1 -0
  50. package/dist/proxmox/qemu-errors.d.ts +1 -0
  51. package/dist/proxmox/qemu-errors.d.ts.map +1 -1
  52. package/dist/proxmox/qemu-form.d.ts.map +1 -1
  53. package/dist/proxmox/qemu-judge.d.ts.map +1 -1
  54. package/dist/proxmox/qemu-lifecycle.d.ts +15 -10
  55. package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -1
  56. package/dist/proxmox/qemu-props.d.ts +10 -0
  57. package/dist/proxmox/qemu-props.d.ts.map +1 -1
  58. package/dist/proxmox/qemu-resize.d.ts +10 -0
  59. package/dist/proxmox/qemu-resize.d.ts.map +1 -0
  60. package/dist/proxmox/qemu-size.d.ts +20 -0
  61. package/dist/proxmox/qemu-size.d.ts.map +1 -0
  62. package/dist/proxmox/qemu-spec.d.ts +25 -0
  63. package/dist/proxmox/qemu-spec.d.ts.map +1 -0
  64. package/dist/verify/fake-engine.d.ts +4 -2
  65. package/dist/verify/fake-engine.d.ts.map +1 -1
  66. package/docs/postgres-role.md +18 -6
  67. package/docs/proxmox-qemu-resize.md +62 -0
  68. package/package.json +1 -1
  69. package/src/ceph/ceph-argv.test.ts +1 -1
  70. package/src/launchd/sudo-allowlist.test.ts +2 -2
  71. package/src/linux/sudo-allowlist.test.ts +3 -3
  72. package/src/linux/sudo-runner.test.ts +2 -2
  73. package/src/postgres/fake-role-sql.ts +26 -6
  74. package/src/postgres/fake-sql.ts +10 -5
  75. package/src/postgres/index.ts +1 -0
  76. package/src/postgres/role-attrs.ts +7 -4
  77. package/src/postgres/role-diff.ts +7 -3
  78. package/src/postgres/role-errors.ts +22 -14
  79. package/src/postgres/role-guard.test.ts +3 -3
  80. package/src/postgres/role-identity.test.ts +14 -2
  81. package/src/postgres/role-lifecycle.test.ts +2 -2
  82. package/src/postgres/role-membership-options.test.ts +73 -0
  83. package/src/postgres/role-membership-repair.test.ts +153 -0
  84. package/src/postgres/role-membership-sql.ts +88 -63
  85. package/src/postgres/role-password-ascii.test.ts +53 -0
  86. package/src/postgres/role-password-transports.test.ts +123 -0
  87. package/src/postgres/role-privilege.test.ts +37 -10
  88. package/src/postgres/role-provenance.test.ts +1 -1
  89. package/src/postgres/role-provider.ts +3 -2
  90. package/src/postgres/role-refusals.test.ts +0 -25
  91. package/src/postgres/role-scram.ts +2 -0
  92. package/src/postgres/role-secrets.ts +15 -0
  93. package/src/postgres/role-sql.ts +3 -1
  94. package/src/postgres/role.ts +9 -1
  95. package/src/postgres/schema-socket.test.ts +1 -0
  96. package/src/proxmox/generated/constraints/index.ts +1 -1
  97. package/src/proxmox/generated/constraints/pve-access.ts +1 -1
  98. package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
  99. package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
  100. package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
  101. package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
  102. package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
  103. package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
  104. package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
  105. package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
  106. package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
  107. package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
  108. package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
  109. package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
  110. package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +6 -1
  111. package/src/proxmox/generated/constraints/pve-nodes-storage.ts +1 -1
  112. package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
  113. package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
  114. package/src/proxmox/index.ts +1 -0
  115. package/src/proxmox/qemu-dns.test.ts +73 -0
  116. package/src/proxmox/qemu-dns.ts +32 -0
  117. package/src/proxmox/qemu-errors.ts +1 -0
  118. package/src/proxmox/qemu-form.ts +6 -3
  119. package/src/proxmox/qemu-judge.ts +2 -1
  120. package/src/proxmox/qemu-lifecycle.ts +28 -47
  121. package/src/proxmox/qemu-props.ts +14 -2
  122. package/src/proxmox/qemu-resize-lock.test.ts +121 -0
  123. package/src/proxmox/qemu-resize-recovery.test.ts +81 -0
  124. package/src/proxmox/qemu-resize.test.ts +199 -0
  125. package/src/proxmox/qemu-resize.ts +86 -0
  126. package/src/proxmox/qemu-size.test.ts +64 -0
  127. package/src/proxmox/qemu-size.ts +95 -0
  128. package/src/proxmox/qemu-spec.ts +44 -0
  129. package/src/proxmox/qemu-volume.ts +5 -5
  130. package/src/verify/fake-engine.ts +6 -3
@@ -0,0 +1,199 @@
1
+ /** Real SDK and Alchemy engine, fake PVE only: imports must finish before resize or power-on. */
2
+ import { expect, test } from 'bun:test';
3
+ import * as Effect from 'effect/Effect';
4
+ import * as Layer from 'effect/Layer';
5
+ import { engineOver } from '../verify/fake-engine.ts';
6
+ import { FAKE_TARGET, fakePve, withoutBao } from './fake-pve.ts';
7
+ import { qemuHandlers } from './qemu-lifecycle.ts';
8
+ import { ProxmoxVm, ProxmoxVmProvider } from './qemu.ts';
9
+
10
+ const base = { target: FAKE_TARGET, node: 'n2', vmid: 10000 };
11
+ const props = {
12
+ ...base,
13
+ scsi0: 'cephtb4:0,import-from=cephfs-tb4:import/talos.raw',
14
+ diskSizesGiB: { scsi0: 64 },
15
+ };
16
+ const path = 'nodes/n2/qemu/10000';
17
+ const importTask = 'UPID:n2:1:0:0:qmcreate:10000:test@pve!fake:';
18
+ const resizeTask = 'UPID:n2:2:0:0:qmresize:10000:test@pve!fake:';
19
+ const config = (size: string) => ({
20
+ digest: 'a'.repeat(40),
21
+ scsi0: `cephtb4:vm-10000-disk-0,size=${size}`,
22
+ });
23
+ const output = { ...base, config: config('4.15G') };
24
+ const missing = () =>
25
+ Response.json(
26
+ {
27
+ data: null,
28
+ message: "Configuration file 'nodes/n2/qemu-server/10000.conf' does not exist\n",
29
+ },
30
+ { status: 500 },
31
+ );
32
+
33
+ test('import completes, allocated size is read, resize completes, then reconciliation returns', async () => {
34
+ let created = false;
35
+ let imported = false;
36
+ let resized = false;
37
+ const fake = fakePve((call) => {
38
+ if (call.path.startsWith('cluster/resources')) return [];
39
+ if (call.path.includes(encodeURIComponent(importTask))) {
40
+ imported = true;
41
+ return { status: 'stopped', exitstatus: 'OK' };
42
+ }
43
+ if (call.path.includes(encodeURIComponent(resizeTask))) {
44
+ resized = true;
45
+ return { status: 'stopped', exitstatus: 'OK' };
46
+ }
47
+ if (call.path === `${path}/config`)
48
+ return created ? config(resized ? '64G' : '4.15G') : missing();
49
+ if (call.method === 'POST') {
50
+ expect(call.form['diskSizesGiB']).toBeUndefined();
51
+ expect(call.form['start']).toBeUndefined();
52
+ expect(call.form['scsi0']).toBe(props.scsi0);
53
+ created = true;
54
+ return importTask;
55
+ }
56
+ if (call.path === `${path}/resize`) {
57
+ expect(imported).toBe(true);
58
+ expect(call.form).toEqual({ disk: 'scsi0', size: '64G' });
59
+ return resizeTask;
60
+ }
61
+ throw new Error(`unexpected ${call.method} ${call.path}`);
62
+ });
63
+ await withoutBao(async () => {
64
+ const stack = engineOver(ProxmoxVmProvider().pipe(Layer.provideMerge(fake.layer)));
65
+ await stack.deploy(ProxmoxVm('row', props));
66
+ expect(resized).toBe(true); // The caller's separate power-on step can proceed only now.
67
+ await stack.deploy(ProxmoxVm('row', props));
68
+ expect((await stack.verify(ProxmoxVm('row', props), { all: true })).rows[0]).toMatchObject({
69
+ diff: 'noop',
70
+ });
71
+ });
72
+ expect(fake.writes()).toEqual(['POST nodes/n2/qemu', `PUT ${path}/resize`]);
73
+ const resizeIndex = fake.calls.findIndex((call) => call.path === `${path}/resize`);
74
+ expect(fake.calls[resizeIndex - 1]?.path).toBe(`${path}/config`);
75
+ });
76
+
77
+ for (const initialSize of ['4250M', '63.9999999990687G']) {
78
+ test(`recovery of an imported disk at ${initialSize} grows once without re-importing`, async () => {
79
+ let size = initialSize;
80
+ const fake = fakePve((call) => {
81
+ if (call.path.includes('/tasks/')) {
82
+ size = '64G';
83
+ return { status: 'stopped', exitstatus: 'OK' };
84
+ }
85
+ if (call.method === 'GET') return config(size);
86
+ if (call.path === `${path}/resize`) return resizeTask;
87
+ throw new Error('must not re-import');
88
+ });
89
+ await withoutBao(async () => {
90
+ const stack = engineOver(ProxmoxVmProvider().pipe(Layer.provideMerge(fake.layer)));
91
+ expect((await stack.verify(ProxmoxVm('row', props), { all: true })).rows[0]).toMatchObject({
92
+ diff: 'update',
93
+ });
94
+ await stack.deploy(ProxmoxVm('row', props));
95
+ await stack.deploy(ProxmoxVm('row', props));
96
+ });
97
+ expect(fake.writes()).toEqual([`PUT ${path}/resize`]);
98
+ });
99
+ }
100
+
101
+ test('equal and larger live disks are no-ops, including equivalent units', async () => {
102
+ for (const size of ['64G', '65536M', '128G', '1T', '64.0000000009313G']) {
103
+ const fake = fakePve(() => config(size));
104
+ await withoutBao(async () => {
105
+ const stack = engineOver(ProxmoxVmProvider().pipe(Layer.provideMerge(fake.layer)));
106
+ await stack.deploy(ProxmoxVm('row', props));
107
+ expect((await stack.verify(ProxmoxVm('row', props), { all: true })).rows[0]).toMatchObject({
108
+ diff: 'noop',
109
+ });
110
+ });
111
+ expect(fake.writes()).toEqual([]);
112
+ }
113
+ });
114
+
115
+ test('a declared decrease is catchTag-able and refuses both diff and reconcile before writes', async () => {
116
+ const fake = fakePve(() => config('64G'));
117
+ const news = { ...props, diskSizesGiB: { scsi0: 32 } };
118
+ await withoutBao(async () => {
119
+ for (const operation of [
120
+ qemuHandlers.diff({ news, olds: props, output }).pipe(Effect.asVoid),
121
+ qemuHandlers
122
+ .reconcile({ fqn: 'row', instanceId: 'row', news, olds: props, output })
123
+ .pipe(Effect.asVoid),
124
+ ]) {
125
+ const result = await Effect.runPromise(
126
+ operation.pipe(
127
+ Effect.catchTag('QemuDiskResizeRefused', (error) => Effect.succeed(error.message)),
128
+ Effect.provide(fake.layer),
129
+ ),
130
+ );
131
+ expect(result).toContain('refusing shrink');
132
+ }
133
+ });
134
+ expect(fake.calls).toEqual([]);
135
+ });
136
+
137
+ test('failed resize tasks and false success never claim the target was reached', async () => {
138
+ for (const exitstatus of ['disk resize failed', 'OK']) {
139
+ const fake = fakePve((call) => {
140
+ if (call.path.includes('/tasks/')) return { status: 'stopped', exitstatus };
141
+ if (call.method === 'GET') return config('4.15G');
142
+ return resizeTask;
143
+ });
144
+ await withoutBao(async () => {
145
+ const stack = engineOver(ProxmoxVmProvider().pipe(Layer.provideMerge(fake.layer)));
146
+ await expect(stack.deploy(ProxmoxVm('row', props))).rejects.toThrow(
147
+ exitstatus === 'OK' ? /diskSizesGiB.scsi0 still differ/ : /disk resize failed/,
148
+ );
149
+ });
150
+ expect(fake.writes()).toEqual([`PUT ${path}/resize`]);
151
+ }
152
+ });
153
+
154
+ test('unreadable live capacity blocks unrelated config writes too', async () => {
155
+ const fake = fakePve(() => config('unknown'));
156
+ await withoutBao(async () => {
157
+ const result = await Effect.runPromise(
158
+ qemuHandlers
159
+ .diff({
160
+ news: { ...props, memory: 8192 },
161
+ olds: props,
162
+ output,
163
+ })
164
+ .pipe(
165
+ Effect.catchTag('QemuDiskResizeRefused', (error) => Effect.succeed(error._tag)),
166
+ Effect.provide(fake.layer),
167
+ ),
168
+ );
169
+ expect(result).toBe('QemuDiskResizeRefused');
170
+ });
171
+ expect(fake.writes()).toEqual([]);
172
+ });
173
+
174
+ test('SDK resize failures preserve generated tags and never start a task or retry a write', async () => {
175
+ const fake = fakePve((call) =>
176
+ call.method === 'GET'
177
+ ? config('4.15G')
178
+ : Response.json({ data: null, message: 'permission denied' }, { status: 403 }),
179
+ );
180
+ await withoutBao(async () => {
181
+ const result = await Effect.runPromise(
182
+ qemuHandlers
183
+ .reconcile({
184
+ fqn: 'row',
185
+ instanceId: 'row',
186
+ news: props,
187
+ olds: props,
188
+ output,
189
+ })
190
+ .pipe(
191
+ Effect.catchTag('Forbidden', (error) => Effect.succeed(error._tag)),
192
+ Effect.provide(fake.layer),
193
+ ),
194
+ );
195
+ expect(result).toBe('Forbidden');
196
+ });
197
+ expect(fake.writes()).toEqual([`PUT ${path}/resize`]);
198
+ expect(fake.calls.some((call) => call.path.includes('/tasks/'))).toBe(false);
199
+ });
@@ -0,0 +1,86 @@
1
+ /** PVE 9.2.11 QEMU resize, already generated as nodes.putNodeQemuResize in the distilled SDK. */
2
+ import * as nodes from '@distilled.cloud/proxmox/nodes';
3
+ import * as Effect from 'effect/Effect';
4
+ import { type VmProps, storedConfig } from './qemu-props.ts';
5
+ import { readVm } from './qemu-read.ts';
6
+ import { QemuDiskResizeRefused, checkLiveDiskSizes, diskBytes } from './qemu-size.ts';
7
+ import { qemuTask } from './qemu-task.ts';
8
+ import { formViolations } from './constraint-guard.ts';
9
+
10
+ const RESIZE_POLLS = 60;
11
+
12
+ /**
13
+ * ⛔ Recovery can reach here while a timed-out import still holds `lock`. Read the raw
14
+ * config: storedConfig intentionally drops it. Lock waits and task polls share one budget.
15
+ * Never skip/unset a lock; PVE also checks it if another worker races this observation.
16
+ */
17
+ const unlockedConfig = (props: VmProps, disk: string) =>
18
+ Effect.gen(function* () {
19
+ for (let attempt = 0; ; attempt++) {
20
+ const live = yield* readVm(props);
21
+ if (live === undefined)
22
+ return yield* Effect.fail(
23
+ new QemuDiskResizeRefused({ disk, message: 'VM disappeared before resize' }),
24
+ );
25
+ if (live.lock === undefined)
26
+ return {
27
+ config: storedConfig(live as unknown as Record<string, unknown>),
28
+ polls: RESIZE_POLLS - attempt,
29
+ };
30
+ if (attempt === RESIZE_POLLS - 1)
31
+ return yield* Effect.fail(
32
+ new QemuDiskResizeRefused({
33
+ disk,
34
+ message: `${disk}: VM ${props.vmid} remains locked (${live.lock}) after ${RESIZE_POLLS} observations; refusing resize`,
35
+ }),
36
+ );
37
+ yield* Effect.sleep('1 second');
38
+ }
39
+ });
40
+
41
+ /**
42
+ * ⛔ Observe the unlocked, allocated disk before each resize, including recovery after import.
43
+ * Absolute targets make retries safe; `+60G` would grow again after interrupted state writes.
44
+ * This resource never starts a guest. Its caller can power on only after reconcile returns.
45
+ * SDK failures retain their generated tags; provider refusals are QemuDiskResizeRefused.
46
+ */
47
+ export const resizeDisks = (props: VmProps) =>
48
+ Effect.gen(function* () {
49
+ const targets = Object.entries(props.diskSizesGiB ?? {}).filter(
50
+ (entry): entry is [string, number] => entry[1] !== undefined,
51
+ );
52
+ const first = targets[0];
53
+ if (first === undefined) return;
54
+ const live = yield* readVm(props);
55
+ if (live === undefined)
56
+ return yield* Effect.fail(
57
+ new QemuDiskResizeRefused({
58
+ disk: first[0],
59
+ message: 'VM disappeared before resize',
60
+ }),
61
+ );
62
+ const observed = storedConfig(live as unknown as Record<string, unknown>);
63
+ yield* checkLiveDiskSizes(props, observed);
64
+ for (const [disk, size] of targets) {
65
+ // ⚠️ A backup lock must not delay/refuse a no-op. Only growth needs an unlocked VM.
66
+ if ((diskBytes(observed[disk]) ?? 0) >= size * 1024 ** 3) continue;
67
+ const { config, polls } = yield* unlockedConfig(props, disk);
68
+ yield* checkLiveDiskSizes(props, config);
69
+ if ((diskBytes(config[disk]) ?? 0) >= size * 1024 ** 3) continue;
70
+ const form = { disk, size: `${size}G` };
71
+ const refused = formViolations('pve:PUT /nodes/{node}/qemu/{vmid}/resize', form, true);
72
+ if (refused.length > 0) {
73
+ return yield* Effect.fail(new QemuDiskResizeRefused({ disk, message: refused.join('\n') }));
74
+ }
75
+ yield* qemuTask(
76
+ props,
77
+ nodes.putNodeQemuResize({
78
+ node: props.node,
79
+ vmid: String(props.vmid),
80
+ ...form,
81
+ }),
82
+ `resize VM ${props.vmid} ${disk} to ${size} GiB`,
83
+ polls,
84
+ );
85
+ }
86
+ });
@@ -0,0 +1,64 @@
1
+ import { expect, test } from 'bun:test';
2
+ import * as Effect from 'effect/Effect';
3
+ import { FAKE_TARGET } from './fake-pve.ts';
4
+ import { form } from './qemu-form.ts';
5
+ import { judge } from './qemu-judge.ts';
6
+ import type { VmProps } from './qemu-props.ts';
7
+ import { diskBytes, validateDiskSizes } from './qemu-size.ts';
8
+
9
+ const props = {
10
+ target: FAKE_TARGET,
11
+ node: 'n2',
12
+ vmid: 10000,
13
+ scsi0: 'cephtb4:0,import-from=local:import/talos.raw',
14
+ };
15
+
16
+ test('size units compare capacity, while targets never become config form keys', () => {
17
+ expect(diskBytes('ceph:disk,size=65536M')).toBe(64 * 1024 ** 3);
18
+ expect(diskBytes('ceph:disk,size=4.15G')).toBe(4.15 * 1024 ** 3);
19
+ expect(diskBytes('ceph:disk,size=4096K')).toBe(4 * 1024 ** 2);
20
+ expect(diskBytes('ceph:disk,size=1T')).toBe(1024 ** 4);
21
+ for (const size of ['NaNG', '0G', '-1G', '64GB', 'unknown']) {
22
+ expect(diskBytes(`ceph:disk,size=${size}`)).toBeUndefined();
23
+ }
24
+ const news = { ...props, diskSizesGiB: { scsi0: 64 } };
25
+ expect(form(news)).toEqual({ scsi0: props.scsi0 });
26
+ expect(judge(news, { scsi0: 'cephtb4:vm-10000-disk-0,size=4.15G' })).toEqual({
27
+ put: {},
28
+ refuse: [],
29
+ drift: ['diskSizesGiB.scsi0'],
30
+ });
31
+ });
32
+
33
+ test('invalid targets, unsupported slots, undeclared slots and seed disks fail with a typed tag', async () => {
34
+ const bad = [
35
+ { diskSizesGiB: null },
36
+ { diskSizesGiB: [] },
37
+ { diskSizesGiB: '64G' },
38
+ ...[0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER, '64'].map((scsi0) => ({
39
+ diskSizesGiB: { scsi0 },
40
+ })),
41
+ { diskSizesGiB: { scsi31: 64 }, scsi31: 'cephtb4:1' },
42
+ { diskSizesGiB: { scsi1: 64 } },
43
+ { diskSizesGiB: { ide2: 64 }, ide2: 'cephtb4:cloudinit' },
44
+ { diskSizesGiB: { ide2: 64 }, ide2: 'local:iso/image.iso,media=cdrom' },
45
+ ];
46
+ for (const extra of bad) {
47
+ const result = await Effect.runPromise(
48
+ validateDiskSizes({ ...props, ...extra } as unknown as VmProps).pipe(
49
+ Effect.catchTag('QemuDiskResizeRefused', (error) => Effect.succeed(error._tag)),
50
+ ),
51
+ );
52
+ expect(result).toBe('QemuDiskResizeRefused');
53
+ }
54
+ });
55
+
56
+ test('an import source whose filename starts with cloudinit is still a regular disk', async () => {
57
+ await Effect.runPromise(
58
+ validateDiskSizes({
59
+ ...props,
60
+ scsi0: 'cephtb4:0,import-from=local:cloudinit-disk.raw',
61
+ diskSizesGiB: { scsi0: 64 },
62
+ }),
63
+ );
64
+ });
@@ -0,0 +1,95 @@
1
+ /** Grow-only targets are separate from config: PUT config's `size=` does not grow a disk. */
2
+ import * as Data from 'effect/Data';
3
+ import * as Effect from 'effect/Effect';
4
+ import { type VmProps, declaredValue, indexedKey } from './qemu-props.ts';
5
+ import { parseDisk } from './qemu-volume.ts';
6
+
7
+ export class QemuDiskResizeRefused extends Data.TaggedError('QemuDiskResizeRefused')<{
8
+ readonly disk: string;
9
+ readonly message: string;
10
+ }> {}
11
+
12
+ /** PVE config reports binary sizes, including fractional G and integer M/K for imported images. */
13
+ export const diskBytes = (config: string | undefined): number | undefined => {
14
+ const size = config === undefined ? undefined : parseDisk(config).size;
15
+ const match = size === undefined ? null : /^(\d+(?:\.\d+)?)([KMGT])?$/.exec(size);
16
+ if (match === null) return undefined;
17
+ const scale = { K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 };
18
+ const bytes = Number(match[1]) * (scale[match[2] as keyof typeof scale] ?? 1);
19
+ return Number.isFinite(bytes) && bytes > 0 ? bytes : undefined;
20
+ };
21
+
22
+ /** Validate the whole declaration before any write, including a later attempted target decrease. */
23
+ export const validateDiskSizes = (props: VmProps, olds?: VmProps) =>
24
+ Effect.gen(function* () {
25
+ const sizes = props.diskSizesGiB;
26
+ if (sizes === undefined) return;
27
+ if (sizes === null || typeof sizes !== 'object' || Array.isArray(sizes)) {
28
+ return yield* Effect.fail(
29
+ new QemuDiskResizeRefused({
30
+ disk: 'diskSizesGiB',
31
+ message: 'diskSizesGiB must be a map of disk slots to GiB targets',
32
+ }),
33
+ );
34
+ }
35
+ for (const [disk, size] of Object.entries(sizes)) {
36
+ if (size === undefined) continue;
37
+ const family = indexedKey(disk)?.[0];
38
+ const value = declaredValue(props, disk);
39
+ const previous = olds?.diskSizesGiB?.[disk as keyof typeof sizes];
40
+ let reason: string | undefined;
41
+ if ((family !== 'scsi' && family !== 'ide') || typeof value !== 'string') {
42
+ reason = 'declare a supported scsi/ide disk slot before assigning a resize target';
43
+ } else if (
44
+ !Number.isSafeInteger(size) ||
45
+ size <= 0 ||
46
+ !Number.isSafeInteger(size * 1024 ** 3)
47
+ ) {
48
+ reason = 'the target must be a positive, safely representable integer GiB size';
49
+ } else if (
50
+ value.split(',')[0]?.endsWith(':cloudinit') === true ||
51
+ parseDisk(value).options.get('media') === 'cdrom'
52
+ ) {
53
+ reason = 'cloud-init and CD-ROM drives cannot be resized';
54
+ } else if (previous !== undefined && size < previous) {
55
+ reason = `refusing shrink from declared ${previous} GiB to ${size} GiB`;
56
+ }
57
+ if (reason !== undefined) {
58
+ return yield* Effect.fail(
59
+ new QemuDiskResizeRefused({ disk, message: `${disk}: ${reason}` }),
60
+ );
61
+ }
62
+ }
63
+ });
64
+
65
+ /** Capacity drift drives reconcile; recovery ownership ignores it because resize follows create. */
66
+ export const diskSizeDrift = (props: VmProps, config: Record<string, string>): string[] =>
67
+ Object.entries(props.diskSizesGiB ?? {})
68
+ .filter(
69
+ ([disk, size]) => size !== undefined && (diskBytes(config[disk]) ?? 0) < size * 1024 ** 3,
70
+ )
71
+ .map(([disk]) => `diskSizesGiB.${disk}`);
72
+
73
+ /** Missing slots may be imported by the upcoming config write; unreadable existing sizes fail closed. */
74
+ export const checkLiveDiskSizes = (
75
+ props: VmProps,
76
+ config: Record<string, string>,
77
+ allowMissing = false,
78
+ ) =>
79
+ Effect.gen(function* () {
80
+ for (const [disk, size] of Object.entries(props.diskSizesGiB ?? {})) {
81
+ if (size === undefined) continue;
82
+ if (allowMissing && config[disk] === undefined) continue;
83
+ if (
84
+ diskBytes(config[disk]) === undefined ||
85
+ parseDisk(config[disk] ?? '').options.get('media') === 'cdrom'
86
+ ) {
87
+ return yield* Effect.fail(
88
+ new QemuDiskResizeRefused({
89
+ disk,
90
+ message: `${disk}: the live disk has no readable positive size or is a CD-ROM`,
91
+ }),
92
+ );
93
+ }
94
+ }
95
+ });
@@ -0,0 +1,44 @@
1
+ /** QEMU config forms and vendor guards, separated from lifecycle to keep both reviewable. */
2
+ import { createForm, updateForm } from './qemu-form.ts';
3
+ import {
4
+ type VmAttributes,
5
+ type VmProps,
6
+ declaredKeys,
7
+ declaredValue,
8
+ isManagedKey,
9
+ storedConfig,
10
+ wireValue,
11
+ } from './qemu-props.ts';
12
+ import type { PveSpec } from './resource-spec.ts';
13
+ import { specGuards } from './resource-guard.ts';
14
+
15
+ export const attributesOf = (live: Record<string, unknown>, props: VmProps): VmAttributes => ({
16
+ config: storedConfig(live),
17
+ node: props.node,
18
+ vmid: props.vmid,
19
+ });
20
+
21
+ /**
22
+ * ⚠️ KEPT ONLY TO SATISFY `PveSpec`'S REQUIRED FIELD — `qemu-judge.ts`'s `judge` is what diff and
23
+ * reconcile actually decide by, since a plain string compare is exactly the bug C1 found (it
24
+ * never matches the "new disk" or MAC-less spellings a declaration may use).
25
+ */
26
+ const matches = (attributes: VmAttributes, props: VmProps) =>
27
+ declaredKeys(props)
28
+ .filter(isManagedKey)
29
+ .every((key) => attributes.config[key] === wireValue(declaredValue(props, key)));
30
+
31
+ export const spec = {
32
+ attributes: attributesOf,
33
+ collection: (props: VmProps) => `nodes/${props.node}/qemu`,
34
+ createForm,
35
+ endpoint: {
36
+ create: 'pve:POST /nodes/{node}/qemu',
37
+ update: 'pve:PUT /nodes/{node}/qemu/{vmid}/config',
38
+ },
39
+ matches,
40
+ path: (props: VmProps) => `nodes/${props.node}/qemu/${String(props.vmid)}/config`,
41
+ updateForm,
42
+ } satisfies PveSpec<VmProps, VmAttributes>;
43
+
44
+ export const { guardCreate, guardUpdate } = specGuards(spec);
@@ -39,10 +39,10 @@
39
39
  * whose volname is the literal `"0"` and whose options include an `import-from` key is `fresh`,
40
40
  * with `import-from` itself dropped and every other declared option carried through for
41
41
  * `judgeDisk` to enforce (see its `want.kind === 'fresh'` branch) -- independent of option order.
42
- * ⛔ NO RESIZE IS MADE HERE. lxc-volume.ts drives a second endpoint (`PUT .../resize`) this resource
43
- * does not call — Talos's disks are declared once at their create size. A size difference between
44
- * two already-adopted volume declarations is REFUSED with the `qm resize` to run by hand, never
45
- * guessed at or silently written.
42
+ * ⛔ NO RESIZE IS MADE BY A CONFIG PUT. Before 2026-10-05 this resource did not call the resize
43
+ * endpoint at all. `diskSizesGiB` now drives it separately (qemu-resize.ts), after import;
44
+ * `size=` inside an adopted volume string still describes its observed size, never a resize.
45
+ * A mismatch there is refused so a config PUT cannot record capacity the disk does not have.
46
46
  * ⚠️ OPTIONS BEYOND `size` ARE COMPARED AS A WHOLE, NOT KEY BY KEY, FOR AN ADOPTED `volume` -- BUT AS
47
47
  * A SUBSET FOR A `fresh` IMPORT. Unlike lxc-volume.ts, this file does not model pve-qemu-server's
48
48
  * per-option defaults (`ssd`, `discard`, `iothread`, …) — that table is unverified here. For an
@@ -169,7 +169,7 @@ export const judgeDisk = (key: string, declared: string, live: string): DiskVerd
169
169
  return {
170
170
  refuse:
171
171
  `${key}: declared size ${want.size} differs from the live ${have.size}. This resource does ` +
172
- 'not resize a disk -- grow it by hand (`qm resize`) and declare the size it then reports.',
172
+ 'not resize a disk through config -- omit size= and declare a diskSizesGiB target instead.',
173
173
  };
174
174
  }
175
175
  if (sameMap(want.options, have.options)) return {};
@@ -129,7 +129,7 @@ type PlanView = { readonly resources: Readonly<Record<string, { readonly action:
129
129
  export type DriftRow = { readonly action: string };
130
130
 
131
131
  export interface FakeEngine {
132
- /** Plan and apply `body`, as `alchemy deploy --adopt` would; resolves to the planned actions. */
132
+ /** Plan and apply `body` with the configured adoption policy; resolves to planned actions. */
133
133
  readonly deploy: (body: Body) => Promise<Record<string, string>>;
134
134
  /** Verify `body` against what the earlier deploys recorded. */
135
135
  readonly verify: (body: Body, options?: VerifyOptions) => Promise<AdoptReport>;
@@ -155,7 +155,10 @@ export interface FakeEngine {
155
155
  * hands in its real provider over a fake transport (proxmox/ceph-pool-adopt.test.ts).
156
156
  * ⚠️ THE CASTS ARE AT THE ENGINE'S TYPED BOUNDARY — openbao/fake-stack.ts has the reasoning.
157
157
  */
158
- export const engineOver = <ROut, E, RIn>(layer: Layer.Layer<ROut, E, RIn>): FakeEngine => {
158
+ export const engineOver = <ROut, E, RIn>(
159
+ layer: Layer.Layer<ROut, E, RIn>,
160
+ options: { readonly adopt?: boolean } = {},
161
+ ): FakeEngine => {
159
162
  const rows: NonNullable<Parameters<typeof Alchemy.inMemoryState>[0]> = {};
160
163
  const state = Alchemy.inMemoryState(rows);
161
164
  const stack = Alchemy.Stack as unknown as (
@@ -176,7 +179,7 @@ export const engineOver = <ROut, E, RIn>(layer: Layer.Layer<ROut, E, RIn>): Fake
176
179
  const compiled = yield* stack('VerifyStack', { providers: layer, state }, body);
177
180
  return yield* next(compiled).pipe(Effect.provide(Layer.succeedContext(compiled.services)));
178
181
  }).pipe(
179
- Effect.provideService(AdoptPolicy, true),
182
+ Effect.provideService(AdoptPolicy, options.adopt ?? true),
180
183
  Effect.provideService(Alchemy.Stage, 'test'),
181
184
  Effect.provide(state),
182
185
  Effect.scoped,