@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.
- package/dist/proxmox/generated/constraints/index.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
- package/dist/proxmox/index.d.ts +1 -0
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +283 -130
- package/dist/proxmox/index.js.map +31 -27
- package/dist/proxmox/qemu-dns.d.ts +2 -0
- package/dist/proxmox/qemu-dns.d.ts.map +1 -0
- package/dist/proxmox/qemu-errors.d.ts +1 -0
- package/dist/proxmox/qemu-errors.d.ts.map +1 -1
- package/dist/proxmox/qemu-form.d.ts.map +1 -1
- package/dist/proxmox/qemu-judge.d.ts.map +1 -1
- package/dist/proxmox/qemu-lifecycle.d.ts +15 -10
- package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -1
- package/dist/proxmox/qemu-props.d.ts +10 -0
- package/dist/proxmox/qemu-props.d.ts.map +1 -1
- package/dist/proxmox/qemu-resize.d.ts +10 -0
- package/dist/proxmox/qemu-resize.d.ts.map +1 -0
- package/dist/proxmox/qemu-size.d.ts +20 -0
- package/dist/proxmox/qemu-size.d.ts.map +1 -0
- package/dist/proxmox/qemu-spec.d.ts +25 -0
- package/dist/proxmox/qemu-spec.d.ts.map +1 -0
- package/dist/verify/fake-engine.d.ts +4 -2
- package/dist/verify/fake-engine.d.ts.map +1 -1
- package/docs/proxmox-qemu-resize.md +62 -0
- package/package.json +1 -1
- package/src/proxmox/generated/constraints/index.ts +1 -1
- package/src/proxmox/generated/constraints/pve-access.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +6 -1
- package/src/proxmox/generated/constraints/pve-nodes-storage.ts +1 -1
- package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
- package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
- package/src/proxmox/index.ts +1 -0
- package/src/proxmox/qemu-dns.test.ts +73 -0
- package/src/proxmox/qemu-dns.ts +32 -0
- package/src/proxmox/qemu-errors.ts +1 -0
- package/src/proxmox/qemu-form.ts +6 -3
- package/src/proxmox/qemu-judge.ts +2 -1
- package/src/proxmox/qemu-lifecycle.ts +28 -47
- package/src/proxmox/qemu-props.ts +14 -2
- package/src/proxmox/qemu-resize-lock.test.ts +121 -0
- package/src/proxmox/qemu-resize-recovery.test.ts +81 -0
- package/src/proxmox/qemu-resize.test.ts +199 -0
- package/src/proxmox/qemu-resize.ts +86 -0
- package/src/proxmox/qemu-size.test.ts +64 -0
- package/src/proxmox/qemu-size.ts +95 -0
- package/src/proxmox/qemu-spec.ts +44 -0
- package/src/proxmox/qemu-volume.ts +5 -5
- 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
|
+
};
|
package/src/proxmox/qemu-form.ts
CHANGED
|
@@ -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
|
|
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
|
|
36
|
-
import {
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
*
|
|
119
|
-
*
|
|
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
|
-
() =>
|
|
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
|
-
/**
|
|
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
|
+
});
|