@homeflare/alchemy 0.12.0 → 0.14.0
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/README.md +7 -0
- package/dist/github/index.d.ts +18 -0
- package/dist/github/index.d.ts.map +1 -0
- package/dist/github/index.js +161 -0
- package/dist/github/index.js.map +12 -0
- package/dist/github/repo-policy-form.d.ts +86 -0
- package/dist/github/repo-policy-form.d.ts.map +1 -0
- package/dist/github/repo-policy-guards.d.ts +72 -0
- package/dist/github/repo-policy-guards.d.ts.map +1 -0
- package/dist/github/repo-policy.d.ts +21 -0
- package/dist/github/repo-policy.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/proxmox/alertmanager-body.d.ts +54 -0
- package/dist/proxmox/alertmanager-body.d.ts.map +1 -0
- package/dist/proxmox/credential-literals.d.ts +36 -0
- package/dist/proxmox/credential-literals.d.ts.map +1 -0
- package/dist/proxmox/fake-pbs-notify.d.ts +23 -0
- package/dist/proxmox/fake-pbs-notify.d.ts.map +1 -0
- package/dist/proxmox/fake-pve.d.ts +5 -0
- package/dist/proxmox/fake-pve.d.ts.map +1 -1
- package/dist/proxmox/generated/pbs.d.ts +278 -0
- package/dist/proxmox/generated/pbs.d.ts.map +1 -1
- package/dist/proxmox/generated/pve.d.ts +57 -0
- package/dist/proxmox/generated/pve.d.ts.map +1 -1
- package/dist/proxmox/index.d.ts +8 -0
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +681 -183
- package/dist/proxmox/index.js.map +14 -4
- package/dist/proxmox/notification-matcher-form.d.ts +100 -0
- package/dist/proxmox/notification-matcher-form.d.ts.map +1 -0
- package/dist/proxmox/notification-matcher.d.ts +46 -0
- package/dist/proxmox/notification-matcher.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-matcher.d.ts +53 -0
- package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-form.d.ts +63 -0
- package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +100 -0
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-wire.d.ts +30 -0
- package/dist/proxmox/pbs-notification-target-wire.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target.d.ts +123 -0
- package/dist/proxmox/pbs-notification-target.d.ts.map +1 -0
- package/dist/proxmox/resource.d.ts +4 -3
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/write-only.d.ts +35 -0
- package/dist/proxmox/write-only.d.ts.map +1 -0
- package/dist/verify/fake-engine.d.ts +5 -0
- package/dist/verify/fake-engine.d.ts.map +1 -1
- package/docs/adopted-deploys.md +8 -0
- package/docs/pbs-alertmanager-body.md +73 -0
- package/docs/pbs-notifications.md +163 -0
- package/docs/proxmox.md +3 -0
- package/docs/repo-policy-ruleset-hazards.md +65 -0
- package/docs/repo-policy.md +155 -0
- package/package.json +5 -1
- package/src/github/index.ts +23 -0
- package/src/github/repo-policy-form.test.ts +239 -0
- package/src/github/repo-policy-form.ts +173 -0
- package/src/github/repo-policy-guards.ts +179 -0
- package/src/github/repo-policy.test.ts +58 -0
- package/src/github/repo-policy.ts +65 -0
- package/src/index.ts +1 -0
- package/src/proxmox/alertmanager-body.ts +125 -0
- package/src/proxmox/credential-literals.test.ts +148 -0
- package/src/proxmox/credential-literals.ts +74 -0
- package/src/proxmox/fake-pbs-notify.ts +108 -0
- package/src/proxmox/fake-pve.ts +12 -2
- package/src/proxmox/generated/pbs.ts +329 -0
- package/src/proxmox/generated/pve.ts +62 -0
- package/src/proxmox/index.test.ts +3 -0
- package/src/proxmox/index.ts +18 -0
- package/src/proxmox/notification-matcher-form.ts +221 -0
- package/src/proxmox/notification-matcher.test.ts +234 -0
- package/src/proxmox/notification-matcher.ts +76 -0
- package/src/proxmox/pbs-notification-matcher.ts +81 -0
- package/src/proxmox/pbs-notification-target-drift.test.ts +83 -0
- package/src/proxmox/pbs-notification-target-form.ts +229 -0
- package/src/proxmox/pbs-notification-target-lifecycle.ts +177 -0
- package/src/proxmox/pbs-notification-target-state.test.ts +191 -0
- package/src/proxmox/pbs-notification-target-wire.ts +112 -0
- package/src/proxmox/pbs-notification-target.test.ts +226 -0
- package/src/proxmox/pbs-notification-target.ts +139 -0
- package/src/proxmox/pbs-notifications-stack.test.ts +94 -0
- package/src/proxmox/resource.ts +6 -6
- package/src/proxmox/write-only.test.ts +75 -0
- package/src/proxmox/write-only.ts +106 -0
- package/src/verify/fake-engine.ts +8 -1
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A notification matcher — the rule that routes a notification to its targets — as ONE shape
|
|
3
|
+
* shared by PVE (`Proxmox.NotificationMatcher`) and PBS (`Pbs.NotificationMatcher`).
|
|
4
|
+
*
|
|
5
|
+
* ★ ONE FILE FOR BOTH PRODUCTS BECAUSE IT IS ONE IMPLEMENTATION. Both servers run the same Rust
|
|
6
|
+
* crate, `proxmox-notify` (matcher.rs), PVE through its Perl bindings; the schemas in
|
|
7
|
+
* generated/pve.ts and generated/pbs.ts carry the same nine fields, and the forms below are
|
|
8
|
+
* typed against BOTH, so a drift on either side fails `tsc` here rather than on a server.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ A MATCHER IS ITS WHOLE RULE, SO EVERY RULE FIELD IS COMPARED, DECLARED OR NOT. Most families
|
|
11
|
+
* here leave an undeclared field alone (backup-job.ts's trade). A matcher cannot: an undeclared
|
|
12
|
+
* `match-field` on a matcher that has one is a declaration claiming "route everything" over a
|
|
13
|
+
* server that routes one job type — and `invert-match` flips the meaning of every other line.
|
|
14
|
+
* So `match-*` default to empty, `mode` to `all`, `invert-match` and `disable` to false, all are
|
|
15
|
+
* compared, and a PUT sends all of them (an emptied list as `delete`). Only `comment` keeps the
|
|
16
|
+
* usual "undeclared is unmanaged" rule, because nothing routes on it.
|
|
17
|
+
* ★ THE COST IS THAT ADOPTING A MATCHER MEANS DECLARING IT AS IT IS. PBS ships `default-matcher`
|
|
18
|
+
* with `invert-match`, a `match-field` and a `match-severity` (measured on PBS 4.2, 2026-09-22),
|
|
19
|
+
* so adopting it as-is means writing those three down. A declaration that leaves them out
|
|
20
|
+
* plans an UPDATE, which is the plan telling the truth about what the deploy would do.
|
|
21
|
+
*/
|
|
22
|
+
import type {
|
|
23
|
+
ClusterNotificationsMatchersNamePutParams,
|
|
24
|
+
ClusterNotificationsMatchersPostParams,
|
|
25
|
+
} from './generated/pve.ts';
|
|
26
|
+
import type {
|
|
27
|
+
ConfigNotificationsMatchersNamePutParams,
|
|
28
|
+
ConfigNotificationsMatchersPostParams,
|
|
29
|
+
} from './generated/pbs.ts';
|
|
30
|
+
import { bool, text } from './values.ts';
|
|
31
|
+
|
|
32
|
+
/** Everything a matcher declares except where it lives — the resource files add `target`. */
|
|
33
|
+
export interface NotificationMatcherFields {
|
|
34
|
+
/** The primary key, a safe id (2–32 chars on PBS). Changing it is a replace. */
|
|
35
|
+
name: string;
|
|
36
|
+
/**
|
|
37
|
+
* ⛔ THE TARGETS, BY NAME, AND REQUIRED: a matcher that notifies nobody is a routing rule for
|
|
38
|
+
* silence. `[]` is still declarable, and clears the list.
|
|
39
|
+
* ⚠️ `targets`, NOT THE SERVER'S `target`: every resource here already spends `target` on the
|
|
40
|
+
* HOST (resource.ts `WithTarget`), and one key cannot mean both. The wire still says `target`.
|
|
41
|
+
*/
|
|
42
|
+
targets: readonly string[];
|
|
43
|
+
/**
|
|
44
|
+
* Severity rules. EACH ITEM IS ITSELF A COMMA LIST: `'warning,error'` matches either.
|
|
45
|
+
* ⛔ `['warning', 'error']` IS NOT THE SAME RULE AND UNDER `mode: 'all'` NEVER MATCHES. Items are
|
|
46
|
+
* combined with `mode`; the severities inside one item are always OR-ed (matcher.rs
|
|
47
|
+
* `SeverityMatcher`). A notification has one severity, so two items under AND ask for a
|
|
48
|
+
* notification that is both — and routing nothing is the quietest failure a matcher has.
|
|
49
|
+
* Severities: `info`, `notice`, `warning`, `error`, `unknown`.
|
|
50
|
+
*/
|
|
51
|
+
'match-severity'?: readonly string[];
|
|
52
|
+
/**
|
|
53
|
+
* Metadata rules, `exact:<field>=<v1>,<v2>` or `regex:<field>=<re>`. ⚠️ THE PREFIX IS REQUIRED:
|
|
54
|
+
* the schema's pattern makes it optional and the parser then refuses it. A notification WITHOUT
|
|
55
|
+
* the field never matches its rule — so `exact:job-id=…` excludes every garbage collection.
|
|
56
|
+
*/
|
|
57
|
+
'match-field'?: readonly string[];
|
|
58
|
+
/** Time windows, e.g. `mon-fri 9:00-17:00`. Compared as written; the server keeps the text. */
|
|
59
|
+
'match-calendar'?: readonly string[];
|
|
60
|
+
/** How the rule items combine. The server's default, and this package's, is `all`. */
|
|
61
|
+
mode?: 'all' | 'any';
|
|
62
|
+
/** Invert the whole rule. ⚠️ IGNORED WHEN THERE ARE NO RULES — an empty matcher always matches. */
|
|
63
|
+
'invert-match'?: boolean;
|
|
64
|
+
/** A disabled matcher routes nothing. */
|
|
65
|
+
disable?: boolean;
|
|
66
|
+
/** Free text. The one field left alone when undeclared; `''` clears it. */
|
|
67
|
+
comment?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** ⚠️ Lists are CANONICAL and SORTED, so a re-ordered declaration is not a diff. */
|
|
71
|
+
export interface NotificationMatcherAttributes {
|
|
72
|
+
name: string;
|
|
73
|
+
/**
|
|
74
|
+
* `builtin`, `modified-builtin` or `user-created`. REPORTED, NEVER COMPARED.
|
|
75
|
+
* ⛔ ON A BUILT-IN A DELETE REVERTS RATHER THAN REMOVES, and reports success either way — Alchemy
|
|
76
|
+
* drops the row while `default-matcher` goes on routing. Read this before destroying one.
|
|
77
|
+
* ★ MEASURED ON THE PER-OBJECT READ, which the published schema does not list: PVE 9.2.11 and
|
|
78
|
+
* PBS 4.2 both return it from `…/matchers/default-matcher` (2026-09-22).
|
|
79
|
+
*/
|
|
80
|
+
origin: string;
|
|
81
|
+
comment: string;
|
|
82
|
+
disable: boolean;
|
|
83
|
+
'invert-match': boolean;
|
|
84
|
+
mode: string;
|
|
85
|
+
targets: readonly string[];
|
|
86
|
+
'match-severity': readonly string[];
|
|
87
|
+
'match-field': readonly string[];
|
|
88
|
+
'match-calendar': readonly string[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const list = (value: unknown): readonly string[] =>
|
|
92
|
+
Array.isArray(value) ? value.map((item: unknown) => String(item)) : [];
|
|
93
|
+
|
|
94
|
+
/** Trimmed items, empty ones dropped. */
|
|
95
|
+
const items = (values: readonly string[], canonical: (item: string) => string): string[] =>
|
|
96
|
+
values.map((item) => canonical(item.trim())).filter((item) => item !== '');
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* ⚠️ THE SERVER WRITES A RULE BACK IN ITS OWN SPELLING, SO BOTH SIDES ARE SPELLED ITS WAY FIRST.
|
|
100
|
+
* matcher.rs parses `exact:type=gc, verify` into values and `Display`s them joined by a bare
|
|
101
|
+
* comma; a severity item is split, trimmed and re-joined the same way. Without this a
|
|
102
|
+
* declaration with a space after a comma would plan an update forever.
|
|
103
|
+
*/
|
|
104
|
+
const severity = (item: string): string =>
|
|
105
|
+
item
|
|
106
|
+
.split(',')
|
|
107
|
+
.map((part) => part.trim())
|
|
108
|
+
.filter((part) => part !== '')
|
|
109
|
+
.sort()
|
|
110
|
+
.join(',');
|
|
111
|
+
|
|
112
|
+
const field = (item: string): string => {
|
|
113
|
+
if (!item.startsWith('exact:')) return item;
|
|
114
|
+
const at = item.indexOf('=');
|
|
115
|
+
if (at < 0) return item;
|
|
116
|
+
const values = item.slice(at + 1).split(',');
|
|
117
|
+
return `${item.slice(0, at)}=${values.map((value) => value.trim()).join(',')}`;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const same = (item: string): string => item;
|
|
121
|
+
|
|
122
|
+
/** A declaration's lists, canonical and sorted — the form `matches` compares. */
|
|
123
|
+
export const canonicalRules = (fields: NotificationMatcherFields) => ({
|
|
124
|
+
'match-calendar': items(fields['match-calendar'] ?? [], same).sort(),
|
|
125
|
+
'match-field': items(fields['match-field'] ?? [], field).sort(),
|
|
126
|
+
'match-severity': items(fields['match-severity'] ?? [], severity).sort(),
|
|
127
|
+
targets: items(fields.targets, same).sort(),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
export const matcherAttributes = (
|
|
131
|
+
live: Record<string, unknown>,
|
|
132
|
+
name: string,
|
|
133
|
+
): NotificationMatcherAttributes => ({
|
|
134
|
+
comment: text(live['comment']),
|
|
135
|
+
disable: bool(live['disable']),
|
|
136
|
+
'invert-match': bool(live['invert-match']),
|
|
137
|
+
'match-calendar': items(list(live['match-calendar']), same).sort(),
|
|
138
|
+
'match-field': items(list(live['match-field']), field).sort(),
|
|
139
|
+
'match-severity': items(list(live['match-severity']), severity).sort(),
|
|
140
|
+
// ⚠️ ABSENT IS `all` — matcher.rs `mode.unwrap_or_default()`, and `All` is the default variant.
|
|
141
|
+
mode: text(live['mode'], 'all') || 'all',
|
|
142
|
+
name,
|
|
143
|
+
origin: text(live['origin']),
|
|
144
|
+
targets: items(list(live['target']), same).sort(),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const equal = (a: readonly string[], b: readonly string[]): boolean =>
|
|
148
|
+
a.length === b.length && a.every((item, index) => item === b[index]);
|
|
149
|
+
|
|
150
|
+
export const matcherMatches = (
|
|
151
|
+
attributes: NotificationMatcherAttributes,
|
|
152
|
+
fields: NotificationMatcherFields,
|
|
153
|
+
): boolean => {
|
|
154
|
+
const rules = canonicalRules(fields);
|
|
155
|
+
return (
|
|
156
|
+
attributes.disable === (fields.disable === true) &&
|
|
157
|
+
attributes['invert-match'] === (fields['invert-match'] === true) &&
|
|
158
|
+
attributes.mode === (fields.mode ?? 'all') &&
|
|
159
|
+
equal(attributes.targets, rules.targets) &&
|
|
160
|
+
equal(attributes['match-severity'], rules['match-severity']) &&
|
|
161
|
+
equal(attributes['match-field'], rules['match-field']) &&
|
|
162
|
+
equal(attributes['match-calendar'], rules['match-calendar']) &&
|
|
163
|
+
(fields.comment === undefined || attributes.comment === fields.comment)
|
|
164
|
+
);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
type Deletable = NonNullable<ConfigNotificationsMatchersNamePutParams['delete']>[number];
|
|
168
|
+
type Lists = 'match-calendar' | 'match-field' | 'match-severity' | 'target';
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The fields both forms send. ⚠️ LISTS GO AS ARRAYS — REPEATED KEYS ON THE WIRE (client.ts
|
|
172
|
+
* `encode`) — because every list here is `type: array` on both servers, and a comma inside a
|
|
173
|
+
* severity item or an `exact:` rule means a comma-joined string could not carry them anyway.
|
|
174
|
+
*/
|
|
175
|
+
const always = (fields: NotificationMatcherFields) => ({
|
|
176
|
+
disable: fields.disable === true ? ('1' as const) : ('0' as const),
|
|
177
|
+
'invert-match': fields['invert-match'] === true ? ('1' as const) : ('0' as const),
|
|
178
|
+
mode: fields.mode ?? ('all' as const),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const lists = (fields: NotificationMatcherFields): Partial<Record<Lists, readonly string[]>> => {
|
|
182
|
+
const rules = canonicalRules(fields);
|
|
183
|
+
const out: Partial<Record<Lists, readonly string[]>> = {};
|
|
184
|
+
if (rules['match-calendar'].length > 0) out['match-calendar'] = rules['match-calendar'];
|
|
185
|
+
if (rules['match-field'].length > 0) out['match-field'] = rules['match-field'];
|
|
186
|
+
if (rules['match-severity'].length > 0) out['match-severity'] = rules['match-severity'];
|
|
187
|
+
if (rules.targets.length > 0) out.target = rules.targets;
|
|
188
|
+
return out;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
export type MatcherCreateForm = ConfigNotificationsMatchersPostParams &
|
|
192
|
+
ClusterNotificationsMatchersPostParams;
|
|
193
|
+
export type MatcherUpdateForm = ConfigNotificationsMatchersNamePutParams &
|
|
194
|
+
ClusterNotificationsMatchersNamePutParams;
|
|
195
|
+
|
|
196
|
+
/** ⛔ `name` is create-only: the body carries it once and the path forever after. */
|
|
197
|
+
export const matcherCreateForm = (fields: NotificationMatcherFields): MatcherCreateForm => ({
|
|
198
|
+
...always(fields),
|
|
199
|
+
...lists(fields),
|
|
200
|
+
...(fields.comment === undefined || fields.comment === '' ? {} : { comment: fields.comment }),
|
|
201
|
+
name: fields.name,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* ⛔ AN EMPTIED LIST IS A `delete`, NOT AN EMPTY VALUE. A form cannot carry an empty array — no
|
|
206
|
+
* key is sent at all — so without the `delete` entry a declaration of `'match-field': []`
|
|
207
|
+
* would plan an update, PUT nothing for that field, and plan the same update forever.
|
|
208
|
+
*/
|
|
209
|
+
export const matcherUpdateForm = (fields: NotificationMatcherFields): MatcherUpdateForm => {
|
|
210
|
+
const present = lists(fields);
|
|
211
|
+
const cleared: Deletable[] = (
|
|
212
|
+
['match-calendar', 'match-field', 'match-severity', 'target'] as const
|
|
213
|
+
).filter((key) => present[key] === undefined);
|
|
214
|
+
if (fields.comment === '') cleared.push('comment');
|
|
215
|
+
return {
|
|
216
|
+
...always(fields),
|
|
217
|
+
...present,
|
|
218
|
+
...(fields.comment === undefined || fields.comment === '' ? {} : { comment: fields.comment }),
|
|
219
|
+
...(cleared.length === 0 ? {} : { delete: cleared }),
|
|
220
|
+
};
|
|
221
|
+
};
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.NotificationMatcher` and `Pbs.NotificationMatcher` — forms against the generated
|
|
3
|
+
* schemas, the canonical spellings, and both built-in `default-matcher`s adopted as they are.
|
|
4
|
+
*
|
|
5
|
+
* ★ THE LIVE FIXTURES ARE THE MEASURED ONES (2026-09-22): PVE 9.2.11 `pvesh get
|
|
6
|
+
* /cluster/notifications/matchers/default-matcher` and PBS 4.2 `proxmox-backup-manager
|
|
7
|
+
* notification matcher show default-matcher`, comment text shortened.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import * as Layer from 'effect/Layer';
|
|
11
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
12
|
+
import type { PbsTarget } from './credentials.ts';
|
|
13
|
+
import { fakeNotify } from './fake-pbs-notify.ts';
|
|
14
|
+
import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
|
|
15
|
+
import type {
|
|
16
|
+
ClusterNotificationsMatchersNamePutParams,
|
|
17
|
+
ClusterNotificationsMatchersPostParams,
|
|
18
|
+
} from './generated/pve.ts';
|
|
19
|
+
import type {
|
|
20
|
+
ConfigNotificationsMatchersNamePutParams,
|
|
21
|
+
ConfigNotificationsMatchersPostParams,
|
|
22
|
+
} from './generated/pbs.ts';
|
|
23
|
+
import {
|
|
24
|
+
ProxmoxNotificationMatcher,
|
|
25
|
+
ProxmoxNotificationMatcherProvider,
|
|
26
|
+
} from './notification-matcher.ts';
|
|
27
|
+
import {
|
|
28
|
+
type NotificationMatcherFields,
|
|
29
|
+
matcherAttributes,
|
|
30
|
+
matcherCreateForm,
|
|
31
|
+
matcherMatches,
|
|
32
|
+
matcherUpdateForm,
|
|
33
|
+
} from './notification-matcher-form.ts';
|
|
34
|
+
import {
|
|
35
|
+
PbsNotificationMatcher,
|
|
36
|
+
PbsNotificationMatcherProvider,
|
|
37
|
+
} from './pbs-notification-matcher.ts';
|
|
38
|
+
|
|
39
|
+
const PBS: PbsTarget = { api: 'https://pbs.test:8007/api2/json', mount: 'pbs-test', scheme: 'pbs' };
|
|
40
|
+
const PVE_PATH = 'cluster/notifications/matchers/default-matcher';
|
|
41
|
+
const PBS_PATH = 'config/notifications/matchers/default-matcher';
|
|
42
|
+
|
|
43
|
+
/** ★ The live shape, `digest` included: the per-object GET carries one, the collection GET not. */
|
|
44
|
+
const PVE_DEFAULT = {
|
|
45
|
+
comment: 'Route all notifications to mail-to-root',
|
|
46
|
+
digest: 'd1'.repeat(32), // placeholder; a real one is a sha256 of notifications.cfg
|
|
47
|
+
mode: 'all',
|
|
48
|
+
name: 'default-matcher',
|
|
49
|
+
origin: 'builtin',
|
|
50
|
+
target: ['mail-to-root'],
|
|
51
|
+
};
|
|
52
|
+
const PBS_DEFAULT = {
|
|
53
|
+
comment: 'Route everything but successful prune jobs',
|
|
54
|
+
'invert-match': true,
|
|
55
|
+
'match-field': ['exact:type=prune'],
|
|
56
|
+
'match-severity': ['info'],
|
|
57
|
+
mode: 'all',
|
|
58
|
+
name: 'default-matcher',
|
|
59
|
+
origin: 'builtin',
|
|
60
|
+
target: ['mail-to-root'],
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const pbsDefault = (over: Partial<NotificationMatcherFields> = {}) =>
|
|
64
|
+
PbsNotificationMatcher('default-matcher', {
|
|
65
|
+
'invert-match': true,
|
|
66
|
+
'match-field': ['exact:type=prune'],
|
|
67
|
+
'match-severity': ['info'],
|
|
68
|
+
name: 'default-matcher',
|
|
69
|
+
target: PBS,
|
|
70
|
+
targets: ['mail-to-root'],
|
|
71
|
+
...over,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('forms are typed against BOTH generated schemas', () => {
|
|
75
|
+
const fields: NotificationMatcherFields = {
|
|
76
|
+
'match-severity': ['error'],
|
|
77
|
+
name: 'pager',
|
|
78
|
+
targets: ['hook'],
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
test('create and update assign to PVE and PBS params without a cast', () => {
|
|
82
|
+
const pveCreate: ClusterNotificationsMatchersPostParams = matcherCreateForm(fields);
|
|
83
|
+
const pbsCreate: ConfigNotificationsMatchersPostParams = matcherCreateForm(fields);
|
|
84
|
+
const pveUpdate: ClusterNotificationsMatchersNamePutParams = matcherUpdateForm(fields);
|
|
85
|
+
const pbsUpdate: ConfigNotificationsMatchersNamePutParams = matcherUpdateForm(fields);
|
|
86
|
+
expect(pveCreate).toEqual(pbsCreate);
|
|
87
|
+
expect(pveUpdate).toEqual(pbsUpdate);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('would fail tsc if `name` were dropped from a create', () => {
|
|
91
|
+
// @ts-expect-error name is required on POST .../matchers
|
|
92
|
+
const bad: ConfigNotificationsMatchersPostParams = { target: ['hook'] };
|
|
93
|
+
expect('name' in bad).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('create sends lists as lists and the three always-compared flags', () => {
|
|
97
|
+
expect(matcherCreateForm(fields)).toEqual({
|
|
98
|
+
disable: '0',
|
|
99
|
+
'invert-match': '0',
|
|
100
|
+
'match-severity': ['error'],
|
|
101
|
+
mode: 'all',
|
|
102
|
+
name: 'pager',
|
|
103
|
+
target: ['hook'],
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('update clears every emptied rule list with `delete`, and an empty comment too', () => {
|
|
108
|
+
expect(matcherUpdateForm({ ...fields, comment: '', 'match-severity': [] })).toEqual({
|
|
109
|
+
delete: ['match-calendar', 'match-field', 'match-severity', 'comment'],
|
|
110
|
+
disable: '0',
|
|
111
|
+
'invert-match': '0',
|
|
112
|
+
mode: 'all',
|
|
113
|
+
target: ['hook'],
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('matches reads both sides in the server spelling', () => {
|
|
119
|
+
const live = matcherAttributes(
|
|
120
|
+
{
|
|
121
|
+
'match-field': ['exact:type=gc,verify'],
|
|
122
|
+
'match-severity': ['error,warning'],
|
|
123
|
+
target: ['b', 'a'],
|
|
124
|
+
},
|
|
125
|
+
'm',
|
|
126
|
+
);
|
|
127
|
+
const base: NotificationMatcherFields = { name: 'm', targets: ['a', 'b'] };
|
|
128
|
+
|
|
129
|
+
test('spaces after commas and list order are not a diff; absent mode is `all`', () => {
|
|
130
|
+
expect(
|
|
131
|
+
matcherMatches(live, {
|
|
132
|
+
...base,
|
|
133
|
+
'match-field': ['exact:type=gc, verify'],
|
|
134
|
+
'match-severity': ['warning, error'],
|
|
135
|
+
mode: 'all',
|
|
136
|
+
}),
|
|
137
|
+
).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('an undeclared rule is compared as empty — a matcher is its whole rule', () => {
|
|
141
|
+
expect(matcherMatches(live, { ...base, 'match-severity': ['error,warning'] })).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('invert-match, mode and disable are compared against their defaults', () => {
|
|
145
|
+
const rules = { 'match-field': ['exact:type=gc,verify'], 'match-severity': ['error,warning'] };
|
|
146
|
+
expect(matcherMatches(live, { ...base, ...rules })).toBe(true);
|
|
147
|
+
expect(matcherMatches(live, { ...base, ...rules, 'invert-match': true })).toBe(false);
|
|
148
|
+
expect(matcherMatches(live, { ...base, ...rules, mode: 'any' })).toBe(false);
|
|
149
|
+
expect(matcherMatches(live, { ...base, ...rules, disable: true })).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('adopting a built-in default-matcher as it is only reads', () => {
|
|
154
|
+
test('PVE: target and name, nothing else, is a noop with no write', async () => {
|
|
155
|
+
const fake = fakeNotify({ [PVE_PATH]: PVE_DEFAULT });
|
|
156
|
+
const declare = () =>
|
|
157
|
+
ProxmoxNotificationMatcher('default-matcher', {
|
|
158
|
+
name: 'default-matcher',
|
|
159
|
+
target: FAKE_TARGET,
|
|
160
|
+
targets: ['mail-to-root'],
|
|
161
|
+
});
|
|
162
|
+
await withoutBao(async () => {
|
|
163
|
+
const engine = engineOver(
|
|
164
|
+
ProxmoxNotificationMatcherProvider().pipe(Layer.provideMerge(fake.layer)),
|
|
165
|
+
);
|
|
166
|
+
expect((await engine.verify(declare())).rows).toEqual([
|
|
167
|
+
expect.objectContaining({ diff: 'noop', ok: true }),
|
|
168
|
+
]);
|
|
169
|
+
expect(Object.values(await engine.deploy(declare()))).toEqual(['adopted']);
|
|
170
|
+
expect((await engine.verify(declare(), { all: true })).rows[0]).toMatchObject({
|
|
171
|
+
diff: 'noop',
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
expect(fake.writes()).toEqual([]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('PBS: its three rule fields declared is a noop with no write', async () => {
|
|
178
|
+
const fake = fakeNotify({ [PBS_PATH]: PBS_DEFAULT });
|
|
179
|
+
await withoutBao(async () => {
|
|
180
|
+
const engine = engineOver(
|
|
181
|
+
PbsNotificationMatcherProvider().pipe(Layer.provideMerge(fake.layer)),
|
|
182
|
+
);
|
|
183
|
+
expect((await engine.verify(pbsDefault())).rows).toEqual([
|
|
184
|
+
expect.objectContaining({ diff: 'noop', ok: true }),
|
|
185
|
+
]);
|
|
186
|
+
await engine.deploy(pbsDefault());
|
|
187
|
+
});
|
|
188
|
+
expect(fake.writes()).toEqual([]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('PBS: leaving the rules out plans an update, and the PUT clears them by name', async () => {
|
|
192
|
+
const fake = fakeNotify({ [PBS_PATH]: PBS_DEFAULT });
|
|
193
|
+
const bare = () =>
|
|
194
|
+
pbsDefault({ 'invert-match': false, 'match-field': [], 'match-severity': [] });
|
|
195
|
+
await withoutBao(async () => {
|
|
196
|
+
const engine = engineOver(
|
|
197
|
+
PbsNotificationMatcherProvider().pipe(Layer.provideMerge(fake.layer)),
|
|
198
|
+
);
|
|
199
|
+
expect((await engine.verify(bare())).rows[0]).toMatchObject({ diff: 'update', ok: false });
|
|
200
|
+
await engine.deploy(bare());
|
|
201
|
+
});
|
|
202
|
+
expect(fake.writes()).toEqual([`PUT ${PBS_PATH}`]);
|
|
203
|
+
const put = fake.calls.find((call) => call.method === 'PUT');
|
|
204
|
+
expect(put?.pairs).toContainEqual(['delete', 'match-field']);
|
|
205
|
+
expect(put?.pairs).toContainEqual(['delete', 'match-severity']);
|
|
206
|
+
expect(put?.pairs).toContainEqual(['invert-match', '0']);
|
|
207
|
+
expect(fake.objects.get(PBS_PATH)?.['match-field']).toBeUndefined();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('PBS: a new matcher is POSTed with each list item as its own repeated key', async () => {
|
|
211
|
+
const fake = fakeNotify();
|
|
212
|
+
const pager = () =>
|
|
213
|
+
PbsNotificationMatcher('pager', {
|
|
214
|
+
'match-field': ['exact:type=gc,sync,verify'],
|
|
215
|
+
'match-severity': ['error'],
|
|
216
|
+
name: 'pager',
|
|
217
|
+
target: PBS,
|
|
218
|
+
targets: ['alertmanager', 'mail-to-root'],
|
|
219
|
+
});
|
|
220
|
+
await withoutBao(async () => {
|
|
221
|
+
const engine = engineOver(
|
|
222
|
+
PbsNotificationMatcherProvider().pipe(Layer.provideMerge(fake.layer)),
|
|
223
|
+
);
|
|
224
|
+
await engine.deploy(pager());
|
|
225
|
+
expect((await engine.verify(pager(), { all: true })).rows[0]).toMatchObject({ diff: 'noop' });
|
|
226
|
+
});
|
|
227
|
+
const post = fake.calls.find((call) => call.method === 'POST');
|
|
228
|
+
expect(post?.path).toBe('config/notifications/matchers');
|
|
229
|
+
expect(post?.pairs.filter(([key]) => key === 'target')).toEqual([
|
|
230
|
+
['target', 'alertmanager'],
|
|
231
|
+
['target', 'mail-to-root'],
|
|
232
|
+
]);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.NotificationMatcher` — which notifications a PVE cluster sends to which targets.
|
|
3
|
+
*
|
|
4
|
+
* ★ THE HALF OF THE NOTIFICATION SYSTEM notification-target.ts LEAVES OPEN. A target is where a
|
|
5
|
+
* message CAN go; a matcher decides whether it DOES. A cluster ships one, `default-matcher`,
|
|
6
|
+
* routing everything to `mail-to-root`, and a target no matcher names receives nothing — so a
|
|
7
|
+
* declared webhook with no matcher is a delivery path that is never used, and looks finished.
|
|
8
|
+
*
|
|
9
|
+
* ★ ADOPTING THE BUILT-IN AS IT IS: declare what it holds and nothing more. MEASURED on PVE
|
|
10
|
+
* 9.2.11 (2026-09-22, `pvesh get /cluster/notifications/matchers/default-matcher`): mode `all`,
|
|
11
|
+
* one target `mail-to-root`, no rules, the comment "Route all notifications to mail-to-root".
|
|
12
|
+
* ```ts
|
|
13
|
+
* ProxmoxNotificationMatcher('default-matcher', {
|
|
14
|
+
* target: cluster, name: 'default-matcher', targets: ['mail-to-root'],
|
|
15
|
+
* });
|
|
16
|
+
* ```
|
|
17
|
+
* That plans `noop` and deploys with no write (notification-matcher.test.ts). Leaving `comment`
|
|
18
|
+
* out leaves it alone; every rule field is compared — notification-matcher-form.ts says why.
|
|
19
|
+
*
|
|
20
|
+
* ★ WHAT THIS NEEDS, READ OFF THE CLUSTER'S OWN SCHEMA (generated/pve.ts source, 9.2.11):
|
|
21
|
+
* read GET matchers/{name} Mapping.Audit or Mapping.Modify on /mapping/notifications
|
|
22
|
+
* write POST, PUT, DELETE Mapping.Modify on /mapping/notifications
|
|
23
|
+
* The same pair as `Proxmox.NotificationTarget`, with that file's ⚠️ about granting it on `/`.
|
|
24
|
+
* ⛔ `Mapping.Use` IS NOT ENOUGH, though the COLLECTION GET accepts it: the per-object read this
|
|
25
|
+
* family makes lists only Audit and Modify (pve-manager API2/Cluster/Notifications.pm
|
|
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.
|
|
30
|
+
*/
|
|
31
|
+
import { Resource } from 'alchemy';
|
|
32
|
+
import * as Provider from 'alchemy/Provider';
|
|
33
|
+
import * as Effect from 'effect/Effect';
|
|
34
|
+
import {
|
|
35
|
+
type NotificationMatcherAttributes,
|
|
36
|
+
type NotificationMatcherFields,
|
|
37
|
+
matcherAttributes,
|
|
38
|
+
matcherCreateForm,
|
|
39
|
+
matcherMatches,
|
|
40
|
+
matcherUpdateForm,
|
|
41
|
+
} from './notification-matcher-form.ts';
|
|
42
|
+
import { type PveRequirements, type WithTarget, pveHandlers } from './resource.ts';
|
|
43
|
+
|
|
44
|
+
export interface NotificationMatcherProps extends NotificationMatcherFields, WithTarget {}
|
|
45
|
+
|
|
46
|
+
export interface ProxmoxNotificationMatcher extends Resource<
|
|
47
|
+
'Proxmox.NotificationMatcher',
|
|
48
|
+
NotificationMatcherProps,
|
|
49
|
+
NotificationMatcherAttributes,
|
|
50
|
+
never,
|
|
51
|
+
PveRequirements
|
|
52
|
+
> {}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* ⚠️ NO `defaultRemovalPolicy: 'retain'`, for notification-target.ts's reason: a matcher holds no
|
|
56
|
+
* data, and a plan line is the only warning a delete gets. On `default-matcher` the delete
|
|
57
|
+
* REVERTS it to the shipped rule instead — see `origin` on the attributes.
|
|
58
|
+
*/
|
|
59
|
+
export const ProxmoxNotificationMatcher = Resource<ProxmoxNotificationMatcher>(
|
|
60
|
+
'Proxmox.NotificationMatcher',
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const handlers = pveHandlers<NotificationMatcherProps, NotificationMatcherAttributes>({
|
|
64
|
+
attributes: (live, props) => matcherAttributes(live, props.name),
|
|
65
|
+
collection: () => 'cluster/notifications/matchers',
|
|
66
|
+
createForm: matcherCreateForm,
|
|
67
|
+
matches: matcherMatches,
|
|
68
|
+
path: (props) => `cluster/notifications/matchers/${props.name}`,
|
|
69
|
+
updateForm: matcherUpdateForm,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export const ProxmoxNotificationMatcherProvider = () =>
|
|
73
|
+
Provider.effect(
|
|
74
|
+
ProxmoxNotificationMatcher,
|
|
75
|
+
Effect.succeed(ProxmoxNotificationMatcher.Provider.of(handlers)),
|
|
76
|
+
);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Pbs.NotificationMatcher` — which Proxmox Backup Server notifications reach which targets.
|
|
3
|
+
*
|
|
4
|
+
* ★ THIS IS WHERE A FAILED VERIFY, SYNC OR GARBAGE COLLECTION BECOMES A PAGE. pbs-verify-job.ts
|
|
5
|
+
* explains why verification is the only thing that notices rotten chunks; a matcher is what
|
|
6
|
+
* turns what it notices into a message. Route `match-severity: ['error']` to a webhook target
|
|
7
|
+
* (pbs-notification-target.ts, docs/pbs-notifications.md) and a failure pages someone.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ A DATASTORE IN `legacy-sendmail` MODE BYPASSES EVERY MATCHER. PBS reads the datastore's
|
|
10
|
+
* `notification-mode` before it builds anything (src/server/notifications/mod.rs
|
|
11
|
+
* `lookup_datastore_notify_settings`, read at HEAD 2026-09-22): `legacy-sendmail` mails the
|
|
12
|
+
* `notify-user` directly and never consults a matcher. The default is `notification-system`
|
|
13
|
+
* today (pbs-api-types `NotificationMode`), but a datastore created before PBS 3.2 may say
|
|
14
|
+
* `legacy-sendmail` explicitly. `Pbs.Datastore` deliberately does not declare the mode (see
|
|
15
|
+
* `notify` in pbs-datastore.ts), so check it by hand before trusting a matcher with a datastore:
|
|
16
|
+
* `proxmox-backup-manager datastore show <name>`.
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ THE EVENT `type` FOR A VERIFICATION JOB IS `verify`, NOT `verification`. PBS's own
|
|
19
|
+
* notifications chapter lists `verification` in its event table and `verify` in its example,
|
|
20
|
+
* and the source settles it: `send_verify_status` sets `("type", "verify")` (same file). A
|
|
21
|
+
* `match-field: ['exact:type=verification']` rule matches nothing, silently.
|
|
22
|
+
* Types at HEAD: `gc`, `prune`, `sync`, `verify`, `tape-backup`, `tape-load`,
|
|
23
|
+
* `package-updates`, `acme`, `thresholds`, `system-mail`. GC carries `datastore` and `hostname`
|
|
24
|
+
* but NO `job-id`; prune, sync and verify carry all three; the UI's "Test" carries no fields.
|
|
25
|
+
*
|
|
26
|
+
* ★ ADOPTING THE BUILT-IN AS IT IS. MEASURED on PBS 4.2 (2026-09-22, `proxmox-backup-manager
|
|
27
|
+
* notification matcher show default-matcher`): it routes everything EXCEPT successful prune
|
|
28
|
+
* jobs to `mail-to-root` — `invert-match` over `exact:type=prune` AND severity `info`:
|
|
29
|
+
* ```ts
|
|
30
|
+
* PbsNotificationMatcher('default-matcher', {
|
|
31
|
+
* target: pbs, name: 'default-matcher', targets: ['mail-to-root'],
|
|
32
|
+
* 'invert-match': true, 'match-field': ['exact:type=prune'], 'match-severity': ['info'],
|
|
33
|
+
* });
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* ★ WHAT THIS NEEDS, READ OFF THE SERVER'S SCHEMA (generated/pbs.ts source):
|
|
37
|
+
* read GET Sys.Audit on /system/notifications
|
|
38
|
+
* write POST, PUT, DELETE Sys.Modify on /system/notifications
|
|
39
|
+
* ⚠️ NOT A DATASTORE PRIVILEGE. A PBS credential scoped to `/datastore/...` reads every matcher
|
|
40
|
+
* as absent — the "absent, then already exists" signature notification-target.ts describes.
|
|
41
|
+
*/
|
|
42
|
+
import { Resource } from 'alchemy';
|
|
43
|
+
import * as Provider from 'alchemy/Provider';
|
|
44
|
+
import * as Effect from 'effect/Effect';
|
|
45
|
+
import {
|
|
46
|
+
type NotificationMatcherAttributes,
|
|
47
|
+
type NotificationMatcherFields,
|
|
48
|
+
matcherAttributes,
|
|
49
|
+
matcherCreateForm,
|
|
50
|
+
matcherMatches,
|
|
51
|
+
matcherUpdateForm,
|
|
52
|
+
} from './notification-matcher-form.ts';
|
|
53
|
+
import { type PveRequirements, type WithPbsTarget, pveHandlers } from './resource.ts';
|
|
54
|
+
|
|
55
|
+
export interface PbsNotificationMatcherProps extends NotificationMatcherFields, WithPbsTarget {}
|
|
56
|
+
|
|
57
|
+
export interface PbsNotificationMatcher extends Resource<
|
|
58
|
+
'Pbs.NotificationMatcher',
|
|
59
|
+
PbsNotificationMatcherProps,
|
|
60
|
+
NotificationMatcherAttributes,
|
|
61
|
+
never,
|
|
62
|
+
PveRequirements
|
|
63
|
+
> {}
|
|
64
|
+
|
|
65
|
+
/** ⚠️ No `retain`, for notification-matcher.ts's reason; a built-in REVERTS on delete. */
|
|
66
|
+
export const PbsNotificationMatcher = Resource<PbsNotificationMatcher>('Pbs.NotificationMatcher');
|
|
67
|
+
|
|
68
|
+
const handlers = pveHandlers<PbsNotificationMatcherProps, NotificationMatcherAttributes>({
|
|
69
|
+
attributes: (live, props) => matcherAttributes(live, props.name),
|
|
70
|
+
collection: () => 'config/notifications/matchers',
|
|
71
|
+
createForm: matcherCreateForm,
|
|
72
|
+
matches: matcherMatches,
|
|
73
|
+
path: (props) => `config/notifications/matchers/${props.name}`,
|
|
74
|
+
updateForm: matcherUpdateForm,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const PbsNotificationMatcherProvider = () =>
|
|
78
|
+
Provider.effect(
|
|
79
|
+
PbsNotificationMatcher,
|
|
80
|
+
Effect.succeed(PbsNotificationMatcher.Provider.of(handlers)),
|
|
81
|
+
);
|