@homeflare/alchemy 0.37.6 → 0.37.8
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/linux/container-form.d.ts +1 -1
- package/dist/linux/container-generator.d.ts +28 -0
- package/dist/linux/container-generator.d.ts.map +1 -1
- package/dist/linux/container-lifecycle.d.ts +18 -1
- package/dist/linux/container-lifecycle.d.ts.map +1 -1
- package/dist/linux/container-preflight.d.ts +34 -0
- package/dist/linux/container-preflight.d.ts.map +1 -1
- package/dist/linux/container-settle.d.ts.map +1 -1
- package/dist/linux/fake-sudo.d.ts +14 -2
- package/dist/linux/fake-sudo.d.ts.map +1 -1
- package/dist/linux/index.js +76 -29
- package/dist/linux/index.js.map +11 -10
- package/dist/linux/sudo-exec.d.ts +16 -0
- package/dist/linux/sudo-exec.d.ts.map +1 -1
- package/dist/linux/unit-form.d.ts.map +1 -1
- package/dist/linux/unit-text.d.ts +35 -0
- package/dist/linux/unit-text.d.ts.map +1 -0
- 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/linux-sudo.md +18 -7
- 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/linux/container-form.ts +1 -1
- package/src/linux/container-generator.test.ts +33 -0
- package/src/linux/container-generator.ts +72 -0
- package/src/linux/container-lifecycle.test.ts +6 -3
- package/src/linux/container-lifecycle.ts +23 -2
- package/src/linux/container-preflight.ts +47 -0
- package/src/linux/container-settle.ts +12 -1
- package/src/linux/container-shadow.test.ts +131 -0
- package/src/linux/fake-sudo.ts +21 -5
- package/src/linux/sudo-exec.ts +60 -10
- package/src/linux/sudo-lifecycle.test.ts +64 -3
- package/src/linux/unit-form.ts +4 -18
- package/src/linux/unit-text.test.ts +67 -0
- package/src/linux/unit-text.ts +58 -0
- 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,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
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
|
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 {
|
|
90
|
-
import {
|
|
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(
|
|
176
|
+
Effect.succeed(ProxmoxReplicationJob.Provider.of(replicationJobHandlers)),
|
|
254
177
|
);
|
package/src/release/archive.ts
CHANGED
|
@@ -54,7 +54,10 @@ export const extractMembers = async (
|
|
|
54
54
|
offset += SLICE;
|
|
55
55
|
},
|
|
56
56
|
});
|
|
57
|
-
|
|
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();
|