@homeflare/alchemy 0.37.5 → 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/openbao/auth-role-form.d.ts +0 -6
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role-identity.d.ts +11 -0
- package/dist/openbao/auth-role-identity.d.ts.map +1 -0
- package/dist/openbao/auth-role-wire.d.ts +23 -0
- package/dist/openbao/auth-role-wire.d.ts.map +1 -0
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/bao-http.d.ts +34 -13
- package/dist/openbao/bao-http.d.ts.map +1 -1
- package/dist/openbao/distilled.d.ts +25 -0
- package/dist/openbao/distilled.d.ts.map +1 -0
- package/dist/openbao/fake-engines-roles.d.ts +5 -1
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -1
- package/dist/openbao/index.js +358 -305
- package/dist/openbao/index.js.map +13 -9
- package/dist/openbao/policy-identity.d.ts +12 -0
- package/dist/openbao/policy-identity.d.ts.map +1 -0
- package/dist/openbao/policy-wire.d.ts +9 -8
- package/dist/openbao/policy-wire.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/rename.d.ts +5 -0
- package/dist/openbao/rename.d.ts.map +1 -1
- 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 +24 -11
- 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/node-network-wire.d.ts +14 -10
- package/dist/proxmox/node-network-wire.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/docs/upstream-conformance.md +5 -3
- package/package.json +3 -2
- package/src/openbao/auth-role-form.test.ts +11 -9
- package/src/openbao/auth-role-form.ts +0 -19
- package/src/openbao/auth-role-identity.ts +24 -0
- package/src/openbao/auth-role-sdk.test.ts +155 -0
- package/src/openbao/auth-role-wire.test.ts +128 -0
- package/src/openbao/auth-role-wire.ts +89 -0
- package/src/openbao/auth-role.ts +9 -9
- package/src/openbao/bao-http.ts +9 -7
- package/src/openbao/distilled-retry.test.ts +164 -0
- package/src/openbao/distilled.test.ts +146 -0
- package/src/openbao/distilled.ts +88 -0
- package/src/openbao/fake-engines-roles.ts +9 -2
- package/src/openbao/policy-distilled.test.ts +98 -0
- package/src/openbao/policy-identity.ts +24 -0
- package/src/openbao/policy-rename.test.ts +2 -2
- package/src/openbao/policy-wire.test.ts +5 -5
- package/src/openbao/policy-wire.ts +31 -11
- package/src/openbao/policy.ts +10 -13
- package/src/openbao/rename.ts +17 -1
- 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/node-network-read-failure.test.ts +24 -0
- package/src/proxmox/node-network-wire.ts +15 -17
- 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,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One QEMU config read, then a cluster-wide vmid check when that file is absent.
|
|
3
|
+
* ⛔ QemuConfigNotFound means the file is missing on THIS node. AbstractConfig raises it
|
|
4
|
+
* before any other guest type is considered. A vmid is shared with containers, so absence
|
|
5
|
+
* here is not permission to POST until the same credential's guest index agrees.
|
|
6
|
+
*/
|
|
7
|
+
import * as cluster from '@distilled.cloud/proxmox/cluster';
|
|
8
|
+
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import { runPveWith } from './distilled-pve.ts';
|
|
11
|
+
import { leased } from './lease-cache.ts';
|
|
12
|
+
import { QemuRefusedError } from './qemu-errors.ts';
|
|
13
|
+
import type { VmProps } from './qemu.ts';
|
|
14
|
+
import { text } from './values.ts';
|
|
15
|
+
|
|
16
|
+
const guestTypes = ['lxc', 'qemu'] as const;
|
|
17
|
+
|
|
18
|
+
export const readVm = (props: VmProps) =>
|
|
19
|
+
Effect.gen(function* () {
|
|
20
|
+
const credential = yield* leased(props.target, 'read');
|
|
21
|
+
const answer = yield* runPveWith(
|
|
22
|
+
props.target,
|
|
23
|
+
credential,
|
|
24
|
+
false,
|
|
25
|
+
nodes.getNodeQemuConfig({ node: props.node, vmid: String(props.vmid) }),
|
|
26
|
+
).pipe(
|
|
27
|
+
Effect.map((data) => ({ data, error: undefined })),
|
|
28
|
+
Effect.catchTag('QemuConfigNotFound', (error) => Effect.succeed({ data: undefined, error })),
|
|
29
|
+
);
|
|
30
|
+
if (answer.error === undefined) {
|
|
31
|
+
const live = answer.data;
|
|
32
|
+
// ⛔ The vendor requires digest. Null or an empty object is not absence.
|
|
33
|
+
if (typeof live.digest !== 'string' || live.digest === '') {
|
|
34
|
+
return yield* Effect.fail(
|
|
35
|
+
new QemuRefusedError(
|
|
36
|
+
`VM ${String(props.vmid)}: config read did not return a digest-bearing config.`,
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return live;
|
|
41
|
+
}
|
|
42
|
+
const rows = yield* runPveWith(
|
|
43
|
+
props.target,
|
|
44
|
+
credential,
|
|
45
|
+
false,
|
|
46
|
+
cluster.listClusterResources({ type: 'vm' }),
|
|
47
|
+
);
|
|
48
|
+
if (
|
|
49
|
+
!Array.isArray(rows) ||
|
|
50
|
+
rows.some(
|
|
51
|
+
(row) =>
|
|
52
|
+
!Number.isSafeInteger(Number(row?.vmid)) ||
|
|
53
|
+
Number(row?.vmid) <= 0 ||
|
|
54
|
+
typeof row?.node !== 'string' ||
|
|
55
|
+
row.node === '' ||
|
|
56
|
+
!guestTypes.includes(row.type as (typeof guestTypes)[number]),
|
|
57
|
+
)
|
|
58
|
+
) {
|
|
59
|
+
return yield* Effect.fail(
|
|
60
|
+
new QemuRefusedError('The cluster guest index did not identify its rows.'),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const found = rows.find((row) => Number(row.vmid) === props.vmid);
|
|
64
|
+
if (found === undefined) return undefined;
|
|
65
|
+
if (found.node === props.node && found.type === 'qemu') return yield* Effect.fail(answer.error);
|
|
66
|
+
return yield* Effect.fail(
|
|
67
|
+
new QemuRefusedError(
|
|
68
|
+
`vmid ${String(props.vmid)} is a ${text(found.type, 'guest')} on ` +
|
|
69
|
+
`${text(found.node, '?')}, not a VM on ${props.node}. A migration is not an ` +
|
|
70
|
+
'update and vmids are cluster-wide: declare the node it is on, or pick a free vmid.',
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Bounded QEMU task observation on the credential that started the worker. */
|
|
2
|
+
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
3
|
+
import type { ProxmoxOpContext } from '@distilled.cloud/proxmox/Protocol';
|
|
4
|
+
import * as Effect from 'effect/Effect';
|
|
5
|
+
import { runPveWith } from './distilled-pve.ts';
|
|
6
|
+
import { mint } from './mint.ts';
|
|
7
|
+
import { QemuRefusedError } from './qemu-errors.ts';
|
|
8
|
+
import type { VmProps } from './qemu.ts';
|
|
9
|
+
import { text } from './values.ts';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* ⛔ Fresh provision mint, then the same lease for every poll. A timed-out poll is uncertainty,
|
|
13
|
+
* never a reason to send the write again.
|
|
14
|
+
*/
|
|
15
|
+
export const qemuTask = <E>(
|
|
16
|
+
props: VmProps,
|
|
17
|
+
operation: Effect.Effect<string, E, ProxmoxOpContext>,
|
|
18
|
+
what: string,
|
|
19
|
+
polls: number,
|
|
20
|
+
) =>
|
|
21
|
+
Effect.gen(function* () {
|
|
22
|
+
const credential = yield* mint(props.target, 'provision');
|
|
23
|
+
const upid = text(yield* runPveWith(props.target, credential, true, operation));
|
|
24
|
+
if (upid === '') {
|
|
25
|
+
return yield* Effect.fail(
|
|
26
|
+
new QemuRefusedError(
|
|
27
|
+
`${what}: PVE returned no task id, so the write cannot be confirmed and is not claimed.`,
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
let exit = 'still running when this provider stopped waiting';
|
|
32
|
+
for (let attempt = 0; attempt < polls; attempt++) {
|
|
33
|
+
const status = yield* runPveWith(
|
|
34
|
+
props.target,
|
|
35
|
+
credential,
|
|
36
|
+
false,
|
|
37
|
+
nodes.getNodeTaskStatus({ node: props.node, upid }),
|
|
38
|
+
).pipe(Effect.orElseSucceed(() => undefined));
|
|
39
|
+
// ⚠️ "COULD NOT BE READ" IS A MISSING ANSWER OR A MISSING `status` FIELD, NOT AN UNEXPECTED
|
|
40
|
+
// ONE. `GetNodeTaskStatusResponseStatus` types `status` as the closed union
|
|
41
|
+
// `"running" | "stopped"`, but the runtime validator is `S.String` (measured in
|
|
42
|
+
// distilled-proxmox's nodes.ts), so a well-formed answer with a status word this
|
|
43
|
+
// endpoint's contract was never proven to exclude (something other than exactly
|
|
44
|
+
// "running") must keep polling rather than abort, since only "stopped" ends the task
|
|
45
|
+
// (2026-09-25 adversarial review; matches lxc-task.ts and network-apply-read.ts's
|
|
46
|
+
// awaitTask).
|
|
47
|
+
if (status === undefined || status.status === undefined) {
|
|
48
|
+
exit = 'the task could not be read';
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
if (status.status === 'stopped') {
|
|
52
|
+
exit = text(status.exitstatus, 'stopped with no exitstatus');
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
yield* Effect.sleep('1 second');
|
|
56
|
+
}
|
|
57
|
+
if (exit !== 'OK') {
|
|
58
|
+
return yield* Effect.fail(
|
|
59
|
+
new QemuRefusedError(
|
|
60
|
+
`${what}: task ${upid} ended "${exit}". Read the task log before running the deploy again.`,
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
});
|
package/src/proxmox/qemu.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `Proxmox.Vm` — a QEMU virtual machine, declared.
|
|
3
3
|
*
|
|
4
|
-
* ⚠️
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* ⚠️ CREATE is `POST /nodes/{node}/qemu`. GET and PUT stay on `.../qemu/{vmid}/config`.
|
|
5
|
+
* ⛔ DELETE is `DELETE /nodes/{node}/qemu/{vmid}` (`destroy_vm` in qemu-server Qemu.pm). The
|
|
6
|
+
* config route is not a destroy. A missing config file is `QemuConfigNotFound` only.
|
|
7
7
|
*
|
|
8
8
|
* ⛔ `vmid` IS CLUSTER-WIDE AND SHARED WITH CONTAINERS. A VM and an LXC cannot hold the same id, so
|
|
9
9
|
* these two resources compete for one number space. Declaring both with vmid 101 is not a
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
import { Resource } from 'alchemy';
|
|
18
18
|
import * as Provider from 'alchemy/Provider';
|
|
19
19
|
import * as Effect from 'effect/Effect';
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
20
|
+
import { qemuHandlers } from './qemu-lifecycle.ts';
|
|
21
|
+
import type { PveRequirements, WithTarget } from './resource.ts';
|
|
22
22
|
|
|
23
23
|
export interface VmProps extends WithTarget {
|
|
24
24
|
node: string;
|
|
@@ -54,44 +54,11 @@ export interface ProxmoxVm extends Resource<
|
|
|
54
54
|
|
|
55
55
|
export const ProxmoxVm = Resource<ProxmoxVm>('Proxmox.Vm');
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
export const createForm = (props: VmProps) => ({ ...shape(props), vmid: String(props.vmid) });
|
|
59
|
-
|
|
60
|
-
const shape = (props: VmProps) => ({
|
|
61
|
-
cores: String(props.cores ?? 1),
|
|
62
|
-
memory: String(props.memory ?? 512),
|
|
63
|
-
name: props.name ?? `vm${String(props.vmid)}`,
|
|
64
|
-
onboot: props.onboot === true ? '1' : '0',
|
|
65
|
-
sockets: String(props.sockets ?? 1),
|
|
66
|
-
...(props.net0 === undefined ? {} : { net0: props.net0 }),
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
const handlers = pveHandlers<VmProps, VmAttributes>({
|
|
70
|
-
attributes: (live, props) => ({
|
|
71
|
-
cores: num(live['cores'], 1),
|
|
72
|
-
memory: num(live['memory'], 512),
|
|
73
|
-
name: typeof live['name'] === 'string' ? live['name'] : '',
|
|
74
|
-
node: props.node,
|
|
75
|
-
onboot: live['onboot'] === 1 || live['onboot'] === true,
|
|
76
|
-
sockets: num(live['sockets'], 1),
|
|
77
|
-
vmid: props.vmid,
|
|
78
|
-
}),
|
|
79
|
-
collection: (props) => `nodes/${props.node}/qemu`,
|
|
80
|
-
createForm,
|
|
81
|
-
/** The vendor rules both forms are checked against at plan time — resource-spec.ts. */
|
|
82
|
-
endpoint: {
|
|
83
|
-
create: 'pve:POST /nodes/{node}/qemu',
|
|
84
|
-
update: 'pve:PUT /nodes/{node}/qemu/{vmid}/config',
|
|
85
|
-
},
|
|
86
|
-
matches: (attributes, props) =>
|
|
87
|
-
attributes.name === (props.name ?? `vm${String(props.vmid)}`) &&
|
|
88
|
-
attributes.memory === (props.memory ?? 512) &&
|
|
89
|
-
attributes.cores === (props.cores ?? 1) &&
|
|
90
|
-
attributes.sockets === (props.sockets ?? 1) &&
|
|
91
|
-
attributes.onboot === (props.onboot === true),
|
|
92
|
-
path: (props) => `nodes/${props.node}/qemu/${String(props.vmid)}/config`,
|
|
93
|
-
updateForm: shape,
|
|
94
|
-
});
|
|
57
|
+
export { createForm } from './qemu-form.ts';
|
|
95
58
|
|
|
59
|
+
/**
|
|
60
|
+
* ⛔ Destroy uses deleteNodeQemu (`DELETE /nodes/{node}/qemu/{vmid}`), not the config path.
|
|
61
|
+
* The old factory sent both reads and deletes to `.../config`.
|
|
62
|
+
*/
|
|
96
63
|
export const ProxmoxVmProvider = () =>
|
|
97
|
-
Provider.effect(ProxmoxVm, Effect.succeed(ProxmoxVm.Provider.of(
|
|
64
|
+
Provider.effect(ProxmoxVm, Effect.succeed(ProxmoxVm.Provider.of(qemuHandlers)));
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** Source-backed vendor responses; no live host or credential is used by these tests. */
|
|
2
|
+
import * as Layer from 'effect/Layer';
|
|
3
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
4
|
+
import { FAKE_TARGET } from './fake-pve.ts';
|
|
5
|
+
import type { CoreCase } from './live-core-fixtures.ts';
|
|
6
|
+
import { ProxmoxReplicationJob, ProxmoxReplicationJobProvider } from './replication-job.ts';
|
|
7
|
+
import { deleteReplicationJob, readReplicationJob } from './replication-job-lifecycle.ts';
|
|
8
|
+
import { ProxmoxFirewallAlias, ProxmoxFirewallAliasProvider } from './firewall-alias.ts';
|
|
9
|
+
import { deleteFirewallAlias, readFirewallAlias } from './firewall-alias-lifecycle.ts';
|
|
10
|
+
|
|
11
|
+
export const replicationProps = { target: FAKE_TARGET, guest: 900, jobnum: 0, targetNode: 'pve-b' };
|
|
12
|
+
export const aliasProps = {
|
|
13
|
+
target: FAKE_TARGET,
|
|
14
|
+
name: 'HomeLan',
|
|
15
|
+
cidr: '192.0.2.1/32',
|
|
16
|
+
comment: ' 0 ',
|
|
17
|
+
};
|
|
18
|
+
export const replicationLive = {
|
|
19
|
+
id: '900-0',
|
|
20
|
+
guest: 900,
|
|
21
|
+
jobnum: 0,
|
|
22
|
+
target: 'pve-b',
|
|
23
|
+
type: 'local',
|
|
24
|
+
source: 'pve-a',
|
|
25
|
+
};
|
|
26
|
+
export const aliasLive = { name: 'homelan', cidr: '192.0.2.1', ipversion: 4 };
|
|
27
|
+
export const replicationMissing = () =>
|
|
28
|
+
Response.json({ data: null, message: "no such replication job '900-0'\n" }, { status: 500 });
|
|
29
|
+
export const aliasMissing = () =>
|
|
30
|
+
Response.json(
|
|
31
|
+
{ data: null, message: 'Parameter verification failed.\n', errors: { name: 'no such alias' } },
|
|
32
|
+
{ status: 400 },
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
export const replicationFirewallCases: CoreCase[] = [
|
|
36
|
+
{
|
|
37
|
+
name: 'ReplicationJob',
|
|
38
|
+
path: 'cluster/replication/900-0',
|
|
39
|
+
createPath: 'cluster/replication',
|
|
40
|
+
declare: () => ProxmoxReplicationJob('row', replicationProps),
|
|
41
|
+
engine: (fake) =>
|
|
42
|
+
engineOver(ProxmoxReplicationJobProvider().pipe(Layer.provideMerge(fake.layer))),
|
|
43
|
+
read: () => readReplicationJob(replicationProps),
|
|
44
|
+
remove: () => deleteReplicationJob(replicationProps),
|
|
45
|
+
live: replicationLive,
|
|
46
|
+
drift: { ...replicationLive, remove_job: 'full' },
|
|
47
|
+
missing: replicationMissing,
|
|
48
|
+
createForm: {
|
|
49
|
+
id: '900-0',
|
|
50
|
+
type: 'local',
|
|
51
|
+
target: 'pve-b',
|
|
52
|
+
comment: '',
|
|
53
|
+
disable: '0',
|
|
54
|
+
schedule: '*/15',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'FirewallAlias',
|
|
59
|
+
path: 'cluster/firewall/aliases/HomeLan',
|
|
60
|
+
createPath: 'cluster/firewall/aliases',
|
|
61
|
+
declare: () => ProxmoxFirewallAlias('row', aliasProps),
|
|
62
|
+
engine: (fake) =>
|
|
63
|
+
engineOver(ProxmoxFirewallAliasProvider().pipe(Layer.provideMerge(fake.layer))),
|
|
64
|
+
read: () => readFirewallAlias(aliasProps),
|
|
65
|
+
remove: () => deleteFirewallAlias(aliasProps),
|
|
66
|
+
live: aliasLive,
|
|
67
|
+
drift: { ...aliasLive, comment: 'old' },
|
|
68
|
+
missing: aliasMissing,
|
|
69
|
+
createForm: { name: 'HomeLan', cidr: '192.0.2.1' },
|
|
70
|
+
},
|
|
71
|
+
];
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** Real Alchemy Plan/Apply and distilled requests; fixtures never reach a real host. */
|
|
2
|
+
import { describe, expect, test } from 'bun:test';
|
|
3
|
+
import * as Effect from 'effect/Effect';
|
|
4
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
5
|
+
import { fakePve, withoutBao } from './fake-pve.ts';
|
|
6
|
+
import { replicationFirewallCases } from './replication-firewall-fixtures.ts';
|
|
7
|
+
|
|
8
|
+
for (const entry of replicationFirewallCases) {
|
|
9
|
+
describe(`distilled ${entry.name}`, () => {
|
|
10
|
+
test('adoption, subsequent plan and apply have zero writes', async () => {
|
|
11
|
+
const fake = fakePve(() => entry.live);
|
|
12
|
+
await withoutBao(async () => {
|
|
13
|
+
const engine = entry.engine(fake);
|
|
14
|
+
expect((await engine.verify(entry.declare(), { all: true })).rows).toEqual([
|
|
15
|
+
expect.objectContaining({ diff: 'noop', ok: true }),
|
|
16
|
+
]);
|
|
17
|
+
expect(await engine.deploy(entry.declare())).toEqual({ row: 'adopted' });
|
|
18
|
+
expect((await engine.verify(entry.declare(), { all: true })).rows).toEqual([
|
|
19
|
+
expect.objectContaining({ diff: 'noop', ok: true }),
|
|
20
|
+
]);
|
|
21
|
+
await engine.deploy(entry.declare());
|
|
22
|
+
expect(engine.stored()).not.toContain('fake-not-a-secret');
|
|
23
|
+
});
|
|
24
|
+
expect(fake.writes()).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('genuine absence creates once, then reads and converges', async () => {
|
|
28
|
+
let exists = false;
|
|
29
|
+
const fake = fakePve((call) => {
|
|
30
|
+
if (call.method === 'GET') return exists ? entry.live : entry.missing();
|
|
31
|
+
exists = true;
|
|
32
|
+
return null;
|
|
33
|
+
});
|
|
34
|
+
await withoutBao(async () => {
|
|
35
|
+
const engine = entry.engine(fake);
|
|
36
|
+
await engine.deploy(entry.declare());
|
|
37
|
+
expect((await engine.verify(entry.declare(), { all: true })).rows[0]).toMatchObject({
|
|
38
|
+
diff: 'noop',
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
expect(fake.writes()).toEqual([`POST ${entry.createPath}`]);
|
|
42
|
+
expect(fake.calls.find((call) => call.method === 'POST')?.form).toEqual(entry.createForm);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('live drift produces exactly one PUT and preserves omitted fields', async () => {
|
|
46
|
+
let live = entry.drift;
|
|
47
|
+
const fake = fakePve((call) => {
|
|
48
|
+
if (call.method === 'GET') return live;
|
|
49
|
+
live = entry.live;
|
|
50
|
+
return null;
|
|
51
|
+
});
|
|
52
|
+
await withoutBao(async () => {
|
|
53
|
+
const engine = entry.engine(fake);
|
|
54
|
+
expect((await engine.verify(entry.declare(), { all: true })).rows[0]).toMatchObject({
|
|
55
|
+
diff: 'update',
|
|
56
|
+
});
|
|
57
|
+
await engine.deploy(entry.declare());
|
|
58
|
+
expect((await engine.verify(entry.declare(), { all: true })).rows[0]).toMatchObject({
|
|
59
|
+
diff: 'noop',
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
expect(fake.writes()).toEqual([`PUT ${entry.path}`]);
|
|
63
|
+
const form = fake.calls.find((call) => call.method === 'PUT')?.form;
|
|
64
|
+
if (entry.name === 'ReplicationJob') {
|
|
65
|
+
expect(form).toEqual({ comment: '', disable: '0', schedule: '*/15', delete: 'remove_job' });
|
|
66
|
+
} else {
|
|
67
|
+
// PUT replaces the alias entry: omitted comment clears it, no delete/rename/id fields.
|
|
68
|
+
expect(form).toEqual({ cidr: '192.0.2.1' });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
for (const status of [401, 403, 500]) {
|
|
73
|
+
test(`${status} read failure stops cold adoption and confirmed drift before writes`, async () => {
|
|
74
|
+
let fail = true;
|
|
75
|
+
const fake = fakePve(() =>
|
|
76
|
+
fail
|
|
77
|
+
? Response.json({ data: null, message: 'unrelated failure' }, { status })
|
|
78
|
+
: entry.live,
|
|
79
|
+
);
|
|
80
|
+
await withoutBao(async () => {
|
|
81
|
+
const engine = entry.engine(fake);
|
|
82
|
+
await expect(engine.deploy(entry.declare())).rejects.toBeDefined();
|
|
83
|
+
fail = false;
|
|
84
|
+
await engine.deploy(entry.declare());
|
|
85
|
+
fail = true;
|
|
86
|
+
await expect(engine.drift(entry.declare())).rejects.toBeDefined();
|
|
87
|
+
await expect(engine.deploy(entry.declare())).rejects.toBeDefined();
|
|
88
|
+
});
|
|
89
|
+
expect(fake.writes()).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
test('delete failure retains the engine state and propagates', async () => {
|
|
94
|
+
let failing = false;
|
|
95
|
+
const fake = fakePve(() =>
|
|
96
|
+
failing
|
|
97
|
+
? Response.json({ data: null, message: 'permission denied' }, { status: 403 })
|
|
98
|
+
: entry.live,
|
|
99
|
+
);
|
|
100
|
+
await withoutBao(async () => {
|
|
101
|
+
const engine = entry.engine(fake);
|
|
102
|
+
await engine.deploy(entry.declare().pipe(RemovalPolicy.destroy()));
|
|
103
|
+
failing = true;
|
|
104
|
+
await expect(engine.deploy(Effect.void)).rejects.toBeDefined();
|
|
105
|
+
expect(engine.stored()).toContain('row');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/** Exact absence, state identity and deletion semantics through the real distilled protocol. */
|
|
2
|
+
import { expect, test } from 'bun:test';
|
|
3
|
+
import * as Effect from 'effect/Effect';
|
|
4
|
+
import * as Layer from 'effect/Layer';
|
|
5
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
6
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
7
|
+
import { fakePve, withoutBao } from './fake-pve.ts';
|
|
8
|
+
import {
|
|
9
|
+
aliasLive,
|
|
10
|
+
aliasProps,
|
|
11
|
+
replicationFirewallCases,
|
|
12
|
+
replicationLive,
|
|
13
|
+
replicationProps,
|
|
14
|
+
} from './replication-firewall-fixtures.ts';
|
|
15
|
+
import { ProxmoxFirewallAlias, ProxmoxFirewallAliasProvider } from './firewall-alias.ts';
|
|
16
|
+
import { ProxmoxReplicationJob, ProxmoxReplicationJobProvider } from './replication-job.ts';
|
|
17
|
+
|
|
18
|
+
for (const entry of replicationFirewallCases) {
|
|
19
|
+
test(`${entry.name}: malformed successful reads never authorize a create`, async () => {
|
|
20
|
+
for (const live of [null, {}, [], 'invalid', { ...entry.live, name: 'wrong', id: '901-0' }]) {
|
|
21
|
+
const fake = fakePve(() => live);
|
|
22
|
+
await withoutBao(async () => {
|
|
23
|
+
expect(
|
|
24
|
+
await Effect.runPromise(entry.read().pipe(Effect.flip, Effect.provide(fake.layer))),
|
|
25
|
+
).toMatchObject({ _tag: 'ProxmoxParseError', body: undefined });
|
|
26
|
+
await expect(entry.engine(fake).deploy(entry.declare())).rejects.toBeDefined();
|
|
27
|
+
});
|
|
28
|
+
expect(fake.writes()).toEqual([]);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test(`${entry.name}: only source-backed absence is folded`, async () => {
|
|
33
|
+
const fake = fakePve(entry.missing);
|
|
34
|
+
await withoutBao(async () => {
|
|
35
|
+
expect(
|
|
36
|
+
await Effect.runPromise(entry.read().pipe(Effect.provide(fake.layer))),
|
|
37
|
+
).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
for (const [status, tag] of [
|
|
40
|
+
[401, 'Unauthorized'],
|
|
41
|
+
[403, 'Forbidden'],
|
|
42
|
+
[500, 'InternalServerError'],
|
|
43
|
+
] as const) {
|
|
44
|
+
const failed = fakePve(() =>
|
|
45
|
+
Response.json({ data: null, message: 'unrelated failure' }, { status }),
|
|
46
|
+
);
|
|
47
|
+
await withoutBao(async () => {
|
|
48
|
+
expect(
|
|
49
|
+
await Effect.runPromise(entry.read().pipe(Effect.flip, Effect.provide(failed.layer))),
|
|
50
|
+
).toMatchObject({ _tag: tag });
|
|
51
|
+
expect(
|
|
52
|
+
await Effect.runPromise(entry.remove().pipe(Effect.flip, Effect.provide(failed.layer))),
|
|
53
|
+
).toMatchObject({ _tag: tag });
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test(`${entry.name}: deletion succeeds when already absent`, async () => {
|
|
59
|
+
const fake = fakePve(() =>
|
|
60
|
+
entry.name === 'ReplicationJob'
|
|
61
|
+
? Response.json({ data: null, message: "no such job '900-0'\n" }, { status: 500 })
|
|
62
|
+
: null,
|
|
63
|
+
);
|
|
64
|
+
await withoutBao(async () => {
|
|
65
|
+
await Effect.runPromise(entry.remove().pipe(Effect.provide(fake.layer)));
|
|
66
|
+
});
|
|
67
|
+
expect(fake.writes()).toEqual([`DELETE ${entry.path}`]);
|
|
68
|
+
expect(fake.calls[0]?.form).toEqual({});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test(`${entry.name}: explicit retain avoids DELETE, default destroy is implemented`, async () => {
|
|
72
|
+
for (const retain of [true, false]) {
|
|
73
|
+
const fake = fakePve((call) => (call.method === 'GET' ? entry.live : null));
|
|
74
|
+
await withoutBao(async () => {
|
|
75
|
+
const engine = entry.engine(fake);
|
|
76
|
+
await engine.deploy(
|
|
77
|
+
retain ? entry.declare().pipe(RemovalPolicy.retain()) : entry.declare(),
|
|
78
|
+
);
|
|
79
|
+
await engine.deploy(Effect.void);
|
|
80
|
+
});
|
|
81
|
+
expect(fake.writes()).toEqual(retain ? [] : [`DELETE ${entry.path}`]);
|
|
82
|
+
expect(fake.calls.find((call) => call.method === 'DELETE')?.form ?? {}).toEqual({});
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
test('alias rename is create-first replacement; case-only rename remains no-op', async () => {
|
|
88
|
+
const rows = new Map([['homelan', aliasLive]]);
|
|
89
|
+
const fake = fakePve((call) => {
|
|
90
|
+
const name = call.path.split('/').at(-1)?.toLowerCase() ?? '';
|
|
91
|
+
if (call.method === 'GET')
|
|
92
|
+
return (
|
|
93
|
+
rows.get(name) ??
|
|
94
|
+
Response.json(
|
|
95
|
+
{
|
|
96
|
+
data: null,
|
|
97
|
+
message: 'Parameter verification failed.\n',
|
|
98
|
+
errors: { name: 'no such alias' },
|
|
99
|
+
},
|
|
100
|
+
{ status: 400 },
|
|
101
|
+
)
|
|
102
|
+
);
|
|
103
|
+
if (call.method === 'POST') {
|
|
104
|
+
const createdName = call.form['name'];
|
|
105
|
+
if (createdName === undefined) throw new Error('alias create omitted its required name');
|
|
106
|
+
rows.set(createdName.toLowerCase(), { ...aliasLive, name: createdName });
|
|
107
|
+
}
|
|
108
|
+
if (call.method === 'DELETE') rows.delete(name);
|
|
109
|
+
return null;
|
|
110
|
+
});
|
|
111
|
+
await withoutBao(async () => {
|
|
112
|
+
const engine = engineOver(ProxmoxFirewallAliasProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
113
|
+
await engine.deploy(ProxmoxFirewallAlias('row', aliasProps));
|
|
114
|
+
await engine.deploy(ProxmoxFirewallAlias('row', { ...aliasProps, name: 'HOMELAN' }));
|
|
115
|
+
expect(fake.writes()).toEqual([]);
|
|
116
|
+
await engine.deploy(ProxmoxFirewallAlias('row', { ...aliasProps, name: 'NewLan' }));
|
|
117
|
+
});
|
|
118
|
+
expect(fake.writes()).toEqual([
|
|
119
|
+
'POST cluster/firewall/aliases',
|
|
120
|
+
'DELETE cluster/firewall/aliases/HomeLan',
|
|
121
|
+
]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('replication ignores migrated source/target and undeclared fractional rate', async () => {
|
|
125
|
+
const fake = fakePve(() => ({
|
|
126
|
+
...replicationLive,
|
|
127
|
+
target: 'pve-c',
|
|
128
|
+
source: 'pve-b',
|
|
129
|
+
rate: '10.5',
|
|
130
|
+
}));
|
|
131
|
+
await withoutBao(async () => {
|
|
132
|
+
const engine = engineOver(ProxmoxReplicationJobProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
133
|
+
await engine.deploy(ProxmoxReplicationJob('row', replicationProps));
|
|
134
|
+
expect(
|
|
135
|
+
(
|
|
136
|
+
await engine.verify(ProxmoxReplicationJob('row', { ...replicationProps, rate: 10.5 }), {
|
|
137
|
+
all: true,
|
|
138
|
+
})
|
|
139
|
+
).rows[0],
|
|
140
|
+
).toMatchObject({ diff: 'noop' });
|
|
141
|
+
});
|
|
142
|
+
expect(fake.writes()).toEqual([]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('replication tolerates guest/jobnum echoed as text, not just as numbers', async () => {
|
|
146
|
+
// ⚠️ SectionConfig releases can echo `guest`/`jobnum` as text the same way `rate` sometimes
|
|
147
|
+
// arrives as text above; a strict `===` against the raw wire value would misread the exact
|
|
148
|
+
// same job as an identity mismatch and fail the read closed (2026-09-25 adversarial review).
|
|
149
|
+
const fake = fakePve(() => ({ ...replicationLive, guest: '900', jobnum: '0' }));
|
|
150
|
+
await withoutBao(async () => {
|
|
151
|
+
const engine = engineOver(ProxmoxReplicationJobProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
152
|
+
expect(
|
|
153
|
+
(await engine.verify(ProxmoxReplicationJob('row', replicationProps), { all: true })).rows[0],
|
|
154
|
+
).toMatchObject({ diff: 'noop', ok: true });
|
|
155
|
+
});
|
|
156
|
+
expect(fake.writes()).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('vendor constraints reject invalid declarations before any write', async () => {
|
|
160
|
+
for (const replication of [true, false]) {
|
|
161
|
+
const fake = fakePve(() => (replication ? replicationLive : { ...aliasLive, name: 'x' }));
|
|
162
|
+
await withoutBao(async () => {
|
|
163
|
+
const engine = replication
|
|
164
|
+
? engineOver(ProxmoxReplicationJobProvider().pipe(Layer.provideMerge(fake.layer)))
|
|
165
|
+
: engineOver(ProxmoxFirewallAliasProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
166
|
+
const declaration = replication
|
|
167
|
+
? ProxmoxReplicationJob('row', { ...replicationProps, rate: 0 })
|
|
168
|
+
: ProxmoxFirewallAlias('row', { ...aliasProps, name: 'x' });
|
|
169
|
+
await expect(engine.deploy(declaration)).rejects.toBeDefined();
|
|
170
|
+
});
|
|
171
|
+
expect(fake.writes()).toEqual([]);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Existing comparison and form semantics, separated from the named SDK transport. */
|
|
2
|
+
import type { ReplicationJobAttributes, ReplicationJobProps } from './replication-job.ts';
|
|
3
|
+
import type { PveSpec } from './resource-spec.ts';
|
|
4
|
+
import { DEFAULT_SCHEDULE, createBody, jobId, updateBody } from './replication-job-form.ts';
|
|
5
|
+
import { bool, int, text } from './values.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* ⚠️ `rate` IS THE ONLY FLOAT IN THIS PACKAGE, so it gets a coercion of its own rather than a sixth
|
|
9
|
+
* entry in `values.ts` — the rule that module states for itself: a coercion only one PVE object
|
|
10
|
+
* needs belongs in that object's file, next to the field it serves. `int` cannot serve it, since
|
|
11
|
+
* `Number.parseInt('10.5')` is 10 and a rate silently rounded down is both a limit nobody chose
|
|
12
|
+
* and a comparison that never settles. It accepts a string for the reason `int` does: these are
|
|
13
|
+
* SectionConfig sections, and whether `10` arrives as a number is a property of the release.
|
|
14
|
+
* ⚠️ 0 MEANS UNSET, and is safe as the marker because PVE's schema gives `rate` a minimum of 1 —
|
|
15
|
+
* the same trick as `metric-server.ts`'s `UNSET = -1`, for the same reason.
|
|
16
|
+
*/
|
|
17
|
+
const rateOf = (value: unknown): number => {
|
|
18
|
+
if (typeof value === 'number') return value;
|
|
19
|
+
const parsed = Number.parseFloat(text(value));
|
|
20
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const replicationJobSpec = {
|
|
24
|
+
/**
|
|
25
|
+
* ⚠️ EVERY FALLBACK IS PVE'S DOCUMENTED DEFAULT, NOT `false`/`''` PICKED FOR CONVENIENCE, so the
|
|
26
|
+
* answer does not depend on whether a given release echoes back a key it never wrote.
|
|
27
|
+
* SectionConfig stores what it was handed; a job created in the UI without a schedule has no
|
|
28
|
+
* schedule line, and reading that absence as anything but `DEFAULT_SCHEDULE` would report
|
|
29
|
+
* drift on a job nobody touched and then WRITE the reading back.
|
|
30
|
+
* ⛔ The distilled read validates the response and requested identity before applying defaults.
|
|
31
|
+
* Only the precise typed vendor exception means absence. `digest` is deliberately absent
|
|
32
|
+
* here — it changes when ANY job changes, including one somebody else declared.
|
|
33
|
+
*/
|
|
34
|
+
attributes: (live, props) => ({
|
|
35
|
+
comment: text(live['comment'], ''),
|
|
36
|
+
disable: bool(live['disable'], false),
|
|
37
|
+
guest: int(live['guest'], props.guest),
|
|
38
|
+
id: text(live['id'], jobId(props)),
|
|
39
|
+
jobnum: int(live['jobnum'], props.jobnum),
|
|
40
|
+
rate: rateOf(live['rate']),
|
|
41
|
+
remove_job: text(live['remove_job'], ''),
|
|
42
|
+
schedule: text(live['schedule'], DEFAULT_SCHEDULE),
|
|
43
|
+
source: text(live['source'], ''),
|
|
44
|
+
targetNode: text(live['target'], props.targetNode),
|
|
45
|
+
type: text(live['type'], 'local'),
|
|
46
|
+
}),
|
|
47
|
+
collection: () => 'cluster/replication',
|
|
48
|
+
createForm: createBody,
|
|
49
|
+
/**
|
|
50
|
+
* ⚠️ `targetNode` AND `source` ARE OUT OF THIS COMPARISON FOR TWO REASONS EACH, AND EITHER ALONE
|
|
51
|
+
* WOULD BE ENOUGH. They are create-only — `target` is a `fixed` option, `source` has no honest
|
|
52
|
+
* declared value — AND PVE REWRITES BOTH BEHIND YOUR BACK. MEASURED in
|
|
53
|
+
* `switch_replication_job_target_nolock`: when a guest migrates, PVE sets
|
|
54
|
+
* `$jobcfg->{target} = $new_target` and `$jobcfg->{source} = $old_target`, so the job follows
|
|
55
|
+
* the guest. Compared, a single migration would make every later plan report an update that the
|
|
56
|
+
* PUT cannot perform, forever. The cost of leaving them out is stated on the `targetNode` prop:
|
|
57
|
+
* a changed target plans as noop, and a migrated job is not dragged back.
|
|
58
|
+
* ⚠️ `guest`, `jobnum`, `type` AND `id` ARE NOT COMPARED EITHER: the first two are the id in
|
|
59
|
+
* integer form, `type` has one legal value, and `id` is the path — a change there is a
|
|
60
|
+
* different object, which reads as absent and is created.
|
|
61
|
+
* ⛔ `remove_job` IS COMPARED, AND IT IS THE ONE FIELD HERE THAT LOOKS LIKE A FOREVER-UPDATE AND
|
|
62
|
+
* IS NOT. A DELETE marks the job rather than removing it, so a re-declared id can name a job
|
|
63
|
+
* that is busy deleting itself, and matching that would be a noop over a vanishing object. It
|
|
64
|
+
* settles because `updateBody` actually clears the marker — see there.
|
|
65
|
+
*/
|
|
66
|
+
/** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
|
|
67
|
+
endpoint: {
|
|
68
|
+
create: 'pve:POST /cluster/replication',
|
|
69
|
+
update: 'pve:PUT /cluster/replication/{id}',
|
|
70
|
+
},
|
|
71
|
+
matches: (attributes, props) =>
|
|
72
|
+
attributes.remove_job === '' &&
|
|
73
|
+
attributes.schedule === (props.schedule ?? DEFAULT_SCHEDULE) &&
|
|
74
|
+
attributes.disable === (props.disable === true) &&
|
|
75
|
+
attributes.comment === (props.comment ?? '') &&
|
|
76
|
+
(props.rate === undefined || attributes.rate === props.rate),
|
|
77
|
+
path: (props) => `cluster/replication/${jobId(props)}`,
|
|
78
|
+
updateForm: updateBody,
|
|
79
|
+
} satisfies PveSpec<ReplicationJobProps, ReplicationJobAttributes>;
|