@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,190 @@
|
|
|
1
|
+
/** Real Plan/Apply must refuse an invalid replacement before deleting its working target. */
|
|
2
|
+
import { afterEach, beforeEach, expect } from 'bun:test';
|
|
3
|
+
import * as Test from 'alchemy/Test/Bun';
|
|
4
|
+
import * as Effect from 'effect/Effect';
|
|
5
|
+
import * as Layer from 'effect/Layer';
|
|
6
|
+
import { fakeNotify } from './fake-pbs-notify.ts';
|
|
7
|
+
import { FAKE_TARGET, fakePve } from './fake-pve.ts';
|
|
8
|
+
import {
|
|
9
|
+
type NotificationTargetProps,
|
|
10
|
+
ProxmoxNotificationTarget,
|
|
11
|
+
ProxmoxNotificationTargetProvider,
|
|
12
|
+
} from './notification-target.ts';
|
|
13
|
+
import {
|
|
14
|
+
PbsNotificationTarget,
|
|
15
|
+
type PbsNotificationTargetProps,
|
|
16
|
+
PbsNotificationTargetProvider,
|
|
17
|
+
} from './pbs-notification-target.ts';
|
|
18
|
+
|
|
19
|
+
const MISSING = 'HF_NOTIFICATION_REPLACEMENT_MISSING';
|
|
20
|
+
const PBS = { api: 'https://pbs.test:8007/api2/json', mount: 'pbs-test', scheme: 'pbs' } as const;
|
|
21
|
+
const fake = fakeNotify();
|
|
22
|
+
let saved: [string, string | undefined][] = [];
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
saved = Object.entries(process.env).filter(
|
|
25
|
+
([key]) => /^(BAO|VAULT)_/.test(key) || key === MISSING,
|
|
26
|
+
);
|
|
27
|
+
for (const [key] of saved) delete process.env[key];
|
|
28
|
+
process.env['BAO_ADDR'] = 'http://bao.invalid';
|
|
29
|
+
fake.objects.clear();
|
|
30
|
+
fake.secrets.clear();
|
|
31
|
+
fake.calls.length = 0;
|
|
32
|
+
});
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
for (const key of Object.keys(process.env))
|
|
35
|
+
if (/^(BAO|VAULT)_/.test(key) || key === MISSING) delete process.env[key];
|
|
36
|
+
for (const [key, value] of saved) process.env[key] = value;
|
|
37
|
+
});
|
|
38
|
+
const { test } = Test.make({
|
|
39
|
+
adopt: true,
|
|
40
|
+
dev: false,
|
|
41
|
+
sidecar: false,
|
|
42
|
+
stage: 'test',
|
|
43
|
+
providers: Layer.mergeAll(
|
|
44
|
+
ProxmoxNotificationTargetProvider(),
|
|
45
|
+
PbsNotificationTargetProvider(),
|
|
46
|
+
).pipe(Layer.provideMerge(fake.layer)),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const pve = (props: Omit<NotificationTargetProps, 'target'>) =>
|
|
50
|
+
ProxmoxNotificationTarget('endpoint', { ...props, target: FAKE_TARGET });
|
|
51
|
+
const pbs = (props: Omit<PbsNotificationTargetProps, 'target'>) =>
|
|
52
|
+
PbsNotificationTarget('endpoint', { ...props, target: PBS });
|
|
53
|
+
const invalidPve: readonly Omit<NotificationTargetProps, 'target' | 'name'>[] = [
|
|
54
|
+
{ type: 'webhook', method: 'post' },
|
|
55
|
+
{ type: 'gotify', server: 'https://gotify.example.com' },
|
|
56
|
+
{ type: 'smtp', server: 'smtp.example.com' },
|
|
57
|
+
{
|
|
58
|
+
type: 'webhook',
|
|
59
|
+
method: 'post',
|
|
60
|
+
url: 'https://pager.example.com',
|
|
61
|
+
mailto: ['ops@example.com'],
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
const invalidPbs: readonly Omit<PbsNotificationTargetProps, 'target' | 'name'>[] = [
|
|
65
|
+
{ type: 'webhook', method: 'post' },
|
|
66
|
+
{
|
|
67
|
+
type: 'webhook',
|
|
68
|
+
method: 'post',
|
|
69
|
+
url: 'https://pager.example.com',
|
|
70
|
+
secret: { token: { fromEnv: MISSING } },
|
|
71
|
+
},
|
|
72
|
+
{ type: 'webhook', method: 'post', url: 'https://pager.example.com', comment: 'x'.repeat(129) },
|
|
73
|
+
];
|
|
74
|
+
for (const [index, props] of invalidPve.entries()) {
|
|
75
|
+
test.provider(`PVE replacement refusal ${index} preserves the old target`, (scratch) =>
|
|
76
|
+
Effect.gen(function* () {
|
|
77
|
+
const old = pve({ name: 'pager', type: 'sendmail' });
|
|
78
|
+
yield* scratch.deploy(old);
|
|
79
|
+
const path = 'cluster/notifications/endpoints/sendmail/pager';
|
|
80
|
+
const before = fake.objects.get(path);
|
|
81
|
+
fake.calls.length = 0;
|
|
82
|
+
expect((yield* Effect.exit(scratch.deploy(pve({ ...props, name: 'pager' }))))._tag).toBe(
|
|
83
|
+
'Failure',
|
|
84
|
+
);
|
|
85
|
+
expect(fake.writes()).toEqual([]);
|
|
86
|
+
expect(fake.objects.get(path)).toEqual(before);
|
|
87
|
+
expect(Object.values((yield* scratch.plan(old)).resources).map((row) => row.action)).toEqual([
|
|
88
|
+
'noop',
|
|
89
|
+
]);
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
for (const [index, props] of invalidPbs.entries()) {
|
|
94
|
+
test.provider(`PBS replacement refusal ${index} preserves the old target`, (scratch) =>
|
|
95
|
+
Effect.gen(function* () {
|
|
96
|
+
const old = pbs({ name: 'pager', type: 'sendmail' });
|
|
97
|
+
yield* scratch.deploy(old);
|
|
98
|
+
const path = 'config/notifications/endpoints/sendmail/pager';
|
|
99
|
+
const before = fake.objects.get(path);
|
|
100
|
+
fake.calls.length = 0;
|
|
101
|
+
expect((yield* Effect.exit(scratch.deploy(pbs({ ...props, name: 'pager' }))))._tag).toBe(
|
|
102
|
+
'Failure',
|
|
103
|
+
);
|
|
104
|
+
expect(fake.writes()).toEqual([]);
|
|
105
|
+
expect(fake.objects.get(path)).toEqual(before);
|
|
106
|
+
expect(Object.values((yield* scratch.plan(old)).resources).map((row) => row.action)).toEqual([
|
|
107
|
+
'noop',
|
|
108
|
+
]);
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
test.provider(
|
|
113
|
+
'PVE can replace by adopting an existing renamed gotify target without its token',
|
|
114
|
+
(scratch) =>
|
|
115
|
+
Effect.gen(function* () {
|
|
116
|
+
yield* scratch.deploy(pve({ name: 'old', type: 'sendmail' }));
|
|
117
|
+
const path = 'cluster/notifications/endpoints/gotify/existing';
|
|
118
|
+
fake.objects.set(path, { name: 'existing', server: 'https://gotify.example.com' });
|
|
119
|
+
fake.calls.length = 0;
|
|
120
|
+
yield* scratch.deploy(
|
|
121
|
+
pve({ name: 'existing', type: 'gotify', server: 'https://gotify.example.com' }),
|
|
122
|
+
);
|
|
123
|
+
expect(fake.objects.has(path)).toBe(true);
|
|
124
|
+
expect(fake.writes()).toEqual(['DELETE cluster/notifications/endpoints/sendmail/old']);
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const forbidden = fakePve((call) =>
|
|
129
|
+
call.path.endsWith('/sendmail/pager')
|
|
130
|
+
? { name: 'pager' }
|
|
131
|
+
: Response.json({ message: 'forbidden' }, { status: 403 }),
|
|
132
|
+
);
|
|
133
|
+
const blocked = Test.make({
|
|
134
|
+
adopt: true,
|
|
135
|
+
dev: false,
|
|
136
|
+
sidecar: false,
|
|
137
|
+
stage: 'test',
|
|
138
|
+
providers: Layer.mergeAll(
|
|
139
|
+
ProxmoxNotificationTargetProvider(),
|
|
140
|
+
PbsNotificationTargetProvider(),
|
|
141
|
+
).pipe(Layer.provideMerge(forbidden.layer)),
|
|
142
|
+
}).test;
|
|
143
|
+
for (const declare of [pve, pbs]) {
|
|
144
|
+
blocked.provider(`${declare.name} destination refusal prevents replacement deletion`, (scratch) =>
|
|
145
|
+
Effect.gen(function* () {
|
|
146
|
+
forbidden.calls.length = 0;
|
|
147
|
+
yield* scratch.deploy(
|
|
148
|
+
Effect.gen(function* () {
|
|
149
|
+
yield* declare({ name: 'pager', type: 'sendmail' });
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
const replacement = Effect.gen(function* () {
|
|
153
|
+
yield* declare({
|
|
154
|
+
name: 'pager',
|
|
155
|
+
type: 'webhook',
|
|
156
|
+
url: 'https://pager.example.com',
|
|
157
|
+
method: 'post',
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
expect((yield* Effect.exit(scratch.deploy(replacement)))._tag).toBe('Failure');
|
|
161
|
+
expect(forbidden.writes()).toEqual([]);
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
test.provider(
|
|
166
|
+
'PBS can replace by adopting an existing renamed webhook without its secret value',
|
|
167
|
+
(scratch) =>
|
|
168
|
+
Effect.gen(function* () {
|
|
169
|
+
yield* scratch.deploy(pbs({ name: 'old', type: 'sendmail' }));
|
|
170
|
+
const path = 'config/notifications/endpoints/webhook/existing';
|
|
171
|
+
fake.objects.set(path, {
|
|
172
|
+
name: 'existing',
|
|
173
|
+
url: 'https://pager.example.com',
|
|
174
|
+
method: 'post',
|
|
175
|
+
secret: ['name=token'],
|
|
176
|
+
});
|
|
177
|
+
fake.calls.length = 0;
|
|
178
|
+
yield* scratch.deploy(
|
|
179
|
+
pbs({
|
|
180
|
+
name: 'existing',
|
|
181
|
+
type: 'webhook',
|
|
182
|
+
url: 'https://pager.example.com',
|
|
183
|
+
method: 'post',
|
|
184
|
+
secret: { token: { fromEnv: MISSING } },
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
expect(fake.objects.has(path)).toBe(true);
|
|
188
|
+
expect(fake.writes()).toEqual(['DELETE config/notifications/endpoints/sendmail/old']);
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** PVE 9.2.11 form names mapped to the generated SDK's TypeScript field names. */
|
|
2
|
+
import { ProxmoxParseError } from '@distilled.cloud/proxmox/Errors';
|
|
3
|
+
import * as Data from 'effect/Data';
|
|
4
|
+
import * as Effect from 'effect/Effect';
|
|
5
|
+
import * as Schema from 'effect/Schema';
|
|
6
|
+
|
|
7
|
+
/** Local schema refusal names the operation, never a form value (headers may be sensitive). */
|
|
8
|
+
export class NotificationRequestInvalid extends Data.TaggedError('NotificationRequestInvalid')<{
|
|
9
|
+
readonly operation: string;
|
|
10
|
+
}> {}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The provider's forms already enforce vendor constraints. This final generated-schema
|
|
14
|
+
* check validates the translated Type-side fields without a cast. Unexpected family fields
|
|
15
|
+
* fail explicitly instead of disappearing through the decoder's default excess-property stripping.
|
|
16
|
+
*/
|
|
17
|
+
export const notificationRequest = <S extends Schema.Top>(schema: S, fields: object) =>
|
|
18
|
+
Schema.decodeUnknownEffect(Schema.toType(schema), { onExcessProperty: 'error' })(
|
|
19
|
+
Object.fromEntries(
|
|
20
|
+
Object.entries(fields).map(([key, value]) => [key.replaceAll('-', '_'), value]),
|
|
21
|
+
),
|
|
22
|
+
).pipe(
|
|
23
|
+
Effect.mapError(() => new NotificationRequestInvalid({ operation: 'PVE notification write' })),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
/** Validate required response fields with the SDK schema without discarding vendor extras. */
|
|
27
|
+
export const notificationResponse =
|
|
28
|
+
<S extends Schema.Top>(schema: S) =>
|
|
29
|
+
(body: S['Type']) =>
|
|
30
|
+
Schema.decodeUnknownEffect(Schema.toType(schema))(body).pipe(
|
|
31
|
+
Effect.as(body),
|
|
32
|
+
Effect.mapError(
|
|
33
|
+
() =>
|
|
34
|
+
new ProxmoxParseError({
|
|
35
|
+
body: undefined,
|
|
36
|
+
cause: 'PVE notification response does not match its SDK schema',
|
|
37
|
+
}),
|
|
38
|
+
),
|
|
39
|
+
);
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/** PVE 9.2.11 target operations. Named SDK calls retain each endpoint's own request schema. */
|
|
2
|
+
import * as cluster from '@distilled.cloud/proxmox/cluster';
|
|
3
|
+
import type { ProxmoxOpContext, ProxmoxOpError } from '@distilled.cloud/proxmox/Protocol';
|
|
4
|
+
import * as Effect from 'effect/Effect';
|
|
5
|
+
import { runPve } from './distilled-pve.ts';
|
|
6
|
+
import type { NotificationTargetProps as Props } from './notification-target.ts';
|
|
7
|
+
import { addressList, createShape, shape } from './notification-target-form.ts';
|
|
8
|
+
import { notificationRequest, notificationResponse } from './notification-sdk-input.ts';
|
|
9
|
+
import { targetSpec } from './notification-target-spec.ts';
|
|
10
|
+
|
|
11
|
+
const read = (
|
|
12
|
+
props: Props,
|
|
13
|
+
): Effect.Effect<Record<string, unknown>, ProxmoxOpError, ProxmoxOpContext> => {
|
|
14
|
+
const request = { name: props.name };
|
|
15
|
+
switch (props.type) {
|
|
16
|
+
case 'gotify':
|
|
17
|
+
return cluster.getClusterNotificationEndpointGotify(request).pipe(
|
|
18
|
+
Effect.flatMap(notificationResponse(cluster.GetClusterNotificationEndpointGotifyResponse)),
|
|
19
|
+
Effect.map((row) => ({ ...row })),
|
|
20
|
+
);
|
|
21
|
+
case 'sendmail':
|
|
22
|
+
return cluster.getClusterNotificationEndpointSendmail(request).pipe(
|
|
23
|
+
Effect.flatMap(
|
|
24
|
+
notificationResponse(cluster.GetClusterNotificationEndpointSendmailResponse),
|
|
25
|
+
),
|
|
26
|
+
Effect.map((row) => ({
|
|
27
|
+
...row,
|
|
28
|
+
'from-address': row.from_address,
|
|
29
|
+
'mailto-user': row.mailto_user,
|
|
30
|
+
})),
|
|
31
|
+
);
|
|
32
|
+
case 'smtp':
|
|
33
|
+
return cluster.getClusterNotificationEndpointSmtp(request).pipe(
|
|
34
|
+
Effect.flatMap(notificationResponse(cluster.GetClusterNotificationEndpointSmtpResponse)),
|
|
35
|
+
Effect.map((row) => ({
|
|
36
|
+
...row,
|
|
37
|
+
'from-address': row.from_address,
|
|
38
|
+
'mailto-user': row.mailto_user,
|
|
39
|
+
})),
|
|
40
|
+
);
|
|
41
|
+
case 'webhook':
|
|
42
|
+
return cluster.getClusterNotificationEndpointWebhook(request).pipe(
|
|
43
|
+
Effect.flatMap(notificationResponse(cluster.GetClusterNotificationEndpointWebhookResponse)),
|
|
44
|
+
Effect.map((row) => ({ ...row })),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const readTarget = (props: Props) =>
|
|
50
|
+
runPve(props.target, 'read', false, read(props)).pipe(
|
|
51
|
+
Effect.map((row) => targetSpec.attributes(row, props)),
|
|
52
|
+
Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
/** The legacy forms spell scalar lists explicitly; SDK arrays preserve each logical value. */
|
|
56
|
+
const fields = (props: Props, create: boolean) => {
|
|
57
|
+
const form: Record<string, string | string[]> = {
|
|
58
|
+
...(create ? createShape(props) : shape(props)),
|
|
59
|
+
name: props.name,
|
|
60
|
+
};
|
|
61
|
+
const cleared: string[] = [];
|
|
62
|
+
for (const [key, values] of [
|
|
63
|
+
['header', props.header === undefined ? undefined : [...props.header]],
|
|
64
|
+
[
|
|
65
|
+
'mailto',
|
|
66
|
+
props.mailto === undefined ? undefined : addressList(props.mailto).split(',').filter(Boolean),
|
|
67
|
+
],
|
|
68
|
+
[
|
|
69
|
+
'mailto-user',
|
|
70
|
+
props['mailto-user'] === undefined
|
|
71
|
+
? undefined
|
|
72
|
+
: addressList(props['mailto-user']).split(',').filter(Boolean),
|
|
73
|
+
],
|
|
74
|
+
] as const) {
|
|
75
|
+
if (values === undefined) continue;
|
|
76
|
+
if (values.length > 0) form[key] = values;
|
|
77
|
+
else {
|
|
78
|
+
delete form[key];
|
|
79
|
+
if (!create) cleared.push(key);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Empty SDK arrays emit no keys; a PUT must explicitly clear a declared empty list.
|
|
83
|
+
if (cleared.length > 0) form.delete = cleared;
|
|
84
|
+
return form;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** No request: replacement planning must reject SDK-only input errors before any DELETE. */
|
|
88
|
+
export const validateTargetWrite = (props: Props, create: boolean) => {
|
|
89
|
+
const schema = create
|
|
90
|
+
? {
|
|
91
|
+
sendmail: cluster.CreateClusterNotificationEndpointSendmailRequest,
|
|
92
|
+
smtp: cluster.CreateClusterNotificationEndpointSmtpRequest,
|
|
93
|
+
gotify: cluster.CreateClusterNotificationEndpointGotifyRequest,
|
|
94
|
+
webhook: cluster.CreateClusterNotificationEndpointWebhookRequest,
|
|
95
|
+
}[props.type]
|
|
96
|
+
: {
|
|
97
|
+
sendmail: cluster.PutClusterNotificationEndpointSendmailRequest,
|
|
98
|
+
smtp: cluster.PutClusterNotificationEndpointSmtpRequest,
|
|
99
|
+
gotify: cluster.PutClusterNotificationEndpointGotifyRequest,
|
|
100
|
+
webhook: cluster.PutClusterNotificationEndpointWebhookRequest,
|
|
101
|
+
}[props.type];
|
|
102
|
+
return notificationRequest(schema, fields(props, create)).pipe(Effect.asVoid);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const createTarget = (props: Props) => {
|
|
106
|
+
const form = fields(props, true);
|
|
107
|
+
const operation =
|
|
108
|
+
props.type === 'sendmail'
|
|
109
|
+
? notificationRequest(cluster.CreateClusterNotificationEndpointSendmailRequest, form).pipe(
|
|
110
|
+
Effect.flatMap(cluster.createClusterNotificationEndpointSendmail),
|
|
111
|
+
)
|
|
112
|
+
: props.type === 'smtp'
|
|
113
|
+
? notificationRequest(cluster.CreateClusterNotificationEndpointSmtpRequest, form).pipe(
|
|
114
|
+
Effect.flatMap(cluster.createClusterNotificationEndpointSmtp),
|
|
115
|
+
)
|
|
116
|
+
: props.type === 'gotify'
|
|
117
|
+
? notificationRequest(cluster.CreateClusterNotificationEndpointGotifyRequest, form).pipe(
|
|
118
|
+
Effect.flatMap(cluster.createClusterNotificationEndpointGotify),
|
|
119
|
+
)
|
|
120
|
+
: notificationRequest(cluster.CreateClusterNotificationEndpointWebhookRequest, form).pipe(
|
|
121
|
+
Effect.flatMap(cluster.createClusterNotificationEndpointWebhook),
|
|
122
|
+
);
|
|
123
|
+
return runPve(props.target, 'provision', true, operation);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const updateTarget = (props: Props) => {
|
|
127
|
+
const form = fields(props, false);
|
|
128
|
+
const operation =
|
|
129
|
+
props.type === 'sendmail'
|
|
130
|
+
? notificationRequest(cluster.PutClusterNotificationEndpointSendmailRequest, form).pipe(
|
|
131
|
+
Effect.flatMap(cluster.putClusterNotificationEndpointSendmail),
|
|
132
|
+
)
|
|
133
|
+
: props.type === 'smtp'
|
|
134
|
+
? notificationRequest(cluster.PutClusterNotificationEndpointSmtpRequest, form).pipe(
|
|
135
|
+
Effect.flatMap(cluster.putClusterNotificationEndpointSmtp),
|
|
136
|
+
)
|
|
137
|
+
: props.type === 'gotify'
|
|
138
|
+
? notificationRequest(cluster.PutClusterNotificationEndpointGotifyRequest, form).pipe(
|
|
139
|
+
Effect.flatMap(cluster.putClusterNotificationEndpointGotify),
|
|
140
|
+
)
|
|
141
|
+
: notificationRequest(cluster.PutClusterNotificationEndpointWebhookRequest, form).pipe(
|
|
142
|
+
Effect.flatMap(cluster.putClusterNotificationEndpointWebhook),
|
|
143
|
+
);
|
|
144
|
+
return runPve(props.target, 'provision', true, operation);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export const deleteTarget = (props: Props) => {
|
|
148
|
+
const request = { name: props.name };
|
|
149
|
+
const operation =
|
|
150
|
+
props.type === 'sendmail'
|
|
151
|
+
? cluster.deleteClusterNotificationEndpointSendmail(request)
|
|
152
|
+
: props.type === 'smtp'
|
|
153
|
+
? cluster.deleteClusterNotificationEndpointSmtp(request)
|
|
154
|
+
: props.type === 'gotify'
|
|
155
|
+
? cluster.deleteClusterNotificationEndpointGotify(request)
|
|
156
|
+
: cluster.deleteClusterNotificationEndpointWebhook(request);
|
|
157
|
+
return runPve(props.target, 'provision', true, operation).pipe(
|
|
158
|
+
Effect.catchTag('NotFound', () => Effect.void),
|
|
159
|
+
);
|
|
160
|
+
};
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* one metric-server uses: this file turns a declaration into a form, and the resource file says
|
|
7
7
|
* what a target IS and when it has changed. The `import type` back is erased, so it is a cycle
|
|
8
8
|
* on paper only.
|
|
9
|
+
* ★ These shapes still feed specGuards and preserve scalar/default/omission mapping. The
|
|
10
|
+
* distilled adapter replaces list fields with typed arrays; the SDK owns their wire encoding.
|
|
9
11
|
*/
|
|
10
12
|
import type { NotificationTargetProps } from './notification-target.ts';
|
|
11
13
|
import { text } from './values.ts';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** PVE targets: observe before writes, retain secret omission and built-in delete behavior. */
|
|
2
|
+
import { isResolved } from 'alchemy/Diff';
|
|
3
|
+
import type { Input } from 'alchemy/Input';
|
|
4
|
+
import * as Effect from 'effect/Effect';
|
|
5
|
+
import type {
|
|
6
|
+
NotificationTargetAttributes as Attributes,
|
|
7
|
+
NotificationTargetProps as Props,
|
|
8
|
+
} from './notification-target.ts';
|
|
9
|
+
import { specGuards } from './resource-guard.ts';
|
|
10
|
+
import { targetSpec } from './notification-target-spec.ts';
|
|
11
|
+
import {
|
|
12
|
+
createTarget,
|
|
13
|
+
deleteTarget,
|
|
14
|
+
readTarget,
|
|
15
|
+
updateTarget,
|
|
16
|
+
validateTargetWrite,
|
|
17
|
+
} from './notification-target-distilled.ts';
|
|
18
|
+
|
|
19
|
+
const { guardCreate, guardUpdate } = specGuards(targetSpec);
|
|
20
|
+
const guard = (props: Props, create: boolean) =>
|
|
21
|
+
guardCreate(props, create).pipe(Effect.andThen(guardUpdate(props)));
|
|
22
|
+
export const targetHandlers = {
|
|
23
|
+
/** Targets include built-ins and human-owned endpoints; enrollment remains explicit. */
|
|
24
|
+
list: () => Effect.succeed([]),
|
|
25
|
+
read: ({ olds }: { olds: Props }) =>
|
|
26
|
+
Effect.gen(function* () {
|
|
27
|
+
const live = yield* readTarget(olds);
|
|
28
|
+
yield* guard(olds, live === undefined);
|
|
29
|
+
return live;
|
|
30
|
+
}),
|
|
31
|
+
diff: ({
|
|
32
|
+
news,
|
|
33
|
+
olds,
|
|
34
|
+
output,
|
|
35
|
+
}: {
|
|
36
|
+
news: Input<Props>;
|
|
37
|
+
olds: Props;
|
|
38
|
+
output: Attributes | undefined;
|
|
39
|
+
}) =>
|
|
40
|
+
Effect.gen(function* () {
|
|
41
|
+
if (!isResolved(news)) return undefined;
|
|
42
|
+
// Names are unique across endpoint types. A same-name type change must delete first.
|
|
43
|
+
if (olds.name !== news.name || olds.type !== news.type) {
|
|
44
|
+
// ⛔ Validate before delete-first can remove the working target. An existing destination
|
|
45
|
+
// is adoption: do not demand create-only secrets that the provider cannot declare.
|
|
46
|
+
const destination = yield* readTarget(news);
|
|
47
|
+
yield* guard(news, destination === undefined);
|
|
48
|
+
yield* validateTargetWrite(news, destination === undefined);
|
|
49
|
+
return { action: 'replace', deleteFirst: olds.name === news.name } as const;
|
|
50
|
+
}
|
|
51
|
+
yield* guard(news, output === undefined);
|
|
52
|
+
if (output === undefined) return undefined;
|
|
53
|
+
const live = yield* readTarget(news);
|
|
54
|
+
if (live === undefined) {
|
|
55
|
+
yield* guard(news, true);
|
|
56
|
+
return { action: 'update' } as const;
|
|
57
|
+
}
|
|
58
|
+
return { action: targetSpec.matches(live, news) ? 'noop' : 'update' } as const;
|
|
59
|
+
}),
|
|
60
|
+
reconcile: ({ news }: { news: Props }) =>
|
|
61
|
+
Effect.gen(function* () {
|
|
62
|
+
const live = yield* readTarget(news);
|
|
63
|
+
yield* guard(news, live === undefined);
|
|
64
|
+
if (live === undefined) yield* createTarget(news);
|
|
65
|
+
else if (!targetSpec.matches(live, news)) yield* updateTarget(news);
|
|
66
|
+
const after = yield* readTarget(news);
|
|
67
|
+
if (after === undefined)
|
|
68
|
+
return yield* Effect.die(
|
|
69
|
+
new Error(`Notification target ${news.name}: write returned but read-back is absent.`),
|
|
70
|
+
);
|
|
71
|
+
return after;
|
|
72
|
+
}),
|
|
73
|
+
/** Vendor DELETE reverts built-ins; SDK NotFound makes an absent custom target idempotent. */
|
|
74
|
+
delete: ({ olds }: { olds: Props }) => deleteTarget(olds),
|
|
75
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Pure target state and vendor guards, kept separate from the SDK lifecycle. */
|
|
2
|
+
import type {
|
|
3
|
+
NotificationTargetAttributes,
|
|
4
|
+
NotificationTargetProps,
|
|
5
|
+
} from './notification-target.ts';
|
|
6
|
+
import type { PveSpec } from './resource-spec.ts';
|
|
7
|
+
import { targetEndpoint } from './notification-target-endpoint.ts';
|
|
8
|
+
import { addressList, createShape, headerList, portOf, shape } from './notification-target-form.ts';
|
|
9
|
+
import { text } from './values.ts';
|
|
10
|
+
|
|
11
|
+
export const targetSpec: PveSpec<NotificationTargetProps, NotificationTargetAttributes> = {
|
|
12
|
+
/**
|
|
13
|
+
* ⛔ NAMED FIELDS, NEVER A SPREAD OF `live`. gotify's `token` and smtp's `password` really are
|
|
14
|
+
* absent from their read schemas — but webhook's read schema DECLARES `secret`, so "PVE will
|
|
15
|
+
* not hand it to us" is not a defence. Listing what is kept is what keeps secret material out
|
|
16
|
+
* of a state row, whatever a future PVE decides to start returning.
|
|
17
|
+
*/
|
|
18
|
+
attributes: (live, props) => ({
|
|
19
|
+
author: text(live['author']),
|
|
20
|
+
body: text(live['body']),
|
|
21
|
+
comment: text(live['comment']),
|
|
22
|
+
disable: live['disable'] === 1 || live['disable'] === true,
|
|
23
|
+
'from-address': text(live['from-address']),
|
|
24
|
+
header: headerList(live['header']),
|
|
25
|
+
mailto: addressList(live['mailto']),
|
|
26
|
+
'mailto-user': addressList(live['mailto-user']),
|
|
27
|
+
method: text(live['method']),
|
|
28
|
+
mode: text(live['mode']),
|
|
29
|
+
name: props.name,
|
|
30
|
+
port: portOf(live['port']),
|
|
31
|
+
server: text(live['server']),
|
|
32
|
+
type: props.type,
|
|
33
|
+
url: text(live['url']),
|
|
34
|
+
username: text(live['username']),
|
|
35
|
+
}),
|
|
36
|
+
collection: (props) => `cluster/notifications/endpoints/${props.type}`,
|
|
37
|
+
/** ⛔ `name` is create-only: PVE takes it in the body once, and in the path forever after. */
|
|
38
|
+
createForm: createShape,
|
|
39
|
+
/** ⚠️ A function, because the type picks the endpoint — see `targetEndpoint`. */
|
|
40
|
+
endpoint: targetEndpoint,
|
|
41
|
+
/**
|
|
42
|
+
* Each line reads "not declared, or equal"; `disable` is the one field with no undeclared case.
|
|
43
|
+
*
|
|
44
|
+
* ⚠️ IF A PLAN NEVER SETTLES, SUSPECT A SCHEMA DEFAULT COMING BACK AS A VALUE. PVE defaults smtp
|
|
45
|
+
* `mode` to `tls` and smtp `author` to `Proxmox VE`, and applies both when the mail is sent
|
|
46
|
+
* rather than writing them into the config — so an undeclared field reads back empty and its
|
|
47
|
+
* line is skipped. A version that STORED them would answer with a field nobody declared. The
|
|
48
|
+
* fix is to declare the field, never to stop comparing it.
|
|
49
|
+
*/
|
|
50
|
+
matches: (attributes, props) =>
|
|
51
|
+
attributes.disable === (props.disable === true) &&
|
|
52
|
+
(props.comment === undefined || attributes.comment === props.comment) &&
|
|
53
|
+
(props.server === undefined || attributes.server === props.server) &&
|
|
54
|
+
(props.port === undefined || attributes.port === props.port) &&
|
|
55
|
+
(props.mode === undefined || attributes.mode === props.mode) &&
|
|
56
|
+
(props.username === undefined || attributes.username === props.username) &&
|
|
57
|
+
(props['from-address'] === undefined || attributes['from-address'] === props['from-address']) &&
|
|
58
|
+
(props.author === undefined || attributes.author === props.author) &&
|
|
59
|
+
(props.url === undefined || attributes.url === props.url) &&
|
|
60
|
+
(props.method === undefined || attributes.method === props.method) &&
|
|
61
|
+
(props.body === undefined || attributes.body === props.body) &&
|
|
62
|
+
(props.mailto === undefined || attributes.mailto === addressList(props.mailto)) &&
|
|
63
|
+
(props['mailto-user'] === undefined ||
|
|
64
|
+
attributes['mailto-user'] === addressList(props['mailto-user'])) &&
|
|
65
|
+
(props.header === undefined || attributes.header === headerList(props.header)),
|
|
66
|
+
path: (props) => `cluster/notifications/endpoints/${props.type}/${props.name}`,
|
|
67
|
+
updateForm: shape,
|
|
68
|
+
};
|
|
@@ -45,18 +45,15 @@
|
|
|
45
45
|
* ⚠️ PREFER A SECOND ROLE GRANTED AT `/mapping/notifications` OVER WIDENING THE PROVISIONING ROLE
|
|
46
46
|
* ON `/`. `/mapping` also holds the PCI and USB passthrough maps, so Mapping.Modify at the root
|
|
47
47
|
* lets a credential scoped to make containers rewire somebody's hardware.
|
|
48
|
-
* ⛔
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* is the signature — grant Mapping.Audit to the READ role before changing anything else.
|
|
48
|
+
* ⛔ THE OLD HAND CLIENT FOLDED 403 INTO "ABSENT": the plan said create, PVE refused the
|
|
49
|
+
* duplicate, and the fault was an ACL all along. Distilled now propagates that failure.
|
|
50
|
+
* Grant Mapping.Audit to the READ role before adopting; only typed NotFound means absent.
|
|
52
51
|
*/
|
|
53
52
|
import { Resource } from 'alchemy';
|
|
54
53
|
import * as Provider from 'alchemy/Provider';
|
|
55
54
|
import * as Effect from 'effect/Effect';
|
|
56
|
-
import {
|
|
57
|
-
import {
|
|
58
|
-
import { type PveRequirements, type WithTarget, pveHandlers } from './resource.ts';
|
|
59
|
-
import { text } from './values.ts';
|
|
55
|
+
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
56
|
+
import { targetHandlers } from './notification-target-lifecycle.ts';
|
|
60
57
|
|
|
61
58
|
/** The four families PVE ships. `GET /cluster/notifications/endpoints` lists exactly these. */
|
|
62
59
|
export type NotificationTargetType = 'gotify' | 'sendmail' | 'smtp' | 'webhook';
|
|
@@ -154,65 +151,6 @@ export const ProxmoxNotificationTarget = Resource<ProxmoxNotificationTarget>(
|
|
|
154
151
|
'Proxmox.NotificationTarget',
|
|
155
152
|
);
|
|
156
153
|
|
|
157
|
-
const handlers = pveHandlers<NotificationTargetProps, NotificationTargetAttributes>({
|
|
158
|
-
/**
|
|
159
|
-
* ⛔ NAMED FIELDS, NEVER A SPREAD OF `live`. gotify's `token` and smtp's `password` really are
|
|
160
|
-
* absent from their read schemas — but webhook's read schema DECLARES `secret`, so "PVE will
|
|
161
|
-
* not hand it to us" is not a defence. Listing what is kept is what keeps secret material out
|
|
162
|
-
* of a state row, whatever a future PVE decides to start returning.
|
|
163
|
-
*/
|
|
164
|
-
attributes: (live, props) => ({
|
|
165
|
-
author: text(live['author']),
|
|
166
|
-
body: text(live['body']),
|
|
167
|
-
comment: text(live['comment']),
|
|
168
|
-
disable: live['disable'] === 1 || live['disable'] === true,
|
|
169
|
-
'from-address': text(live['from-address']),
|
|
170
|
-
header: headerList(live['header']),
|
|
171
|
-
mailto: addressList(live['mailto']),
|
|
172
|
-
'mailto-user': addressList(live['mailto-user']),
|
|
173
|
-
method: text(live['method']),
|
|
174
|
-
mode: text(live['mode']),
|
|
175
|
-
name: props.name,
|
|
176
|
-
port: portOf(live['port']),
|
|
177
|
-
server: text(live['server']),
|
|
178
|
-
type: props.type,
|
|
179
|
-
url: text(live['url']),
|
|
180
|
-
username: text(live['username']),
|
|
181
|
-
}),
|
|
182
|
-
collection: (props) => `cluster/notifications/endpoints/${props.type}`,
|
|
183
|
-
/** ⛔ `name` is create-only: PVE takes it in the body once, and in the path forever after. */
|
|
184
|
-
createForm: createShape,
|
|
185
|
-
/** ⚠️ A function, because the type picks the endpoint — see `targetEndpoint`. */
|
|
186
|
-
endpoint: targetEndpoint,
|
|
187
|
-
/**
|
|
188
|
-
* Each line reads "not declared, or equal"; `disable` is the one field with no undeclared case.
|
|
189
|
-
*
|
|
190
|
-
* ⚠️ IF A PLAN NEVER SETTLES, SUSPECT A SCHEMA DEFAULT COMING BACK AS A VALUE. PVE defaults smtp
|
|
191
|
-
* `mode` to `tls` and smtp `author` to `Proxmox VE`, and applies both when the mail is sent
|
|
192
|
-
* rather than writing them into the config — so an undeclared field reads back empty and its
|
|
193
|
-
* line is skipped. A version that STORED them would answer with a field nobody declared. The
|
|
194
|
-
* fix is to declare the field, never to stop comparing it.
|
|
195
|
-
*/
|
|
196
|
-
matches: (attributes, props) =>
|
|
197
|
-
attributes.disable === (props.disable === true) &&
|
|
198
|
-
(props.comment === undefined || attributes.comment === props.comment) &&
|
|
199
|
-
(props.server === undefined || attributes.server === props.server) &&
|
|
200
|
-
(props.port === undefined || attributes.port === props.port) &&
|
|
201
|
-
(props.mode === undefined || attributes.mode === props.mode) &&
|
|
202
|
-
(props.username === undefined || attributes.username === props.username) &&
|
|
203
|
-
(props['from-address'] === undefined || attributes['from-address'] === props['from-address']) &&
|
|
204
|
-
(props.author === undefined || attributes.author === props.author) &&
|
|
205
|
-
(props.url === undefined || attributes.url === props.url) &&
|
|
206
|
-
(props.method === undefined || attributes.method === props.method) &&
|
|
207
|
-
(props.body === undefined || attributes.body === props.body) &&
|
|
208
|
-
(props.mailto === undefined || attributes.mailto === addressList(props.mailto)) &&
|
|
209
|
-
(props['mailto-user'] === undefined ||
|
|
210
|
-
attributes['mailto-user'] === addressList(props['mailto-user'])) &&
|
|
211
|
-
(props.header === undefined || attributes.header === headerList(props.header)),
|
|
212
|
-
path: (props) => `cluster/notifications/endpoints/${props.type}/${props.name}`,
|
|
213
|
-
updateForm: shape,
|
|
214
|
-
});
|
|
215
|
-
|
|
216
154
|
/**
|
|
217
155
|
* ⚠️ ON A BUILT-IN TARGET A DELETE REVERTS RATHER THAN REMOVES, and PVE reports success either way.
|
|
218
156
|
* It is passed through unguarded because everywhere else in this package the cluster's answer is
|
|
@@ -222,5 +160,5 @@ const handlers = pveHandlers<NotificationTargetProps, NotificationTargetAttribut
|
|
|
222
160
|
export const ProxmoxNotificationTargetProvider = () =>
|
|
223
161
|
Provider.effect(
|
|
224
162
|
ProxmoxNotificationTarget,
|
|
225
|
-
Effect.succeed(ProxmoxNotificationTarget.Provider.of(
|
|
163
|
+
Effect.succeed(ProxmoxNotificationTarget.Provider.of(targetHandlers)),
|
|
226
164
|
);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* `Pbs.Datastore`'s two vendor endpoints, and the plan-time check over its two forms.
|
|
3
3
|
*
|
|
4
4
|
* ★ A FILE OF ITS OWN BECAUSE THIS FAMILY DOES NOT USE `pveHandlers`. It writes its own `diff` and
|
|
5
|
-
* `reconcile` (
|
|
5
|
+
* `reconcile` (bounded readback handles settling after a write; create returns no task id), so
|
|
6
6
|
* it cannot inherit the guard from resource.ts — and a copy of the two calls inside that already
|
|
7
7
|
* long file is exactly the per-resource copy-paste the shared handler exists to prevent.
|
|
8
8
|
*
|