@homeflare/alchemy 0.37.6 → 0.37.7
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/firewall-alias-config.d.ts +36 -0
- package/dist/proxmox/firewall-alias-config.d.ts.map +1 -0
- package/dist/proxmox/firewall-alias-form.d.ts +3 -1
- package/dist/proxmox/firewall-alias-form.d.ts.map +1 -1
- package/dist/proxmox/firewall-alias-lifecycle.d.ts +43 -0
- package/dist/proxmox/firewall-alias-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/firewall-alias.d.ts +2 -8
- package/dist/proxmox/firewall-alias.d.ts.map +1 -1
- package/dist/proxmox/index.js +795 -526
- package/dist/proxmox/index.js.map +23 -10
- package/dist/proxmox/lxc-errors.d.ts +12 -0
- package/dist/proxmox/lxc-errors.d.ts.map +1 -0
- package/dist/proxmox/lxc-lifecycle.d.ts +9 -31
- package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -1
- package/dist/proxmox/lxc-read.d.ts +4 -0
- package/dist/proxmox/lxc-read.d.ts.map +1 -0
- package/dist/proxmox/lxc-task.d.ts +15 -0
- package/dist/proxmox/lxc-task.d.ts.map +1 -0
- package/dist/proxmox/network-apply-read.d.ts +7 -14
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/network-apply-run.d.ts +7 -0
- package/dist/proxmox/network-apply-run.d.ts.map +1 -0
- package/dist/proxmox/network-apply.d.ts +2 -3
- package/dist/proxmox/network-apply.d.ts.map +1 -1
- package/dist/proxmox/qemu-errors.d.ts +5 -0
- package/dist/proxmox/qemu-errors.d.ts.map +1 -0
- package/dist/proxmox/qemu-form.d.ts +22 -0
- package/dist/proxmox/qemu-form.d.ts.map +1 -0
- package/dist/proxmox/qemu-lifecycle.d.ts +26 -0
- package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/qemu-read.d.ts +5 -0
- package/dist/proxmox/qemu-read.d.ts.map +1 -0
- package/dist/proxmox/qemu-task.d.ts +9 -0
- package/dist/proxmox/qemu-task.d.ts.map +1 -0
- package/dist/proxmox/qemu.d.ts +9 -14
- package/dist/proxmox/qemu.d.ts.map +1 -1
- package/dist/proxmox/replication-firewall-fixtures.d.ts +30 -0
- package/dist/proxmox/replication-firewall-fixtures.d.ts.map +1 -0
- package/dist/proxmox/replication-job-config.d.ts +56 -0
- package/dist/proxmox/replication-job-config.d.ts.map +1 -0
- package/dist/proxmox/replication-job-lifecycle.d.ts +62 -0
- package/dist/proxmox/replication-job-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/replication-job.d.ts +5 -6
- package/dist/proxmox/replication-job.d.ts.map +1 -1
- package/dist/release/archive.d.ts.map +1 -1
- package/dist/release/index.js +3 -2
- package/dist/release/index.js.map +3 -3
- package/docs/proxmox-lxc-distilled.md +56 -0
- package/docs/proxmox-replication-firewall-distilled.md +53 -0
- package/docs/proxmox-sdk-migration.md +23 -18
- package/package.json +2 -2
- package/src/proxmox/firewall-alias-config.ts +39 -0
- package/src/proxmox/firewall-alias-form.ts +1 -1
- package/src/proxmox/firewall-alias-lifecycle.ts +100 -0
- package/src/proxmox/firewall-alias.ts +4 -67
- package/src/proxmox/lxc-create.test.ts +4 -1
- package/src/proxmox/lxc-distilled.test.ts +239 -0
- package/src/proxmox/lxc-errors.ts +9 -0
- package/src/proxmox/lxc-lifecycle.ts +31 -113
- package/src/proxmox/lxc-read.test.ts +29 -6
- package/src/proxmox/lxc-read.ts +88 -0
- package/src/proxmox/lxc-task.ts +71 -0
- package/src/proxmox/network-apply-distilled.test.ts +76 -0
- package/src/proxmox/network-apply-read.ts +18 -39
- package/src/proxmox/network-apply-run.ts +97 -0
- package/src/proxmox/network-apply.ts +5 -94
- package/src/proxmox/qemu-errors.ts +7 -0
- package/src/proxmox/qemu-form.ts +15 -0
- package/src/proxmox/qemu-lifecycle.test.ts +171 -0
- package/src/proxmox/qemu-lifecycle.ts +134 -0
- package/src/proxmox/qemu-read.ts +73 -0
- package/src/proxmox/qemu-task.ts +64 -0
- package/src/proxmox/qemu.ts +11 -44
- package/src/proxmox/replication-firewall-fixtures.ts +71 -0
- package/src/proxmox/replication-firewall-lifecycle.test.ts +109 -0
- package/src/proxmox/replication-firewall-wire.test.ts +173 -0
- package/src/proxmox/replication-job-config.ts +79 -0
- package/src/proxmox/replication-job-lifecycle.ts +113 -0
- package/src/proxmox/replication-job.ts +7 -84
- package/src/release/archive.ts +4 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** Named SDK operations; pve-firewall 6.0.5 Aliases.pm supplies exact absence and idempotent delete. */
|
|
2
|
+
import { isResolved } from 'alchemy/Diff';
|
|
3
|
+
import type { Input } from 'alchemy/Input';
|
|
4
|
+
import * as cluster from '@distilled.cloud/proxmox/cluster';
|
|
5
|
+
import { ProxmoxParseError } from '@distilled.cloud/proxmox/Errors';
|
|
6
|
+
import * as Effect from 'effect/Effect';
|
|
7
|
+
import type { FirewallAliasAttributes, FirewallAliasProps } from './firewall-alias.ts';
|
|
8
|
+
import { firewallAliasSpec as spec } from './firewall-alias-config.ts';
|
|
9
|
+
import { cidr, fold } from './firewall-alias-form.ts';
|
|
10
|
+
import { runPve } from './distilled-pve.ts';
|
|
11
|
+
import { specGuards } from './resource-guard.ts';
|
|
12
|
+
import { formToSend } from './update-guard.ts';
|
|
13
|
+
|
|
14
|
+
const { guardCreate, guardUpdate } = specGuards(spec);
|
|
15
|
+
|
|
16
|
+
export const readFirewallAlias = (props: FirewallAliasProps) =>
|
|
17
|
+
runPve(props.target, 'read', false, cluster.getClusterFirewallAlias({ name: props.name })).pipe(
|
|
18
|
+
Effect.flatMap((live) => {
|
|
19
|
+
// Vendor's GET schema is bare object; read_alias returns the stored name/cidr entry.
|
|
20
|
+
// ⛔ A malformed successful read is not absence and must never authorize a POST.
|
|
21
|
+
if (
|
|
22
|
+
live === null ||
|
|
23
|
+
typeof live !== 'object' ||
|
|
24
|
+
Array.isArray(live) ||
|
|
25
|
+
!('name' in live) ||
|
|
26
|
+
typeof live.name !== 'string' ||
|
|
27
|
+
fold(live.name) !== fold(props.name) ||
|
|
28
|
+
!('cidr' in live) ||
|
|
29
|
+
typeof live.cidr !== 'string' ||
|
|
30
|
+
cidr(live.cidr) === ''
|
|
31
|
+
) {
|
|
32
|
+
return Effect.fail(
|
|
33
|
+
new ProxmoxParseError({
|
|
34
|
+
body: undefined,
|
|
35
|
+
cause: 'Firewall alias response does not match the requested vendor identity',
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return Effect.succeed(spec.attributes({ ...live }, props));
|
|
40
|
+
}),
|
|
41
|
+
Effect.catchTag('FirewallAliasNotFound', () => Effect.succeed(undefined)),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
/** Vendor deletion silently removes a missing key. All actual SDK failures still propagate. */
|
|
45
|
+
export const deleteFirewallAlias = (props: FirewallAliasProps) =>
|
|
46
|
+
runPve(props.target, 'provision', true, cluster.deleteClusterFirewallAlias({ name: props.name }));
|
|
47
|
+
|
|
48
|
+
export const firewallAliasHandlers = {
|
|
49
|
+
list: () => Effect.succeed([]),
|
|
50
|
+
read: ({ olds }: { olds: FirewallAliasProps }) => readFirewallAlias(olds),
|
|
51
|
+
diff: Effect.fn(function* ({
|
|
52
|
+
news,
|
|
53
|
+
output,
|
|
54
|
+
}: {
|
|
55
|
+
news: Input<FirewallAliasProps>;
|
|
56
|
+
output: FirewallAliasAttributes | undefined;
|
|
57
|
+
}) {
|
|
58
|
+
if (!isResolved(news)) return undefined;
|
|
59
|
+
yield* guardCreate(news, output === undefined);
|
|
60
|
+
yield* guardUpdate(news);
|
|
61
|
+
if (output === undefined) return undefined;
|
|
62
|
+
// A case-only spelling is the same vendor key; a real rename remains create-first replacement.
|
|
63
|
+
if (fold(news.name) !== fold(output.name)) return { action: 'replace' } as const;
|
|
64
|
+
const live = yield* readFirewallAlias(news);
|
|
65
|
+
if (live === undefined) {
|
|
66
|
+
yield* guardCreate(news, true);
|
|
67
|
+
return { action: 'update' } as const;
|
|
68
|
+
}
|
|
69
|
+
return { action: spec.matches(live, news) ? 'noop' : 'update' } as const;
|
|
70
|
+
}),
|
|
71
|
+
reconcile: Effect.fn(function* ({ news }: { news: FirewallAliasProps }) {
|
|
72
|
+
const live = yield* readFirewallAlias(news);
|
|
73
|
+
yield* guardCreate(news, live === undefined);
|
|
74
|
+
yield* guardUpdate(news);
|
|
75
|
+
if (live === undefined) {
|
|
76
|
+
yield* runPve(
|
|
77
|
+
news.target,
|
|
78
|
+
'provision',
|
|
79
|
+
true,
|
|
80
|
+
cluster.createClusterFirewallAlias(spec.createForm(news)),
|
|
81
|
+
);
|
|
82
|
+
} else {
|
|
83
|
+
const form = formToSend(spec.matches, live, news, spec.updateForm(news));
|
|
84
|
+
if (form !== undefined)
|
|
85
|
+
yield* runPve(
|
|
86
|
+
news.target,
|
|
87
|
+
'provision',
|
|
88
|
+
true,
|
|
89
|
+
cluster.putClusterFirewallAlias({ ...form, name: news.name }),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
const after = yield* readFirewallAlias(news);
|
|
93
|
+
if (after === undefined)
|
|
94
|
+
return yield* Effect.fail(
|
|
95
|
+
new Error(`${spec.path(news)}: write returned success but the resource is still absent`),
|
|
96
|
+
);
|
|
97
|
+
return after;
|
|
98
|
+
}),
|
|
99
|
+
delete: ({ olds }: { olds: FirewallAliasProps }) => deleteFirewallAlias(olds),
|
|
100
|
+
};
|
|
@@ -93,12 +93,10 @@
|
|
|
93
93
|
* confirm the SECOND plan says noop before trusting this file.
|
|
94
94
|
*/
|
|
95
95
|
import { Resource } from 'alchemy';
|
|
96
|
-
import { isResolved } from 'alchemy/Diff';
|
|
97
96
|
import * as Provider from 'alchemy/Provider';
|
|
98
97
|
import * as Effect from 'effect/Effect';
|
|
99
|
-
import {
|
|
100
|
-
import {
|
|
101
|
-
import { int, text } from './values.ts';
|
|
98
|
+
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
99
|
+
import { firewallAliasHandlers } from './firewall-alias-lifecycle.ts';
|
|
102
100
|
|
|
103
101
|
export interface FirewallAliasProps extends WithTarget {
|
|
104
102
|
/**
|
|
@@ -144,70 +142,9 @@ export interface ProxmoxFirewallAlias extends Resource<
|
|
|
144
142
|
|
|
145
143
|
export const ProxmoxFirewallAlias = Resource<ProxmoxFirewallAlias>('Proxmox.FirewallAlias');
|
|
146
144
|
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* ⚠️ AN ENTRY WITH NO CIDR IS NOT AN ALIAS. `read_alias` raises for a name it does not hold, so
|
|
150
|
-
* absence normally arrives as a failed call the factory folds into `undefined`; this guard
|
|
151
|
-
* covers the other shape — a 200 carrying something that is not an alias — rather than
|
|
152
|
-
* recording state for an object with an empty address. The next reconcile then POSTs and PVE
|
|
153
|
-
* answers "alias already exists", which is loud and points at the read.
|
|
154
|
-
*/
|
|
155
|
-
attributes: (live, props) => {
|
|
156
|
-
const address = cidr(live['cidr']);
|
|
157
|
-
return address === ''
|
|
158
|
-
? undefined
|
|
159
|
-
: {
|
|
160
|
-
cidr: address,
|
|
161
|
-
comment: comment(live['comment']),
|
|
162
|
-
ipversion: int(live['ipversion'], 0),
|
|
163
|
-
/** ⚠️ FROM THE CLUSTER, falling back to props: the stored spelling is the interesting one. */
|
|
164
|
-
name: text(live['name'], props.name),
|
|
165
|
-
};
|
|
166
|
-
},
|
|
167
|
-
collection: () => 'cluster/firewall/aliases',
|
|
168
|
-
createForm: (props) => ({ ...body(props), name: props.name }),
|
|
169
|
-
/**
|
|
170
|
-
* ⚠️ EXACTLY THE TWO FIELDS A PUT CAN PUT BACK, both sides through the same normalisers. `name`
|
|
171
|
-
* is identity and case-only drift (see the header); `ipversion` is derived and unwritable.
|
|
172
|
-
* Comparing either would report an update that no update can settle.
|
|
173
|
-
*/
|
|
174
|
-
/** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
|
|
175
|
-
endpoint: {
|
|
176
|
-
create: 'pve:POST /cluster/firewall/aliases',
|
|
177
|
-
update: 'pve:PUT /cluster/firewall/aliases/{name}',
|
|
178
|
-
},
|
|
179
|
-
matches: (attributes, props) =>
|
|
180
|
-
attributes.cidr === cidr(props.cidr) && attributes.comment === comment(props.comment),
|
|
181
|
-
/**
|
|
182
|
-
* ⚠️ UNESCAPED ON PURPOSE: `pve-fw-alias` admits only `[A-Za-z][A-Za-z0-9\-\_]+`, so there is no
|
|
183
|
-
* character here that a URL would need to encode, and PVE lowercases the segment on arrival.
|
|
184
|
-
*/
|
|
185
|
-
path: (props) => `cluster/firewall/aliases/${props.name}`,
|
|
186
|
-
updateForm: body,
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* ⛔ ONE HANDLER IS OVERRIDDEN AND THE REST COME FROM THE FACTORY, for the reason `acl.ts` spells
|
|
191
|
-
* out at length: the factory answers `replace` only for an object with no update path, and this
|
|
192
|
-
* one has a PUT. Left to delegate, a changed `name` would read absent at the new path, plan as an
|
|
193
|
-
* update, and quietly leave the old alias behind. Everything else — the empty `list`, the read,
|
|
194
|
-
* the read-back-guarded reconcile, the delete — is the factory's, unchanged.
|
|
195
|
-
*/
|
|
145
|
+
/** Named distilled operations preserve create-first identity replacement and explicit adoption. */
|
|
196
146
|
export const ProxmoxFirewallAliasProvider = () =>
|
|
197
147
|
Provider.effect(
|
|
198
148
|
ProxmoxFirewallAlias,
|
|
199
|
-
Effect.succeed(
|
|
200
|
-
ProxmoxFirewallAlias.Provider.of({
|
|
201
|
-
...handlers,
|
|
202
|
-
diff: Effect.fn(function* ({ news, output }) {
|
|
203
|
-
// ⚠️ `isResolved` FIRST: at plan time `news.name` can still be an unresolved Output, and
|
|
204
|
-
// folding a placeholder would report a replace nobody asked for — the worst possible
|
|
205
|
-
// false positive on this family, since a replace here DELETES an alias.
|
|
206
|
-
if (output !== undefined && isResolved(news) && fold(news.name) !== fold(output.name)) {
|
|
207
|
-
return { action: 'replace' } as const;
|
|
208
|
-
}
|
|
209
|
-
return yield* handlers.diff({ news, output });
|
|
210
|
-
}),
|
|
211
|
-
}),
|
|
212
|
-
),
|
|
149
|
+
Effect.succeed(ProxmoxFirewallAlias.Provider.of(firewallAliasHandlers)),
|
|
213
150
|
);
|
|
@@ -96,7 +96,10 @@ describe('removing a container', () => {
|
|
|
96
96
|
`POST nodes/${NODE}/lxc`,
|
|
97
97
|
`DELETE nodes/${NODE}/lxc/${String(VMID)}`,
|
|
98
98
|
]);
|
|
99
|
-
|
|
99
|
+
// PVE APIServer/AnyEvent.pm uri_unescape runs before routing; the SDK encodes labels.
|
|
100
|
+
expect(
|
|
101
|
+
pve.seen.some((call) => /\/tasks\/UPID:.*vzdestroy/.test(decodeURIComponent(call.path))),
|
|
102
|
+
).toBe(true);
|
|
100
103
|
// ⛔ No `force` (stops a running guest), `purge` (drops it from HA and backup jobs) or
|
|
101
104
|
// `destroy-unreferenced-disks` (deletes volumes the config does not even name).
|
|
102
105
|
const del = pve.seen.find((call) => call.method === 'DELETE');
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/** Real SDK HTTP boundary: credential continuity, dynamic fields, digest and task uncertainty. */
|
|
2
|
+
import { expect, test } from 'bun:test';
|
|
3
|
+
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
4
|
+
import * as Deferred from 'effect/Deferred';
|
|
5
|
+
import * as Effect from 'effect/Effect';
|
|
6
|
+
import * as Exit from 'effect/Exit';
|
|
7
|
+
import * as Fiber from 'effect/Fiber';
|
|
8
|
+
import * as Layer from 'effect/Layer';
|
|
9
|
+
import * as TestClock from 'effect/testing/TestClock';
|
|
10
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
11
|
+
import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
|
|
12
|
+
import { createGuest, updateGuest } from './lxc-lifecycle.ts';
|
|
13
|
+
import type { LxcProps } from './lxc-props.ts';
|
|
14
|
+
import { readLive } from './lxc-read.ts';
|
|
15
|
+
import { lxcTask } from './lxc-task.ts';
|
|
16
|
+
|
|
17
|
+
const props: LxcProps = {
|
|
18
|
+
hostname: 'ct-example',
|
|
19
|
+
node: 'pve1',
|
|
20
|
+
vmid: 900,
|
|
21
|
+
target: FAKE_TARGET,
|
|
22
|
+
ostemplate: 'local:vztmpl/test.tar.zst',
|
|
23
|
+
rootfs: 'local-zfs:8',
|
|
24
|
+
net0: 'name=eth0,bridge=vmbr0,ip=dhcp',
|
|
25
|
+
mp0: 'tank:10,mp=/data',
|
|
26
|
+
};
|
|
27
|
+
type Seen = { method: string; path: string; auth: string | null; form: URLSearchParams };
|
|
28
|
+
const UPID = 'UPID:pve1:00000001:0:0:vzcreate:900:test@pve!lease:';
|
|
29
|
+
|
|
30
|
+
const transport = (
|
|
31
|
+
poll: () => Response = () => Response.json({ data: { status: 'stopped', exitstatus: 'OK' } }),
|
|
32
|
+
index: unknown = [],
|
|
33
|
+
) => {
|
|
34
|
+
const seen: Seen[] = [];
|
|
35
|
+
let mints = 0;
|
|
36
|
+
const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
37
|
+
const request =
|
|
38
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
39
|
+
const url = new URL(request.url);
|
|
40
|
+
if (url.pathname.includes('/creds/')) {
|
|
41
|
+
mints++;
|
|
42
|
+
return Response.json({
|
|
43
|
+
data: { secret: `fake-${mints}`, token_id: `test@pve!lease-${mints}` },
|
|
44
|
+
lease_duration: 0,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const body = await request.text();
|
|
48
|
+
if (request.method === 'GET') expect(body).toBe('');
|
|
49
|
+
const path = decodeURIComponent(url.pathname.replace(/^\/api2\/json\//, ''));
|
|
50
|
+
seen.push({
|
|
51
|
+
method: request.method,
|
|
52
|
+
path,
|
|
53
|
+
auth: request.headers.get('Authorization'),
|
|
54
|
+
form: new URLSearchParams(body),
|
|
55
|
+
});
|
|
56
|
+
if (path.endsWith('/status')) return poll();
|
|
57
|
+
if (request.method === 'GET' && path.endsWith('/config'))
|
|
58
|
+
return Response.json(
|
|
59
|
+
{ data: null, message: "Configuration file 'nodes/pve1/lxc/900.conf' does not exist\n" },
|
|
60
|
+
{ status: 500 },
|
|
61
|
+
);
|
|
62
|
+
if (path === 'cluster/resources') {
|
|
63
|
+
expect(url.searchParams.get('type')).toBe('vm');
|
|
64
|
+
return Response.json({ data: index });
|
|
65
|
+
}
|
|
66
|
+
return Response.json({ data: path.endsWith('/config') ? null : UPID });
|
|
67
|
+
};
|
|
68
|
+
const fake = Object.assign(stub, { preconnect: globalThis.fetch.preconnect }) as typeof fetch;
|
|
69
|
+
return {
|
|
70
|
+
seen,
|
|
71
|
+
mints: () => mints,
|
|
72
|
+
layer: FetchHttpClient.layer.pipe(
|
|
73
|
+
Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fake)),
|
|
74
|
+
),
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
test('missing config and permission-filtered guest index use one read credential', async () => {
|
|
79
|
+
const fake = transport();
|
|
80
|
+
const result = await withoutBao(() =>
|
|
81
|
+
Effect.runPromise(readLive(props).pipe(Effect.provide(fake.layer))),
|
|
82
|
+
);
|
|
83
|
+
expect(result).toBeUndefined();
|
|
84
|
+
expect(fake.mints()).toBe(1);
|
|
85
|
+
expect(fake.seen.map((call) => call.path)).toEqual([
|
|
86
|
+
'nodes/pve1/lxc/900/config',
|
|
87
|
+
'cluster/resources',
|
|
88
|
+
]);
|
|
89
|
+
expect(fake.seen[0]?.auth).toBe(fake.seen[1]?.auth);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('each task starts with a fresh credential and every poll keeps its owner', async () => {
|
|
93
|
+
const fake = transport();
|
|
94
|
+
await withoutBao(() =>
|
|
95
|
+
Effect.runPromise(
|
|
96
|
+
Effect.gen(function* () {
|
|
97
|
+
yield* createGuest(props);
|
|
98
|
+
yield* updateGuest(
|
|
99
|
+
props,
|
|
100
|
+
{ put: {}, clear: [], resize: [{ disk: 'rootfs', size: '12G' }], refuse: [], drift: [] },
|
|
101
|
+
'digest-live',
|
|
102
|
+
);
|
|
103
|
+
}).pipe(Effect.provide(fake.layer)),
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
expect(fake.mints()).toBe(2);
|
|
107
|
+
expect(fake.seen.map((call) => call.method)).toEqual(['POST', 'GET', 'PUT', 'GET']);
|
|
108
|
+
expect(fake.seen[0]?.auth).toBe(fake.seen[1]?.auth);
|
|
109
|
+
expect(fake.seen[2]?.auth).toBe(fake.seen[3]?.auth);
|
|
110
|
+
expect(fake.seen[0]?.auth).not.toBe(fake.seen[2]?.auth);
|
|
111
|
+
const form = fake.seen[0]?.form;
|
|
112
|
+
expect(form?.get('net0')).toBe(props.net0);
|
|
113
|
+
expect(form?.get('mp0')).toBe(props.mp0);
|
|
114
|
+
expect(form?.has('net[n]')).toBe(false);
|
|
115
|
+
expect(form?.has('node')).toBe(false);
|
|
116
|
+
expect(fake.seen[2]?.form.get('size')).toBe('12G');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('config updates retain the observed digest and concrete indexed form keys', async () => {
|
|
120
|
+
const fake = transport();
|
|
121
|
+
await withoutBao(() =>
|
|
122
|
+
Effect.runPromise(
|
|
123
|
+
updateGuest(
|
|
124
|
+
props,
|
|
125
|
+
{
|
|
126
|
+
put: { net1: 'name=eth1,bridge=vmbr1,ip=dhcp' },
|
|
127
|
+
clear: ['tags'],
|
|
128
|
+
resize: [],
|
|
129
|
+
refuse: [],
|
|
130
|
+
drift: [],
|
|
131
|
+
},
|
|
132
|
+
'observed-digest',
|
|
133
|
+
).pipe(Effect.provide(fake.layer)),
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
expect(fake.seen).toHaveLength(1);
|
|
137
|
+
const form = fake.seen[0]?.form;
|
|
138
|
+
expect(form?.get('digest')).toBe('observed-digest');
|
|
139
|
+
expect(form?.get('net1')).toBe('name=eth1,bridge=vmbr1,ip=dhcp');
|
|
140
|
+
expect(form?.get('delete')).toBe('tags');
|
|
141
|
+
expect(form?.has('vmid')).toBe(false);
|
|
142
|
+
expect(form?.has('node')).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
for (const [name, response] of [
|
|
146
|
+
['denied', () => Response.json({ data: null, message: 'denied' }, { status: 403 })],
|
|
147
|
+
['malformed', () => Response.json({ data: null })],
|
|
148
|
+
] as const) {
|
|
149
|
+
test(`${name} task polling reports uncertainty with UPID, never repeats POST`, async () => {
|
|
150
|
+
const fake = transport(response);
|
|
151
|
+
const error = await withoutBao(() =>
|
|
152
|
+
Effect.runPromise(createGuest(props).pipe(Effect.flip, Effect.provide(fake.layer))),
|
|
153
|
+
);
|
|
154
|
+
expect(String(error)).toContain(UPID);
|
|
155
|
+
expect(String(error)).toContain('task could not be read');
|
|
156
|
+
expect(fake.seen.map((call) => call.method)).toEqual(['POST', 'GET']);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
test('a still-running task stops at its bound with no second POST', async () => {
|
|
161
|
+
let polls = 0;
|
|
162
|
+
const fake = transport(() => {
|
|
163
|
+
polls++;
|
|
164
|
+
return Response.json({ data: { status: 'running' } });
|
|
165
|
+
});
|
|
166
|
+
const outcome = await withoutBao(() =>
|
|
167
|
+
Effect.runPromise(
|
|
168
|
+
Effect.gen(function* () {
|
|
169
|
+
const ready = yield* Deferred.make<void>();
|
|
170
|
+
// Start the virtual clock only after the SDK transport has completed its first response.
|
|
171
|
+
const observed = fake.layer;
|
|
172
|
+
const worker = yield* lxcTask(
|
|
173
|
+
props,
|
|
174
|
+
nodes.createNodeLxc({
|
|
175
|
+
node: props.node,
|
|
176
|
+
vmid: String(props.vmid),
|
|
177
|
+
ostemplate: props.ostemplate ?? '',
|
|
178
|
+
}),
|
|
179
|
+
'create test CT',
|
|
180
|
+
2,
|
|
181
|
+
).pipe(Effect.exit, Effect.provide(observed), Effect.forkChild);
|
|
182
|
+
const signal = yield* Effect.gen(function* () {
|
|
183
|
+
while (polls < 1) yield* Effect.yieldNow;
|
|
184
|
+
yield* Deferred.succeed(ready, undefined);
|
|
185
|
+
}).pipe(Effect.forkChild);
|
|
186
|
+
yield* Deferred.await(ready);
|
|
187
|
+
yield* TestClock.adjust('2 seconds');
|
|
188
|
+
yield* Fiber.join(signal);
|
|
189
|
+
return yield* Fiber.join(worker);
|
|
190
|
+
}).pipe(Effect.provide(TestClock.layer())),
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
expect(Exit.isFailure(outcome)).toBe(true);
|
|
194
|
+
expect(polls).toBe(2);
|
|
195
|
+
expect(fake.seen.filter((call) => call.method === 'POST')).toHaveLength(1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('a transitional status value keeps polling, never aborts like an unreadable poll', async () => {
|
|
199
|
+
// ⚠️ `GetNodeTaskStatusResponseStatus` TYPES `status` AS "running" | "stopped" BUT VALIDATES IT
|
|
200
|
+
// AT RUNTIME AS A PLAIN STRING — that union is not actually enforced on the wire, so a value
|
|
201
|
+
// this endpoint's real contract was never proven to exclude must be tolerated the same way
|
|
202
|
+
// `awaitTask` (network-apply-read.ts) tolerates it, rather than read as "task could not be
|
|
203
|
+
// read" and abort the deploy. The sibling malformed-answer tests above pin the other half:
|
|
204
|
+
// a response with no `status` field at all still aborts (2026-09-25 adversarial review).
|
|
205
|
+
let polls = 0;
|
|
206
|
+
const fake = transport(() => {
|
|
207
|
+
polls++;
|
|
208
|
+
return Response.json({
|
|
209
|
+
data: { status: polls === 1 ? 'queued' : 'stopped', exitstatus: 'OK' },
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
const outcome = await withoutBao(() =>
|
|
213
|
+
Effect.runPromise(
|
|
214
|
+
lxcTask(
|
|
215
|
+
props,
|
|
216
|
+
nodes.createNodeLxc({
|
|
217
|
+
node: props.node,
|
|
218
|
+
vmid: String(props.vmid),
|
|
219
|
+
ostemplate: props.ostemplate ?? '',
|
|
220
|
+
}),
|
|
221
|
+
'create test CT',
|
|
222
|
+
5,
|
|
223
|
+
).pipe(Effect.provide(fake.layer)),
|
|
224
|
+
),
|
|
225
|
+
);
|
|
226
|
+
expect(outcome).toBeUndefined();
|
|
227
|
+
expect(polls).toBe(2);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
for (const index of [null, [{}], [{ vmid: 'unknown', node: 'pve1', type: 'lxc' }]]) {
|
|
231
|
+
test(`malformed guest index ${JSON.stringify(index)} cannot prove absence`, async () => {
|
|
232
|
+
const fake = transport(undefined, index);
|
|
233
|
+
const error = await withoutBao(() =>
|
|
234
|
+
Effect.runPromise(readLive(props).pipe(Effect.flip, Effect.provide(fake.layer))),
|
|
235
|
+
);
|
|
236
|
+
expect(String(error)).toContain('did not identify its rows');
|
|
237
|
+
expect(fake.seen.every((call) => call.method === 'GET')).toBe(true);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PveTarget } from './credentials.ts';
|
|
2
|
+
|
|
3
|
+
/** A lifecycle refusal. Post-write refusals carry the task diagnostic, never imply rollback. */
|
|
4
|
+
export class LxcRefusedError extends Error {
|
|
5
|
+
override readonly name = 'LxcRefusedError';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Where one guest lives. Its identity, and nothing else. */
|
|
9
|
+
export type LxcWhere = { readonly target: PveTarget; readonly node: string; readonly vmid: number };
|
|
@@ -1,50 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The calls a `Proxmox.Lxc` makes: read one config, create, update in place, grow, delete.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* 403 or a node that did not answer plans a CREATE over a running container. PVE answers a
|
|
7
|
-
* missing guest with HTTP 500, `Configuration file 'nodes/<node>/lxc/<vmid>.conf' does not
|
|
8
|
-
* exist` (pve-guest-common `AbstractConfig::load_config`), so only a 500 may mean absent — and
|
|
9
|
-
* even then only after the cluster says so, below. Anything else fails the plan.
|
|
10
|
-
*
|
|
11
|
-
* ⛔ "ABSENT" IS DECIDED BY THE WHOLE CLUSTER, NOT BY THE ERROR TEXT. A config lives under its
|
|
12
|
-
* node, so a guest migrated to another node — or a QEMU VM holding the same cluster-wide vmid —
|
|
13
|
-
* also reads as missing at the declared path. `GET /cluster/resources?type=vm` settles it: the
|
|
14
|
-
* vmid nowhere is absent; the vmid anywhere else FAILS the read with where it is; the vmid on
|
|
15
|
-
* the declared node fails with PVE's own error. ★ Keyed on the cluster rather than on matching
|
|
16
|
-
* the message, so a PVE that words it differently still cannot turn into a create.
|
|
4
|
+
* ★ Discovery lives in lxc-read.ts; it proves precise absence with one read credential.
|
|
5
|
+
* Writes use named distilled operations, and lxc-task.ts preserves task ownership and bounds.
|
|
17
6
|
*
|
|
18
7
|
* ⚠️ CREATE, RESIZE AND DELETE ARE ASYNCHRONOUS; A CONFIG PUT IS NOT. The first three answer a
|
|
19
8
|
* UPID the moment PVE forks the worker (`NodesNodeLxcPostReturn`, `…ResizePutReturn`,
|
|
20
9
|
* `…VmidDeleteReturn` are all `string`), so each waits for its task's exitstatus. `PUT …/config`
|
|
21
10
|
* answers `null` after writing (`…ConfigPutReturn`), so it needs no wait.
|
|
22
|
-
* ⛔ EACH TASK IS POLLED WITH THE CREDENTIAL THAT STARTED IT — see `
|
|
11
|
+
* ⛔ EACH TASK IS POLLED WITH THE CREDENTIAL THAT STARTED IT — see `lxcTask`: only the owner
|
|
23
12
|
* skips `Sys.Audit`. A fresh `provision` mint per write gives the full 300s lease a create needs;
|
|
24
13
|
* a cached lease can be handed out with 60s left (lease-cache.ts).
|
|
25
14
|
*/
|
|
26
15
|
import * as Effect from 'effect/Effect';
|
|
27
|
-
import
|
|
16
|
+
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
17
|
+
import { runPve } from './distilled-pve.ts';
|
|
18
|
+
import { LxcRefusedError, type LxcWhere } from './lxc-errors.ts';
|
|
19
|
+
import { readLive } from './lxc-read.ts';
|
|
20
|
+
import { lxcTask } from './lxc-task.ts';
|
|
28
21
|
import { guardForm } from './constraint-guard.ts';
|
|
29
22
|
import type { EndpointKey } from './constraints.ts';
|
|
30
|
-
import type { PveTarget } from './credentials.ts';
|
|
31
|
-
import { mint } from './mint.ts';
|
|
32
23
|
import { createForm } from './lxc-create-form.ts';
|
|
33
24
|
import { type LxcChange, judge } from './lxc-judge.ts';
|
|
34
25
|
import type { LxcAttributes, LxcProps } from './lxc-props.ts';
|
|
35
26
|
import { rawKeysOf, storedConfig } from './lxc-props.ts';
|
|
36
|
-
import { awaitTask } from './network-apply-read.ts';
|
|
37
27
|
import { text, withClears } from './values.ts';
|
|
38
28
|
|
|
39
|
-
|
|
40
|
-
export
|
|
41
|
-
override readonly name = 'LxcRefusedError';
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Where one guest lives. Its identity, and nothing else. */
|
|
45
|
-
export type LxcWhere = { readonly target: PveTarget; readonly node: string; readonly vmid: number };
|
|
46
|
-
|
|
47
|
-
export const guestPath = (where: LxcWhere) => `nodes/${where.node}/lxc/${String(where.vmid)}`;
|
|
29
|
+
export { LxcRefusedError, type LxcWhere } from './lxc-errors.ts';
|
|
30
|
+
export { readLive } from './lxc-read.ts';
|
|
48
31
|
|
|
49
32
|
/**
|
|
50
33
|
* The three vendor endpoints a guest is written through, as the generated tables key them.
|
|
@@ -70,52 +53,6 @@ const LXC_ENDPOINTS = {
|
|
|
70
53
|
const CREATE_POLLS = 180;
|
|
71
54
|
const TASK_POLLS = 60;
|
|
72
55
|
|
|
73
|
-
const isServerError = (error: unknown): error is PveError =>
|
|
74
|
-
error instanceof PveError && error.status === 500;
|
|
75
|
-
|
|
76
|
-
/** One `GET /cluster/resources?type=vm` row. */
|
|
77
|
-
type GuestRow = { readonly vmid?: unknown; readonly node?: unknown; readonly type?: unknown };
|
|
78
|
-
|
|
79
|
-
/** The live config, or undefined when no guest holds this vmid anywhere in the cluster. */
|
|
80
|
-
export const readLive = (where: LxcWhere) =>
|
|
81
|
-
Effect.gen(function* () {
|
|
82
|
-
const answer = yield* pve<Record<string, unknown> | null>(
|
|
83
|
-
where.target,
|
|
84
|
-
'read',
|
|
85
|
-
'GET',
|
|
86
|
-
`${guestPath(where)}/config`,
|
|
87
|
-
).pipe(
|
|
88
|
-
Effect.map((data) => ({ data, error: undefined })),
|
|
89
|
-
Effect.catchIf(isServerError, (error) => Effect.succeed({ data: undefined, error })),
|
|
90
|
-
);
|
|
91
|
-
if (answer.data !== undefined && answer.data !== null) return answer.data;
|
|
92
|
-
const rows = yield* pve<readonly GuestRow[]>(
|
|
93
|
-
where.target,
|
|
94
|
-
'read',
|
|
95
|
-
'GET',
|
|
96
|
-
'cluster/resources?type=vm',
|
|
97
|
-
);
|
|
98
|
-
// ⚠️ `Number()`, NOT `===` ON THE RAW VALUE: a vmid that came back as `"900"` would otherwise
|
|
99
|
-
// match nothing, and "listed nowhere" is the one answer that turns into a create.
|
|
100
|
-
const found = (rows ?? []).find((row) => Number(row.vmid) === where.vmid);
|
|
101
|
-
if (found !== undefined && found.node === where.node && found.type === 'lxc') {
|
|
102
|
-
return yield* Effect.fail(
|
|
103
|
-
answer.error ??
|
|
104
|
-
new LxcRefusedError(`CT ${String(where.vmid)} is listed but its config read empty.`),
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
if (found !== undefined) {
|
|
108
|
-
return yield* Effect.fail(
|
|
109
|
-
new LxcRefusedError(
|
|
110
|
-
`vmid ${String(where.vmid)} is a ${text(found.type, 'guest')} on ` +
|
|
111
|
-
`${text(found.node, '?')}, not a container on ${where.node}. A migration is not an ` +
|
|
112
|
-
'update and vmids are cluster-wide: declare the node it is on, or pick a free vmid.',
|
|
113
|
-
),
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
return undefined;
|
|
117
|
-
});
|
|
118
|
-
|
|
119
56
|
export const attributesOf = (where: LxcWhere, live: Record<string, unknown>): LxcAttributes => ({
|
|
120
57
|
config: storedConfig(live),
|
|
121
58
|
node: where.node,
|
|
@@ -123,36 +60,6 @@ export const attributesOf = (where: LxcWhere, live: Record<string, unknown>): Lx
|
|
|
123
60
|
vmid: where.vmid,
|
|
124
61
|
});
|
|
125
62
|
|
|
126
|
-
/** Run one task-starting call and wait for it on the same credential. Fails unless `OK`. */
|
|
127
|
-
const task = (
|
|
128
|
-
where: LxcWhere,
|
|
129
|
-
method: 'POST' | 'PUT' | 'DELETE',
|
|
130
|
-
path: string,
|
|
131
|
-
what: string,
|
|
132
|
-
polls: number,
|
|
133
|
-
form?: Record<string, string>,
|
|
134
|
-
) =>
|
|
135
|
-
Effect.gen(function* () {
|
|
136
|
-
const credential = yield* mint(where.target, 'provision');
|
|
137
|
-
const upid = text(yield* pveWith<string>(where.target, credential, method, path, form));
|
|
138
|
-
if (upid === '') {
|
|
139
|
-
return yield* Effect.fail(
|
|
140
|
-
new LxcRefusedError(
|
|
141
|
-
`${what}: PVE returned no task id, so the write cannot be confirmed and is not claimed.`,
|
|
142
|
-
),
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
const exit = yield* awaitTask(where.target, credential, where.node, upid, polls);
|
|
146
|
-
if (exit !== 'OK') {
|
|
147
|
-
return yield* Effect.fail(
|
|
148
|
-
new LxcRefusedError(
|
|
149
|
-
`${what}: task ${upid} ended "${exit}". Read \`pvesh get /nodes/${where.node}/tasks/` +
|
|
150
|
-
`${upid}/log\` before running the deploy again.`,
|
|
151
|
-
),
|
|
152
|
-
);
|
|
153
|
-
}
|
|
154
|
-
});
|
|
155
|
-
|
|
156
63
|
/** POST the create and wait for the template to unpack. ⛔ Refusals are the caller's to check. */
|
|
157
64
|
export const createGuest = (props: LxcProps) =>
|
|
158
65
|
Effect.gen(function* () {
|
|
@@ -165,13 +72,16 @@ export const createGuest = (props: LxcProps) =>
|
|
|
165
72
|
* knew: `cores` 1..8192, `memory` ≥ 16, `hostname` as a dns-name, `tags`, `startup`.
|
|
166
73
|
*/
|
|
167
74
|
yield* guardForm(LXC_ENDPOINTS.create, form, true);
|
|
168
|
-
return yield*
|
|
75
|
+
return yield* lxcTask(
|
|
169
76
|
props,
|
|
170
|
-
|
|
171
|
-
|
|
77
|
+
nodes.createNodeLxc({
|
|
78
|
+
...form,
|
|
79
|
+
node: props.node,
|
|
80
|
+
vmid: String(props.vmid),
|
|
81
|
+
ostemplate: text(form['ostemplate']),
|
|
82
|
+
}),
|
|
172
83
|
`create CT ${String(props.vmid)}`,
|
|
173
84
|
CREATE_POLLS,
|
|
174
|
-
form,
|
|
175
85
|
);
|
|
176
86
|
});
|
|
177
87
|
|
|
@@ -194,20 +104,23 @@ export const updateGuest = (props: LxcProps, change: LxcChange, digest: string)
|
|
|
194
104
|
);
|
|
195
105
|
/** ⚠️ NO PRESENCE: this form is partial by construction — it is `judge`'s answer. */
|
|
196
106
|
yield* guardForm(LXC_ENDPOINTS.update, form, false);
|
|
197
|
-
yield*
|
|
107
|
+
yield* runPve(
|
|
108
|
+
props.target,
|
|
109
|
+
'provision',
|
|
110
|
+
true,
|
|
111
|
+
nodes.putNodeLxcConfig({ ...form, node: props.node, vmid: String(props.vmid) }),
|
|
112
|
+
);
|
|
198
113
|
}
|
|
199
114
|
for (const grow of change.resize) {
|
|
200
115
|
const grown = { disk: grow.disk, size: grow.size };
|
|
201
116
|
// ⛔ WITH PRESENCE: `disk` and `size` are both required and both always sent, so this is a
|
|
202
117
|
// value check over PVE's own `+<n>` size pattern and its `rootfs|mp0..mp255` enum.
|
|
203
118
|
yield* guardForm(LXC_ENDPOINTS.resize, grown, true);
|
|
204
|
-
yield*
|
|
119
|
+
yield* lxcTask(
|
|
205
120
|
props,
|
|
206
|
-
|
|
207
|
-
`${guestPath(props)}/resize`,
|
|
121
|
+
nodes.putNodeLxcResize({ ...grown, node: props.node, vmid: String(props.vmid) }),
|
|
208
122
|
`grow ${grow.disk} of CT ${String(props.vmid)} to ${grow.size}`,
|
|
209
123
|
TASK_POLLS,
|
|
210
|
-
grown,
|
|
211
124
|
);
|
|
212
125
|
}
|
|
213
126
|
});
|
|
@@ -236,5 +149,10 @@ export const destroyGuest = (where: LxcWhere, declared: LxcProps) =>
|
|
|
236
149
|
),
|
|
237
150
|
);
|
|
238
151
|
}
|
|
239
|
-
yield*
|
|
152
|
+
yield* lxcTask(
|
|
153
|
+
where,
|
|
154
|
+
nodes.deleteNodeLxc({ node: where.node, vmid: String(where.vmid) }),
|
|
155
|
+
`destroy CT ${String(where.vmid)}`,
|
|
156
|
+
TASK_POLLS,
|
|
157
|
+
);
|
|
240
158
|
});
|