@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.
Files changed (80) hide show
  1. package/dist/proxmox/firewall-alias-config.d.ts +36 -0
  2. package/dist/proxmox/firewall-alias-config.d.ts.map +1 -0
  3. package/dist/proxmox/firewall-alias-form.d.ts +3 -1
  4. package/dist/proxmox/firewall-alias-form.d.ts.map +1 -1
  5. package/dist/proxmox/firewall-alias-lifecycle.d.ts +43 -0
  6. package/dist/proxmox/firewall-alias-lifecycle.d.ts.map +1 -0
  7. package/dist/proxmox/firewall-alias.d.ts +2 -8
  8. package/dist/proxmox/firewall-alias.d.ts.map +1 -1
  9. package/dist/proxmox/index.js +795 -526
  10. package/dist/proxmox/index.js.map +23 -10
  11. package/dist/proxmox/lxc-errors.d.ts +12 -0
  12. package/dist/proxmox/lxc-errors.d.ts.map +1 -0
  13. package/dist/proxmox/lxc-lifecycle.d.ts +9 -31
  14. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -1
  15. package/dist/proxmox/lxc-read.d.ts +4 -0
  16. package/dist/proxmox/lxc-read.d.ts.map +1 -0
  17. package/dist/proxmox/lxc-task.d.ts +15 -0
  18. package/dist/proxmox/lxc-task.d.ts.map +1 -0
  19. package/dist/proxmox/network-apply-read.d.ts +7 -14
  20. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  21. package/dist/proxmox/network-apply-run.d.ts +7 -0
  22. package/dist/proxmox/network-apply-run.d.ts.map +1 -0
  23. package/dist/proxmox/network-apply.d.ts +2 -3
  24. package/dist/proxmox/network-apply.d.ts.map +1 -1
  25. package/dist/proxmox/qemu-errors.d.ts +5 -0
  26. package/dist/proxmox/qemu-errors.d.ts.map +1 -0
  27. package/dist/proxmox/qemu-form.d.ts +22 -0
  28. package/dist/proxmox/qemu-form.d.ts.map +1 -0
  29. package/dist/proxmox/qemu-lifecycle.d.ts +26 -0
  30. package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -0
  31. package/dist/proxmox/qemu-read.d.ts +5 -0
  32. package/dist/proxmox/qemu-read.d.ts.map +1 -0
  33. package/dist/proxmox/qemu-task.d.ts +9 -0
  34. package/dist/proxmox/qemu-task.d.ts.map +1 -0
  35. package/dist/proxmox/qemu.d.ts +9 -14
  36. package/dist/proxmox/qemu.d.ts.map +1 -1
  37. package/dist/proxmox/replication-firewall-fixtures.d.ts +30 -0
  38. package/dist/proxmox/replication-firewall-fixtures.d.ts.map +1 -0
  39. package/dist/proxmox/replication-job-config.d.ts +56 -0
  40. package/dist/proxmox/replication-job-config.d.ts.map +1 -0
  41. package/dist/proxmox/replication-job-lifecycle.d.ts +62 -0
  42. package/dist/proxmox/replication-job-lifecycle.d.ts.map +1 -0
  43. package/dist/proxmox/replication-job.d.ts +5 -6
  44. package/dist/proxmox/replication-job.d.ts.map +1 -1
  45. package/dist/release/archive.d.ts.map +1 -1
  46. package/dist/release/index.js +3 -2
  47. package/dist/release/index.js.map +3 -3
  48. package/docs/proxmox-lxc-distilled.md +56 -0
  49. package/docs/proxmox-replication-firewall-distilled.md +53 -0
  50. package/docs/proxmox-sdk-migration.md +23 -18
  51. package/package.json +2 -2
  52. package/src/proxmox/firewall-alias-config.ts +39 -0
  53. package/src/proxmox/firewall-alias-form.ts +1 -1
  54. package/src/proxmox/firewall-alias-lifecycle.ts +100 -0
  55. package/src/proxmox/firewall-alias.ts +4 -67
  56. package/src/proxmox/lxc-create.test.ts +4 -1
  57. package/src/proxmox/lxc-distilled.test.ts +239 -0
  58. package/src/proxmox/lxc-errors.ts +9 -0
  59. package/src/proxmox/lxc-lifecycle.ts +31 -113
  60. package/src/proxmox/lxc-read.test.ts +29 -6
  61. package/src/proxmox/lxc-read.ts +88 -0
  62. package/src/proxmox/lxc-task.ts +71 -0
  63. package/src/proxmox/network-apply-distilled.test.ts +76 -0
  64. package/src/proxmox/network-apply-read.ts +18 -39
  65. package/src/proxmox/network-apply-run.ts +97 -0
  66. package/src/proxmox/network-apply.ts +5 -94
  67. package/src/proxmox/qemu-errors.ts +7 -0
  68. package/src/proxmox/qemu-form.ts +15 -0
  69. package/src/proxmox/qemu-lifecycle.test.ts +171 -0
  70. package/src/proxmox/qemu-lifecycle.ts +134 -0
  71. package/src/proxmox/qemu-read.ts +73 -0
  72. package/src/proxmox/qemu-task.ts +64 -0
  73. package/src/proxmox/qemu.ts +11 -44
  74. package/src/proxmox/replication-firewall-fixtures.ts +71 -0
  75. package/src/proxmox/replication-firewall-lifecycle.test.ts +109 -0
  76. package/src/proxmox/replication-firewall-wire.test.ts +173 -0
  77. package/src/proxmox/replication-job-config.ts +79 -0
  78. package/src/proxmox/replication-job-lifecycle.ts +113 -0
  79. package/src/proxmox/replication-job.ts +7 -84
  80. package/src/release/archive.ts +4 -1
@@ -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>;
@@ -0,0 +1,113 @@
1
+ /** Named SDK transport; pve-manager 9.2.11 ReplicationConfig.pm defines absence and removal. */
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 * as Schema from 'effect/Schema';
8
+ import { runPve } from './distilled-pve.ts';
9
+ import type { ReplicationJobAttributes, ReplicationJobProps } from './replication-job.ts';
10
+ import { replicationJobSpec as spec } from './replication-job-config.ts';
11
+ import { jobId } from './replication-job-form.ts';
12
+ import { specGuards } from './resource-guard.ts';
13
+ import { formToSend } from './update-guard.ts';
14
+ import { int } from './values.ts';
15
+
16
+ const { guardCreate, guardUpdate } = specGuards(spec);
17
+ const malformed = () =>
18
+ new ProxmoxParseError({
19
+ body: undefined,
20
+ cause: 'Replication job response does not match the requested vendor identity',
21
+ });
22
+
23
+ export const readReplicationJob = (props: ReplicationJobProps) =>
24
+ runPve(props.target, 'read', false, cluster.getClusterReplication({ id: jobId(props) })).pipe(
25
+ Effect.flatMap((live) =>
26
+ // ⚠️ SectionConfig releases can echo `guest`/`jobnum` (and `rate`) as text rather than a JSON
27
+ // number — measured on replication-job-config.ts's own `int()`/`rateOf()` fields, which is
28
+ // why every other numeric read in this family goes through `int()` rather than a strict
29
+ // type. `GetClusterReplicationResponse` types `guest`/`jobnum` as `S.Number` with no
30
+ // coercion, so a string echo fails decode before the identity check below ever runs;
31
+ // normalize both through `int()` first, same as `rate` is normalized to skip its own
32
+ // release-dependent shape.
33
+ Schema.decodeUnknownEffect(Schema.toType(cluster.GetClusterReplicationResponse))({
34
+ ...live,
35
+ guest: int(live.guest, Number.NaN),
36
+ jobnum: int(live.jobnum, Number.NaN),
37
+ rate: undefined,
38
+ }).pipe(Effect.mapError(malformed), Effect.as(live)),
39
+ ),
40
+ Effect.flatMap((live) =>
41
+ // ⛔ `id` alone (a string PVE always returns, e.g. "900-0") already proves identity; the
42
+ // guest/jobnum recheck is defense in depth, so it must tolerate the same string-or-number
43
+ // wire shape as the decode above rather than `===` against the raw, possibly-text value.
44
+ live.id === jobId(props) &&
45
+ int(live.guest, Number.NaN) === props.guest &&
46
+ int(live.jobnum, Number.NaN) === props.jobnum
47
+ ? Effect.succeed(spec.attributes({ ...live }, props))
48
+ : Effect.fail(malformed()),
49
+ ),
50
+ Effect.catchTag('ReplicationJobNotFound', () => Effect.succeed(undefined)),
51
+ );
52
+
53
+ /** DELETE without force/keep marks full background removal; it must never force immediate removal. */
54
+ export const deleteReplicationJob = (props: ReplicationJobProps) =>
55
+ runPve(
56
+ props.target,
57
+ 'provision',
58
+ true,
59
+ cluster.deleteClusterReplication({ id: jobId(props) }),
60
+ ).pipe(Effect.catchTag('ReplicationJobNotFound', () => Effect.void));
61
+
62
+ export const replicationJobHandlers = {
63
+ // Explicit declaration is the only way to adopt a job and gain deletion authority over replicas.
64
+ list: () => Effect.succeed([]),
65
+ read: ({ olds }: { olds: ReplicationJobProps }) => readReplicationJob(olds),
66
+ diff: Effect.fn(function* ({
67
+ news,
68
+ output,
69
+ }: {
70
+ news: Input<ReplicationJobProps>;
71
+ output: ReplicationJobAttributes | undefined;
72
+ }) {
73
+ if (!isResolved(news)) return undefined;
74
+ yield* guardCreate(news, output === undefined);
75
+ yield* guardUpdate(news);
76
+ if (output === undefined) return undefined;
77
+ const live = yield* readReplicationJob(news);
78
+ if (live === undefined) {
79
+ yield* guardCreate(news, true);
80
+ return { action: 'update' } as const;
81
+ }
82
+ return { action: spec.matches(live, news) ? 'noop' : 'update' } as const;
83
+ }),
84
+ reconcile: Effect.fn(function* ({ news }: { news: ReplicationJobProps }) {
85
+ const live = yield* readReplicationJob(news);
86
+ yield* guardCreate(news, live === undefined);
87
+ yield* guardUpdate(news);
88
+ if (live === undefined) {
89
+ yield* runPve(
90
+ news.target,
91
+ 'provision',
92
+ true,
93
+ cluster.createClusterReplication(spec.createForm(news)),
94
+ );
95
+ } else {
96
+ const form = formToSend(spec.matches, live, news, spec.updateForm(news));
97
+ if (form !== undefined)
98
+ yield* runPve(
99
+ news.target,
100
+ 'provision',
101
+ true,
102
+ cluster.putClusterReplication({ ...form, id: jobId(news) }),
103
+ );
104
+ }
105
+ const after = yield* readReplicationJob(news);
106
+ if (after === undefined)
107
+ return yield* Effect.fail(
108
+ new Error(`${spec.path(news)}: write returned success but the resource is still absent`),
109
+ );
110
+ return after;
111
+ }),
112
+ delete: ({ olds }: { olds: ReplicationJobProps }) => deleteReplicationJob(olds),
113
+ };
@@ -56,13 +56,12 @@
56
56
  * checks `VM.Audit` on `/vms/<guest>`, `hf-read@pve` holds the built-in `PVEAuditor` at `/`
57
57
  * with propagate — both MEASURED — and `PVEAuditor` carries `VM.Audit`. This family is not a
58
58
  * fourth one that must borrow the provision lease.
59
- * ⚠️ A MISSING JOB IS A 500, NOT A 404 — "no such replication job '100-0'" — and the factory
60
- * folds any failed read into "absent", so a 403 reads as absent too. A plan insisting on
61
- * creating a job that plainly exists is a credential problem; the honest error arrives one step
62
- * later, from the create.
59
+ * ⚠️ A MISSING JOB IS A 500, NOT A 404 — "no such replication job '100-0'" — and distilled now
60
+ * exposes a precise missing-job tag. Permission failures and unrelated server errors propagate
61
+ * before any write; only the vendor missing-job signal means absent.
63
62
  *
64
63
  * ⛔ DELETE DOES NOT DELETE, AND A DESTROY IS NOT FREE. MEASURED: with neither `force` nor `keep` —
65
- * and the factory's `destroy` sends no form at all — the handler only sets `remove_job = 'full'`
64
+ * and the named SDK delete sends neither option — the handler only sets `remove_job = 'full'`
66
65
  * on the job and writes the config back. The job stays in replication.cfg until the SOURCE node's
67
66
  * `pvesr` timer next runs it, at which point it removes the local replication snapshots, removes
68
67
  * the replicated volumes on the target, and finally removes itself. So a destroy costs the
@@ -86,9 +85,8 @@
86
85
  import { Resource } from 'alchemy';
87
86
  import * as Provider from 'alchemy/Provider';
88
87
  import * as Effect from 'effect/Effect';
89
- import { DEFAULT_SCHEDULE, createBody, jobId, updateBody } from './replication-job-form.ts';
90
- import { type PveRequirements, type WithTarget, pveHandlers } from './resource.ts';
91
- import { bool, int, text } from './values.ts';
88
+ import type { PveRequirements, WithTarget } from './resource-spec.ts';
89
+ import { replicationJobHandlers } from './replication-job-lifecycle.ts';
92
90
 
93
91
  export interface ReplicationJobProps extends WithTarget {
94
92
  /** ⛔ The guest being replicated. 100 or above — PVE reserves everything below. Identity. */
@@ -164,81 +162,6 @@ export interface ProxmoxReplicationJob extends Resource<
164
162
 
165
163
  export const ProxmoxReplicationJob = Resource<ProxmoxReplicationJob>('Proxmox.ReplicationJob');
166
164
 
167
- /**
168
- * ⚠️ `rate` IS THE ONLY FLOAT IN THIS PACKAGE, so it gets a coercion of its own rather than a sixth
169
- * entry in `values.ts` — the rule that module states for itself: a coercion only one PVE object
170
- * needs belongs in that object's file, next to the field it serves. `int` cannot serve it, since
171
- * `Number.parseInt('10.5')` is 10 and a rate silently rounded down is both a limit nobody chose
172
- * and a comparison that never settles. It accepts a string for the reason `int` does: these are
173
- * SectionConfig sections, and whether `10` arrives as a number is a property of the release.
174
- * ⚠️ 0 MEANS UNSET, and is safe as the marker because PVE's schema gives `rate` a minimum of 1 —
175
- * the same trick as `metric-server.ts`'s `UNSET = -1`, for the same reason.
176
- */
177
- const rateOf = (value: unknown): number => {
178
- if (typeof value === 'number') return value;
179
- const parsed = Number.parseFloat(text(value));
180
- return Number.isFinite(parsed) ? parsed : 0;
181
- };
182
-
183
- const handlers = pveHandlers<ReplicationJobProps, ReplicationJobAttributes>({
184
- /**
185
- * ⚠️ EVERY FALLBACK IS PVE'S DOCUMENTED DEFAULT, NOT `false`/`''` PICKED FOR CONVENIENCE, so the
186
- * answer does not depend on whether a given release echoes back a key it never wrote.
187
- * SectionConfig stores what it was handed; a job created in the UI without a schedule has no
188
- * schedule line, and reading that absence as anything but `DEFAULT_SCHEDULE` would report
189
- * drift on a job nobody touched and then WRITE the reading back.
190
- * ⛔ NO "IS IT REALLY THERE" GUARD, for `backup-job.ts`'s reason: absence is the API declining to
191
- * answer, which `pveOperations.read` already handles, not a key missing from an answer that did
192
- * arrive. `digest` is deliberately absent too — it changes when ANY job in the file changes,
193
- * including one somebody else declared (`ha-resource.ts` hit exactly that).
194
- */
195
- attributes: (live, props) => ({
196
- comment: text(live['comment'], ''),
197
- disable: bool(live['disable'], false),
198
- guest: int(live['guest'], props.guest),
199
- id: text(live['id'], jobId(props)),
200
- jobnum: int(live['jobnum'], props.jobnum),
201
- rate: rateOf(live['rate']),
202
- remove_job: text(live['remove_job'], ''),
203
- schedule: text(live['schedule'], DEFAULT_SCHEDULE),
204
- source: text(live['source'], ''),
205
- targetNode: text(live['target'], props.targetNode),
206
- type: text(live['type'], 'local'),
207
- }),
208
- collection: () => 'cluster/replication',
209
- createForm: createBody,
210
- /**
211
- * ⚠️ `targetNode` AND `source` ARE OUT OF THIS COMPARISON FOR TWO REASONS EACH, AND EITHER ALONE
212
- * WOULD BE ENOUGH. They are create-only — `target` is a `fixed` option, `source` has no honest
213
- * declared value — AND PVE REWRITES BOTH BEHIND YOUR BACK. MEASURED in
214
- * `switch_replication_job_target_nolock`: when a guest migrates, PVE sets
215
- * `$jobcfg->{target} = $new_target` and `$jobcfg->{source} = $old_target`, so the job follows
216
- * the guest. Compared, a single migration would make every later plan report an update that the
217
- * PUT cannot perform, forever. The cost of leaving them out is stated on the `targetNode` prop:
218
- * a changed target plans as noop, and a migrated job is not dragged back.
219
- * ⚠️ `guest`, `jobnum`, `type` AND `id` ARE NOT COMPARED EITHER: the first two are the id in
220
- * integer form, `type` has one legal value, and `id` is the path — a change there is a
221
- * different object, which reads as absent and is created.
222
- * ⛔ `remove_job` IS COMPARED, AND IT IS THE ONE FIELD HERE THAT LOOKS LIKE A FOREVER-UPDATE AND
223
- * IS NOT. A DELETE marks the job rather than removing it, so a re-declared id can name a job
224
- * that is busy deleting itself, and matching that would be a noop over a vanishing object. It
225
- * settles because `updateBody` actually clears the marker — see there.
226
- */
227
- /** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
228
- endpoint: {
229
- create: 'pve:POST /cluster/replication',
230
- update: 'pve:PUT /cluster/replication/{id}',
231
- },
232
- matches: (attributes, props) =>
233
- attributes.remove_job === '' &&
234
- attributes.schedule === (props.schedule ?? DEFAULT_SCHEDULE) &&
235
- attributes.disable === (props.disable === true) &&
236
- attributes.comment === (props.comment ?? '') &&
237
- (props.rate === undefined || attributes.rate === props.rate),
238
- path: (props) => `cluster/replication/${jobId(props)}`,
239
- updateForm: updateBody,
240
- });
241
-
242
165
  /**
243
166
  * ⛔ Empty `list` like every other resource here: `GET /cluster/replication` hands back every job an
244
167
  * operator ever made, and adopting one is how a later `alchemy destroy` takes away a standby copy
@@ -250,5 +173,5 @@ const handlers = pveHandlers<ReplicationJobProps, ReplicationJobAttributes>({
250
173
  export const ProxmoxReplicationJobProvider = () =>
251
174
  Provider.effect(
252
175
  ProxmoxReplicationJob,
253
- Effect.succeed(ProxmoxReplicationJob.Provider.of(handlers)),
176
+ Effect.succeed(ProxmoxReplicationJob.Provider.of(replicationJobHandlers)),
254
177
  );
@@ -54,7 +54,10 @@ export const extractMembers = async (
54
54
  offset += SLICE;
55
55
  },
56
56
  });
57
- const unzipped = source.pipeThrough(new DecompressionStream('gzip')).getReader();
57
+ // ⚠️ TS 7's DOM lib types this writable chunk as BufferSource, which pipeThrough
58
+ // will not accept as Uint8Array. The slices enqueued above are Uint8Array.
59
+ const gunzip = new DecompressionStream('gzip') as ReadableWritablePair<Uint8Array, Uint8Array>;
60
+ const unzipped = source.pipeThrough(gunzip).getReader();
58
61
  try {
59
62
  for (;;) {
60
63
  const { done, value } = await unzipped.read();