@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,215 @@
|
|
|
1
|
+
/** Four PBS families through the real SDK and Alchemy's upstream test harness. */
|
|
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 { PBS_JOBS_TARGET, fakePbsJobs } from './fake-pbs-jobs.ts';
|
|
7
|
+
import { PbsDatastore, PbsDatastoreProvider } from './pbs-datastore.ts';
|
|
8
|
+
import { deleteOne as deleteDatastore } from './pbs-datastore-wire.ts';
|
|
9
|
+
import { PbsPruneJob, PbsPruneJobProvider } from './pbs-prune-job.ts';
|
|
10
|
+
import { PbsSyncJob, PbsSyncJobProvider } from './pbs-sync-job.ts';
|
|
11
|
+
import { PbsVerifyJob, PbsVerifyJobProvider } from './pbs-verify-job.ts';
|
|
12
|
+
|
|
13
|
+
const fake = fakePbsJobs();
|
|
14
|
+
// ⛔ Test harness hooks take Effects, so isolate the shell before its per-test runtime starts.
|
|
15
|
+
let saved: [string, string | undefined][] = [];
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
saved = Object.entries(process.env).filter(([key]) => /^(BAO|VAULT)_/.test(key));
|
|
18
|
+
for (const [key] of saved) delete process.env[key];
|
|
19
|
+
process.env['BAO_ADDR'] = 'http://bao.invalid';
|
|
20
|
+
fake.reset();
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
for (const key of Object.keys(process.env))
|
|
24
|
+
if (/^(BAO|VAULT)_/.test(key)) delete process.env[key];
|
|
25
|
+
for (const [key, value] of saved) process.env[key] = value;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const { test } = Test.make({
|
|
29
|
+
adopt: true,
|
|
30
|
+
dev: false,
|
|
31
|
+
sidecar: false,
|
|
32
|
+
stage: 'test',
|
|
33
|
+
providers: Layer.mergeAll(
|
|
34
|
+
PbsDatastoreProvider(),
|
|
35
|
+
PbsPruneJobProvider(),
|
|
36
|
+
PbsSyncJobProvider(),
|
|
37
|
+
PbsVerifyJobProvider(),
|
|
38
|
+
).pipe(Layer.provideMerge(fake.layer)),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const stack = (comment = 'current') =>
|
|
42
|
+
Effect.gen(function* () {
|
|
43
|
+
yield* PbsDatastore('store', {
|
|
44
|
+
name: 'store',
|
|
45
|
+
path: '/mnt/store',
|
|
46
|
+
'gc-schedule': 'daily',
|
|
47
|
+
'verify-new': false,
|
|
48
|
+
comment,
|
|
49
|
+
target: PBS_JOBS_TARGET,
|
|
50
|
+
});
|
|
51
|
+
yield* PbsPruneJob('prune', {
|
|
52
|
+
id: 'prune',
|
|
53
|
+
store: 'store',
|
|
54
|
+
schedule: 'daily',
|
|
55
|
+
'keep-daily': 30,
|
|
56
|
+
'max-depth': 0,
|
|
57
|
+
comment,
|
|
58
|
+
target: PBS_JOBS_TARGET,
|
|
59
|
+
});
|
|
60
|
+
yield* PbsSyncJob('sync', {
|
|
61
|
+
id: 'sync',
|
|
62
|
+
store: 'store',
|
|
63
|
+
'remote-store': 'other',
|
|
64
|
+
'remote-ns': 'archive',
|
|
65
|
+
'remove-vanished': false,
|
|
66
|
+
'max-depth': 0,
|
|
67
|
+
'verified-only': true,
|
|
68
|
+
comment,
|
|
69
|
+
target: PBS_JOBS_TARGET,
|
|
70
|
+
});
|
|
71
|
+
yield* PbsVerifyJob('verify', {
|
|
72
|
+
id: 'verify',
|
|
73
|
+
store: 'store',
|
|
74
|
+
schedule: null,
|
|
75
|
+
'outdated-after': 30,
|
|
76
|
+
comment,
|
|
77
|
+
target: PBS_JOBS_TARGET,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const existing = () => {
|
|
82
|
+
fake.rows.set('config/datastore/store', {
|
|
83
|
+
name: 'store',
|
|
84
|
+
path: '/mnt/store',
|
|
85
|
+
'gc-schedule': 'daily',
|
|
86
|
+
'verify-new': false,
|
|
87
|
+
comment: 'current',
|
|
88
|
+
});
|
|
89
|
+
fake.rows.set('config/prune/prune', {
|
|
90
|
+
id: 'prune',
|
|
91
|
+
store: 'store',
|
|
92
|
+
schedule: 'daily',
|
|
93
|
+
'keep-daily': 30,
|
|
94
|
+
'max-depth': 0,
|
|
95
|
+
comment: 'current',
|
|
96
|
+
});
|
|
97
|
+
fake.rows.set('config/sync/sync', {
|
|
98
|
+
id: 'sync',
|
|
99
|
+
store: 'store',
|
|
100
|
+
'remote-store': 'other',
|
|
101
|
+
'remote-ns': 'archive',
|
|
102
|
+
'remove-vanished': false,
|
|
103
|
+
'max-depth': 0,
|
|
104
|
+
'verified-only': true,
|
|
105
|
+
comment: 'current',
|
|
106
|
+
});
|
|
107
|
+
fake.rows.set('config/verify/verify', {
|
|
108
|
+
id: 'verify',
|
|
109
|
+
store: 'store',
|
|
110
|
+
'ignore-verified': true,
|
|
111
|
+
'outdated-after': 30,
|
|
112
|
+
comment: 'current',
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const actions = (plan: { resources: Record<string, { action: string }> }) =>
|
|
117
|
+
Object.values(plan.resources)
|
|
118
|
+
.map((row) => row.action)
|
|
119
|
+
.sort();
|
|
120
|
+
|
|
121
|
+
test.provider(
|
|
122
|
+
'matching datastore and three job families adopt without writes, then plan noop',
|
|
123
|
+
(scratch) =>
|
|
124
|
+
Effect.gen(function* () {
|
|
125
|
+
existing();
|
|
126
|
+
yield* scratch.deploy(stack());
|
|
127
|
+
expect(fake.writes).toEqual([]);
|
|
128
|
+
expect(actions(yield* scratch.plan(stack()))).toEqual(['noop', 'noop', 'noop', 'noop']);
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
test.provider(
|
|
133
|
+
'measured missing sections create through typed SDK forms and settle to noop',
|
|
134
|
+
(scratch) =>
|
|
135
|
+
Effect.gen(function* () {
|
|
136
|
+
yield* scratch.deploy(stack());
|
|
137
|
+
expect(fake.writes.map((row) => `${row.method} ${row.path}`).sort()).toEqual([
|
|
138
|
+
'POST config/datastore',
|
|
139
|
+
'POST config/prune',
|
|
140
|
+
'POST config/sync',
|
|
141
|
+
'POST config/verify',
|
|
142
|
+
]);
|
|
143
|
+
expect(fake.writes.find((row) => row.path === 'config/prune')?.form).toMatchObject({
|
|
144
|
+
'keep-daily': '30',
|
|
145
|
+
'max-depth': '0',
|
|
146
|
+
});
|
|
147
|
+
expect(fake.writes.find((row) => row.path === 'config/sync')?.form).toMatchObject({
|
|
148
|
+
'remote-store': 'other',
|
|
149
|
+
'remote-ns': 'archive',
|
|
150
|
+
'remove-vanished': '0',
|
|
151
|
+
'verified-only': '1',
|
|
152
|
+
});
|
|
153
|
+
expect(fake.writes.find((row) => row.path === 'config/verify')?.form).not.toHaveProperty(
|
|
154
|
+
'schedule',
|
|
155
|
+
);
|
|
156
|
+
expect(actions(yield* scratch.plan(stack()))).toEqual(['noop', 'noop', 'noop', 'noop']);
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
test.provider(
|
|
161
|
+
'drift updates only declared fields through SDK forms and the next plan is noop',
|
|
162
|
+
(scratch) =>
|
|
163
|
+
Effect.gen(function* () {
|
|
164
|
+
existing();
|
|
165
|
+
yield* scratch.deploy(stack());
|
|
166
|
+
yield* scratch.deploy(stack('changed'));
|
|
167
|
+
expect(fake.writes.map((row) => `${row.method} ${row.path}`).sort()).toEqual([
|
|
168
|
+
'PUT config/datastore/store',
|
|
169
|
+
'PUT config/prune/prune',
|
|
170
|
+
'PUT config/sync/sync',
|
|
171
|
+
'PUT config/verify/verify',
|
|
172
|
+
]);
|
|
173
|
+
for (const write of fake.writes) expect(write.form).not.toHaveProperty('delete');
|
|
174
|
+
expect(actions(yield* scratch.plan(stack('changed')))).toEqual([
|
|
175
|
+
'noop',
|
|
176
|
+
'noop',
|
|
177
|
+
'noop',
|
|
178
|
+
'noop',
|
|
179
|
+
]);
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
for (const status of [200, 400, 401, 403, 500]) {
|
|
184
|
+
test.provider(
|
|
185
|
+
`HTTP ${status} malformed or refused read never becomes create or noop`,
|
|
186
|
+
(scratch) =>
|
|
187
|
+
Effect.gen(function* () {
|
|
188
|
+
existing();
|
|
189
|
+
yield* scratch.deploy(stack());
|
|
190
|
+
fake.failRead(status, { message: 'unrelated server refusal', data: null });
|
|
191
|
+
const result = yield* Effect.exit(scratch.plan(stack()));
|
|
192
|
+
expect(result._tag).toBe('Failure');
|
|
193
|
+
expect(fake.writes).toEqual([]);
|
|
194
|
+
// Clear the injected failure before Test.Bun's automatic fixture teardown.
|
|
195
|
+
fake.reset();
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
test.provider(
|
|
201
|
+
'a refused datastore delete read cannot confirm deletion; already absent needs no write',
|
|
202
|
+
(scratch) =>
|
|
203
|
+
Effect.gen(function* () {
|
|
204
|
+
existing();
|
|
205
|
+
yield* scratch.deploy(stack());
|
|
206
|
+
const props = { name: 'store', path: '/mnt/store', target: PBS_JOBS_TARGET };
|
|
207
|
+
fake.failRead(403, { message: 'permission denied', data: null });
|
|
208
|
+
expect((yield* Effect.exit(deleteDatastore(props)))._tag).toBe('Failure');
|
|
209
|
+
expect(fake.writes).toEqual([]);
|
|
210
|
+
fake.reset();
|
|
211
|
+
yield* deleteDatastore(props);
|
|
212
|
+
yield* scratch.destroy();
|
|
213
|
+
expect(fake.writes).toEqual([]);
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Read failures must never turn an existing notification route into a planned create. */
|
|
2
|
+
import { 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 type { PbsTarget } from './credentials.ts';
|
|
7
|
+
import { fakePve, withoutBao } from './fake-pve.ts';
|
|
8
|
+
import { deleteMatcher, readMatcher } from './pbs-notification-matcher-distilled.ts';
|
|
9
|
+
import {
|
|
10
|
+
PbsNotificationMatcher,
|
|
11
|
+
PbsNotificationMatcherProvider,
|
|
12
|
+
} from './pbs-notification-matcher.ts';
|
|
13
|
+
import { createTarget, deleteTarget, readTarget } from './pbs-notification-target-distilled.ts';
|
|
14
|
+
import { PbsNotificationTarget, PbsNotificationTargetProvider } from './pbs-notification-target.ts';
|
|
15
|
+
|
|
16
|
+
const PBS: PbsTarget = { api: 'https://pbs.test:8007', mount: 'pbs-test', scheme: 'pbs' };
|
|
17
|
+
const MATCHER = { name: 'pager', target: PBS, targets: ['mail-to-root'] };
|
|
18
|
+
const TARGET = { name: 'mail-to-root', target: PBS, type: 'sendmail' as const };
|
|
19
|
+
|
|
20
|
+
for (const status of [401, 403, 500, 503]) {
|
|
21
|
+
test(`PBS ${String(status)} fails cold adoption and cannot POST either notification family`, async () => {
|
|
22
|
+
const fake = fakePve(() => Response.json({ message: 'read refused' }, { status }));
|
|
23
|
+
const providers = Layer.mergeAll(
|
|
24
|
+
PbsNotificationMatcherProvider(),
|
|
25
|
+
PbsNotificationTargetProvider(),
|
|
26
|
+
);
|
|
27
|
+
const stack = Effect.gen(function* () {
|
|
28
|
+
yield* PbsNotificationMatcher('pager', MATCHER);
|
|
29
|
+
yield* PbsNotificationTarget('mail', TARGET);
|
|
30
|
+
});
|
|
31
|
+
await withoutBao(async () => {
|
|
32
|
+
const engine = engineOver(providers.pipe(Layer.provideMerge(fake.layer)));
|
|
33
|
+
await expect(engine.deploy(stack)).rejects.toBeDefined();
|
|
34
|
+
});
|
|
35
|
+
expect(fake.writes()).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('only the typed NotFound is absence, and deleting it is idempotent for both families', async () => {
|
|
40
|
+
// PBS 4.2 read-only missing-name probe, 2026-09-24: notification absence is plain-text 404.
|
|
41
|
+
const fake = fakePve(() => new Response("matcher 'pager' not found", { status: 404 }));
|
|
42
|
+
await withoutBao(async () => {
|
|
43
|
+
const outcome = await Effect.runPromise(
|
|
44
|
+
Effect.gen(function* () {
|
|
45
|
+
const matcher = yield* readMatcher(MATCHER);
|
|
46
|
+
const target = yield* readTarget(TARGET);
|
|
47
|
+
yield* deleteMatcher(MATCHER);
|
|
48
|
+
yield* deleteTarget(TARGET);
|
|
49
|
+
return { matcher, target };
|
|
50
|
+
}).pipe(Effect.provide(fake.layer)),
|
|
51
|
+
);
|
|
52
|
+
expect(outcome).toEqual({ matcher: undefined, target: undefined });
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a malformed successful resource response is a failure, never absence', async () => {
|
|
57
|
+
const fake = fakePve(() => undefined);
|
|
58
|
+
await withoutBao(async () => {
|
|
59
|
+
for (const read of [
|
|
60
|
+
readMatcher(MATCHER).pipe(Effect.asVoid),
|
|
61
|
+
readTarget(TARGET).pipe(Effect.asVoid),
|
|
62
|
+
]) {
|
|
63
|
+
await expect(Effect.runPromise(read.pipe(Effect.provide(fake.layer)))).rejects.toBeDefined();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
expect(fake.writes()).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('typed write errors also propagate instead of being treated as completed deletes', async () => {
|
|
70
|
+
const fake = fakePve(() => Response.json({ message: 'permission denied' }, { status: 403 }));
|
|
71
|
+
await withoutBao(async () => {
|
|
72
|
+
for (const remove of [deleteMatcher(MATCHER), deleteTarget(TARGET)]) {
|
|
73
|
+
const error = await Effect.runPromise(remove.pipe(Effect.flip, Effect.provide(fake.layer)));
|
|
74
|
+
expect(error).toMatchObject({ _tag: 'Forbidden' });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
expect(fake.writes()).toHaveLength(2);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('invalid SDK input never echoes a write-only value or sends a vendor request', async () => {
|
|
81
|
+
const fake = fakePve(() => undefined);
|
|
82
|
+
await withoutBao(async () => {
|
|
83
|
+
const error = await Effect.runPromise(
|
|
84
|
+
createTarget(
|
|
85
|
+
{ ...TARGET, type: 'smtp' },
|
|
86
|
+
{ name: TARGET.name, password: 'invalid-input-secret-must-stay-private' },
|
|
87
|
+
).pipe(Effect.flip, Effect.provide(fake.layer)),
|
|
88
|
+
);
|
|
89
|
+
expect(error).toMatchObject({ _tag: 'PbsNotificationInputRefusal' });
|
|
90
|
+
expect(JSON.stringify(error)).not.toContain('invalid-input-secret-must-stay-private');
|
|
91
|
+
});
|
|
92
|
+
expect(fake.calls).toEqual([]);
|
|
93
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Validate translated fields with the SDK's own schema without echoing write-only values. */
|
|
2
|
+
import * as Data from 'effect/Data';
|
|
3
|
+
import * as Effect from 'effect/Effect';
|
|
4
|
+
import * as Schema from 'effect/Schema';
|
|
5
|
+
|
|
6
|
+
export class PbsNotificationInputRefusal extends Data.TaggedError('PbsNotificationInputRefusal')<{
|
|
7
|
+
readonly message: string;
|
|
8
|
+
}> {}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Fields already use SDK Type-side names; toType validates without any wire transforms.
|
|
12
|
+
* A schema diagnostic can reproduce the whole submitted form, including a webhook secret or
|
|
13
|
+
* SMTP password. Sanitize only our pre-request validation failure. SDK operation errors below
|
|
14
|
+
* this boundary retain their original typed tags and are never remapped.
|
|
15
|
+
*/
|
|
16
|
+
export const notificationInput = <S extends Schema.Top>(schema: S, fields: unknown) =>
|
|
17
|
+
Schema.decodeUnknownEffect(Schema.toType(schema))(fields).pipe(
|
|
18
|
+
Effect.mapError(
|
|
19
|
+
() =>
|
|
20
|
+
new PbsNotificationInputRefusal({
|
|
21
|
+
message:
|
|
22
|
+
'PBS notification fields do not match the SDK request schema; no PBS request was sent.',
|
|
23
|
+
}),
|
|
24
|
+
),
|
|
25
|
+
);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Mail target CRUD uses the SDK's field spellings without losing lists or the password seal. */
|
|
2
|
+
import { afterEach, expect, test } from 'bun:test';
|
|
3
|
+
import * as Layer from 'effect/Layer';
|
|
4
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
5
|
+
import type { PbsTarget } from './credentials.ts';
|
|
6
|
+
import { fakeNotify } from './fake-pbs-notify.ts';
|
|
7
|
+
import { withoutBao } from './fake-pve.ts';
|
|
8
|
+
import { PbsNotificationTarget, PbsNotificationTargetProvider } from './pbs-notification-target.ts';
|
|
9
|
+
|
|
10
|
+
const PBS: PbsTarget = { api: 'https://pbs.test:8007', mount: 'pbs-test', scheme: 'pbs' };
|
|
11
|
+
const PASSWORD = 'smtp-test-secret-not-real';
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
delete process.env['HF_PBS_SMTP_TEST_PASSWORD'];
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
for (const type of ['sendmail', 'smtp'] as const) {
|
|
17
|
+
test(`${type} creates, reads its dashed fields, clears recipients and redeploys without a write`, async () => {
|
|
18
|
+
process.env['HF_PBS_SMTP_TEST_PASSWORD'] = PASSWORD;
|
|
19
|
+
const fake = fakeNotify();
|
|
20
|
+
const declare = (mailto: readonly string[]) =>
|
|
21
|
+
PbsNotificationTarget('pager', {
|
|
22
|
+
type,
|
|
23
|
+
name: 'pager',
|
|
24
|
+
target: PBS,
|
|
25
|
+
'from-address': 'sender@example.com',
|
|
26
|
+
'mailto-user': ['root@pam'],
|
|
27
|
+
mailto,
|
|
28
|
+
...(type === 'smtp'
|
|
29
|
+
? {
|
|
30
|
+
mode: 'starttls' as const,
|
|
31
|
+
password: { fromEnv: 'HF_PBS_SMTP_TEST_PASSWORD' },
|
|
32
|
+
port: 587,
|
|
33
|
+
server: 'smtp.example.com',
|
|
34
|
+
username: 'pager',
|
|
35
|
+
}
|
|
36
|
+
: {}),
|
|
37
|
+
});
|
|
38
|
+
await withoutBao(async () => {
|
|
39
|
+
const engine = engineOver(
|
|
40
|
+
PbsNotificationTargetProvider().pipe(Layer.provideMerge(fake.layer)),
|
|
41
|
+
);
|
|
42
|
+
await engine.deploy(declare(['ops@example.com', 'backup@example.com']));
|
|
43
|
+
expect(
|
|
44
|
+
(await engine.verify(declare(['ops@example.com', 'backup@example.com']), { all: true }))
|
|
45
|
+
.rows[0]?.diff,
|
|
46
|
+
).toBe('noop');
|
|
47
|
+
expect(engine.stored()).not.toContain(PASSWORD);
|
|
48
|
+
await engine.deploy(declare([]));
|
|
49
|
+
expect((await engine.verify(declare([]), { all: true })).rows[0]?.diff).toBe('noop');
|
|
50
|
+
await engine.deploy(declare([]));
|
|
51
|
+
});
|
|
52
|
+
expect(fake.writes()).toEqual([
|
|
53
|
+
`POST config/notifications/endpoints/${type}`,
|
|
54
|
+
`PUT config/notifications/endpoints/${type}/pager`,
|
|
55
|
+
]);
|
|
56
|
+
const post = fake.calls.find((call) => call.method === 'POST');
|
|
57
|
+
expect(post?.pairs).toContainEqual(['from-address', 'sender@example.com']);
|
|
58
|
+
expect(post?.pairs).toContainEqual(['mailto-user', 'root@pam']);
|
|
59
|
+
expect(post?.pairs.filter(([key]) => key === 'mailto')).toHaveLength(2);
|
|
60
|
+
const put = fake.calls.find((call) => call.method === 'PUT');
|
|
61
|
+
expect(put?.pairs).toContainEqual(['delete', 'mailto']);
|
|
62
|
+
expect(put?.pairs.map(([key]) => key)).not.toContain('password');
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** PBS 4.2.6-1 matcher operations through SDK 0.3.0; failed reads never become absence. */
|
|
2
|
+
import * as config from '@distilled.cloud/proxmox-backup/config';
|
|
3
|
+
import * as Effect from 'effect/Effect';
|
|
4
|
+
import { notificationInput } from './pbs-notification-input.ts';
|
|
5
|
+
import { runPbs } from './distilled-pbs.ts';
|
|
6
|
+
import {
|
|
7
|
+
type MatcherCreateForm,
|
|
8
|
+
type MatcherUpdateForm,
|
|
9
|
+
matcherAttributes,
|
|
10
|
+
} from './notification-matcher-form.ts';
|
|
11
|
+
import type { PbsNotificationMatcherProps as Props } from './pbs-notification-matcher.ts';
|
|
12
|
+
|
|
13
|
+
export const readMatcher = (props: Props) =>
|
|
14
|
+
runPbs(props.target, 'read', config.getConfigNotificationMatcher({ name: props.name })).pipe(
|
|
15
|
+
Effect.map((row) =>
|
|
16
|
+
matcherAttributes(
|
|
17
|
+
{
|
|
18
|
+
...row,
|
|
19
|
+
'invert-match': row.invert_match,
|
|
20
|
+
'match-calendar': row.match_calendar,
|
|
21
|
+
'match-field': row.match_field,
|
|
22
|
+
'match-severity': row.match_severity,
|
|
23
|
+
},
|
|
24
|
+
props.name,
|
|
25
|
+
),
|
|
26
|
+
),
|
|
27
|
+
Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
/** Validate with the SDK's request schema after spelling the shared PVE/PBS form's keys. */
|
|
31
|
+
const requestFields = (props: Props, form: MatcherCreateForm | MatcherUpdateForm) => ({
|
|
32
|
+
...form,
|
|
33
|
+
invert_match: form['invert-match'],
|
|
34
|
+
match_calendar: form['match-calendar'],
|
|
35
|
+
match_field: form['match-field'],
|
|
36
|
+
match_severity: form['match-severity'],
|
|
37
|
+
name: props.name,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const createMatcher = (props: Props, form: MatcherCreateForm) =>
|
|
41
|
+
runPbs(
|
|
42
|
+
props.target,
|
|
43
|
+
'provision',
|
|
44
|
+
notificationInput(
|
|
45
|
+
config.CreateConfigNotificationMatcherRequest,
|
|
46
|
+
requestFields(props, form),
|
|
47
|
+
).pipe(Effect.flatMap(config.createConfigNotificationMatcher)),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
export const updateMatcher = (props: Props, form: MatcherUpdateForm) =>
|
|
51
|
+
runPbs(
|
|
52
|
+
props.target,
|
|
53
|
+
'provision',
|
|
54
|
+
notificationInput(config.PutConfigNotificationMatcherRequest, requestFields(props, form)).pipe(
|
|
55
|
+
Effect.flatMap(config.putConfigNotificationMatcher),
|
|
56
|
+
),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
/** PBS reverts built-ins on deletion; it reports NotFound only for an absent user-created row. */
|
|
60
|
+
export const deleteMatcher = (props: Props) =>
|
|
61
|
+
runPbs(
|
|
62
|
+
props.target,
|
|
63
|
+
'provision',
|
|
64
|
+
config.deleteConfigNotificationMatcher({ name: props.name }),
|
|
65
|
+
).pipe(Effect.catchTag('NotFound', () => Effect.void));
|
|
@@ -36,21 +36,30 @@
|
|
|
36
36
|
* ★ WHAT THIS NEEDS, READ OFF THE SERVER'S SCHEMA (generated/pbs.ts source):
|
|
37
37
|
* read GET Sys.Audit on /system/notifications
|
|
38
38
|
* write POST, PUT, DELETE Sys.Modify on /system/notifications
|
|
39
|
-
* ⚠️ NOT A DATASTORE PRIVILEGE. A
|
|
40
|
-
*
|
|
39
|
+
* ⚠️ NOT A DATASTORE PRIVILEGE. A credential scoped to `/datastore/...` cannot read matchers;
|
|
40
|
+
* the old client treated that refusal as absent — notification-target.ts describes the
|
|
41
|
+
* resulting "absent, then already exists" signature.
|
|
41
42
|
*/
|
|
42
43
|
import { Resource } from 'alchemy';
|
|
43
44
|
import * as Provider from 'alchemy/Provider';
|
|
45
|
+
import { isResolved } from 'alchemy/Diff';
|
|
46
|
+
import type { Input } from 'alchemy/Input';
|
|
44
47
|
import * as Effect from 'effect/Effect';
|
|
45
48
|
import {
|
|
46
49
|
type NotificationMatcherAttributes,
|
|
47
50
|
type NotificationMatcherFields,
|
|
48
|
-
matcherAttributes,
|
|
49
51
|
matcherCreateForm,
|
|
50
52
|
matcherMatches,
|
|
51
53
|
matcherUpdateForm,
|
|
52
54
|
} from './notification-matcher-form.ts';
|
|
53
|
-
import {
|
|
55
|
+
import type { PveRequirements, WithPbsTarget } from './resource.ts';
|
|
56
|
+
import { guardForm } from './constraint-guard.ts';
|
|
57
|
+
import {
|
|
58
|
+
createMatcher,
|
|
59
|
+
deleteMatcher,
|
|
60
|
+
readMatcher,
|
|
61
|
+
updateMatcher,
|
|
62
|
+
} from './pbs-notification-matcher-distilled.ts';
|
|
54
63
|
|
|
55
64
|
export interface PbsNotificationMatcherProps extends NotificationMatcherFields, WithPbsTarget {}
|
|
56
65
|
|
|
@@ -65,19 +74,63 @@ export interface PbsNotificationMatcher extends Resource<
|
|
|
65
74
|
/** ⚠️ No `retain`, for notification-matcher.ts's reason; a built-in REVERTS on delete. */
|
|
66
75
|
export const PbsNotificationMatcher = Resource<PbsNotificationMatcher>('Pbs.NotificationMatcher');
|
|
67
76
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
77
|
+
type Props = PbsNotificationMatcherProps;
|
|
78
|
+
type Attributes = NotificationMatcherAttributes;
|
|
79
|
+
|
|
80
|
+
const guardCreate = (props: Props, requirePresence: boolean) =>
|
|
81
|
+
guardForm('pbs:POST /config/notifications/matchers', matcherCreateForm(props), requirePresence);
|
|
82
|
+
const guardUpdate = (props: Props) =>
|
|
83
|
+
guardForm('pbs:PUT /config/notifications/matchers/{name}', matcherUpdateForm(props), false);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* SDK-backed lifecycle: read before writing and compare the entire rule so adoption is free.
|
|
87
|
+
* ⛔ A 401, 403, malformed response or failed credential mint must fail the plan. Only the
|
|
88
|
+
* SDK's typed NotFound means absent; the old factory turned all of these into false creates.
|
|
89
|
+
*/
|
|
90
|
+
const handlers = {
|
|
91
|
+
/** Built-ins belong to the host; adopting them must stay an explicit declaration. */
|
|
92
|
+
list: () => Effect.succeed([]),
|
|
93
|
+
read: ({ olds }: { olds: Props }) => readMatcher(olds),
|
|
94
|
+
diff: ({
|
|
95
|
+
news,
|
|
96
|
+
olds,
|
|
97
|
+
output,
|
|
98
|
+
}: {
|
|
99
|
+
news: Input<Props>;
|
|
100
|
+
olds: Props;
|
|
101
|
+
output: Attributes | undefined;
|
|
102
|
+
}) =>
|
|
103
|
+
Effect.gen(function* () {
|
|
104
|
+
if (!isResolved(news)) return undefined;
|
|
105
|
+
yield* guardCreate(news, output === undefined);
|
|
106
|
+
yield* guardUpdate(news);
|
|
107
|
+
if (output === undefined) return undefined;
|
|
108
|
+
if (olds.name !== news.name) return { action: 'replace' } as const;
|
|
109
|
+
const live = yield* readMatcher(news);
|
|
110
|
+
if (live === undefined) {
|
|
111
|
+
yield* guardCreate(news, true);
|
|
112
|
+
return { action: 'update' } as const;
|
|
113
|
+
}
|
|
114
|
+
return { action: matcherMatches(live, news) ? 'noop' : 'update' } as const;
|
|
115
|
+
}),
|
|
116
|
+
reconcile: ({ news }: { news: Props }) =>
|
|
117
|
+
Effect.gen(function* () {
|
|
118
|
+
const live = yield* readMatcher(news);
|
|
119
|
+
yield* guardCreate(news, live === undefined);
|
|
120
|
+
yield* guardUpdate(news);
|
|
121
|
+
if (live === undefined) yield* createMatcher(news, matcherCreateForm(news));
|
|
122
|
+
else if (!matcherMatches(live, news)) yield* updateMatcher(news, matcherUpdateForm(news));
|
|
123
|
+
const after = yield* readMatcher(news);
|
|
124
|
+
if (after === undefined)
|
|
125
|
+
return yield* Effect.fail(
|
|
126
|
+
new Error(
|
|
127
|
+
`config/notifications/matchers/${news.name}: the write returned no error but the matcher is still absent.`,
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
return after;
|
|
131
|
+
}),
|
|
132
|
+
delete: ({ olds }: { olds: Props }) => deleteMatcher(olds),
|
|
133
|
+
};
|
|
81
134
|
|
|
82
135
|
export const PbsNotificationMatcherProvider = () =>
|
|
83
136
|
Provider.effect(
|