@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
|
@@ -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
|
+
);
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/** Notification lifecycles through the real SDK and upstream Alchemy test harness. */
|
|
2
|
+
import { afterEach, beforeEach, expect, test as unit } 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, withoutBao } from './fake-pve.ts';
|
|
8
|
+
import {
|
|
9
|
+
ProxmoxNotificationMatcher,
|
|
10
|
+
ProxmoxNotificationMatcherProvider,
|
|
11
|
+
} from './notification-matcher.ts';
|
|
12
|
+
import {
|
|
13
|
+
ProxmoxNotificationTarget,
|
|
14
|
+
ProxmoxNotificationTargetProvider,
|
|
15
|
+
} from './notification-target.ts';
|
|
16
|
+
import { readMatcher } from './notification-matcher-distilled.ts';
|
|
17
|
+
import { readTarget } from './notification-target-distilled.ts';
|
|
18
|
+
|
|
19
|
+
const fake = fakeNotify();
|
|
20
|
+
let saved: [string, string | undefined][] = [];
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
saved = Object.entries(process.env).filter(([key]) => /^(BAO|VAULT)_/.test(key));
|
|
23
|
+
for (const [key] of saved) delete process.env[key];
|
|
24
|
+
process.env['BAO_ADDR'] = 'http://bao.invalid';
|
|
25
|
+
fake.objects.clear();
|
|
26
|
+
fake.secrets.clear();
|
|
27
|
+
fake.calls.length = 0;
|
|
28
|
+
});
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
for (const key of Object.keys(process.env))
|
|
31
|
+
if (/^(BAO|VAULT)_/.test(key)) delete process.env[key];
|
|
32
|
+
for (const [key, value] of saved) process.env[key] = value;
|
|
33
|
+
});
|
|
34
|
+
const { test } = Test.make({
|
|
35
|
+
adopt: true,
|
|
36
|
+
dev: false,
|
|
37
|
+
sidecar: false,
|
|
38
|
+
stage: 'test',
|
|
39
|
+
providers: Layer.mergeAll(
|
|
40
|
+
ProxmoxNotificationTargetProvider(),
|
|
41
|
+
ProxmoxNotificationMatcherProvider(),
|
|
42
|
+
).pipe(Layer.provideMerge(fake.layer)),
|
|
43
|
+
});
|
|
44
|
+
const target = (clear = false) =>
|
|
45
|
+
ProxmoxNotificationTarget('mail', {
|
|
46
|
+
target: FAKE_TARGET,
|
|
47
|
+
type: 'sendmail',
|
|
48
|
+
name: 'mail',
|
|
49
|
+
mailto: clear ? [] : ['a@example.com', 'b@example.com'],
|
|
50
|
+
'mailto-user': ['root@pam'],
|
|
51
|
+
});
|
|
52
|
+
const matcher = (clear = false) =>
|
|
53
|
+
ProxmoxNotificationMatcher('route', {
|
|
54
|
+
target: FAKE_TARGET,
|
|
55
|
+
name: 'route',
|
|
56
|
+
targets: ['mail'],
|
|
57
|
+
'match-field': clear ? [] : ['exact:type=prune,sync', 'regex:hostname=^node'],
|
|
58
|
+
});
|
|
59
|
+
const stack = (clear = false) =>
|
|
60
|
+
Effect.gen(function* () {
|
|
61
|
+
yield* target(clear);
|
|
62
|
+
yield* matcher(clear);
|
|
63
|
+
});
|
|
64
|
+
const existing = () => {
|
|
65
|
+
fake.objects.set('cluster/notifications/endpoints/sendmail/mail', {
|
|
66
|
+
name: 'mail',
|
|
67
|
+
mailto: ['b@example.com', 'a@example.com'],
|
|
68
|
+
'mailto-user': ['root@pam'],
|
|
69
|
+
});
|
|
70
|
+
fake.objects.set('cluster/notifications/matchers/route', {
|
|
71
|
+
name: 'route',
|
|
72
|
+
target: ['mail'],
|
|
73
|
+
'match-field': ['exact:type=prune,sync', 'regex:hostname=^node'],
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
const actions = (plan: { resources: Record<string, { action: string }> }) =>
|
|
77
|
+
Object.values(plan.resources)
|
|
78
|
+
.map((row) => row.action)
|
|
79
|
+
.sort();
|
|
80
|
+
|
|
81
|
+
test.provider('matching target and matcher adopt with zero writes and redeploy noop', (scratch) =>
|
|
82
|
+
Effect.gen(function* () {
|
|
83
|
+
existing();
|
|
84
|
+
yield* scratch.deploy(stack());
|
|
85
|
+
expect(fake.writes()).toEqual([]);
|
|
86
|
+
expect(actions(yield* scratch.plan(stack()))).toEqual(['noop', 'noop']);
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
test.provider('new rows send repeated arrays and preserve commas inside rules', (scratch) =>
|
|
90
|
+
Effect.gen(function* () {
|
|
91
|
+
yield* scratch.deploy(stack());
|
|
92
|
+
expect(fake.objects.get('cluster/notifications/matchers/route')?.['match-field']).toEqual([
|
|
93
|
+
'exact:type=prune,sync',
|
|
94
|
+
'regex:hostname=^node',
|
|
95
|
+
]);
|
|
96
|
+
expect(fake.objects.get('cluster/notifications/endpoints/sendmail/mail')?.['mailto']).toEqual([
|
|
97
|
+
'a@example.com',
|
|
98
|
+
'b@example.com',
|
|
99
|
+
]);
|
|
100
|
+
expect(actions(yield* scratch.plan(stack()))).toEqual(['noop', 'noop']);
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
test.provider('empty recipient and rule arrays clear explicitly and settle to noop', (scratch) =>
|
|
104
|
+
Effect.gen(function* () {
|
|
105
|
+
existing();
|
|
106
|
+
yield* scratch.deploy(stack());
|
|
107
|
+
yield* scratch.deploy(stack(true));
|
|
108
|
+
expect(fake.objects.get('cluster/notifications/endpoints/sendmail/mail')).not.toHaveProperty(
|
|
109
|
+
'mailto',
|
|
110
|
+
);
|
|
111
|
+
expect(fake.objects.get('cluster/notifications/matchers/route')).not.toHaveProperty(
|
|
112
|
+
'match-field',
|
|
113
|
+
);
|
|
114
|
+
expect(actions(yield* scratch.plan(stack(true)))).toEqual(['noop', 'noop']);
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
for (const status of [200, 400, 401, 403, 500]) {
|
|
118
|
+
unit(`malformed or refused HTTP ${status} cannot become absence`, async () => {
|
|
119
|
+
const broken = fakePve(() => Response.json({ data: null, message: 'refused' }, { status }));
|
|
120
|
+
await withoutBao(async () => {
|
|
121
|
+
const targetRead = readTarget({ target: FAKE_TARGET, name: 'mail', type: 'sendmail' });
|
|
122
|
+
const matcherRead = readMatcher({ target: FAKE_TARGET, name: 'route', targets: ['mail'] });
|
|
123
|
+
expect(
|
|
124
|
+
(await Effect.runPromise(Effect.exit(targetRead).pipe(Effect.provide(broken.layer))))._tag,
|
|
125
|
+
).toBe('Failure');
|
|
126
|
+
expect(
|
|
127
|
+
(await Effect.runPromise(Effect.exit(matcherRead).pipe(Effect.provide(broken.layer))))._tag,
|
|
128
|
+
).toBe('Failure');
|
|
129
|
+
});
|
|
130
|
+
expect(broken.writes()).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
test.provider('same-name endpoint type changes replace delete-first', (scratch) =>
|
|
135
|
+
Effect.gen(function* () {
|
|
136
|
+
const hook = ProxmoxNotificationTarget('endpoint', {
|
|
137
|
+
target: FAKE_TARGET,
|
|
138
|
+
name: 'pager',
|
|
139
|
+
type: 'webhook',
|
|
140
|
+
url: 'https://pager.example.com',
|
|
141
|
+
method: 'post',
|
|
142
|
+
header: ['name=X-One,value=b25l', 'name=X-Two,value=dHdv'],
|
|
143
|
+
});
|
|
144
|
+
const mail = ProxmoxNotificationTarget('endpoint', {
|
|
145
|
+
target: FAKE_TARGET,
|
|
146
|
+
name: 'pager',
|
|
147
|
+
type: 'sendmail',
|
|
148
|
+
'mailto-user': ['root@pam'],
|
|
149
|
+
});
|
|
150
|
+
yield* scratch.deploy(hook);
|
|
151
|
+
expect(fake.objects.get('cluster/notifications/endpoints/webhook/pager')?.['header']).toEqual([
|
|
152
|
+
'name=X-One,value=b25l',
|
|
153
|
+
'name=X-Two,value=dHdv',
|
|
154
|
+
]);
|
|
155
|
+
yield* scratch.deploy(mail);
|
|
156
|
+
expect(fake.writes()).toEqual([
|
|
157
|
+
'POST cluster/notifications/endpoints/webhook',
|
|
158
|
+
'DELETE cluster/notifications/endpoints/webhook/pager',
|
|
159
|
+
'POST cluster/notifications/endpoints/sendmail',
|
|
160
|
+
]);
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** PVE 9.2.11 matchers through distilled; 404 is measured absence, other failures propagate. */
|
|
2
|
+
import * as cluster from '@distilled.cloud/proxmox/cluster';
|
|
3
|
+
import * as Effect from 'effect/Effect';
|
|
4
|
+
import type { NotificationMatcherProps } from './notification-matcher.ts';
|
|
5
|
+
import {
|
|
6
|
+
matcherAttributes,
|
|
7
|
+
matcherCreateForm,
|
|
8
|
+
matcherUpdateForm,
|
|
9
|
+
} from './notification-matcher-form.ts';
|
|
10
|
+
import { runPve } from './distilled-pve.ts';
|
|
11
|
+
import { notificationRequest, notificationResponse } from './notification-sdk-input.ts';
|
|
12
|
+
|
|
13
|
+
export const readMatcher = (props: NotificationMatcherProps) =>
|
|
14
|
+
runPve(
|
|
15
|
+
props.target,
|
|
16
|
+
'read',
|
|
17
|
+
false,
|
|
18
|
+
cluster.getClusterNotificationMatcher({ name: props.name }),
|
|
19
|
+
).pipe(
|
|
20
|
+
Effect.flatMap(notificationResponse(cluster.GetClusterNotificationMatcherResponse)),
|
|
21
|
+
Effect.map((live) =>
|
|
22
|
+
matcherAttributes(
|
|
23
|
+
{
|
|
24
|
+
...live,
|
|
25
|
+
'invert-match': live.invert_match,
|
|
26
|
+
'match-calendar': live.match_calendar,
|
|
27
|
+
'match-field': live.match_field,
|
|
28
|
+
'match-severity': live.match_severity,
|
|
29
|
+
},
|
|
30
|
+
props.name,
|
|
31
|
+
),
|
|
32
|
+
),
|
|
33
|
+
Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
export const createMatcher = (props: NotificationMatcherProps) =>
|
|
37
|
+
Effect.gen(function* () {
|
|
38
|
+
const input = yield* notificationRequest(
|
|
39
|
+
cluster.CreateClusterNotificationMatcherRequest,
|
|
40
|
+
matcherCreateForm(props),
|
|
41
|
+
);
|
|
42
|
+
yield* runPve(props.target, 'provision', true, cluster.createClusterNotificationMatcher(input));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const updateMatcher = (props: NotificationMatcherProps) =>
|
|
46
|
+
Effect.gen(function* () {
|
|
47
|
+
const input = yield* notificationRequest(cluster.PutClusterNotificationMatcherRequest, {
|
|
48
|
+
...matcherUpdateForm(props),
|
|
49
|
+
name: props.name,
|
|
50
|
+
});
|
|
51
|
+
yield* runPve(props.target, 'provision', true, cluster.putClusterNotificationMatcher(input));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export const deleteMatcher = (props: NotificationMatcherProps) =>
|
|
55
|
+
runPve(
|
|
56
|
+
props.target,
|
|
57
|
+
'provision',
|
|
58
|
+
true,
|
|
59
|
+
cluster.deleteClusterNotificationMatcher({ name: props.name }),
|
|
60
|
+
).pipe(Effect.catchTag('NotFound', () => Effect.void));
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** A matching PVE matcher adoption writes nothing; unreadable is never treated as absent. */
|
|
2
|
+
import { isResolved } from 'alchemy/Diff';
|
|
3
|
+
import type { Input } from 'alchemy/Input';
|
|
4
|
+
import * as Effect from 'effect/Effect';
|
|
5
|
+
import type { NotificationMatcherProps as Props } from './notification-matcher.ts';
|
|
6
|
+
import {
|
|
7
|
+
type NotificationMatcherAttributes as Attributes,
|
|
8
|
+
matcherCreateForm,
|
|
9
|
+
matcherMatches,
|
|
10
|
+
matcherUpdateForm,
|
|
11
|
+
} from './notification-matcher-form.ts';
|
|
12
|
+
import { guardForm } from './constraint-guard.ts';
|
|
13
|
+
import {
|
|
14
|
+
createMatcher,
|
|
15
|
+
deleteMatcher,
|
|
16
|
+
readMatcher,
|
|
17
|
+
updateMatcher,
|
|
18
|
+
} from './notification-matcher-distilled.ts';
|
|
19
|
+
|
|
20
|
+
const guard = (props: Props, create: boolean) =>
|
|
21
|
+
guardForm('pve:POST /cluster/notifications/matchers', matcherCreateForm(props), create).pipe(
|
|
22
|
+
Effect.andThen(
|
|
23
|
+
guardForm('pve:PUT /cluster/notifications/matchers/{name}', matcherUpdateForm(props), false),
|
|
24
|
+
),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
export const matcherHandlers = {
|
|
28
|
+
/** Built-ins and human-created matchers are not auto-enrolled; adoption is explicit. */
|
|
29
|
+
list: () => Effect.succeed([]),
|
|
30
|
+
read: ({ olds }: { olds: Props }) =>
|
|
31
|
+
Effect.gen(function* () {
|
|
32
|
+
const live = yield* readMatcher(olds);
|
|
33
|
+
yield* guard(olds, live === undefined);
|
|
34
|
+
return live;
|
|
35
|
+
}),
|
|
36
|
+
diff: ({
|
|
37
|
+
news,
|
|
38
|
+
olds,
|
|
39
|
+
output,
|
|
40
|
+
}: {
|
|
41
|
+
news: Input<Props>;
|
|
42
|
+
olds: Props;
|
|
43
|
+
output: Attributes | undefined;
|
|
44
|
+
}) =>
|
|
45
|
+
Effect.gen(function* () {
|
|
46
|
+
if (!isResolved(news)) return undefined;
|
|
47
|
+
if (olds.name !== news.name) return { action: 'replace' } as const;
|
|
48
|
+
yield* guard(news, output === undefined);
|
|
49
|
+
if (output === undefined) return undefined;
|
|
50
|
+
const live = yield* readMatcher(news);
|
|
51
|
+
if (live === undefined) {
|
|
52
|
+
yield* guard(news, true);
|
|
53
|
+
return { action: 'update' } as const;
|
|
54
|
+
}
|
|
55
|
+
return { action: matcherMatches(live, news) ? 'noop' : 'update' } as const;
|
|
56
|
+
}),
|
|
57
|
+
reconcile: ({ news }: { news: Props }) =>
|
|
58
|
+
Effect.gen(function* () {
|
|
59
|
+
const live = yield* readMatcher(news);
|
|
60
|
+
yield* guard(news, live === undefined);
|
|
61
|
+
if (live === undefined) yield* createMatcher(news);
|
|
62
|
+
else if (!matcherMatches(live, news)) yield* updateMatcher(news);
|
|
63
|
+
const after = yield* readMatcher(news);
|
|
64
|
+
if (after === undefined)
|
|
65
|
+
return yield* Effect.die(
|
|
66
|
+
new Error(`Notification matcher ${news.name}: write returned but read-back is absent.`),
|
|
67
|
+
);
|
|
68
|
+
return after;
|
|
69
|
+
}),
|
|
70
|
+
delete: ({ olds }: { olds: Props }) => deleteMatcher(olds),
|
|
71
|
+
};
|
|
@@ -24,22 +24,19 @@
|
|
|
24
24
|
* ⛔ `Mapping.Use` IS NOT ENOUGH, though the COLLECTION GET accepts it: the per-object read this
|
|
25
25
|
* family makes lists only Audit and Modify (pve-manager API2/Cluster/Notifications.pm
|
|
26
26
|
* `get_matcher`, read at HEAD 2026-09-22 — corrected here; PR 95 said Use sufficed).
|
|
27
|
-
* ⛔ A MISSING READ PRIVILEGE
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* ⛔ THE OLD HAND CLIENT FOLDED A MISSING READ PRIVILEGE INTO "ABSENT", planning a duplicate
|
|
28
|
+
* create. The distilled read now catches only NotFound: a refused read fails the plan.
|
|
29
|
+
* The noop still requires a read lease holding Mapping.Audit.
|
|
30
30
|
*/
|
|
31
31
|
import { Resource } from 'alchemy';
|
|
32
32
|
import * as Provider from 'alchemy/Provider';
|
|
33
33
|
import * as Effect from 'effect/Effect';
|
|
34
|
-
import {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
matcherAttributes,
|
|
38
|
-
matcherCreateForm,
|
|
39
|
-
matcherMatches,
|
|
40
|
-
matcherUpdateForm,
|
|
34
|
+
import type {
|
|
35
|
+
NotificationMatcherAttributes,
|
|
36
|
+
NotificationMatcherFields,
|
|
41
37
|
} from './notification-matcher-form.ts';
|
|
42
|
-
import {
|
|
38
|
+
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
39
|
+
import { matcherHandlers } from './notification-matcher-lifecycle.ts';
|
|
43
40
|
|
|
44
41
|
export interface NotificationMatcherProps extends NotificationMatcherFields, WithTarget {}
|
|
45
42
|
|
|
@@ -60,22 +57,8 @@ export const ProxmoxNotificationMatcher = Resource<ProxmoxNotificationMatcher>(
|
|
|
60
57
|
'Proxmox.NotificationMatcher',
|
|
61
58
|
);
|
|
62
59
|
|
|
63
|
-
const handlers = pveHandlers<NotificationMatcherProps, NotificationMatcherAttributes>({
|
|
64
|
-
attributes: (live, props) => matcherAttributes(live, props.name),
|
|
65
|
-
collection: () => 'cluster/notifications/matchers',
|
|
66
|
-
createForm: matcherCreateForm,
|
|
67
|
-
/** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
|
|
68
|
-
endpoint: {
|
|
69
|
-
create: 'pve:POST /cluster/notifications/matchers',
|
|
70
|
-
update: 'pve:PUT /cluster/notifications/matchers/{name}',
|
|
71
|
-
},
|
|
72
|
-
matches: matcherMatches,
|
|
73
|
-
path: (props) => `cluster/notifications/matchers/${props.name}`,
|
|
74
|
-
updateForm: matcherUpdateForm,
|
|
75
|
-
});
|
|
76
|
-
|
|
77
60
|
export const ProxmoxNotificationMatcherProvider = () =>
|
|
78
61
|
Provider.effect(
|
|
79
62
|
ProxmoxNotificationMatcher,
|
|
80
|
-
Effect.succeed(ProxmoxNotificationMatcher.Provider.of(
|
|
63
|
+
Effect.succeed(ProxmoxNotificationMatcher.Provider.of(matcherHandlers)),
|
|
81
64
|
);
|