@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.
Files changed (155) hide show
  1. package/dist/proxmox/backup-job-config.d.ts +95 -0
  2. package/dist/proxmox/backup-job-config.d.ts.map +1 -0
  3. package/dist/proxmox/backup-job-distilled.d.ts +46 -0
  4. package/dist/proxmox/backup-job-distilled.d.ts.map +1 -0
  5. package/dist/proxmox/backup-job.d.ts +9 -26
  6. package/dist/proxmox/backup-job.d.ts.map +1 -1
  7. package/dist/proxmox/distilled-pbs.d.ts +12 -0
  8. package/dist/proxmox/distilled-pbs.d.ts.map +1 -0
  9. package/dist/proxmox/fake-pbs-jobs.d.ts +23 -0
  10. package/dist/proxmox/fake-pbs-jobs.d.ts.map +1 -0
  11. package/dist/proxmox/fake-pbs-notify.d.ts.map +1 -1
  12. package/dist/proxmox/fake-pve.d.ts +1 -1
  13. package/dist/proxmox/fake-pve.d.ts.map +1 -1
  14. package/dist/proxmox/index.js +1501 -607
  15. package/dist/proxmox/index.js.map +48 -24
  16. package/dist/proxmox/live-core-fixtures.d.ts +40 -0
  17. package/dist/proxmox/live-core-fixtures.d.ts.map +1 -0
  18. package/dist/proxmox/metric-server-config.d.ts +49 -0
  19. package/dist/proxmox/metric-server-config.d.ts.map +1 -0
  20. package/dist/proxmox/metric-server-distilled.d.ts +43 -0
  21. package/dist/proxmox/metric-server-distilled.d.ts.map +1 -0
  22. package/dist/proxmox/metric-server.d.ts +2 -10
  23. package/dist/proxmox/metric-server.d.ts.map +1 -1
  24. package/dist/proxmox/notification-matcher-distilled.d.ts +9 -0
  25. package/dist/proxmox/notification-matcher-distilled.d.ts.map +1 -0
  26. package/dist/proxmox/notification-matcher-lifecycle.d.ts +27 -0
  27. package/dist/proxmox/notification-matcher-lifecycle.d.ts.map +1 -0
  28. package/dist/proxmox/notification-matcher.d.ts +5 -5
  29. package/dist/proxmox/notification-matcher.d.ts.map +1 -1
  30. package/dist/proxmox/notification-sdk-input.d.ts +22 -0
  31. package/dist/proxmox/notification-sdk-input.d.ts.map +1 -0
  32. package/dist/proxmox/notification-target-distilled.d.ts +12 -0
  33. package/dist/proxmox/notification-target-distilled.d.ts.map +1 -0
  34. package/dist/proxmox/notification-target-form.d.ts +2 -0
  35. package/dist/proxmox/notification-target-form.d.ts.map +1 -1
  36. package/dist/proxmox/notification-target-lifecycle.d.ts +29 -0
  37. package/dist/proxmox/notification-target-lifecycle.d.ts.map +1 -0
  38. package/dist/proxmox/notification-target-spec.d.ts +5 -0
  39. package/dist/proxmox/notification-target-spec.d.ts.map +1 -0
  40. package/dist/proxmox/notification-target.d.ts +4 -5
  41. package/dist/proxmox/notification-target.d.ts.map +1 -1
  42. package/dist/proxmox/pbs-datastore-endpoint.d.ts +1 -1
  43. package/dist/proxmox/pbs-datastore-form.d.ts +18 -42
  44. package/dist/proxmox/pbs-datastore-form.d.ts.map +1 -1
  45. package/dist/proxmox/pbs-datastore-guard.d.ts +7 -4
  46. package/dist/proxmox/pbs-datastore-guard.d.ts.map +1 -1
  47. package/dist/proxmox/pbs-datastore-lifecycle.d.ts +56 -0
  48. package/dist/proxmox/pbs-datastore-lifecycle.d.ts.map +1 -0
  49. package/dist/proxmox/pbs-datastore-wire.d.ts +21 -0
  50. package/dist/proxmox/pbs-datastore-wire.d.ts.map +1 -0
  51. package/dist/proxmox/pbs-datastore.d.ts +5 -6
  52. package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
  53. package/dist/proxmox/pbs-notification-input.d.ts +18 -0
  54. package/dist/proxmox/pbs-notification-input.d.ts.map +1 -0
  55. package/dist/proxmox/pbs-notification-matcher-distilled.d.ts +11 -0
  56. package/dist/proxmox/pbs-notification-matcher-distilled.d.ts.map +1 -0
  57. package/dist/proxmox/pbs-notification-matcher.d.ts +4 -3
  58. package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -1
  59. package/dist/proxmox/pbs-notification-target-distilled.d.ts +20 -0
  60. package/dist/proxmox/pbs-notification-target-distilled.d.ts.map +1 -0
  61. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +4 -4
  62. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts.map +1 -1
  63. package/dist/proxmox/pbs-notification-target.d.ts +1 -1
  64. package/dist/proxmox/pbs-prune-job-lifecycle.d.ts +22 -0
  65. package/dist/proxmox/pbs-prune-job-lifecycle.d.ts.map +1 -0
  66. package/dist/proxmox/pbs-prune-job-spec.d.ts +5 -0
  67. package/dist/proxmox/pbs-prune-job-spec.d.ts.map +1 -0
  68. package/dist/proxmox/pbs-prune-job-wire.d.ts +11 -0
  69. package/dist/proxmox/pbs-prune-job-wire.d.ts.map +1 -0
  70. package/dist/proxmox/pbs-prune-job.d.ts +9 -8
  71. package/dist/proxmox/pbs-prune-job.d.ts.map +1 -1
  72. package/dist/proxmox/pbs-sync-job-attributes.d.ts +34 -0
  73. package/dist/proxmox/pbs-sync-job-attributes.d.ts.map +1 -0
  74. package/dist/proxmox/pbs-sync-job-form.d.ts +21 -22
  75. package/dist/proxmox/pbs-sync-job-form.d.ts.map +1 -1
  76. package/dist/proxmox/pbs-sync-job-lifecycle.d.ts +22 -0
  77. package/dist/proxmox/pbs-sync-job-lifecycle.d.ts.map +1 -0
  78. package/dist/proxmox/pbs-sync-job-wire.d.ts +11 -0
  79. package/dist/proxmox/pbs-sync-job-wire.d.ts.map +1 -0
  80. package/dist/proxmox/pbs-sync-job.d.ts +9 -39
  81. package/dist/proxmox/pbs-sync-job.d.ts.map +1 -1
  82. package/dist/proxmox/pbs-verify-job-lifecycle.d.ts +22 -0
  83. package/dist/proxmox/pbs-verify-job-lifecycle.d.ts.map +1 -0
  84. package/dist/proxmox/pbs-verify-job-spec.d.ts +16 -0
  85. package/dist/proxmox/pbs-verify-job-spec.d.ts.map +1 -0
  86. package/dist/proxmox/pbs-verify-job-wire.d.ts +11 -0
  87. package/dist/proxmox/pbs-verify-job-wire.d.ts.map +1 -0
  88. package/dist/proxmox/pbs-verify-job.d.ts +12 -12
  89. package/dist/proxmox/pbs-verify-job.d.ts.map +1 -1
  90. package/dist/proxmox/pool.d.ts +10 -1
  91. package/dist/proxmox/pool.d.ts.map +1 -1
  92. package/docs/pbs-distilled-jobs.md +31 -0
  93. package/docs/pbs-notifications.md +8 -2
  94. package/docs/proxmox-notification-sdk.md +28 -0
  95. package/docs/proxmox-sdk-migration.md +60 -0
  96. package/docs/upstream-conformance-families.md +6 -2
  97. package/package.json +1 -1
  98. package/src/proxmox/adopt-noop.test.ts +4 -5
  99. package/src/proxmox/backup-job-config.ts +95 -0
  100. package/src/proxmox/backup-job-distilled.ts +86 -0
  101. package/src/proxmox/backup-job.ts +78 -116
  102. package/src/proxmox/constraints.test.ts +9 -8
  103. package/src/proxmox/distilled-pbs.test.ts +87 -0
  104. package/src/proxmox/distilled-pbs.ts +50 -0
  105. package/src/proxmox/fake-pbs-jobs.ts +99 -0
  106. package/src/proxmox/fake-pbs-notify.ts +4 -1
  107. package/src/proxmox/fake-pve.ts +3 -2
  108. package/src/proxmox/live-core-fixtures.ts +126 -0
  109. package/src/proxmox/live-core-lifecycle.test.ts +115 -0
  110. package/src/proxmox/live-core-wire.test.ts +190 -0
  111. package/src/proxmox/metric-server-config.ts +87 -0
  112. package/src/proxmox/metric-server-distilled.ts +81 -0
  113. package/src/proxmox/metric-server.ts +77 -88
  114. package/src/proxmox/notification-distilled.test.ts +162 -0
  115. package/src/proxmox/notification-matcher-distilled.ts +60 -0
  116. package/src/proxmox/notification-matcher-lifecycle.ts +71 -0
  117. package/src/proxmox/notification-matcher.ts +9 -26
  118. package/src/proxmox/notification-replacement.test.ts +190 -0
  119. package/src/proxmox/notification-sdk-input.ts +39 -0
  120. package/src/proxmox/notification-target-distilled.ts +160 -0
  121. package/src/proxmox/notification-target-form.ts +2 -0
  122. package/src/proxmox/notification-target-lifecycle.ts +75 -0
  123. package/src/proxmox/notification-target-spec.ts +68 -0
  124. package/src/proxmox/notification-target.ts +6 -68
  125. package/src/proxmox/pbs-datastore-endpoint.ts +1 -1
  126. package/src/proxmox/pbs-datastore-form.ts +18 -49
  127. package/src/proxmox/pbs-datastore-guard.ts +7 -4
  128. package/src/proxmox/pbs-datastore-lifecycle.ts +114 -0
  129. package/src/proxmox/pbs-datastore-wire.ts +94 -0
  130. package/src/proxmox/pbs-datastore.ts +7 -133
  131. package/src/proxmox/pbs-jobs-delete.test.ts +40 -0
  132. package/src/proxmox/pbs-jobs-sdk.test.ts +215 -0
  133. package/src/proxmox/pbs-notification-errors.test.ts +93 -0
  134. package/src/proxmox/pbs-notification-input.ts +25 -0
  135. package/src/proxmox/pbs-notification-mail.test.ts +64 -0
  136. package/src/proxmox/pbs-notification-matcher-distilled.ts +65 -0
  137. package/src/proxmox/pbs-notification-matcher.ts +70 -17
  138. package/src/proxmox/pbs-notification-target-distilled.ts +124 -0
  139. package/src/proxmox/pbs-notification-target-lifecycle.ts +26 -17
  140. package/src/proxmox/pbs-notification-target.ts +1 -1
  141. package/src/proxmox/pbs-notifications-stack.test.ts +25 -0
  142. package/src/proxmox/pbs-prune-job-lifecycle.ts +52 -0
  143. package/src/proxmox/pbs-prune-job-spec.ts +53 -0
  144. package/src/proxmox/pbs-prune-job-wire.ts +68 -0
  145. package/src/proxmox/pbs-prune-job.ts +10 -58
  146. package/src/proxmox/pbs-sync-job-attributes.ts +33 -0
  147. package/src/proxmox/pbs-sync-job-form.ts +7 -6
  148. package/src/proxmox/pbs-sync-job-lifecycle.ts +46 -0
  149. package/src/proxmox/pbs-sync-job-wire.ts +71 -0
  150. package/src/proxmox/pbs-sync-job.ts +10 -41
  151. package/src/proxmox/pbs-verify-job-lifecycle.ts +52 -0
  152. package/src/proxmox/pbs-verify-job-spec.ts +76 -0
  153. package/src/proxmox/pbs-verify-job-wire.ts +58 -0
  154. package/src/proxmox/pbs-verify-job.ts +13 -85
  155. 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 { type PveRequirements, type WithTarget, pveHandlers } from './resource.ts';
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
- * The type-specific half of `matches`.
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(ProxmoxMetricServer, Effect.succeed(ProxmoxMetricServer.Provider.of(handlers)));
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 LOOKS LIKE "ABSENT", not like a 403 — see the same file. For
28
- * `default-matcher` that turns an adoption into a planned CREATE that PVE refuses as a
29
- * duplicate: the noop below needs a read lease holding Mapping.Audit.
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
- type NotificationMatcherAttributes,
36
- type NotificationMatcherFields,
37
- matcherAttributes,
38
- matcherCreateForm,
39
- matcherMatches,
40
- matcherUpdateForm,
34
+ import type {
35
+ NotificationMatcherAttributes,
36
+ NotificationMatcherFields,
41
37
  } from './notification-matcher-form.ts';
42
- import { type PveRequirements, type WithTarget, pveHandlers } from './resource.ts';
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(handlers)),
63
+ Effect.succeed(ProxmoxNotificationMatcher.Provider.of(matcherHandlers)),
81
64
  );