@homeflare/alchemy 0.37.2 → 0.37.4

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 (178) hide show
  1. package/dist/linux/directory-lifecycle.d.ts.map +1 -1
  2. package/dist/linux/directory.d.ts +5 -3
  3. package/dist/linux/directory.d.ts.map +1 -1
  4. package/dist/linux/index.js +4 -3
  5. package/dist/linux/index.js.map +4 -4
  6. package/dist/litellm/fake-stack.d.ts +7 -7
  7. package/dist/litellm/fake-stack.d.ts.map +1 -1
  8. package/dist/litellm/index.js +2 -2
  9. package/dist/litellm/index.js.map +4 -4
  10. package/dist/litellm/pass-through-endpoint.d.ts +9 -8
  11. package/dist/litellm/pass-through-endpoint.d.ts.map +1 -1
  12. package/dist/litellm/providers.d.ts +8 -2
  13. package/dist/litellm/providers.d.ts.map +1 -1
  14. package/dist/proxmox/backup-job-config.d.ts +95 -0
  15. package/dist/proxmox/backup-job-config.d.ts.map +1 -0
  16. package/dist/proxmox/backup-job-distilled.d.ts +46 -0
  17. package/dist/proxmox/backup-job-distilled.d.ts.map +1 -0
  18. package/dist/proxmox/backup-job.d.ts +9 -26
  19. package/dist/proxmox/backup-job.d.ts.map +1 -1
  20. package/dist/proxmox/distilled-pbs.d.ts +12 -0
  21. package/dist/proxmox/distilled-pbs.d.ts.map +1 -0
  22. package/dist/proxmox/fake-pbs-jobs.d.ts +23 -0
  23. package/dist/proxmox/fake-pbs-jobs.d.ts.map +1 -0
  24. package/dist/proxmox/fake-pbs-notify.d.ts.map +1 -1
  25. package/dist/proxmox/fake-pve.d.ts +1 -1
  26. package/dist/proxmox/fake-pve.d.ts.map +1 -1
  27. package/dist/proxmox/index.js +1501 -607
  28. package/dist/proxmox/index.js.map +48 -24
  29. package/dist/proxmox/live-core-fixtures.d.ts +40 -0
  30. package/dist/proxmox/live-core-fixtures.d.ts.map +1 -0
  31. package/dist/proxmox/metric-server-config.d.ts +49 -0
  32. package/dist/proxmox/metric-server-config.d.ts.map +1 -0
  33. package/dist/proxmox/metric-server-distilled.d.ts +43 -0
  34. package/dist/proxmox/metric-server-distilled.d.ts.map +1 -0
  35. package/dist/proxmox/metric-server.d.ts +2 -10
  36. package/dist/proxmox/metric-server.d.ts.map +1 -1
  37. package/dist/proxmox/notification-matcher-distilled.d.ts +9 -0
  38. package/dist/proxmox/notification-matcher-distilled.d.ts.map +1 -0
  39. package/dist/proxmox/notification-matcher-lifecycle.d.ts +27 -0
  40. package/dist/proxmox/notification-matcher-lifecycle.d.ts.map +1 -0
  41. package/dist/proxmox/notification-matcher.d.ts +5 -5
  42. package/dist/proxmox/notification-matcher.d.ts.map +1 -1
  43. package/dist/proxmox/notification-sdk-input.d.ts +22 -0
  44. package/dist/proxmox/notification-sdk-input.d.ts.map +1 -0
  45. package/dist/proxmox/notification-target-distilled.d.ts +12 -0
  46. package/dist/proxmox/notification-target-distilled.d.ts.map +1 -0
  47. package/dist/proxmox/notification-target-form.d.ts +2 -0
  48. package/dist/proxmox/notification-target-form.d.ts.map +1 -1
  49. package/dist/proxmox/notification-target-lifecycle.d.ts +29 -0
  50. package/dist/proxmox/notification-target-lifecycle.d.ts.map +1 -0
  51. package/dist/proxmox/notification-target-spec.d.ts +5 -0
  52. package/dist/proxmox/notification-target-spec.d.ts.map +1 -0
  53. package/dist/proxmox/notification-target.d.ts +4 -5
  54. package/dist/proxmox/notification-target.d.ts.map +1 -1
  55. package/dist/proxmox/pbs-datastore-endpoint.d.ts +1 -1
  56. package/dist/proxmox/pbs-datastore-form.d.ts +18 -42
  57. package/dist/proxmox/pbs-datastore-form.d.ts.map +1 -1
  58. package/dist/proxmox/pbs-datastore-guard.d.ts +7 -4
  59. package/dist/proxmox/pbs-datastore-guard.d.ts.map +1 -1
  60. package/dist/proxmox/pbs-datastore-lifecycle.d.ts +56 -0
  61. package/dist/proxmox/pbs-datastore-lifecycle.d.ts.map +1 -0
  62. package/dist/proxmox/pbs-datastore-wire.d.ts +21 -0
  63. package/dist/proxmox/pbs-datastore-wire.d.ts.map +1 -0
  64. package/dist/proxmox/pbs-datastore.d.ts +5 -6
  65. package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
  66. package/dist/proxmox/pbs-notification-input.d.ts +18 -0
  67. package/dist/proxmox/pbs-notification-input.d.ts.map +1 -0
  68. package/dist/proxmox/pbs-notification-matcher-distilled.d.ts +11 -0
  69. package/dist/proxmox/pbs-notification-matcher-distilled.d.ts.map +1 -0
  70. package/dist/proxmox/pbs-notification-matcher.d.ts +4 -3
  71. package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -1
  72. package/dist/proxmox/pbs-notification-target-distilled.d.ts +20 -0
  73. package/dist/proxmox/pbs-notification-target-distilled.d.ts.map +1 -0
  74. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +4 -4
  75. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts.map +1 -1
  76. package/dist/proxmox/pbs-notification-target.d.ts +1 -1
  77. package/dist/proxmox/pbs-prune-job-lifecycle.d.ts +22 -0
  78. package/dist/proxmox/pbs-prune-job-lifecycle.d.ts.map +1 -0
  79. package/dist/proxmox/pbs-prune-job-spec.d.ts +5 -0
  80. package/dist/proxmox/pbs-prune-job-spec.d.ts.map +1 -0
  81. package/dist/proxmox/pbs-prune-job-wire.d.ts +11 -0
  82. package/dist/proxmox/pbs-prune-job-wire.d.ts.map +1 -0
  83. package/dist/proxmox/pbs-prune-job.d.ts +9 -8
  84. package/dist/proxmox/pbs-prune-job.d.ts.map +1 -1
  85. package/dist/proxmox/pbs-sync-job-attributes.d.ts +34 -0
  86. package/dist/proxmox/pbs-sync-job-attributes.d.ts.map +1 -0
  87. package/dist/proxmox/pbs-sync-job-form.d.ts +21 -22
  88. package/dist/proxmox/pbs-sync-job-form.d.ts.map +1 -1
  89. package/dist/proxmox/pbs-sync-job-lifecycle.d.ts +22 -0
  90. package/dist/proxmox/pbs-sync-job-lifecycle.d.ts.map +1 -0
  91. package/dist/proxmox/pbs-sync-job-wire.d.ts +11 -0
  92. package/dist/proxmox/pbs-sync-job-wire.d.ts.map +1 -0
  93. package/dist/proxmox/pbs-sync-job.d.ts +9 -39
  94. package/dist/proxmox/pbs-sync-job.d.ts.map +1 -1
  95. package/dist/proxmox/pbs-verify-job-lifecycle.d.ts +22 -0
  96. package/dist/proxmox/pbs-verify-job-lifecycle.d.ts.map +1 -0
  97. package/dist/proxmox/pbs-verify-job-spec.d.ts +16 -0
  98. package/dist/proxmox/pbs-verify-job-spec.d.ts.map +1 -0
  99. package/dist/proxmox/pbs-verify-job-wire.d.ts +11 -0
  100. package/dist/proxmox/pbs-verify-job-wire.d.ts.map +1 -0
  101. package/dist/proxmox/pbs-verify-job.d.ts +12 -12
  102. package/dist/proxmox/pbs-verify-job.d.ts.map +1 -1
  103. package/dist/proxmox/pool.d.ts +10 -1
  104. package/dist/proxmox/pool.d.ts.map +1 -1
  105. package/dist/release/archive.d.ts.map +1 -1
  106. package/dist/release/index.js.map +2 -2
  107. package/docs/pbs-distilled-jobs.md +31 -0
  108. package/docs/pbs-notifications.md +8 -2
  109. package/docs/proxmox-notification-sdk.md +28 -0
  110. package/docs/proxmox-sdk-migration.md +60 -0
  111. package/docs/upstream-conformance-families.md +6 -2
  112. package/package.json +1 -1
  113. package/src/linux/directory-lifecycle.ts +14 -2
  114. package/src/linux/directory.test.ts +24 -0
  115. package/src/linux/directory.ts +5 -3
  116. package/src/litellm/fake-stack.ts +13 -10
  117. package/src/litellm/pass-through-endpoint.ts +5 -6
  118. package/src/litellm/pass-through-stack.test.ts +54 -0
  119. package/src/litellm/providers.ts +8 -2
  120. package/src/proxmox/adopt-noop.test.ts +4 -5
  121. package/src/proxmox/backup-job-config.ts +95 -0
  122. package/src/proxmox/backup-job-distilled.ts +86 -0
  123. package/src/proxmox/backup-job.ts +78 -116
  124. package/src/proxmox/constraints.test.ts +9 -8
  125. package/src/proxmox/distilled-pbs.test.ts +87 -0
  126. package/src/proxmox/distilled-pbs.ts +50 -0
  127. package/src/proxmox/fake-pbs-jobs.ts +99 -0
  128. package/src/proxmox/fake-pbs-notify.ts +4 -1
  129. package/src/proxmox/fake-pve.ts +3 -2
  130. package/src/proxmox/live-core-fixtures.ts +126 -0
  131. package/src/proxmox/live-core-lifecycle.test.ts +115 -0
  132. package/src/proxmox/live-core-wire.test.ts +190 -0
  133. package/src/proxmox/metric-server-config.ts +87 -0
  134. package/src/proxmox/metric-server-distilled.ts +81 -0
  135. package/src/proxmox/metric-server.ts +77 -88
  136. package/src/proxmox/notification-distilled.test.ts +162 -0
  137. package/src/proxmox/notification-matcher-distilled.ts +60 -0
  138. package/src/proxmox/notification-matcher-lifecycle.ts +71 -0
  139. package/src/proxmox/notification-matcher.ts +9 -26
  140. package/src/proxmox/notification-replacement.test.ts +190 -0
  141. package/src/proxmox/notification-sdk-input.ts +39 -0
  142. package/src/proxmox/notification-target-distilled.ts +160 -0
  143. package/src/proxmox/notification-target-form.ts +2 -0
  144. package/src/proxmox/notification-target-lifecycle.ts +75 -0
  145. package/src/proxmox/notification-target-spec.ts +68 -0
  146. package/src/proxmox/notification-target.ts +6 -68
  147. package/src/proxmox/pbs-datastore-endpoint.ts +1 -1
  148. package/src/proxmox/pbs-datastore-form.ts +18 -49
  149. package/src/proxmox/pbs-datastore-guard.ts +7 -4
  150. package/src/proxmox/pbs-datastore-lifecycle.ts +114 -0
  151. package/src/proxmox/pbs-datastore-wire.ts +94 -0
  152. package/src/proxmox/pbs-datastore.ts +7 -133
  153. package/src/proxmox/pbs-jobs-delete.test.ts +40 -0
  154. package/src/proxmox/pbs-jobs-sdk.test.ts +215 -0
  155. package/src/proxmox/pbs-notification-errors.test.ts +93 -0
  156. package/src/proxmox/pbs-notification-input.ts +25 -0
  157. package/src/proxmox/pbs-notification-mail.test.ts +64 -0
  158. package/src/proxmox/pbs-notification-matcher-distilled.ts +65 -0
  159. package/src/proxmox/pbs-notification-matcher.ts +70 -17
  160. package/src/proxmox/pbs-notification-target-distilled.ts +124 -0
  161. package/src/proxmox/pbs-notification-target-lifecycle.ts +26 -17
  162. package/src/proxmox/pbs-notification-target.ts +1 -1
  163. package/src/proxmox/pbs-notifications-stack.test.ts +25 -0
  164. package/src/proxmox/pbs-prune-job-lifecycle.ts +52 -0
  165. package/src/proxmox/pbs-prune-job-spec.ts +53 -0
  166. package/src/proxmox/pbs-prune-job-wire.ts +68 -0
  167. package/src/proxmox/pbs-prune-job.ts +10 -58
  168. package/src/proxmox/pbs-sync-job-attributes.ts +33 -0
  169. package/src/proxmox/pbs-sync-job-form.ts +7 -6
  170. package/src/proxmox/pbs-sync-job-lifecycle.ts +46 -0
  171. package/src/proxmox/pbs-sync-job-wire.ts +71 -0
  172. package/src/proxmox/pbs-sync-job.ts +10 -41
  173. package/src/proxmox/pbs-verify-job-lifecycle.ts +52 -0
  174. package/src/proxmox/pbs-verify-job-spec.ts +76 -0
  175. package/src/proxmox/pbs-verify-job-wire.ts +58 -0
  176. package/src/proxmox/pbs-verify-job.ts +13 -85
  177. package/src/proxmox/pool.ts +92 -10
  178. package/src/release/archive.ts +5 -1
@@ -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
  );
@@ -0,0 +1,190 @@
1
+ /** Real Plan/Apply must refuse an invalid replacement before deleting its working target. */
2
+ import { afterEach, beforeEach, expect } from 'bun:test';
3
+ import * as Test from 'alchemy/Test/Bun';
4
+ import * as Effect from 'effect/Effect';
5
+ import * as Layer from 'effect/Layer';
6
+ import { fakeNotify } from './fake-pbs-notify.ts';
7
+ import { FAKE_TARGET, fakePve } from './fake-pve.ts';
8
+ import {
9
+ type NotificationTargetProps,
10
+ ProxmoxNotificationTarget,
11
+ ProxmoxNotificationTargetProvider,
12
+ } from './notification-target.ts';
13
+ import {
14
+ PbsNotificationTarget,
15
+ type PbsNotificationTargetProps,
16
+ PbsNotificationTargetProvider,
17
+ } from './pbs-notification-target.ts';
18
+
19
+ const MISSING = 'HF_NOTIFICATION_REPLACEMENT_MISSING';
20
+ const PBS = { api: 'https://pbs.test:8007/api2/json', mount: 'pbs-test', scheme: 'pbs' } as const;
21
+ const fake = fakeNotify();
22
+ let saved: [string, string | undefined][] = [];
23
+ beforeEach(() => {
24
+ saved = Object.entries(process.env).filter(
25
+ ([key]) => /^(BAO|VAULT)_/.test(key) || key === MISSING,
26
+ );
27
+ for (const [key] of saved) delete process.env[key];
28
+ process.env['BAO_ADDR'] = 'http://bao.invalid';
29
+ fake.objects.clear();
30
+ fake.secrets.clear();
31
+ fake.calls.length = 0;
32
+ });
33
+ afterEach(() => {
34
+ for (const key of Object.keys(process.env))
35
+ if (/^(BAO|VAULT)_/.test(key) || key === MISSING) delete process.env[key];
36
+ for (const [key, value] of saved) process.env[key] = value;
37
+ });
38
+ const { test } = Test.make({
39
+ adopt: true,
40
+ dev: false,
41
+ sidecar: false,
42
+ stage: 'test',
43
+ providers: Layer.mergeAll(
44
+ ProxmoxNotificationTargetProvider(),
45
+ PbsNotificationTargetProvider(),
46
+ ).pipe(Layer.provideMerge(fake.layer)),
47
+ });
48
+
49
+ const pve = (props: Omit<NotificationTargetProps, 'target'>) =>
50
+ ProxmoxNotificationTarget('endpoint', { ...props, target: FAKE_TARGET });
51
+ const pbs = (props: Omit<PbsNotificationTargetProps, 'target'>) =>
52
+ PbsNotificationTarget('endpoint', { ...props, target: PBS });
53
+ const invalidPve: readonly Omit<NotificationTargetProps, 'target' | 'name'>[] = [
54
+ { type: 'webhook', method: 'post' },
55
+ { type: 'gotify', server: 'https://gotify.example.com' },
56
+ { type: 'smtp', server: 'smtp.example.com' },
57
+ {
58
+ type: 'webhook',
59
+ method: 'post',
60
+ url: 'https://pager.example.com',
61
+ mailto: ['ops@example.com'],
62
+ },
63
+ ];
64
+ const invalidPbs: readonly Omit<PbsNotificationTargetProps, 'target' | 'name'>[] = [
65
+ { type: 'webhook', method: 'post' },
66
+ {
67
+ type: 'webhook',
68
+ method: 'post',
69
+ url: 'https://pager.example.com',
70
+ secret: { token: { fromEnv: MISSING } },
71
+ },
72
+ { type: 'webhook', method: 'post', url: 'https://pager.example.com', comment: 'x'.repeat(129) },
73
+ ];
74
+ for (const [index, props] of invalidPve.entries()) {
75
+ test.provider(`PVE replacement refusal ${index} preserves the old target`, (scratch) =>
76
+ Effect.gen(function* () {
77
+ const old = pve({ name: 'pager', type: 'sendmail' });
78
+ yield* scratch.deploy(old);
79
+ const path = 'cluster/notifications/endpoints/sendmail/pager';
80
+ const before = fake.objects.get(path);
81
+ fake.calls.length = 0;
82
+ expect((yield* Effect.exit(scratch.deploy(pve({ ...props, name: 'pager' }))))._tag).toBe(
83
+ 'Failure',
84
+ );
85
+ expect(fake.writes()).toEqual([]);
86
+ expect(fake.objects.get(path)).toEqual(before);
87
+ expect(Object.values((yield* scratch.plan(old)).resources).map((row) => row.action)).toEqual([
88
+ 'noop',
89
+ ]);
90
+ }),
91
+ );
92
+ }
93
+ for (const [index, props] of invalidPbs.entries()) {
94
+ test.provider(`PBS replacement refusal ${index} preserves the old target`, (scratch) =>
95
+ Effect.gen(function* () {
96
+ const old = pbs({ name: 'pager', type: 'sendmail' });
97
+ yield* scratch.deploy(old);
98
+ const path = 'config/notifications/endpoints/sendmail/pager';
99
+ const before = fake.objects.get(path);
100
+ fake.calls.length = 0;
101
+ expect((yield* Effect.exit(scratch.deploy(pbs({ ...props, name: 'pager' }))))._tag).toBe(
102
+ 'Failure',
103
+ );
104
+ expect(fake.writes()).toEqual([]);
105
+ expect(fake.objects.get(path)).toEqual(before);
106
+ expect(Object.values((yield* scratch.plan(old)).resources).map((row) => row.action)).toEqual([
107
+ 'noop',
108
+ ]);
109
+ }),
110
+ );
111
+ }
112
+ test.provider(
113
+ 'PVE can replace by adopting an existing renamed gotify target without its token',
114
+ (scratch) =>
115
+ Effect.gen(function* () {
116
+ yield* scratch.deploy(pve({ name: 'old', type: 'sendmail' }));
117
+ const path = 'cluster/notifications/endpoints/gotify/existing';
118
+ fake.objects.set(path, { name: 'existing', server: 'https://gotify.example.com' });
119
+ fake.calls.length = 0;
120
+ yield* scratch.deploy(
121
+ pve({ name: 'existing', type: 'gotify', server: 'https://gotify.example.com' }),
122
+ );
123
+ expect(fake.objects.has(path)).toBe(true);
124
+ expect(fake.writes()).toEqual(['DELETE cluster/notifications/endpoints/sendmail/old']);
125
+ }),
126
+ );
127
+
128
+ const forbidden = fakePve((call) =>
129
+ call.path.endsWith('/sendmail/pager')
130
+ ? { name: 'pager' }
131
+ : Response.json({ message: 'forbidden' }, { status: 403 }),
132
+ );
133
+ const blocked = Test.make({
134
+ adopt: true,
135
+ dev: false,
136
+ sidecar: false,
137
+ stage: 'test',
138
+ providers: Layer.mergeAll(
139
+ ProxmoxNotificationTargetProvider(),
140
+ PbsNotificationTargetProvider(),
141
+ ).pipe(Layer.provideMerge(forbidden.layer)),
142
+ }).test;
143
+ for (const declare of [pve, pbs]) {
144
+ blocked.provider(`${declare.name} destination refusal prevents replacement deletion`, (scratch) =>
145
+ Effect.gen(function* () {
146
+ forbidden.calls.length = 0;
147
+ yield* scratch.deploy(
148
+ Effect.gen(function* () {
149
+ yield* declare({ name: 'pager', type: 'sendmail' });
150
+ }),
151
+ );
152
+ const replacement = Effect.gen(function* () {
153
+ yield* declare({
154
+ name: 'pager',
155
+ type: 'webhook',
156
+ url: 'https://pager.example.com',
157
+ method: 'post',
158
+ });
159
+ });
160
+ expect((yield* Effect.exit(scratch.deploy(replacement)))._tag).toBe('Failure');
161
+ expect(forbidden.writes()).toEqual([]);
162
+ }),
163
+ );
164
+ }
165
+ test.provider(
166
+ 'PBS can replace by adopting an existing renamed webhook without its secret value',
167
+ (scratch) =>
168
+ Effect.gen(function* () {
169
+ yield* scratch.deploy(pbs({ name: 'old', type: 'sendmail' }));
170
+ const path = 'config/notifications/endpoints/webhook/existing';
171
+ fake.objects.set(path, {
172
+ name: 'existing',
173
+ url: 'https://pager.example.com',
174
+ method: 'post',
175
+ secret: ['name=token'],
176
+ });
177
+ fake.calls.length = 0;
178
+ yield* scratch.deploy(
179
+ pbs({
180
+ name: 'existing',
181
+ type: 'webhook',
182
+ url: 'https://pager.example.com',
183
+ method: 'post',
184
+ secret: { token: { fromEnv: MISSING } },
185
+ }),
186
+ );
187
+ expect(fake.objects.has(path)).toBe(true);
188
+ expect(fake.writes()).toEqual(['DELETE config/notifications/endpoints/sendmail/old']);
189
+ }),
190
+ );
@@ -0,0 +1,39 @@
1
+ /** PVE 9.2.11 form names mapped to the generated SDK's TypeScript field names. */
2
+ import { ProxmoxParseError } from '@distilled.cloud/proxmox/Errors';
3
+ import * as Data from 'effect/Data';
4
+ import * as Effect from 'effect/Effect';
5
+ import * as Schema from 'effect/Schema';
6
+
7
+ /** Local schema refusal names the operation, never a form value (headers may be sensitive). */
8
+ export class NotificationRequestInvalid extends Data.TaggedError('NotificationRequestInvalid')<{
9
+ readonly operation: string;
10
+ }> {}
11
+
12
+ /**
13
+ * The provider's forms already enforce vendor constraints. This final generated-schema
14
+ * check validates the translated Type-side fields without a cast. Unexpected family fields
15
+ * fail explicitly instead of disappearing through the decoder's default excess-property stripping.
16
+ */
17
+ export const notificationRequest = <S extends Schema.Top>(schema: S, fields: object) =>
18
+ Schema.decodeUnknownEffect(Schema.toType(schema), { onExcessProperty: 'error' })(
19
+ Object.fromEntries(
20
+ Object.entries(fields).map(([key, value]) => [key.replaceAll('-', '_'), value]),
21
+ ),
22
+ ).pipe(
23
+ Effect.mapError(() => new NotificationRequestInvalid({ operation: 'PVE notification write' })),
24
+ );
25
+
26
+ /** Validate required response fields with the SDK schema without discarding vendor extras. */
27
+ export const notificationResponse =
28
+ <S extends Schema.Top>(schema: S) =>
29
+ (body: S['Type']) =>
30
+ Schema.decodeUnknownEffect(Schema.toType(schema))(body).pipe(
31
+ Effect.as(body),
32
+ Effect.mapError(
33
+ () =>
34
+ new ProxmoxParseError({
35
+ body: undefined,
36
+ cause: 'PVE notification response does not match its SDK schema',
37
+ }),
38
+ ),
39
+ );