@homeflare/alchemy 0.37.1 → 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/ceph-fs-distilled.d.ts +17 -10
- package/dist/proxmox/ceph-fs-distilled.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs-wire.d.ts +5 -22
- package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs.d.ts +3 -3
- 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/group-wire.d.ts +2 -2
- package/dist/proxmox/group-wire.d.ts.map +1 -1
- package/dist/proxmox/index.js +1382 -506
- package/dist/proxmox/index.js.map +51 -27
- 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/node-network-wire.d.ts +1 -1
- package/dist/proxmox/node-network-wire.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/dist/proxmox/storage-wire.d.ts +2 -2
- package/dist/proxmox/storage-wire.d.ts.map +1 -1
- package/dist/proxmox/user-wire.d.ts +2 -2
- package/dist/proxmox/user-wire.d.ts.map +1 -1
- package/dist/proxmox/zfs-pool-wire.d.ts +1 -1
- package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -1
- package/docs/changelog/README.md +22 -0
- package/docs/changelog/archive-01.md +176 -0
- package/docs/changelog/archive-02.md +180 -0
- package/docs/changelog/archive-03.md +179 -0
- package/docs/changelog/archive-04.md +178 -0
- package/docs/changelog/archive-05.md +177 -0
- package/docs/changelog/archive-06.md +180 -0
- package/docs/changelog/archive-07.md +180 -0
- package/docs/changelog/archive-08.md +178 -0
- package/docs/changelog/archive-09.md +181 -0
- package/docs/changelog/archive-10.md +182 -0
- package/docs/changelog/archive-11.md +182 -0
- package/docs/changelog/archive-12.md +183 -0
- package/docs/changelog/archive-13.md +180 -0
- package/docs/changelog/archive-14.md +178 -0
- package/docs/changelog/archive-15.md +181 -0
- package/docs/changelog/archive-16.md +182 -0
- package/docs/changelog/archive-17.md +183 -0
- package/docs/changelog/archive-18.md +55 -0
- 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/proxmox.md +9 -3
- package/docs/upstream-conformance-families.md +6 -2
- package/package.json +3 -3
- 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/ceph-fs-delete.test.ts +5 -8
- package/src/proxmox/ceph-fs-destroy-sdk.test.ts +98 -0
- package/src/proxmox/ceph-fs-distilled.ts +41 -13
- package/src/proxmox/ceph-fs-wire.ts +5 -93
- package/src/proxmox/ceph-fs.ts +7 -7
- 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,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
|
+
});
|
|
@@ -29,17 +29,25 @@
|
|
|
29
29
|
* cluster-wide config write. A separate role for metric writes is the narrower answer.
|
|
30
30
|
*/
|
|
31
31
|
import { Resource } from 'alchemy';
|
|
32
|
+
import { isResolved } from 'alchemy/Diff';
|
|
33
|
+
import * as cluster from '@distilled.cloud/proxmox/cluster';
|
|
34
|
+
import { runPve } from './distilled-pve.ts';
|
|
35
|
+
import { specGuards } from './resource-guard.ts';
|
|
36
|
+
import { formToSend } from './update-guard.ts';
|
|
37
|
+
import { metricServerSpec } from './metric-server-config.ts';
|
|
38
|
+
import {
|
|
39
|
+
deleteMetricServer,
|
|
40
|
+
metricServerRequest,
|
|
41
|
+
readMetricServer,
|
|
42
|
+
} from './metric-server-distilled.ts';
|
|
43
|
+
|
|
32
44
|
import * as Provider from 'alchemy/Provider';
|
|
33
45
|
import * as Effect from 'effect/Effect';
|
|
34
|
-
import { UNSET, createBody, update } from './metric-server-form.ts';
|
|
35
46
|
import {
|
|
36
47
|
type MetricServerOtelAttributes,
|
|
37
48
|
type MetricServerOtelProps,
|
|
38
|
-
otelAttributes,
|
|
39
|
-
otelMatches,
|
|
40
49
|
} from './metric-server-otel.ts';
|
|
41
|
-
import {
|
|
42
|
-
import { bool, int, text } from './values.ts';
|
|
50
|
+
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
43
51
|
|
|
44
52
|
/**
|
|
45
53
|
* PVE's status plugins. Which one a server is decides which fields it will even accept.
|
|
@@ -130,88 +138,7 @@ export interface ProxmoxMetricServer extends Resource<
|
|
|
130
138
|
export const ProxmoxMetricServer = Resource<ProxmoxMetricServer>('Proxmox.MetricServer');
|
|
131
139
|
|
|
132
140
|
/**
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
* ⛔ ONLY FIELDS THIS TYPE ACTUALLY SENDS ARE COMPARED. `verify-certificate` is not in graphite's
|
|
136
|
-
* option set and `path` is not in influxdb's, so neither is ever sent to the wrong plugin — and
|
|
137
|
-
* diffing one anyway would report an update that the PUT it triggers cannot satisfy, forever.
|
|
138
|
-
* `mtu` and `timeout` are compared for graphite and influxdb only, because the opentelemetry
|
|
139
|
-
* plugin has neither and `optional` never sends them to it.
|
|
140
|
-
*/
|
|
141
|
-
const matchesType = (attributes: MetricServerAttributes, props: MetricServerProps): boolean => {
|
|
142
|
-
if (props.type === 'opentelemetry') return otelMatches(attributes, props);
|
|
143
|
-
const socket =
|
|
144
|
-
attributes.mtu === (props.mtu ?? UNSET) && attributes.timeout === (props.timeout ?? UNSET);
|
|
145
|
-
if (props.type === 'graphite') return socket && attributes.path === (props.path ?? '');
|
|
146
|
-
return (
|
|
147
|
-
socket &&
|
|
148
|
-
attributes.influxdbproto === (props.influxdbproto ?? '') &&
|
|
149
|
-
attributes.organization === (props.organization ?? '') &&
|
|
150
|
-
attributes.bucket === (props.bucket ?? '') &&
|
|
151
|
-
attributes['api-path-prefix'] === (props['api-path-prefix'] ?? '') &&
|
|
152
|
-
attributes['max-body-size'] === (props['max-body-size'] ?? UNSET) &&
|
|
153
|
-
attributes['verify-certificate'] === (props['verify-certificate'] !== false)
|
|
154
|
-
);
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
const handlers = pveHandlers<MetricServerProps, MetricServerAttributes>({
|
|
158
|
-
attributes: (live, props) => {
|
|
159
|
-
/**
|
|
160
|
-
* ⛔ A SERVER OF ANOTHER TYPE IS ANOTHER OBJECT, AND "ABSENT" IS THE HONEST ANSWER. `type`
|
|
161
|
-
* cannot be changed by a PUT, so reporting the foreign section as missing makes reconcile
|
|
162
|
-
* POST instead and PVE refuses because the id is taken — a loud, accurate error rather than a
|
|
163
|
-
* PUT pushing influxdb fields at a graphite section. An older PVE that omits `type` from the
|
|
164
|
-
* read falls through and is trusted.
|
|
165
|
-
*/
|
|
166
|
-
const liveType = text(live['type']);
|
|
167
|
-
if (liveType !== '' && liveType !== props.type) return undefined;
|
|
168
|
-
return {
|
|
169
|
-
...otelAttributes(live),
|
|
170
|
-
'api-path-prefix': text(live['api-path-prefix']),
|
|
171
|
-
bucket: text(live['bucket']),
|
|
172
|
-
disable: bool(live['disable'], false),
|
|
173
|
-
id: props.id,
|
|
174
|
-
influxdbproto: text(live['influxdbproto']),
|
|
175
|
-
'max-body-size': int(live['max-body-size'], UNSET),
|
|
176
|
-
mtu: int(live['mtu'], UNSET),
|
|
177
|
-
organization: text(live['organization']),
|
|
178
|
-
path: text(live['path']),
|
|
179
|
-
port: int(live['port'], 0),
|
|
180
|
-
server: text(live['server']),
|
|
181
|
-
timeout: int(live['timeout'], UNSET),
|
|
182
|
-
type: props.type,
|
|
183
|
-
'verify-certificate': bool(live['verify-certificate'], true),
|
|
184
|
-
};
|
|
185
|
-
},
|
|
186
|
-
/** ⛔ THE SAME STRING AS `path`, DELIBERATELY — see the ⛔ in the header. */
|
|
187
|
-
collection: (props) => `cluster/metrics/server/${props.id}`,
|
|
188
|
-
/** ⚠️ `type` IS REQUIRED ON CREATE; `id` IS NOT SENT, because the id is the path. */
|
|
189
|
-
createForm: createBody,
|
|
190
|
-
/**
|
|
191
|
-
* The vendor rules both forms are checked against at plan time — resource-spec.ts.
|
|
192
|
-
* ⚠️ POST AND PUT ARE THE SAME PATH HERE, and they are still two different tables: only the
|
|
193
|
-
* POST marks `type` required, and only the PUT accepts `delete` and `digest`.
|
|
194
|
-
*/
|
|
195
|
-
endpoint: {
|
|
196
|
-
create: 'pve:POST /cluster/metrics/server/{id}',
|
|
197
|
-
update: 'pve:PUT /cluster/metrics/server/{id}',
|
|
198
|
-
},
|
|
199
|
-
/**
|
|
200
|
-
* ⛔ `id` and `type` are absent: one is the key the object was read by, the other is refused
|
|
201
|
-
* rather than updated. Everything type-specific is `matchesType`'s, above.
|
|
202
|
-
*/
|
|
203
|
-
matches: (attributes, props) =>
|
|
204
|
-
attributes.server === props.server &&
|
|
205
|
-
attributes.port === props.port &&
|
|
206
|
-
attributes.disable === (props.disable === true) &&
|
|
207
|
-
matchesType(attributes, props),
|
|
208
|
-
path: (props) => `cluster/metrics/server/${props.id}`,
|
|
209
|
-
/** The form and its clear-list, from the one table in metric-server-form.ts. */
|
|
210
|
-
updateForm: update,
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* ⛔ THE EMPTY `list` FROM `pveHandlers` EARNS ITS KEEP HERE. `GET cluster/metrics/server` answers
|
|
141
|
+
* ⛔ THE EMPTY `list` KEEPS ADOPTION EXPLICIT. `GET cluster/metrics/server` answers
|
|
215
142
|
* with every target the cluster already ships to, including the one somebody configured by hand
|
|
216
143
|
* years ago; adopting that is how a later `alchemy destroy` silences a graph nobody declared.
|
|
217
144
|
* ⚠️ AND NOTHING BRAKES THIS DESTROY. A pool refuses while it holds guests, a container while it
|
|
@@ -219,5 +146,67 @@ const handlers = pveHandlers<MetricServerProps, MetricServerAttributes>({
|
|
|
219
146
|
* interval, and no guest or task is affected. The plan diff is the only warning anyone gets —
|
|
220
147
|
* the top of this file again, this time caused by a deploy.
|
|
221
148
|
*/
|
|
149
|
+
const { guardCreate, guardUpdate } = specGuards(metricServerSpec);
|
|
150
|
+
|
|
151
|
+
/** SDK lifecycle walked against pve-manager 9.2.11; failed reads never imply create. */
|
|
222
152
|
export const ProxmoxMetricServerProvider = () =>
|
|
223
|
-
Provider.effect(
|
|
153
|
+
Provider.effect(
|
|
154
|
+
ProxmoxMetricServer,
|
|
155
|
+
Effect.succeed(
|
|
156
|
+
ProxmoxMetricServer.Provider.of({
|
|
157
|
+
list: () => Effect.succeed([]),
|
|
158
|
+
read: ({ olds }) => readMetricServer(olds),
|
|
159
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
160
|
+
if (!isResolved(news)) return undefined;
|
|
161
|
+
yield* guardCreate(news, output === undefined);
|
|
162
|
+
yield* guardUpdate(news);
|
|
163
|
+
if (output === undefined) return undefined;
|
|
164
|
+
const live = yield* readMetricServer(news);
|
|
165
|
+
if (live === undefined) {
|
|
166
|
+
yield* guardCreate(news, true);
|
|
167
|
+
return { action: 'update' } as const;
|
|
168
|
+
}
|
|
169
|
+
return { action: metricServerSpec.matches(live, news) ? 'noop' : 'update' } as const;
|
|
170
|
+
}),
|
|
171
|
+
reconcile: Effect.fn(function* ({ news }) {
|
|
172
|
+
const live = yield* readMetricServer(news);
|
|
173
|
+
yield* guardCreate(news, live === undefined);
|
|
174
|
+
yield* guardUpdate(news);
|
|
175
|
+
if (live === undefined) {
|
|
176
|
+
yield* runPve(
|
|
177
|
+
news.target,
|
|
178
|
+
'provision',
|
|
179
|
+
true,
|
|
180
|
+
cluster.updateClusterMetricsServer({
|
|
181
|
+
...metricServerRequest(news, metricServerSpec.createForm(news)),
|
|
182
|
+
type: news.type,
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
} else {
|
|
186
|
+
const form = formToSend(
|
|
187
|
+
metricServerSpec.matches,
|
|
188
|
+
live,
|
|
189
|
+
news,
|
|
190
|
+
metricServerSpec.updateForm(news),
|
|
191
|
+
);
|
|
192
|
+
if (form !== undefined)
|
|
193
|
+
yield* runPve(
|
|
194
|
+
news.target,
|
|
195
|
+
'provision',
|
|
196
|
+
true,
|
|
197
|
+
cluster.putClusterMetricsServer(metricServerRequest(news, form)),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
const after = yield* readMetricServer(news);
|
|
201
|
+
if (after === undefined)
|
|
202
|
+
return yield* Effect.fail(
|
|
203
|
+
new Error(
|
|
204
|
+
`${metricServerSpec.path(news)}: write returned success but the resource is still absent`,
|
|
205
|
+
),
|
|
206
|
+
);
|
|
207
|
+
return after;
|
|
208
|
+
}),
|
|
209
|
+
delete: ({ olds }) => deleteMetricServer(olds),
|
|
210
|
+
}),
|
|
211
|
+
),
|
|
212
|
+
);
|