@homeflare/alchemy 0.37.2 → 0.37.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/proxmox/backup-job-config.d.ts +95 -0
- package/dist/proxmox/backup-job-config.d.ts.map +1 -0
- package/dist/proxmox/backup-job-distilled.d.ts +46 -0
- package/dist/proxmox/backup-job-distilled.d.ts.map +1 -0
- package/dist/proxmox/backup-job.d.ts +9 -26
- package/dist/proxmox/backup-job.d.ts.map +1 -1
- package/dist/proxmox/distilled-pbs.d.ts +12 -0
- package/dist/proxmox/distilled-pbs.d.ts.map +1 -0
- package/dist/proxmox/fake-pbs-jobs.d.ts +23 -0
- package/dist/proxmox/fake-pbs-jobs.d.ts.map +1 -0
- package/dist/proxmox/fake-pbs-notify.d.ts.map +1 -1
- package/dist/proxmox/fake-pve.d.ts +1 -1
- package/dist/proxmox/fake-pve.d.ts.map +1 -1
- package/dist/proxmox/index.js +1501 -607
- package/dist/proxmox/index.js.map +48 -24
- package/dist/proxmox/live-core-fixtures.d.ts +40 -0
- package/dist/proxmox/live-core-fixtures.d.ts.map +1 -0
- package/dist/proxmox/metric-server-config.d.ts +49 -0
- package/dist/proxmox/metric-server-config.d.ts.map +1 -0
- package/dist/proxmox/metric-server-distilled.d.ts +43 -0
- package/dist/proxmox/metric-server-distilled.d.ts.map +1 -0
- package/dist/proxmox/metric-server.d.ts +2 -10
- package/dist/proxmox/metric-server.d.ts.map +1 -1
- package/dist/proxmox/notification-matcher-distilled.d.ts +9 -0
- package/dist/proxmox/notification-matcher-distilled.d.ts.map +1 -0
- package/dist/proxmox/notification-matcher-lifecycle.d.ts +27 -0
- package/dist/proxmox/notification-matcher-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/notification-matcher.d.ts +5 -5
- package/dist/proxmox/notification-matcher.d.ts.map +1 -1
- package/dist/proxmox/notification-sdk-input.d.ts +22 -0
- package/dist/proxmox/notification-sdk-input.d.ts.map +1 -0
- package/dist/proxmox/notification-target-distilled.d.ts +12 -0
- package/dist/proxmox/notification-target-distilled.d.ts.map +1 -0
- package/dist/proxmox/notification-target-form.d.ts +2 -0
- package/dist/proxmox/notification-target-form.d.ts.map +1 -1
- package/dist/proxmox/notification-target-lifecycle.d.ts +29 -0
- package/dist/proxmox/notification-target-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/notification-target-spec.d.ts +5 -0
- package/dist/proxmox/notification-target-spec.d.ts.map +1 -0
- package/dist/proxmox/notification-target.d.ts +4 -5
- package/dist/proxmox/notification-target.d.ts.map +1 -1
- package/dist/proxmox/pbs-datastore-endpoint.d.ts +1 -1
- package/dist/proxmox/pbs-datastore-form.d.ts +18 -42
- package/dist/proxmox/pbs-datastore-form.d.ts.map +1 -1
- package/dist/proxmox/pbs-datastore-guard.d.ts +7 -4
- package/dist/proxmox/pbs-datastore-guard.d.ts.map +1 -1
- package/dist/proxmox/pbs-datastore-lifecycle.d.ts +56 -0
- package/dist/proxmox/pbs-datastore-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/pbs-datastore-wire.d.ts +21 -0
- package/dist/proxmox/pbs-datastore-wire.d.ts.map +1 -0
- package/dist/proxmox/pbs-datastore.d.ts +5 -6
- package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
- package/dist/proxmox/pbs-notification-input.d.ts +18 -0
- package/dist/proxmox/pbs-notification-input.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-matcher-distilled.d.ts +11 -0
- package/dist/proxmox/pbs-notification-matcher-distilled.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-matcher.d.ts +4 -3
- package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -1
- package/dist/proxmox/pbs-notification-target-distilled.d.ts +20 -0
- package/dist/proxmox/pbs-notification-target-distilled.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +4 -4
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts.map +1 -1
- package/dist/proxmox/pbs-notification-target.d.ts +1 -1
- package/dist/proxmox/pbs-prune-job-lifecycle.d.ts +22 -0
- package/dist/proxmox/pbs-prune-job-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/pbs-prune-job-spec.d.ts +5 -0
- package/dist/proxmox/pbs-prune-job-spec.d.ts.map +1 -0
- package/dist/proxmox/pbs-prune-job-wire.d.ts +11 -0
- package/dist/proxmox/pbs-prune-job-wire.d.ts.map +1 -0
- package/dist/proxmox/pbs-prune-job.d.ts +9 -8
- package/dist/proxmox/pbs-prune-job.d.ts.map +1 -1
- package/dist/proxmox/pbs-sync-job-attributes.d.ts +34 -0
- package/dist/proxmox/pbs-sync-job-attributes.d.ts.map +1 -0
- package/dist/proxmox/pbs-sync-job-form.d.ts +21 -22
- package/dist/proxmox/pbs-sync-job-form.d.ts.map +1 -1
- package/dist/proxmox/pbs-sync-job-lifecycle.d.ts +22 -0
- package/dist/proxmox/pbs-sync-job-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/pbs-sync-job-wire.d.ts +11 -0
- package/dist/proxmox/pbs-sync-job-wire.d.ts.map +1 -0
- package/dist/proxmox/pbs-sync-job.d.ts +9 -39
- package/dist/proxmox/pbs-sync-job.d.ts.map +1 -1
- package/dist/proxmox/pbs-verify-job-lifecycle.d.ts +22 -0
- package/dist/proxmox/pbs-verify-job-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/pbs-verify-job-spec.d.ts +16 -0
- package/dist/proxmox/pbs-verify-job-spec.d.ts.map +1 -0
- package/dist/proxmox/pbs-verify-job-wire.d.ts +11 -0
- package/dist/proxmox/pbs-verify-job-wire.d.ts.map +1 -0
- package/dist/proxmox/pbs-verify-job.d.ts +12 -12
- package/dist/proxmox/pbs-verify-job.d.ts.map +1 -1
- package/dist/proxmox/pool.d.ts +10 -1
- package/dist/proxmox/pool.d.ts.map +1 -1
- package/docs/pbs-distilled-jobs.md +31 -0
- package/docs/pbs-notifications.md +8 -2
- package/docs/proxmox-notification-sdk.md +28 -0
- package/docs/proxmox-sdk-migration.md +60 -0
- package/docs/upstream-conformance-families.md +6 -2
- package/package.json +1 -1
- package/src/proxmox/adopt-noop.test.ts +4 -5
- package/src/proxmox/backup-job-config.ts +95 -0
- package/src/proxmox/backup-job-distilled.ts +86 -0
- package/src/proxmox/backup-job.ts +78 -116
- package/src/proxmox/constraints.test.ts +9 -8
- package/src/proxmox/distilled-pbs.test.ts +87 -0
- package/src/proxmox/distilled-pbs.ts +50 -0
- package/src/proxmox/fake-pbs-jobs.ts +99 -0
- package/src/proxmox/fake-pbs-notify.ts +4 -1
- package/src/proxmox/fake-pve.ts +3 -2
- package/src/proxmox/live-core-fixtures.ts +126 -0
- package/src/proxmox/live-core-lifecycle.test.ts +115 -0
- package/src/proxmox/live-core-wire.test.ts +190 -0
- package/src/proxmox/metric-server-config.ts +87 -0
- package/src/proxmox/metric-server-distilled.ts +81 -0
- package/src/proxmox/metric-server.ts +77 -88
- package/src/proxmox/notification-distilled.test.ts +162 -0
- package/src/proxmox/notification-matcher-distilled.ts +60 -0
- package/src/proxmox/notification-matcher-lifecycle.ts +71 -0
- package/src/proxmox/notification-matcher.ts +9 -26
- package/src/proxmox/notification-replacement.test.ts +190 -0
- package/src/proxmox/notification-sdk-input.ts +39 -0
- package/src/proxmox/notification-target-distilled.ts +160 -0
- package/src/proxmox/notification-target-form.ts +2 -0
- package/src/proxmox/notification-target-lifecycle.ts +75 -0
- package/src/proxmox/notification-target-spec.ts +68 -0
- package/src/proxmox/notification-target.ts +6 -68
- package/src/proxmox/pbs-datastore-endpoint.ts +1 -1
- package/src/proxmox/pbs-datastore-form.ts +18 -49
- package/src/proxmox/pbs-datastore-guard.ts +7 -4
- package/src/proxmox/pbs-datastore-lifecycle.ts +114 -0
- package/src/proxmox/pbs-datastore-wire.ts +94 -0
- package/src/proxmox/pbs-datastore.ts +7 -133
- package/src/proxmox/pbs-jobs-delete.test.ts +40 -0
- package/src/proxmox/pbs-jobs-sdk.test.ts +215 -0
- package/src/proxmox/pbs-notification-errors.test.ts +93 -0
- package/src/proxmox/pbs-notification-input.ts +25 -0
- package/src/proxmox/pbs-notification-mail.test.ts +64 -0
- package/src/proxmox/pbs-notification-matcher-distilled.ts +65 -0
- package/src/proxmox/pbs-notification-matcher.ts +70 -17
- package/src/proxmox/pbs-notification-target-distilled.ts +124 -0
- package/src/proxmox/pbs-notification-target-lifecycle.ts +26 -17
- package/src/proxmox/pbs-notification-target.ts +1 -1
- package/src/proxmox/pbs-notifications-stack.test.ts +25 -0
- package/src/proxmox/pbs-prune-job-lifecycle.ts +52 -0
- package/src/proxmox/pbs-prune-job-spec.ts +53 -0
- package/src/proxmox/pbs-prune-job-wire.ts +68 -0
- package/src/proxmox/pbs-prune-job.ts +10 -58
- package/src/proxmox/pbs-sync-job-attributes.ts +33 -0
- package/src/proxmox/pbs-sync-job-form.ts +7 -6
- package/src/proxmox/pbs-sync-job-lifecycle.ts +46 -0
- package/src/proxmox/pbs-sync-job-wire.ts +71 -0
- package/src/proxmox/pbs-sync-job.ts +10 -41
- package/src/proxmox/pbs-verify-job-lifecycle.ts +52 -0
- package/src/proxmox/pbs-verify-job-spec.ts +76 -0
- package/src/proxmox/pbs-verify-job-wire.ts +58 -0
- package/src/proxmox/pbs-verify-job.ts +13 -85
- package/src/proxmox/pool.ts +92 -10
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/** Test-only declarations and vendor-shaped responses for the three migrated PVE families. */
|
|
2
|
+
import type * as Effect from 'effect/Effect';
|
|
3
|
+
import * as Layer from 'effect/Layer';
|
|
4
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
5
|
+
import { type FakeEngine, engineOver } from '../verify/fake-engine.ts';
|
|
6
|
+
import { ProxmoxBackupJob, ProxmoxBackupJobProvider } from './backup-job.ts';
|
|
7
|
+
import { deleteBackupJob, readBackupJob } from './backup-job-distilled.ts';
|
|
8
|
+
import { FAKE_TARGET, type FakePve } from './fake-pve.ts';
|
|
9
|
+
import { ProxmoxMetricServer, ProxmoxMetricServerProvider } from './metric-server.ts';
|
|
10
|
+
import { deleteMetricServer, readMetricServer } from './metric-server-distilled.ts';
|
|
11
|
+
import { ProxmoxPool, ProxmoxPoolProvider, deletePool, readPool } from './pool.ts';
|
|
12
|
+
|
|
13
|
+
export const poolProps = { poolid: 'lab', comment: 'managed', target: FAKE_TARGET };
|
|
14
|
+
export const backupProps = {
|
|
15
|
+
id: 'nightly',
|
|
16
|
+
schedule: '02:00',
|
|
17
|
+
vmid: [103, 101],
|
|
18
|
+
'prune-backups': 'keep-weekly=4,keep-daily=7',
|
|
19
|
+
target: FAKE_TARGET,
|
|
20
|
+
};
|
|
21
|
+
export const metricProps = {
|
|
22
|
+
id: 'metrics',
|
|
23
|
+
type: 'influxdb' as const,
|
|
24
|
+
server: 'metrics.test',
|
|
25
|
+
port: 8086,
|
|
26
|
+
target: FAKE_TARGET,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export interface CoreCase {
|
|
30
|
+
name: string;
|
|
31
|
+
path: string;
|
|
32
|
+
declare: () => Effect.Effect<unknown, never, unknown>;
|
|
33
|
+
engine: (fake: FakePve) => FakeEngine;
|
|
34
|
+
read: () => Effect.Effect<unknown, unknown, HttpClient.HttpClient>;
|
|
35
|
+
remove: () => Effect.Effect<unknown, unknown, HttpClient.HttpClient>;
|
|
36
|
+
live: Record<string, unknown>;
|
|
37
|
+
drift: Record<string, unknown>;
|
|
38
|
+
missing: () => Response;
|
|
39
|
+
createPath: string;
|
|
40
|
+
/** Actual form fields, including defaults, without SDK identifier spelling leaking onto wire. */
|
|
41
|
+
createForm: Record<string, string>;
|
|
42
|
+
}
|
|
43
|
+
export const coreCases: CoreCase[] = [
|
|
44
|
+
{
|
|
45
|
+
name: 'Pool',
|
|
46
|
+
path: 'pools/lab',
|
|
47
|
+
createPath: 'pools',
|
|
48
|
+
declare: () => ProxmoxPool('row', poolProps),
|
|
49
|
+
engine: (fake) => engineOver(ProxmoxPoolProvider().pipe(Layer.provideMerge(fake.layer))),
|
|
50
|
+
read: () => readPool(poolProps),
|
|
51
|
+
remove: () => deletePool(poolProps),
|
|
52
|
+
live: { comment: 'managed', members: [] },
|
|
53
|
+
drift: { comment: 'old', members: [] },
|
|
54
|
+
missing: () =>
|
|
55
|
+
Response.json({ message: "pool 'lab' does not exist\n", data: null }, { status: 500 }),
|
|
56
|
+
createForm: { poolid: 'lab', comment: 'managed' },
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'BackupJob',
|
|
60
|
+
path: 'cluster/backup/nightly',
|
|
61
|
+
createPath: 'cluster/backup',
|
|
62
|
+
declare: () => ProxmoxBackupJob('row', backupProps),
|
|
63
|
+
engine: (fake) => engineOver(ProxmoxBackupJobProvider().pipe(Layer.provideMerge(fake.layer))),
|
|
64
|
+
read: () => readBackupJob(backupProps),
|
|
65
|
+
remove: () => deleteBackupJob(backupProps),
|
|
66
|
+
live: {
|
|
67
|
+
id: 'nightly',
|
|
68
|
+
schedule: '02:00',
|
|
69
|
+
vmid: '101,103',
|
|
70
|
+
'next-run': 987654,
|
|
71
|
+
'prune-backups': { 'keep-daily': 7, 'keep-weekly': 4 },
|
|
72
|
+
compress: 'zstd',
|
|
73
|
+
mailto: 'operator.invalid',
|
|
74
|
+
},
|
|
75
|
+
drift: {
|
|
76
|
+
id: 'nightly',
|
|
77
|
+
schedule: '03:00',
|
|
78
|
+
vmid: '101,103',
|
|
79
|
+
'prune-backups': { 'keep-daily': 7, 'keep-weekly': 4 },
|
|
80
|
+
},
|
|
81
|
+
missing: () =>
|
|
82
|
+
Response.json(
|
|
83
|
+
{
|
|
84
|
+
message: 'Parameter verification failed.\n',
|
|
85
|
+
errors: { id: "No such job 'nightly'" },
|
|
86
|
+
data: null,
|
|
87
|
+
},
|
|
88
|
+
{ status: 400 },
|
|
89
|
+
),
|
|
90
|
+
createForm: {
|
|
91
|
+
id: 'nightly',
|
|
92
|
+
schedule: '02:00',
|
|
93
|
+
vmid: '101,103',
|
|
94
|
+
all: '0',
|
|
95
|
+
enabled: '1',
|
|
96
|
+
mode: 'snapshot',
|
|
97
|
+
'notification-mode': 'auto',
|
|
98
|
+
'repeat-missed': '0',
|
|
99
|
+
'prune-backups': 'keep-weekly=4,keep-daily=7',
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'MetricServer',
|
|
104
|
+
path: 'cluster/metrics/server/metrics',
|
|
105
|
+
createPath: 'cluster/metrics/server/metrics',
|
|
106
|
+
declare: () => ProxmoxMetricServer('row', metricProps),
|
|
107
|
+
engine: (fake) =>
|
|
108
|
+
engineOver(ProxmoxMetricServerProvider().pipe(Layer.provideMerge(fake.layer))),
|
|
109
|
+
read: () => readMetricServer(metricProps),
|
|
110
|
+
remove: () => deleteMetricServer(metricProps),
|
|
111
|
+
live: { type: 'influxdb', server: 'metrics.test', port: 8086, token: 'fake-not-a-secret' },
|
|
112
|
+
drift: { type: 'influxdb', server: 'old.test', port: 8086, token: 'fake-not-a-secret' },
|
|
113
|
+
missing: () =>
|
|
114
|
+
Response.json(
|
|
115
|
+
{ message: "status server entry 'metrics' does not exist\n", data: null },
|
|
116
|
+
{ status: 500 },
|
|
117
|
+
),
|
|
118
|
+
createForm: {
|
|
119
|
+
type: 'influxdb',
|
|
120
|
+
server: 'metrics.test',
|
|
121
|
+
port: '8086',
|
|
122
|
+
disable: '0',
|
|
123
|
+
'verify-certificate': '1',
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
];
|
|
@@ -0,0 +1,115 @@
|
|
|
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 { coreCases } from './live-core-fixtures.ts';
|
|
7
|
+
|
|
8
|
+
for (const entry of coreCases) {
|
|
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
|
+
expect(form).not.toHaveProperty('token');
|
|
65
|
+
expect(form).not.toHaveProperty('otel-headers');
|
|
66
|
+
expect(form).not.toHaveProperty('compress');
|
|
67
|
+
expect(form).not.toHaveProperty('mailto');
|
|
68
|
+
if (entry.name === 'BackupJob') expect(form).not.toHaveProperty('delete');
|
|
69
|
+
if (entry.name === 'MetricServer') {
|
|
70
|
+
expect(form?.['delete']).toBe(
|
|
71
|
+
'api-path-prefix,bucket,influxdbproto,max-body-size,mtu,organization,timeout',
|
|
72
|
+
);
|
|
73
|
+
expect(form).not.toHaveProperty('id');
|
|
74
|
+
expect(form).not.toHaveProperty('type');
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
for (const status of [401, 403, 500]) {
|
|
79
|
+
test(`${status} read failure stops cold adoption and confirmed drift before writes`, async () => {
|
|
80
|
+
let fail = true;
|
|
81
|
+
const fake = fakePve(() =>
|
|
82
|
+
fail
|
|
83
|
+
? Response.json({ data: null, message: 'unrelated failure' }, { status })
|
|
84
|
+
: entry.live,
|
|
85
|
+
);
|
|
86
|
+
await withoutBao(async () => {
|
|
87
|
+
const engine = entry.engine(fake);
|
|
88
|
+
await expect(engine.deploy(entry.declare())).rejects.toBeDefined();
|
|
89
|
+
fail = false;
|
|
90
|
+
await engine.deploy(entry.declare());
|
|
91
|
+
fail = true;
|
|
92
|
+
await expect(engine.drift(entry.declare())).rejects.toBeDefined();
|
|
93
|
+
await expect(engine.deploy(entry.declare())).rejects.toBeDefined();
|
|
94
|
+
});
|
|
95
|
+
expect(fake.writes()).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
test('delete failure retains the engine state and propagates', async () => {
|
|
100
|
+
let failing = false;
|
|
101
|
+
const fake = fakePve(() =>
|
|
102
|
+
failing
|
|
103
|
+
? Response.json({ data: null, message: 'permission denied' }, { status: 403 })
|
|
104
|
+
: entry.live,
|
|
105
|
+
);
|
|
106
|
+
await withoutBao(async () => {
|
|
107
|
+
const engine = entry.engine(fake);
|
|
108
|
+
await engine.deploy(entry.declare().pipe(RemovalPolicy.destroy()));
|
|
109
|
+
failing = true;
|
|
110
|
+
await expect(engine.deploy(Effect.void)).rejects.toBeDefined();
|
|
111
|
+
expect(engine.stored()).toContain('row');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/** Vendor-specific failures and normalization at the actual distilled HTTP boundary. */
|
|
2
|
+
import { describe, expect, test } from 'bun:test';
|
|
3
|
+
import * as Effect from 'effect/Effect';
|
|
4
|
+
import * as Layer from 'effect/Layer';
|
|
5
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
6
|
+
import { readBackupJob } from './backup-job-distilled.ts';
|
|
7
|
+
import { ProxmoxBackupJob, ProxmoxBackupJobProvider } from './backup-job.ts';
|
|
8
|
+
import { fakePve, withoutBao } from './fake-pve.ts';
|
|
9
|
+
import { backupProps, coreCases, metricProps } from './live-core-fixtures.ts';
|
|
10
|
+
import { deleteMetricServer, readMetricServer } from './metric-server-distilled.ts';
|
|
11
|
+
import { ProxmoxMetricServer, ProxmoxMetricServerProvider } from './metric-server.ts';
|
|
12
|
+
|
|
13
|
+
for (const entry of coreCases) {
|
|
14
|
+
describe(`${entry.name} wire`, () => {
|
|
15
|
+
test('only the typed missing signal returns undefined', async () => {
|
|
16
|
+
const fake = fakePve(entry.missing);
|
|
17
|
+
await withoutBao(async () => {
|
|
18
|
+
expect(
|
|
19
|
+
await Effect.runPromise(entry.read().pipe(Effect.provide(fake.layer))),
|
|
20
|
+
).toBeUndefined();
|
|
21
|
+
});
|
|
22
|
+
expect(fake.writes()).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
test('deleting an already missing object succeeds', async () => {
|
|
25
|
+
const fake = fakePve(entry.missing);
|
|
26
|
+
await withoutBao(async () => {
|
|
27
|
+
await Effect.runPromise(entry.remove().pipe(Effect.provide(fake.layer)));
|
|
28
|
+
});
|
|
29
|
+
expect(fake.writes()).toEqual(entry.name === 'MetricServer' ? [] : [`DELETE ${entry.path}`]);
|
|
30
|
+
});
|
|
31
|
+
for (const [status, tag] of [
|
|
32
|
+
[401, 'Unauthorized'],
|
|
33
|
+
[403, 'Forbidden'],
|
|
34
|
+
[500, 'InternalServerError'],
|
|
35
|
+
] as const) {
|
|
36
|
+
test(`${status} remains typed ${tag}`, async () => {
|
|
37
|
+
const fake = fakePve(() =>
|
|
38
|
+
Response.json({ message: 'unrelated failure', data: null }, { status }),
|
|
39
|
+
);
|
|
40
|
+
await withoutBao(async () => {
|
|
41
|
+
expect(
|
|
42
|
+
await Effect.runPromise(entry.read().pipe(Effect.flip, Effect.provide(fake.layer))),
|
|
43
|
+
).toMatchObject({ _tag: tag });
|
|
44
|
+
});
|
|
45
|
+
expect(fake.writes()).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (entry.name !== 'MetricServer') {
|
|
49
|
+
test('a successful null payload fails the generated response schema', async () => {
|
|
50
|
+
const fake = fakePve(() => null);
|
|
51
|
+
await withoutBao(async () => {
|
|
52
|
+
expect(
|
|
53
|
+
await Effect.runPromise(entry.read().pipe(Effect.flip, Effect.provide(fake.layer))),
|
|
54
|
+
).toMatchObject({ _tag: 'ProxmoxParseError', body: undefined });
|
|
55
|
+
await expect(entry.engine(fake).deploy(entry.declare())).rejects.toBeDefined();
|
|
56
|
+
});
|
|
57
|
+
expect(fake.writes()).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test('BackupJob keeps genuine validation failure distinct from a missing id', async () => {
|
|
64
|
+
const fake = fakePve(() =>
|
|
65
|
+
Response.json(
|
|
66
|
+
{
|
|
67
|
+
data: null,
|
|
68
|
+
message: 'Parameter verification failed.\n',
|
|
69
|
+
errors: { id: "No such job 'nightly'", schedule: 'invalid calendar expression' },
|
|
70
|
+
},
|
|
71
|
+
{ status: 400 },
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
await withoutBao(async () => {
|
|
75
|
+
expect(
|
|
76
|
+
await Effect.runPromise(
|
|
77
|
+
readBackupJob(backupProps).pipe(Effect.flip, Effect.provide(fake.layer)),
|
|
78
|
+
),
|
|
79
|
+
).toMatchObject({ _tag: 'ParameterVerificationFailed' });
|
|
80
|
+
});
|
|
81
|
+
expect(fake.writes()).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
for (const expanded of [false, true]) {
|
|
85
|
+
test(`BackupJob canonicalizes retention and fleecing (${expanded ? 'expanded objects' : 'legacy strings'})`, async () => {
|
|
86
|
+
const fake = fakePve(() => ({
|
|
87
|
+
id: backupProps.id,
|
|
88
|
+
schedule: backupProps.schedule,
|
|
89
|
+
'prune-backups': expanded
|
|
90
|
+
? { 'keep-weekly': 4, 'keep-daily': 7 }
|
|
91
|
+
: 'keep-weekly=4,keep-daily=7',
|
|
92
|
+
fleecing: expanded ? { enabled: 1, storage: 'fast' } : 'enabled=1,storage=fast',
|
|
93
|
+
'next-run': 123456,
|
|
94
|
+
'notes-template': '{{guestname}}',
|
|
95
|
+
'repeat-missed': 1,
|
|
96
|
+
'notification-mode': 'notification-system',
|
|
97
|
+
}));
|
|
98
|
+
await withoutBao(async () => {
|
|
99
|
+
expect(
|
|
100
|
+
await Effect.runPromise(readBackupJob(backupProps).pipe(Effect.provide(fake.layer))),
|
|
101
|
+
).toMatchObject({
|
|
102
|
+
'prune-backups': 'keep-daily=7,keep-weekly=4',
|
|
103
|
+
fleecing: 'enabled=1,storage=fast',
|
|
104
|
+
'next-run': 123456,
|
|
105
|
+
'notes-template': '{{guestname}}',
|
|
106
|
+
'repeat-missed': true,
|
|
107
|
+
'notification-mode': 'notification-system',
|
|
108
|
+
});
|
|
109
|
+
const props = {
|
|
110
|
+
...backupProps,
|
|
111
|
+
vmid: undefined,
|
|
112
|
+
fleecing: 'enabled=1,storage=fast',
|
|
113
|
+
'notes-template': '{{guestname}}',
|
|
114
|
+
'repeat-missed': true,
|
|
115
|
+
'notification-mode': 'notification-system' as const,
|
|
116
|
+
};
|
|
117
|
+
const { vmid: _omitted, ...declared } = props;
|
|
118
|
+
const engine = engineOver(ProxmoxBackupJobProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
119
|
+
expect((await engine.verify(ProxmoxBackupJob('row', declared))).rows[0]).toMatchObject({
|
|
120
|
+
diff: 'noop',
|
|
121
|
+
});
|
|
122
|
+
await engine.deploy(ProxmoxBackupJob('row', declared));
|
|
123
|
+
});
|
|
124
|
+
expect(fake.writes()).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
test('MetricServer rejects a malformed non-object without leaking its payload', async () => {
|
|
129
|
+
const fake = fakePve(() => 'fixture-secret-value');
|
|
130
|
+
await withoutBao(async () => {
|
|
131
|
+
const error = await Effect.runPromise(
|
|
132
|
+
readMetricServer(metricProps).pipe(Effect.flip, Effect.provide(fake.layer)),
|
|
133
|
+
);
|
|
134
|
+
expect(error).toMatchObject({ _tag: 'ProxmoxParseError', body: undefined });
|
|
135
|
+
expect(JSON.stringify(error)).not.toContain('fixture-secret-value');
|
|
136
|
+
});
|
|
137
|
+
expect(fake.writes()).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('MetricServer delete race propagates an unclassified vendor refusal', async () => {
|
|
141
|
+
const fake = fakePve((call) =>
|
|
142
|
+
call.method === 'GET'
|
|
143
|
+
? { type: 'influxdb', server: 'metrics.test', port: 8086 }
|
|
144
|
+
: Response.json({ data: null, message: 'unable to read plugin type' }, { status: 500 }),
|
|
145
|
+
);
|
|
146
|
+
await withoutBao(async () => {
|
|
147
|
+
expect(
|
|
148
|
+
await Effect.runPromise(
|
|
149
|
+
deleteMetricServer(metricProps).pipe(Effect.flip, Effect.provide(fake.layer)),
|
|
150
|
+
),
|
|
151
|
+
).toMatchObject({ _tag: 'InternalServerError' });
|
|
152
|
+
});
|
|
153
|
+
expect(fake.writes()).toEqual(['DELETE cluster/metrics/server/metrics']);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('MetricServer OTEL updates preserve plugin-specific wire names and secrets', async () => {
|
|
157
|
+
const props = {
|
|
158
|
+
...metricProps,
|
|
159
|
+
type: 'opentelemetry' as const,
|
|
160
|
+
'otel-protocol': 'http' as const,
|
|
161
|
+
'otel-path': '/ingest',
|
|
162
|
+
'otel-compression': 'none' as const,
|
|
163
|
+
'otel-timeout': 7,
|
|
164
|
+
'otel-max-body-size': 2048,
|
|
165
|
+
'otel-verify-ssl': false,
|
|
166
|
+
};
|
|
167
|
+
const fake = fakePve(() => ({
|
|
168
|
+
type: 'opentelemetry',
|
|
169
|
+
server: 'old.test',
|
|
170
|
+
port: 8086,
|
|
171
|
+
'otel-headers': 'fixture-secret-value',
|
|
172
|
+
}));
|
|
173
|
+
await withoutBao(async () => {
|
|
174
|
+
const engine = engineOver(ProxmoxMetricServerProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
175
|
+
await engine.deploy(ProxmoxMetricServer('row', props));
|
|
176
|
+
expect(engine.stored()).not.toContain('fixture-secret-value');
|
|
177
|
+
});
|
|
178
|
+
expect(fake.writes()).toEqual(['PUT cluster/metrics/server/metrics']);
|
|
179
|
+
expect(fake.calls.find((call) => call.method === 'PUT')?.form).toEqual({
|
|
180
|
+
server: 'metrics.test',
|
|
181
|
+
port: '8086',
|
|
182
|
+
disable: '0',
|
|
183
|
+
'otel-protocol': 'http',
|
|
184
|
+
'otel-path': '/ingest',
|
|
185
|
+
'otel-compression': 'none',
|
|
186
|
+
'otel-timeout': '7',
|
|
187
|
+
'otel-max-body-size': '2048',
|
|
188
|
+
'otel-verify-ssl': '0',
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** Pure existing forms, normalization and equality, shared by plan and SDK lifecycle. */
|
|
2
|
+
import type { MetricServerAttributes, MetricServerProps } from './metric-server.ts';
|
|
3
|
+
import type { PveSpec } from './resource-spec.ts';
|
|
4
|
+
import { UNSET, createBody, update } from './metric-server-form.ts';
|
|
5
|
+
import { otelAttributes, otelMatches } from './metric-server-otel.ts';
|
|
6
|
+
import { bool, int, text } from './values.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The type-specific half of `matches`.
|
|
10
|
+
*
|
|
11
|
+
* ⛔ ONLY FIELDS THIS TYPE ACTUALLY SENDS ARE COMPARED. `verify-certificate` is not in graphite's
|
|
12
|
+
* option set and `path` is not in influxdb's, so neither is ever sent to the wrong plugin — and
|
|
13
|
+
* diffing one anyway would report an update that the PUT it triggers cannot satisfy, forever.
|
|
14
|
+
* `mtu` and `timeout` are compared for graphite and influxdb only, because the opentelemetry
|
|
15
|
+
* plugin has neither and `optional` never sends them to it.
|
|
16
|
+
*/
|
|
17
|
+
const matchesType = (attributes: MetricServerAttributes, props: MetricServerProps): boolean => {
|
|
18
|
+
if (props.type === 'opentelemetry') return otelMatches(attributes, props);
|
|
19
|
+
const socket =
|
|
20
|
+
attributes.mtu === (props.mtu ?? UNSET) && attributes.timeout === (props.timeout ?? UNSET);
|
|
21
|
+
if (props.type === 'graphite') return socket && attributes.path === (props.path ?? '');
|
|
22
|
+
return (
|
|
23
|
+
socket &&
|
|
24
|
+
attributes.influxdbproto === (props.influxdbproto ?? '') &&
|
|
25
|
+
attributes.organization === (props.organization ?? '') &&
|
|
26
|
+
attributes.bucket === (props.bucket ?? '') &&
|
|
27
|
+
attributes['api-path-prefix'] === (props['api-path-prefix'] ?? '') &&
|
|
28
|
+
attributes['max-body-size'] === (props['max-body-size'] ?? UNSET) &&
|
|
29
|
+
attributes['verify-certificate'] === (props['verify-certificate'] !== false)
|
|
30
|
+
);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const metricServerSpec = {
|
|
34
|
+
attributes: (live, props) => {
|
|
35
|
+
/**
|
|
36
|
+
* ⛔ A SERVER OF ANOTHER TYPE IS ANOTHER OBJECT, AND "ABSENT" IS THE HONEST ANSWER. `type`
|
|
37
|
+
* cannot be changed by a PUT, so reporting the foreign section as missing makes reconcile
|
|
38
|
+
* POST instead and PVE refuses because the id is taken — a loud, accurate error rather than a
|
|
39
|
+
* PUT pushing influxdb fields at a graphite section. An older PVE that omits `type` from the
|
|
40
|
+
* read falls through and is trusted.
|
|
41
|
+
*/
|
|
42
|
+
const liveType = text(live['type']);
|
|
43
|
+
if (liveType !== '' && liveType !== props.type) return undefined;
|
|
44
|
+
return {
|
|
45
|
+
...otelAttributes(live),
|
|
46
|
+
'api-path-prefix': text(live['api-path-prefix']),
|
|
47
|
+
bucket: text(live['bucket']),
|
|
48
|
+
disable: bool(live['disable'], false),
|
|
49
|
+
id: props.id,
|
|
50
|
+
influxdbproto: text(live['influxdbproto']),
|
|
51
|
+
'max-body-size': int(live['max-body-size'], UNSET),
|
|
52
|
+
mtu: int(live['mtu'], UNSET),
|
|
53
|
+
organization: text(live['organization']),
|
|
54
|
+
path: text(live['path']),
|
|
55
|
+
port: int(live['port'], 0),
|
|
56
|
+
server: text(live['server']),
|
|
57
|
+
timeout: int(live['timeout'], UNSET),
|
|
58
|
+
type: props.type,
|
|
59
|
+
'verify-certificate': bool(live['verify-certificate'], true),
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
/** ⛔ THE SAME STRING AS `path`, DELIBERATELY — see the ⛔ in the header. */
|
|
63
|
+
collection: (props) => `cluster/metrics/server/${props.id}`,
|
|
64
|
+
/** ⚠️ `type` IS REQUIRED ON CREATE; `id` IS NOT SENT, because the id is the path. */
|
|
65
|
+
createForm: createBody,
|
|
66
|
+
/**
|
|
67
|
+
* The vendor rules both forms are checked against at plan time — resource-spec.ts.
|
|
68
|
+
* ⚠️ POST AND PUT ARE THE SAME PATH HERE, and they are still two different tables: only the
|
|
69
|
+
* POST marks `type` required, and only the PUT accepts `delete` and `digest`.
|
|
70
|
+
*/
|
|
71
|
+
endpoint: {
|
|
72
|
+
create: 'pve:POST /cluster/metrics/server/{id}',
|
|
73
|
+
update: 'pve:PUT /cluster/metrics/server/{id}',
|
|
74
|
+
},
|
|
75
|
+
/**
|
|
76
|
+
* ⛔ `id` and `type` are absent: one is the key the object was read by, the other is refused
|
|
77
|
+
* rather than updated. Everything type-specific is `matchesType`'s, above.
|
|
78
|
+
*/
|
|
79
|
+
matches: (attributes, props) =>
|
|
80
|
+
attributes.server === props.server &&
|
|
81
|
+
attributes.port === props.port &&
|
|
82
|
+
attributes.disable === (props.disable === true) &&
|
|
83
|
+
matchesType(attributes, props),
|
|
84
|
+
path: (props) => `cluster/metrics/server/${props.id}`,
|
|
85
|
+
/** The form and its clear-list, from the one table in metric-server-form.ts. */
|
|
86
|
+
updateForm: update,
|
|
87
|
+
} satisfies PveSpec<MetricServerProps, MetricServerAttributes>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Metric-server SDK transport; pve-manager 9.2.11, no change to managed/secret fields. */
|
|
2
|
+
import * as cluster from '@distilled.cloud/proxmox/cluster';
|
|
3
|
+
import { ProxmoxParseError } from '@distilled.cloud/proxmox/Errors';
|
|
4
|
+
import * as Effect from 'effect/Effect';
|
|
5
|
+
import * as Schema from 'effect/Schema';
|
|
6
|
+
import { asForm } from './distilled-guard.ts';
|
|
7
|
+
import { runPve } from './distilled-pve.ts';
|
|
8
|
+
import { metricServerSpec } from './metric-server-config.ts';
|
|
9
|
+
import type { MetricServerProps } from './metric-server.ts';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The vendor intentionally leaves this plugin-dependent response unstructured in its
|
|
13
|
+
* schema. Decode only the object envelope, then use the existing per-plugin normalization.
|
|
14
|
+
* A malformed response fails without including header credentials in a schema diagnostic.
|
|
15
|
+
*/
|
|
16
|
+
export const readMetricServerRow = (props: MetricServerProps) =>
|
|
17
|
+
runPve(props.target, 'read', false, cluster.getClusterMetricsServer({ id: props.id })).pipe(
|
|
18
|
+
Effect.flatMap((body) =>
|
|
19
|
+
Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Unknown))(body).pipe(
|
|
20
|
+
Effect.mapError(
|
|
21
|
+
() =>
|
|
22
|
+
new ProxmoxParseError({
|
|
23
|
+
body: undefined,
|
|
24
|
+
cause: 'Metric server response must be an object',
|
|
25
|
+
}),
|
|
26
|
+
),
|
|
27
|
+
),
|
|
28
|
+
),
|
|
29
|
+
Effect.catchTag('MetricServerNotFound', () => Effect.succeed(undefined)),
|
|
30
|
+
Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
export const readMetricServer = (props: MetricServerProps) =>
|
|
34
|
+
readMetricServerRow(props).pipe(
|
|
35
|
+
Effect.map((row) => (row === undefined ? undefined : metricServerSpec.attributes(row, props))),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
/** Map only the existing form's declared keys; token and otel-headers never enter this map. */
|
|
39
|
+
export const metricServerRequest = (
|
|
40
|
+
props: MetricServerProps,
|
|
41
|
+
form: Record<string, string>,
|
|
42
|
+
): cluster.PutClusterMetricsServerRequest => ({
|
|
43
|
+
...asForm({
|
|
44
|
+
api_path_prefix: form['api-path-prefix'],
|
|
45
|
+
bucket: form['bucket'],
|
|
46
|
+
delete: form['delete'],
|
|
47
|
+
disable: form['disable'],
|
|
48
|
+
influxdbproto: form['influxdbproto'],
|
|
49
|
+
max_body_size: form['max-body-size'],
|
|
50
|
+
mtu: form['mtu'],
|
|
51
|
+
organization: form['organization'],
|
|
52
|
+
otel_compression: form['otel-compression'],
|
|
53
|
+
otel_max_body_size: form['otel-max-body-size'],
|
|
54
|
+
otel_path: form['otel-path'],
|
|
55
|
+
otel_protocol: form['otel-protocol'],
|
|
56
|
+
otel_timeout: form['otel-timeout'],
|
|
57
|
+
otel_verify_ssl: form['otel-verify-ssl'],
|
|
58
|
+
path: form['path'],
|
|
59
|
+
timeout: form['timeout'],
|
|
60
|
+
verify_certificate: form['verify-certificate'],
|
|
61
|
+
} satisfies { [K in keyof cluster.PutClusterMetricsServerRequest]?: string | undefined }),
|
|
62
|
+
id: props.id,
|
|
63
|
+
port: String(props.port),
|
|
64
|
+
server: props.server,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Vendor DELETE dereferences the plugin without an absent-row check (MetricServer.pm:293).
|
|
69
|
+
* A typed GET miss therefore skips DELETE. A race or any other SDK error still propagates;
|
|
70
|
+
* never reinterpret an arbitrary 500 from deleting an invalid section as success.
|
|
71
|
+
*/
|
|
72
|
+
export const deleteMetricServer = (props: MetricServerProps) =>
|
|
73
|
+
Effect.gen(function* () {
|
|
74
|
+
if ((yield* readMetricServerRow(props)) === undefined) return;
|
|
75
|
+
yield* runPve(
|
|
76
|
+
props.target,
|
|
77
|
+
'provision',
|
|
78
|
+
true,
|
|
79
|
+
cluster.deleteClusterMetricsServer({ id: props.id }),
|
|
80
|
+
);
|
|
81
|
+
});
|