@homeflare/alchemy 0.45.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 (78) hide show
  1. package/dist/proxmox/generated/constraints/index.d.ts +1 -1
  2. package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
  3. package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
  4. package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
  5. package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
  6. package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
  7. package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
  8. package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
  9. package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
  10. package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
  11. package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
  12. package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
  13. package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
  14. package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
  15. package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
  16. package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts.map +1 -1
  17. package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts +1 -1
  18. package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
  19. package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
  20. package/dist/proxmox/index.d.ts +1 -0
  21. package/dist/proxmox/index.d.ts.map +1 -1
  22. package/dist/proxmox/index.js +283 -130
  23. package/dist/proxmox/index.js.map +31 -27
  24. package/dist/proxmox/qemu-dns.d.ts +2 -0
  25. package/dist/proxmox/qemu-dns.d.ts.map +1 -0
  26. package/dist/proxmox/qemu-errors.d.ts +1 -0
  27. package/dist/proxmox/qemu-errors.d.ts.map +1 -1
  28. package/dist/proxmox/qemu-form.d.ts.map +1 -1
  29. package/dist/proxmox/qemu-judge.d.ts.map +1 -1
  30. package/dist/proxmox/qemu-lifecycle.d.ts +15 -10
  31. package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -1
  32. package/dist/proxmox/qemu-props.d.ts +10 -0
  33. package/dist/proxmox/qemu-props.d.ts.map +1 -1
  34. package/dist/proxmox/qemu-resize.d.ts +10 -0
  35. package/dist/proxmox/qemu-resize.d.ts.map +1 -0
  36. package/dist/proxmox/qemu-size.d.ts +20 -0
  37. package/dist/proxmox/qemu-size.d.ts.map +1 -0
  38. package/dist/proxmox/qemu-spec.d.ts +25 -0
  39. package/dist/proxmox/qemu-spec.d.ts.map +1 -0
  40. package/dist/verify/fake-engine.d.ts +4 -2
  41. package/dist/verify/fake-engine.d.ts.map +1 -1
  42. package/docs/proxmox-qemu-resize.md +62 -0
  43. package/package.json +1 -1
  44. package/src/proxmox/generated/constraints/index.ts +1 -1
  45. package/src/proxmox/generated/constraints/pve-access.ts +1 -1
  46. package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
  47. package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
  48. package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
  49. package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
  50. package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
  51. package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
  52. package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
  53. package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
  54. package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
  55. package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
  56. package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
  57. package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
  58. package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +6 -1
  59. package/src/proxmox/generated/constraints/pve-nodes-storage.ts +1 -1
  60. package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
  61. package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
  62. package/src/proxmox/index.ts +1 -0
  63. package/src/proxmox/qemu-dns.test.ts +73 -0
  64. package/src/proxmox/qemu-dns.ts +32 -0
  65. package/src/proxmox/qemu-errors.ts +1 -0
  66. package/src/proxmox/qemu-form.ts +6 -3
  67. package/src/proxmox/qemu-judge.ts +2 -1
  68. package/src/proxmox/qemu-lifecycle.ts +28 -47
  69. package/src/proxmox/qemu-props.ts +14 -2
  70. package/src/proxmox/qemu-resize-lock.test.ts +121 -0
  71. package/src/proxmox/qemu-resize-recovery.test.ts +81 -0
  72. package/src/proxmox/qemu-resize.test.ts +199 -0
  73. package/src/proxmox/qemu-resize.ts +86 -0
  74. package/src/proxmox/qemu-size.test.ts +64 -0
  75. package/src/proxmox/qemu-size.ts +95 -0
  76. package/src/proxmox/qemu-spec.ts +44 -0
  77. package/src/proxmox/qemu-volume.ts +5 -5
  78. package/src/verify/fake-engine.ts +6 -3
@@ -0,0 +1,32 @@
1
+ /** Cloud-init's one named format, checked against the vendor table and validator source. */
2
+ import { isIP } from 'node:net';
3
+ import { constraintsFor } from './constraint-guard.ts';
4
+
5
+ /**
6
+ * PVE::JSONSchema pve_verify_address/check_format and PVE::ParseUtils split_list,
7
+ * proxmox/pve-common@defd246f31f327463f901a2daaf8dc52efcc5a97 (read 2026-10-05).
8
+ * https://github.com/proxmox/pve-common/blob/defd246f31f327463f901a2daaf8dc52efcc5a97/src/PVE/JSONSchema.pm
9
+ * ★ `address` permits DNS names too; an IP-only check would reject vendor-valid declarations.
10
+ * Empty lists are allowed. NUL-separated lists take precedence over whitespace/comma/semicolon.
11
+ * This supplements the PVE 9.2.11 schema's named format; it does not invent a DNS-server limit.
12
+ */
13
+ const addressList = (value: string): boolean => {
14
+ const values = value.includes('\0')
15
+ ? value.split('\0')
16
+ : value.replace(/[,;]/g, ' ').trimStart().split(/\s+/);
17
+ // Perl split drops trailing empty elements, but retains interior empty elements.
18
+ while (values.at(-1) === '') values.pop();
19
+ const label = '[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?';
20
+ const dns = new RegExp(`^(?:${label}\\.)*${label}(?![\\s\\S])`);
21
+ return values.every((value) => (isIP(value) !== 0 && !value.includes('%')) || dns.test(value));
22
+ };
23
+
24
+ export const dnsRefusals = (nameserver: unknown): string[] => {
25
+ if (nameserver === undefined) return [];
26
+ const rule = constraintsFor('pve:PUT /nodes/{node}/qemu/{vmid}/config')['nameserver'];
27
+ return rule?.format === 'address-list' &&
28
+ typeof nameserver === 'string' &&
29
+ addressList(nameserver)
30
+ ? []
31
+ : ['nameserver: must satisfy the vendor address-list format'];
32
+ };
@@ -1,5 +1,6 @@
1
1
  /** A QEMU write or observation this provider will not turn into a create or a delete. */
2
2
  export class QemuRefusedError extends Error {
3
+ readonly _tag = 'QemuRefusedError';
3
4
  constructor(message: string) {
4
5
  super(message);
5
6
  this.name = 'QemuRefusedError';
@@ -13,16 +13,19 @@ import {
13
13
  isManagedKey,
14
14
  wireValue,
15
15
  } from './qemu-props.ts';
16
+ import { dnsRefusals } from './qemu-dns.ts';
16
17
 
17
18
  /**
18
19
  * Why a declared key cannot go on the wire, checked before every create and every update.
19
20
  * ★ THE SAME RULE `lxc-create-form.ts` ENFORCES FOR `Proxmox.Lxc`: a key smuggled past the types
20
21
  * (a cast, a JS caller) is refused here rather than silently dropped or silently sent.
21
22
  */
22
- export const formRefusals = (props: VmProps): string[] =>
23
- declaredKeys(props)
23
+ export const formRefusals = (props: VmProps): string[] => [
24
+ ...declaredKeys(props)
24
25
  .filter((key) => !isManagedKey(key))
25
- .map((key) => `${key}: not a config key this resource manages (vmid ${String(props.vmid)}).`);
26
+ .map((key) => `${key}: not a config key this resource manages (vmid ${String(props.vmid)}).`),
27
+ ...dnsRefusals(props.nameserver),
28
+ ];
26
29
 
27
30
  /** The declared config, wire-spelled — every key `formRefusals` did not already refuse. */
28
31
  export const form = (props: VmProps): Record<string, string> => {
@@ -13,6 +13,7 @@
13
13
  import { type VmProps, declaredKeys, declaredValue, indexedKey, wireValue } from './qemu-props.ts';
14
14
  import { judgeDisk } from './qemu-volume.ts';
15
15
  import { netWrite, sameNet } from './qemu-net.ts';
16
+ import { diskSizeDrift } from './qemu-size.ts';
16
17
 
17
18
  export type VmChange = {
18
19
  /** Keys to `PUT …/config`, wire-spelled and built on live ids where one exists. */
@@ -26,7 +27,7 @@ export type VmChange = {
26
27
  export const judge = (props: VmProps, config: Record<string, string>): VmChange => {
27
28
  const put: Record<string, string> = {};
28
29
  const refuse: string[] = [];
29
- const drift = new Set<string>();
30
+ const drift = new Set<string>(diskSizeDrift(props, config));
30
31
 
31
32
  for (const key of declaredKeys(props)) {
32
33
  const want = wireValue(declaredValue(props, key));
@@ -15,59 +15,29 @@ import { isResolved } from 'alchemy/Diff';
15
15
  import type { Input } from 'alchemy/Input';
16
16
  import * as nodes from '@distilled.cloud/proxmox/nodes';
17
17
  import * as Effect from 'effect/Effect';
18
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
18
19
  import { refuseTakeover } from '../ownership/adopt.ts';
19
20
  import { ownedRead } from '../ownership/probe.ts';
20
21
  import { runPve } from './distilled-pve.ts';
21
- import { createForm, formRefusals, updateForm } from './qemu-form.ts';
22
+ import { createForm, formRefusals } from './qemu-form.ts';
22
23
  import { QemuRefusedError } from './qemu-errors.ts';
23
24
  import { identityRefusal, judge } from './qemu-judge.ts';
24
- import {
25
- type VmAttributes,
26
- type VmProps,
27
- declaredKeys,
28
- declaredValue,
29
- isManagedKey,
30
- storedConfig,
31
- wireValue,
32
- } from './qemu-props.ts';
25
+ import type { VmAttributes, VmProps } from './qemu-props.ts';
33
26
  import { readVm } from './qemu-read.ts';
34
27
  import { qemuTask } from './qemu-task.ts';
35
- import type { PveSpec } from './resource-spec.ts';
36
- import { specGuards } from './resource-guard.ts';
28
+ import { attributesOf, guardCreate, guardUpdate, spec } from './qemu-spec.ts';
29
+ import { type QemuDiskResizeRefused, checkLiveDiskSizes, validateDiskSizes } from './qemu-size.ts';
30
+ import { resizeDisks } from './qemu-resize.ts';
37
31
 
38
32
  const CREATE_POLLS = 180;
39
33
  const DELETE_POLLS = 60;
40
34
 
41
- const attributesOf = (live: Record<string, unknown>, props: VmProps): VmAttributes => ({
42
- config: storedConfig(live),
43
- node: props.node,
44
- vmid: props.vmid,
45
- });
46
-
47
- /**
48
- * ⚠️ KEPT ONLY TO SATISFY `PveSpec`'S REQUIRED FIELD — `qemu-judge.ts`'s `judge` is what diff and
49
- * reconcile actually decide by, since a plain string compare is exactly the bug C1 found (it
50
- * never matches the "new disk" or MAC-less spellings a declaration may use).
51
- */
52
- const matches = (attributes: VmAttributes, props: VmProps) =>
53
- declaredKeys(props)
54
- .filter(isManagedKey)
55
- .every((key) => attributes.config[key] === wireValue(declaredValue(props, key)));
56
-
57
- const spec = {
58
- attributes: attributesOf,
59
- collection: (props: VmProps) => `nodes/${props.node}/qemu`,
60
- createForm,
61
- endpoint: {
62
- create: 'pve:POST /nodes/{node}/qemu',
63
- update: 'pve:PUT /nodes/{node}/qemu/{vmid}/config',
64
- },
65
- matches,
66
- path: (props: VmProps) => `nodes/${props.node}/qemu/${String(props.vmid)}/config`,
67
- updateForm,
68
- } satisfies PveSpec<VmProps, VmAttributes>;
69
-
70
- const { guardCreate, guardUpdate } = specGuards(spec);
35
+ // ★ mint's legacy Error must not erase these tags from the inferred union (distilled-pve.ts).
36
+ type QemuLifecycleError =
37
+ | Effect.Error<ReturnType<typeof readQemu>>
38
+ | nodes.PutNodeQemuResizeError
39
+ | QemuRefusedError
40
+ | QemuDiskResizeRefused;
71
41
 
72
42
  export const readQemu = (props: VmProps) =>
73
43
  readVm(props).pipe(
@@ -115,8 +85,8 @@ export const qemuHandlers = {
115
85
  /**
116
86
  * ⛔ `Unowned` UNLESS STATE ALREADY VOUCHES FOR IT (C2). With no attributes this is Alchemy's
117
87
  * adoption probe or the recovery read for an interrupted create (ownership/probe.ts); `settled`
118
- * is the same `judge` reconcile uses, so "proven ours" and "reconcile would write nothing" are
119
- * the same question asked once.
88
+ * uses the same config comparison as reconcile. Disk growth is a post-create step, so its
89
+ * drift cannot disown a VM after an interrupted/failed resize (measured 2026-10-05).
120
90
  */
121
91
  read: Effect.fn(function* ({
122
92
  fqn,
@@ -131,7 +101,9 @@ export const qemuHandlers = {
131
101
  }) {
132
102
  const found = yield* readQemu(olds);
133
103
  const settled = Effect.sync(
134
- () => found !== undefined && judge(olds, found.config).drift.length === 0,
104
+ () =>
105
+ found !== undefined &&
106
+ judge(olds, found.config).drift.every((key) => key.startsWith('diskSizesGiB.')),
135
107
  );
136
108
  return yield* ownedRead({ fqn, instanceId, output }, found, settled);
137
109
  }),
@@ -143,11 +115,16 @@ export const qemuHandlers = {
143
115
  news: Input<VmProps>;
144
116
  olds: VmProps;
145
117
  output: VmAttributes | undefined;
146
- }) {
118
+ }): Effect.fn.Return<
119
+ { readonly action: 'noop' | 'update' } | undefined,
120
+ QemuLifecycleError,
121
+ HttpClient.HttpClient
122
+ > {
147
123
  if (!isResolved(news)) return undefined;
148
124
  const identity = identityRefusal(news.vmid, output?.vmid ?? olds.vmid);
149
125
  if (identity !== undefined) return yield* Effect.fail(new QemuRefusedError(identity));
150
126
  yield* refuseUnmanaged(news);
127
+ yield* validateDiskSizes(news, olds);
151
128
  yield* guardCreate(news, output === undefined);
152
129
  yield* guardUpdate(news);
153
130
  if (output === undefined) return undefined;
@@ -162,6 +139,7 @@ export const qemuHandlers = {
162
139
  yield* guardCreate(news, true);
163
140
  return { action: 'update' } as const;
164
141
  }
142
+ yield* checkLiveDiskSizes(news, live.config, true);
165
143
  const change = judge(news, live.config);
166
144
  yield* refuseChange(change);
167
145
  return { action: change.drift.length === 0 ? 'noop' : 'update' } as const;
@@ -178,10 +156,11 @@ export const qemuHandlers = {
178
156
  news: VmProps;
179
157
  olds: VmProps | undefined;
180
158
  output: VmAttributes | undefined;
181
- }) {
159
+ }): Effect.fn.Return<VmAttributes, QemuLifecycleError, HttpClient.HttpClient> {
182
160
  const identity = identityRefusal(news.vmid, output?.vmid ?? olds?.vmid);
183
161
  if (identity !== undefined) return yield* Effect.fail(new QemuRefusedError(identity));
184
162
  yield* refuseUnmanaged(news);
163
+ yield* validateDiskSizes(news, olds);
185
164
  const live = yield* readQemu(news);
186
165
  yield* guardCreate(news, live === undefined);
187
166
  yield* guardUpdate(news);
@@ -199,6 +178,7 @@ export const qemuHandlers = {
199
178
  { fqn, instanceId, output },
200
179
  `Proxmox.Vm VM ${String(news.vmid)} on ${news.node}`,
201
180
  );
181
+ yield* checkLiveDiskSizes(news, live.config, true);
202
182
  const change = judge(news, live.config);
203
183
  yield* refuseChange(change);
204
184
  if (Object.keys(change.put).length > 0) {
@@ -210,6 +190,7 @@ export const qemuHandlers = {
210
190
  );
211
191
  }
212
192
  }
193
+ yield* resizeDisks(news);
213
194
  const after = yield* readQemu(news);
214
195
  if (after === undefined) {
215
196
  return yield* Effect.fail(
@@ -48,6 +48,16 @@ export interface VmProps extends WithTarget {
48
48
  boot?: string;
49
49
  /** e.g. `virtio-scsi-single`. */
50
50
  scsihw?: string;
51
+ /** Cloud-init DNS servers: PVE's `address-list` (IP addresses or DNS names). */
52
+ nameserver?: string;
53
+ /** Cloud-init DNS search domains, in PVE's wire spelling. */
54
+ searchdomain?: string;
55
+ /**
56
+ * Minimum disk sizes in GiB, e.g. `{ scsi0: 64 }`, grown after import completes.
57
+ * Existing larger disks are left alone. Lowering a previously declared target is refused.
58
+ * These are resize targets, never config fields; declare the corresponding disk slot too.
59
+ */
60
+ diskSizesGiB?: Partial<Record<`scsi${number}` | `ide${number}`, number>>;
51
61
  /**
52
62
  * `local-zfs:32` to create a new volume, or the live volume string as adopted —
53
63
  * `cephtb4:vm-101-disk-0,size=32G` (n is 0 to 30, pve-qemu-server's own `$MAX_SCSI_DISKS`).
@@ -81,8 +91,8 @@ export interface VmAttributes {
81
91
  config: Record<string, string>;
82
92
  }
83
93
 
84
- /** Props that identify the object rather than configure it: never sent, never compared. */
85
- const NOT_CONFIG: ReadonlySet<string> = new Set(['node', 'target', 'vmid']);
94
+ /** Identity and resize metadata: never config form fields; sizes are compared separately. */
95
+ const NOT_CONFIG: ReadonlySet<string> = new Set(['node', 'target', 'vmid', 'diskSizesGiB']);
86
96
 
87
97
  /** The indexed families this resource manages, with pve-qemu-server's own published range. */
88
98
  export const INDEXED = {
@@ -111,8 +121,10 @@ const SCALAR_KEYS: ReadonlySet<string> = new Set([
111
121
  'cpu',
112
122
  'memory',
113
123
  'name',
124
+ 'nameserver',
114
125
  'onboot',
115
126
  'scsihw',
127
+ 'searchdomain',
116
128
  'sockets',
117
129
  ]);
118
130
 
@@ -0,0 +1,121 @@
1
+ /** Raw locks survive the SDK even though stored attributes omit them. All waits use TestClock. */
2
+ import { expect, test } from 'bun:test';
3
+ import * as Effect from 'effect/Effect';
4
+ import * as Fiber from 'effect/Fiber';
5
+ import * as TestClock from 'effect/testing/TestClock';
6
+ import { FAKE_TARGET, fakePve, withoutBao } from './fake-pve.ts';
7
+ import { qemuHandlers } from './qemu-lifecycle.ts';
8
+ import { resizeDisks } from './qemu-resize.ts';
9
+
10
+ const props = {
11
+ target: FAKE_TARGET,
12
+ node: 'n2',
13
+ vmid: 10000,
14
+ scsi0: 'cephtb4:0,import-from=cephfs-tb4:import/talos.raw',
15
+ diskSizesGiB: { scsi0: 64 },
16
+ };
17
+ const path = 'nodes/n2/qemu/10000';
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 = { ...props, config: config('4.15G') };
24
+
25
+ test('backup-locked disks at or above target need one read, no wait and no write', async () => {
26
+ const fake = fakePve((call) => {
27
+ expect(call.path).toBe(`${path}/config`);
28
+ return {
29
+ ...config('64G'),
30
+ scsi1: 'cephtb4:vm-10000-disk-1,size=128G',
31
+ lock: 'backup',
32
+ };
33
+ });
34
+ await withoutBao(() =>
35
+ Effect.runPromise(
36
+ Effect.gen(function* () {
37
+ // No clock adjustment: any lock sleep would leave this operation suspended.
38
+ yield* resizeDisks({
39
+ ...props,
40
+ scsi1: 'cephtb4:vm-10000-disk-1',
41
+ diskSizesGiB: { scsi0: 64, scsi1: 64 },
42
+ });
43
+ }).pipe(Effect.provide(fake.layer), Effect.provide(TestClock.layer())),
44
+ ),
45
+ );
46
+ expect(fake.calls).toHaveLength(1);
47
+ expect(fake.writes()).toEqual([]);
48
+ });
49
+
50
+ for (const lockState of ['persists', 'clears', 'clears-at-target', 'task-timeout'] as const) {
51
+ test(`resize observes import lock: ${lockState}`, async () => {
52
+ let reads = 0;
53
+ let polls = 0;
54
+ let resized = false;
55
+ let locked = true;
56
+ const fake = fakePve((call) => {
57
+ if (call.path.includes('/tasks/')) {
58
+ polls++;
59
+ if (lockState === 'task-timeout') return { status: 'running' };
60
+ resized = true;
61
+ return { status: 'stopped', exitstatus: 'OK' };
62
+ }
63
+ if (call.path === `${path}/config`) {
64
+ reads++;
65
+ // Reconcile and the size precheck precede the three raw resize lock checks.
66
+ locked = lockState === 'persists' || reads <= 5;
67
+ return {
68
+ ...config(resized || (!locked && lockState === 'clears-at-target') ? '64G' : '4.15G'),
69
+ ...(locked ? { lock: lockState === 'persists' ? 'backup' : 'create' } : {}),
70
+ };
71
+ }
72
+ if (call.path === `${path}/resize`) {
73
+ expect(locked).toBe(false);
74
+ expect(call.form).toEqual({ disk: 'scsi0', size: '64G' });
75
+ return resizeTask;
76
+ }
77
+ throw new Error(`unexpected ${call.method} ${call.path}`);
78
+ });
79
+ const result = await withoutBao(() =>
80
+ Effect.runPromise(
81
+ Effect.gen(function* () {
82
+ const worker = yield* qemuHandlers
83
+ .reconcile({
84
+ fqn: 'row',
85
+ instanceId: 'row',
86
+ news: props,
87
+ olds: props,
88
+ output,
89
+ })
90
+ .pipe(
91
+ Effect.catchTag('QemuDiskResizeRefused', (error) => Effect.succeed(error)),
92
+ Effect.catchTag('QemuRefusedError', (error) => Effect.succeed(error)),
93
+ Effect.provide(fake.layer),
94
+ Effect.forkChild,
95
+ );
96
+ while (reads < 2) yield* Effect.yieldNow;
97
+ yield* TestClock.adjust('61 seconds');
98
+ return yield* Fiber.join(worker);
99
+ }).pipe(Effect.provide(TestClock.layer())),
100
+ ),
101
+ );
102
+ if (lockState === 'persists') {
103
+ expect(result).toMatchObject({
104
+ _tag: 'QemuDiskResizeRefused',
105
+ disk: 'scsi0',
106
+ message: 'scsi0: VM 10000 remains locked (backup) after 60 observations; refusing resize',
107
+ });
108
+ expect(reads).toBe(62);
109
+ expect(polls).toBe(0);
110
+ } else if (lockState === 'task-timeout') {
111
+ expect(result).toMatchObject({ _tag: 'QemuRefusedError' });
112
+ expect(polls).toBe(57); // Three lock waits leave 57 task observations, not a new budget.
113
+ } else {
114
+ expect(result).toMatchObject({ config: { scsi0: config('64G').scsi0 } });
115
+ expect(polls).toBe(lockState === 'clears' ? 1 : 0);
116
+ }
117
+ expect(fake.writes()).toEqual(
118
+ lockState === 'persists' || lockState === 'clears-at-target' ? [] : [`PUT ${path}/resize`],
119
+ );
120
+ });
121
+ }
@@ -0,0 +1,81 @@
1
+ /** Failed creates resume through Alchemy's real state recovery, with adoption disabled. */
2
+ import { expect, test } from 'bun:test';
3
+ import * as Layer from 'effect/Layer';
4
+ import { engineOver } from '../verify/fake-engine.ts';
5
+ import { FAKE_TARGET, fakePve, withoutBao } from './fake-pve.ts';
6
+ import { ProxmoxVm, ProxmoxVmProvider } from './qemu.ts';
7
+
8
+ const props = {
9
+ target: FAKE_TARGET,
10
+ node: 'n2',
11
+ vmid: 10000,
12
+ name: 'talos',
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
+
20
+ for (const otherDrift of [false, true]) {
21
+ test(`failed resize recovery without adoption (other config drift: ${otherDrift})`, async () => {
22
+ let created = false;
23
+ let resized = false;
24
+ let attempts = 0;
25
+ let name = props.name;
26
+ const fake = fakePve((call) => {
27
+ if (call.path.startsWith('cluster/resources')) return [];
28
+ if (call.path.includes(encodeURIComponent(importTask)))
29
+ return { status: 'stopped', exitstatus: 'OK' };
30
+ if (call.path.includes(encodeURIComponent(resizeTask))) {
31
+ resized = attempts > 1;
32
+ return { status: 'stopped', exitstatus: resized ? 'OK' : 'disk resize failed' };
33
+ }
34
+ if (call.path === `${path}/config`)
35
+ return created
36
+ ? {
37
+ digest: 'a'.repeat(40),
38
+ name,
39
+ scsi0: `cephtb4:vm-10000-disk-0,size=${resized ? '64G' : '4.15G'}`,
40
+ }
41
+ : Response.json(
42
+ {
43
+ data: null,
44
+ message: "Configuration file 'nodes/n2/qemu-server/10000.conf' does not exist\n",
45
+ },
46
+ { status: 500 },
47
+ );
48
+ if (call.method === 'POST') {
49
+ created = true;
50
+ return importTask;
51
+ }
52
+ if (call.path === `${path}/resize`) {
53
+ attempts++;
54
+ return resizeTask;
55
+ }
56
+ throw new Error(`unexpected ${call.method} ${call.path}`);
57
+ });
58
+ await withoutBao(async () => {
59
+ const stack = engineOver(ProxmoxVmProvider().pipe(Layer.provideMerge(fake.layer)), {
60
+ adopt: false,
61
+ });
62
+ await expect(stack.deploy(ProxmoxVm('row', props))).rejects.toThrow(/disk resize failed/);
63
+ if (otherDrift) {
64
+ name = 'someone-else';
65
+ await expect(stack.deploy(ProxmoxVm('row', props))).rejects.toThrow(/adopt/i);
66
+ } else {
67
+ await stack.deploy(ProxmoxVm('row', props));
68
+ await stack.deploy(ProxmoxVm('row', props));
69
+ expect(resized).toBe(true);
70
+ expect((await stack.verify(ProxmoxVm('row', props), { all: true })).rows[0]).toMatchObject({
71
+ diff: 'noop',
72
+ });
73
+ }
74
+ });
75
+ expect(fake.writes()).toEqual([
76
+ 'POST nodes/n2/qemu',
77
+ `PUT ${path}/resize`,
78
+ ...(otherDrift ? [] : [`PUT ${path}/resize`]),
79
+ ]);
80
+ });
81
+ }
@@ -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
+ });