@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,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
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
|
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
|
|
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>(
|
|
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,
|