@homeflare/alchemy 0.12.0 → 0.13.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/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/package.json +1 -1
- 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,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a PBS notification target looks on the wire, and what of it may be remembered.
|
|
3
|
+
*
|
|
4
|
+
* ★ SPLIT OUT OF pbs-notification-target.ts FOR THE 250-LINE CAP, on the seam
|
|
5
|
+
* notification-target-form.ts cut for PVE: the resource file says what a target IS and when it
|
|
6
|
+
* changed; this file turns a live answer into attributes and a value into a wire item. The
|
|
7
|
+
* `import type` back is erased, so the cycle is on paper only.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ THE LIVE ANSWER HOLDS HEADER VALUES, AND NONE OF THEM REACHES AN ATTRIBUTE. PBS returns a
|
|
10
|
+
* webhook's headers in full — `name=Authorization,value=<base64>` — to anyone with Sys.Audit on
|
|
11
|
+
* /system/notifications (proxmox-notify api/webhook.rs `get_endpoint`, read at HEAD 2026-09-22).
|
|
12
|
+
* Copying them into attributes would copy whatever a human pasted into a header, a bearer token
|
|
13
|
+
* included, into the unencrypted state store. So attributes keep header NAMES and a fixed-salt
|
|
14
|
+
* scrypt digest of the pairs (write-only.ts), enough to notice a change and nothing to send.
|
|
15
|
+
* ⚠️ SECRETS ARE SAFER ON THE SERVER, AND THE READ SHOWS IT: `get_endpoint` blanks every secret's
|
|
16
|
+
* value and returns names only, from a root-only `notifications-priv.cfg`. A credential belongs
|
|
17
|
+
* in `secret` and is referenced as `{{ secrets.<name> }}` from a header, the URL or the body.
|
|
18
|
+
*/
|
|
19
|
+
import { Buffer } from 'node:buffer';
|
|
20
|
+
import { addressList, portOf } from './notification-target-form.ts';
|
|
21
|
+
import type {
|
|
22
|
+
PbsNotificationTargetAttributes,
|
|
23
|
+
PbsNotificationTargetProps,
|
|
24
|
+
} from './pbs-notification-target.ts';
|
|
25
|
+
import { bool, text } from './values.ts';
|
|
26
|
+
import { seal } from './write-only.ts';
|
|
27
|
+
|
|
28
|
+
export const base64 = (value: string): string => Buffer.from(value, 'utf8').toString('base64');
|
|
29
|
+
|
|
30
|
+
/** ⚠️ Node's decoder skips characters it does not know rather than throwing, so this never throws. */
|
|
31
|
+
export const unbase64 = (value: string): string => Buffer.from(value, 'base64').toString('utf8');
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One `KeyAndBase64Val` property string — `name=<name>,value=<base64>` — split into its halves.
|
|
35
|
+
* ⚠️ KEYED BY NAME, NOT BY POSITION: the server serialises `name` first today, and a parser that
|
|
36
|
+
* relied on that would read a reordered item as a header called `value=…`.
|
|
37
|
+
* ★ A comma cannot occur inside either half: base64 has none, and `refusals` keeps names clean.
|
|
38
|
+
*/
|
|
39
|
+
export const keyAndValue = (item: string): { name: string; value?: string } => {
|
|
40
|
+
const parts: Record<string, string> = {};
|
|
41
|
+
for (const part of item.split(',')) {
|
|
42
|
+
const at = part.indexOf('=');
|
|
43
|
+
if (at > 0) parts[part.slice(0, at).trim()] = part.slice(at + 1).trim();
|
|
44
|
+
}
|
|
45
|
+
const value = parts['value'];
|
|
46
|
+
return value === undefined ? { name: parts['name'] ?? '' } : { name: parts['name'] ?? '', value };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** The wire item for one header or secret. The value is base64 — the server decodes it to use it. */
|
|
50
|
+
export const wireItem = (name: string, value: string): string =>
|
|
51
|
+
`name=${name},value=${base64(value)}`;
|
|
52
|
+
|
|
53
|
+
const list = (value: unknown): readonly string[] =>
|
|
54
|
+
Array.isArray(value) ? value.map((item: unknown) => String(item)) : [];
|
|
55
|
+
|
|
56
|
+
/** Sorted, comma-joined names — presence, the only thing the store may know about the values. */
|
|
57
|
+
export const names = (keys: readonly string[]): string =>
|
|
58
|
+
[...keys]
|
|
59
|
+
.filter((key) => key !== '')
|
|
60
|
+
.sort()
|
|
61
|
+
.join(',');
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* ★ A FIXED SALT PER TARGET, SO AN UNCHANGED HEADER SET DIGESTS THE SAME ON EVERY READ. A random
|
|
65
|
+
* one would rewrite the state row on every plan. The server shows these values to its auditors
|
|
66
|
+
* anyway; what the salt and scrypt buy is that the STORE does not become a second, weaker copy.
|
|
67
|
+
*/
|
|
68
|
+
export const headerDigest = (target: string, pairs: Readonly<Record<string, string>>): string =>
|
|
69
|
+
seal(pairs, `hf:pbs-notification-target:${target}:header`);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Live JSON to attributes. `sealed` is left empty here: it is not the server's to report, and the
|
|
73
|
+
* handlers in pbs-notification-target.ts carry it forward from the previous state.
|
|
74
|
+
*/
|
|
75
|
+
export const targetAttributes = (
|
|
76
|
+
live: Record<string, unknown>,
|
|
77
|
+
props: PbsNotificationTargetProps,
|
|
78
|
+
): PbsNotificationTargetAttributes => {
|
|
79
|
+
const headers: Record<string, string> = {};
|
|
80
|
+
for (const item of list(live['header'])) {
|
|
81
|
+
const { name, value } = keyAndValue(item);
|
|
82
|
+
if (name !== '') headers[name] = unbase64(value ?? '');
|
|
83
|
+
}
|
|
84
|
+
const secrets = list(live['secret']).map((item) => keyAndValue(item).name);
|
|
85
|
+
return {
|
|
86
|
+
author: text(live['author']),
|
|
87
|
+
// ⚠️ DECODED, unlike the PVE family's: the server stores exactly the base64 it was handed and
|
|
88
|
+
// decodes it to render (endpoints/webhook.rs `build_request`), so decoding here is a pure
|
|
89
|
+
// function of what was written and `matches` compares readable text rather than base64.
|
|
90
|
+
body: unbase64(text(live['body'])),
|
|
91
|
+
comment: text(live['comment']),
|
|
92
|
+
disable: bool(live['disable']),
|
|
93
|
+
'from-address': text(live['from-address']),
|
|
94
|
+
header: names(Object.keys(headers)),
|
|
95
|
+
headerDigest: headerDigest(props.name, headers),
|
|
96
|
+
mailto: addressList(live['mailto']),
|
|
97
|
+
'mailto-user': addressList(live['mailto-user']),
|
|
98
|
+
method: text(live['method']),
|
|
99
|
+
mode: text(live['mode']),
|
|
100
|
+
name: props.name,
|
|
101
|
+
// ★ ON THE PER-OBJECT READ, MEASURED on PBS 4.2 (`notification endpoint sendmail show
|
|
102
|
+
// mail-to-root` answers `origin: builtin`). Reported, never compared.
|
|
103
|
+
origin: text(live['origin']),
|
|
104
|
+
port: portOf(live['port']),
|
|
105
|
+
secret: names(secrets),
|
|
106
|
+
sealed: '',
|
|
107
|
+
server: text(live['server']),
|
|
108
|
+
type: props.type,
|
|
109
|
+
url: text(live['url']),
|
|
110
|
+
username: text(live['username']),
|
|
111
|
+
};
|
|
112
|
+
};
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Pbs.NotificationTarget` — refusals, the wire forms against the generated PBS schema, and the
|
|
3
|
+
* rule that no header or secret VALUE reaches an attribute.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ THE DRIFT CHECK IS A TYPED KEY LIST PER FAMILY. `targetForm` builds one flat body for three
|
|
6
|
+
* families, so it cannot be typed as any one of them; instead each list below `satisfies` the
|
|
7
|
+
* generated parameter keys (and each `delete` value the generated enum), and every form this file
|
|
8
|
+
* builds is asserted to stay inside its list. A renamed parameter fails `tsc` on the list.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import type {
|
|
12
|
+
ConfigNotificationsEndpointsSendmailNamePutParams,
|
|
13
|
+
ConfigNotificationsEndpointsSmtpPostParams,
|
|
14
|
+
ConfigNotificationsEndpointsWebhookNamePutParams,
|
|
15
|
+
ConfigNotificationsEndpointsWebhookPostParams,
|
|
16
|
+
} from './generated/pbs.ts';
|
|
17
|
+
import type { PbsTarget } from './credentials.ts';
|
|
18
|
+
import type { PbsNotificationTargetProps } from './pbs-notification-target.ts';
|
|
19
|
+
import { refusals, resolveGroups, targetForm } from './pbs-notification-target-form.ts';
|
|
20
|
+
import { keyAndValue, targetAttributes, unbase64 } from './pbs-notification-target-wire.ts';
|
|
21
|
+
|
|
22
|
+
const PBS: PbsTarget = { api: 'https://pbs.test:8007/api2/json', mount: 'pbs-test', scheme: 'pbs' };
|
|
23
|
+
const TOKEN = 'tok-5f0c1d2e-not-real';
|
|
24
|
+
const ENV = { HOOK_TOKEN: TOKEN, SMTP_PASSWORD: 'pw-not-real' };
|
|
25
|
+
|
|
26
|
+
const WEBHOOK_POST = [
|
|
27
|
+
'body',
|
|
28
|
+
'comment',
|
|
29
|
+
'disable',
|
|
30
|
+
'header',
|
|
31
|
+
'method',
|
|
32
|
+
'name',
|
|
33
|
+
'secret',
|
|
34
|
+
'url',
|
|
35
|
+
] as const satisfies readonly (keyof ConfigNotificationsEndpointsWebhookPostParams)[];
|
|
36
|
+
const WEBHOOK_PUT = [
|
|
37
|
+
'body',
|
|
38
|
+
'comment',
|
|
39
|
+
'delete',
|
|
40
|
+
'disable',
|
|
41
|
+
'header',
|
|
42
|
+
'method',
|
|
43
|
+
'secret',
|
|
44
|
+
'url',
|
|
45
|
+
] as const satisfies readonly (keyof ConfigNotificationsEndpointsWebhookNamePutParams)[];
|
|
46
|
+
const WEBHOOK_DELETE = [
|
|
47
|
+
'body',
|
|
48
|
+
'comment',
|
|
49
|
+
'header',
|
|
50
|
+
'secret',
|
|
51
|
+
] as const satisfies readonly NonNullable<
|
|
52
|
+
ConfigNotificationsEndpointsWebhookNamePutParams['delete']
|
|
53
|
+
>[number][];
|
|
54
|
+
const SMTP_POST = [
|
|
55
|
+
'author',
|
|
56
|
+
'comment',
|
|
57
|
+
'disable',
|
|
58
|
+
'from-address',
|
|
59
|
+
'mailto',
|
|
60
|
+
'mailto-user',
|
|
61
|
+
'mode',
|
|
62
|
+
'name',
|
|
63
|
+
'password',
|
|
64
|
+
'port',
|
|
65
|
+
'server',
|
|
66
|
+
'username',
|
|
67
|
+
] as const satisfies readonly (keyof ConfigNotificationsEndpointsSmtpPostParams)[];
|
|
68
|
+
const SENDMAIL_DELETE = [
|
|
69
|
+
'author',
|
|
70
|
+
'comment',
|
|
71
|
+
'from-address',
|
|
72
|
+
'mailto',
|
|
73
|
+
'mailto-user',
|
|
74
|
+
] as const satisfies readonly NonNullable<
|
|
75
|
+
ConfigNotificationsEndpointsSendmailNamePutParams['delete']
|
|
76
|
+
>[number][];
|
|
77
|
+
|
|
78
|
+
const webhook = (over: Partial<PbsNotificationTargetProps> = {}): PbsNotificationTargetProps => ({
|
|
79
|
+
body: '{"text": {{ json title }}}',
|
|
80
|
+
header: { Authorization: 'Bearer {{ secrets.token }}', 'Content-Type': 'application/json' },
|
|
81
|
+
method: 'post',
|
|
82
|
+
name: 'alertmanager',
|
|
83
|
+
secret: { token: { fromEnv: 'HOOK_TOKEN' } },
|
|
84
|
+
target: PBS,
|
|
85
|
+
type: 'webhook',
|
|
86
|
+
url: 'https://alertmanager.example.com/api/v2/alerts',
|
|
87
|
+
...over,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const all = { header: true, sealed: true } as const;
|
|
91
|
+
const keysOf = (form: object) => Object.keys(form).sort();
|
|
92
|
+
|
|
93
|
+
describe('refusals, at plan, by field name', () => {
|
|
94
|
+
test('a field outside its family, a missing required field, a name that breaks the wire', () => {
|
|
95
|
+
expect(refusals(webhook())).toEqual([]);
|
|
96
|
+
expect(refusals({ name: 'm', target: PBS, type: 'sendmail', url: 'https://x.test' })).toEqual([
|
|
97
|
+
"'url' belongs to webhook targets, not sendmail",
|
|
98
|
+
]);
|
|
99
|
+
expect(refusals(webhook({ url: undefined as never }))).toContain("webhook needs 'url'");
|
|
100
|
+
expect(refusals({ name: 's', server: 'mx.test', target: PBS, type: 'smtp' })).toEqual([
|
|
101
|
+
"smtp needs 'from-address'",
|
|
102
|
+
]);
|
|
103
|
+
expect(refusals(webhook({ header: { 'X-A,b': 'v' } }))).toEqual([
|
|
104
|
+
"'X-A,b' cannot be a header or secret name",
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('webhook forms', () => {
|
|
110
|
+
const groups = resolveGroups(webhook(), ENV);
|
|
111
|
+
|
|
112
|
+
test('a create carries base64 body, header and secret items, and stays inside the schema', () => {
|
|
113
|
+
const form = targetForm(webhook(), groups, all, 'create');
|
|
114
|
+
expect(keysOf(form).every((key) => (WEBHOOK_POST as readonly string[]).includes(key))).toBe(
|
|
115
|
+
true,
|
|
116
|
+
);
|
|
117
|
+
expect(unbase64(String(form['body']))).toBe('{"text": {{ json title }}}');
|
|
118
|
+
const secret = (form['secret'] as readonly string[]).map(keyAndValue);
|
|
119
|
+
expect(secret).toEqual([{ name: 'token', value: Buffer.from(TOKEN).toString('base64') }]);
|
|
120
|
+
const headers = (form['header'] as readonly string[]).map(keyAndValue);
|
|
121
|
+
expect(headers.map((h) => [h.name, unbase64(h.value ?? '')])).toEqual([
|
|
122
|
+
['Authorization', 'Bearer {{ secrets.token }}'],
|
|
123
|
+
['Content-Type', 'application/json'],
|
|
124
|
+
]);
|
|
125
|
+
expect(form['disable']).toBe('0');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('an update carries only the groups it was told to — omitted means "keep"', () => {
|
|
129
|
+
const form = targetForm(
|
|
130
|
+
webhook({ comment: 'x' }),
|
|
131
|
+
groups,
|
|
132
|
+
{ header: false, sealed: false },
|
|
133
|
+
'update',
|
|
134
|
+
);
|
|
135
|
+
expect(keysOf(form)).toEqual(['body', 'comment', 'disable', 'method', 'url']);
|
|
136
|
+
expect(keysOf(form).every((key) => (WEBHOOK_PUT as readonly string[]).includes(key))).toBe(
|
|
137
|
+
true,
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('clearing is a `delete` inside the generated enum', () => {
|
|
142
|
+
const cleared = webhook({ body: '', comment: '', header: {}, secret: {} });
|
|
143
|
+
const form = targetForm(cleared, resolveGroups(cleared, ENV), all, 'update');
|
|
144
|
+
expect(form['delete']).toEqual(['body', 'comment', 'header', 'secret']);
|
|
145
|
+
expect(
|
|
146
|
+
(form['delete'] as readonly string[]).every((d) =>
|
|
147
|
+
(WEBHOOK_DELETE as readonly string[]).includes(d),
|
|
148
|
+
),
|
|
149
|
+
).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('mail forms', () => {
|
|
154
|
+
test('smtp: recipients sorted, password sent only when its group is carried', () => {
|
|
155
|
+
const smtp: PbsNotificationTargetProps = {
|
|
156
|
+
'from-address': 'pbs@example.com',
|
|
157
|
+
mailto: ['b@example.com', 'a@example.com'],
|
|
158
|
+
name: 'relay',
|
|
159
|
+
password: { fromEnv: 'SMTP_PASSWORD' },
|
|
160
|
+
server: 'mx.example.com',
|
|
161
|
+
target: PBS,
|
|
162
|
+
type: 'smtp',
|
|
163
|
+
username: 'pbs',
|
|
164
|
+
};
|
|
165
|
+
const groups = resolveGroups(smtp, ENV);
|
|
166
|
+
const create = targetForm(smtp, groups, all, 'create');
|
|
167
|
+
expect(create['mailto']).toEqual(['a@example.com', 'b@example.com']);
|
|
168
|
+
expect(create['password']).toBe('pw-not-real');
|
|
169
|
+
expect(keysOf(create).every((key) => (SMTP_POST as readonly string[]).includes(key))).toBe(
|
|
170
|
+
true,
|
|
171
|
+
);
|
|
172
|
+
expect(
|
|
173
|
+
targetForm(smtp, groups, { header: false, sealed: false }, 'update')['password'],
|
|
174
|
+
).toBeUndefined();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('sendmail: an emptied list and an empty from-address clear, inside the enum', () => {
|
|
178
|
+
const sendmail: PbsNotificationTargetProps = {
|
|
179
|
+
'from-address': '',
|
|
180
|
+
mailto: [],
|
|
181
|
+
name: 'mail-to-root',
|
|
182
|
+
target: PBS,
|
|
183
|
+
type: 'sendmail',
|
|
184
|
+
};
|
|
185
|
+
const form = targetForm(sendmail, resolveGroups(sendmail, ENV), all, 'update');
|
|
186
|
+
expect(form['delete']).toEqual(['from-address', 'mailto']);
|
|
187
|
+
expect(
|
|
188
|
+
(form['delete'] as readonly string[]).every((d) =>
|
|
189
|
+
(SENDMAIL_DELETE as readonly string[]).includes(d),
|
|
190
|
+
),
|
|
191
|
+
).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('attributes', () => {
|
|
196
|
+
const live = {
|
|
197
|
+
body: Buffer.from('{}').toString('base64'),
|
|
198
|
+
header: [`name=Authorization,value=${Buffer.from(`Bearer ${TOKEN}`).toString('base64')}`],
|
|
199
|
+
method: 'post',
|
|
200
|
+
name: 'alertmanager',
|
|
201
|
+
origin: 'user-created',
|
|
202
|
+
secret: ['name=token'],
|
|
203
|
+
url: 'https://alertmanager.example.com/api/v2/alerts',
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
test('keep header and secret NAMES and a digest — never a value, raw or base64', () => {
|
|
207
|
+
const attributes = targetAttributes(live, webhook());
|
|
208
|
+
expect(attributes.header).toBe('Authorization');
|
|
209
|
+
expect(attributes.secret).toBe('token');
|
|
210
|
+
expect(attributes.headerDigest).toStartWith('scrypt:');
|
|
211
|
+
const text = JSON.stringify(attributes);
|
|
212
|
+
expect(text).not.toContain(TOKEN);
|
|
213
|
+
expect(text).not.toContain(Buffer.from(`Bearer ${TOKEN}`).toString('base64'));
|
|
214
|
+
expect(attributes.body).toBe('{}');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('the header digest is stable across reads and moves with the value', () => {
|
|
218
|
+
const again = targetAttributes(live, webhook());
|
|
219
|
+
expect(again.headerDigest).toBe(targetAttributes(live, webhook()).headerDigest);
|
|
220
|
+
const other = {
|
|
221
|
+
...live,
|
|
222
|
+
header: [`name=Authorization,value=${Buffer.from('Bearer other').toString('base64')}`],
|
|
223
|
+
};
|
|
224
|
+
expect(targetAttributes(other, webhook()).headerDigest).not.toBe(again.headerDigest);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Pbs.NotificationTarget` — where a Proxmox Backup Server sends a notification: a `webhook`, or
|
|
3
|
+
* mail through `sendmail` or `smtp`.
|
|
4
|
+
*
|
|
5
|
+
* ★ THE WEBHOOK IS THE POINT. A failed verify, sync or garbage collection reaches a human through a
|
|
6
|
+
* target a matcher names (pbs-notification-matcher.ts). Out of the box that is `mail-to-root`,
|
|
7
|
+
* one mailbox on one host; a webhook can post an Alertmanager alert instead, and then the failure
|
|
8
|
+
* pages like everything else. docs/pbs-alertmanager-body.md has the body template.
|
|
9
|
+
*
|
|
10
|
+
* ⛔ SECRETS ARE DECLARED BY THE NAME OF AN ENVIRONMENT VARIABLE, NEVER BY VALUE, and NO VALUE EVER
|
|
11
|
+
* REACHES STATE — not as a prop, not as an attribute. write-only.ts has the whole argument; the
|
|
12
|
+
* short form: Alchemy stores props and attributes unencrypted, so `secret`, `password` and any
|
|
13
|
+
* header holding a credential take `{ fromEnv: 'NAME' }`, read by the deploying process at call
|
|
14
|
+
* time. What the store keeps is presence (names) and digests, and the plan diffs on those.
|
|
15
|
+
* ★ THIS IS WHERE THE PBS FAMILY PARTS FROM notification-target.ts (PVE), which refuses every
|
|
16
|
+
* secret and so cannot create a signed webhook at all.
|
|
17
|
+
*
|
|
18
|
+
* ⛔ THE PER-OBJECT PATH CARRIES THE FAMILY: `config/notifications/endpoints/{type}/{name}`. `type`
|
|
19
|
+
* is identity, and changing it or `name` plans a REPLACE here — `diff` compares them with the
|
|
20
|
+
* recorded props. `/config/notifications/targets` is a read-only union view, as on PVE.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ A BUILT-IN TARGET REVERTS ON DELETE instead of disappearing (`origin: builtin`, measured on
|
|
23
|
+
* PBS 4.2 for `mail-to-root`), and PBS reports success either way — notification-target.ts has
|
|
24
|
+
* the consequence. Read `origin` before declaring a name you did not create.
|
|
25
|
+
*
|
|
26
|
+
* ★ WHAT THIS NEEDS (generated/pbs.ts source): Sys.Audit on /system/notifications to read,
|
|
27
|
+
* Sys.Modify there to write. A datastore-scoped credential reads every target as absent.
|
|
28
|
+
*/
|
|
29
|
+
import { Resource } from 'alchemy';
|
|
30
|
+
import * as Provider from 'alchemy/Provider';
|
|
31
|
+
import * as Effect from 'effect/Effect';
|
|
32
|
+
import { handlers } from './pbs-notification-target-lifecycle.ts';
|
|
33
|
+
import type { PveRequirements, WithPbsTarget } from './resource.ts';
|
|
34
|
+
import type { FromEnv } from './write-only.ts';
|
|
35
|
+
|
|
36
|
+
export type PbsNotificationTargetType = 'sendmail' | 'smtp' | 'webhook';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* ⚠️ PBS'S OWN KEY NAMES, DASHES INCLUDED, for the reason backup-job.ts gives. A field outside its
|
|
40
|
+
* family (a `url` on a sendmail target) is refused at plan by name, not sent for a 400.
|
|
41
|
+
* ⚠️ AN UNDECLARED FIELD IS NEITHER SENT NOR COMPARED; `''` or `[]` (or `{}`) CLEARS ONE.
|
|
42
|
+
*/
|
|
43
|
+
export interface PbsNotificationTargetProps extends WithPbsTarget {
|
|
44
|
+
/** ⛔ IDENTITY: it picks the family and the URL. Changing it is a replace. */
|
|
45
|
+
type: PbsNotificationTargetType;
|
|
46
|
+
/** Unique across ALL families on the host: a webhook cannot be called `mail-to-root`. */
|
|
47
|
+
name: string;
|
|
48
|
+
comment?: string;
|
|
49
|
+
/** Configured but not delivering. Always sent and always compared. */
|
|
50
|
+
disable?: boolean;
|
|
51
|
+
/** sendmail, smtp: recipient addresses. Order is not meaning. */
|
|
52
|
+
mailto?: readonly string[];
|
|
53
|
+
/** sendmail, smtp: PBS users whose configured address receives the mail. */
|
|
54
|
+
'mailto-user'?: readonly string[];
|
|
55
|
+
/** smtp: required. sendmail: optional. */
|
|
56
|
+
'from-address'?: string;
|
|
57
|
+
author?: string;
|
|
58
|
+
/** smtp: the relay host. Required. */
|
|
59
|
+
server?: string;
|
|
60
|
+
/** smtp. Unset lets PBS pick from `mode`. */
|
|
61
|
+
port?: number;
|
|
62
|
+
/** smtp. PBS's default is `tls`. */
|
|
63
|
+
mode?: 'insecure' | 'starttls' | 'tls';
|
|
64
|
+
/** smtp. */
|
|
65
|
+
username?: string;
|
|
66
|
+
/** smtp: ⛔ WRITE-ONLY. The server never returns it; the plan compares its seal. */
|
|
67
|
+
password?: FromEnv;
|
|
68
|
+
/** webhook: required. Supports templating: `{{ secrets.<name> }}` keeps a token out of it. */
|
|
69
|
+
url?: string;
|
|
70
|
+
/** webhook: required. */
|
|
71
|
+
method?: 'get' | 'post' | 'put';
|
|
72
|
+
/**
|
|
73
|
+
* webhook: header name → template text, or `{ fromEnv }` for a value that is itself a secret.
|
|
74
|
+
* ⚠️ PLAIN VALUES ARE PROPS, AND PROPS ARE STORED: `Content-Type` is fine, a token is not. Put a
|
|
75
|
+
* credential in `secret` and write `'Bearer {{ secrets.token }}'` here instead.
|
|
76
|
+
*/
|
|
77
|
+
header?: Readonly<Record<string, FromEnv | string>>;
|
|
78
|
+
/**
|
|
79
|
+
* webhook: the body TEMPLATE, as text. ★ Declared DECODED — the wire is base64, and this family
|
|
80
|
+
* encodes on write and decodes on read, so a plan shows a readable diff.
|
|
81
|
+
*/
|
|
82
|
+
body?: string;
|
|
83
|
+
/** webhook: ⛔ WRITE-ONLY. Name → variable. Referenced as `{{ secrets.<name> }}`. */
|
|
84
|
+
secret?: Readonly<Record<string, FromEnv>>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** ⛔ NO VALUE OF A SECRET, PASSWORD OR HEADER — names and digests only. */
|
|
88
|
+
export interface PbsNotificationTargetAttributes {
|
|
89
|
+
name: string;
|
|
90
|
+
type: PbsNotificationTargetType;
|
|
91
|
+
/** `builtin`, `modified-builtin`, `user-created` — reported, never compared. */
|
|
92
|
+
origin: string;
|
|
93
|
+
comment: string;
|
|
94
|
+
disable: boolean;
|
|
95
|
+
mailto: string;
|
|
96
|
+
'mailto-user': string;
|
|
97
|
+
'from-address': string;
|
|
98
|
+
author: string;
|
|
99
|
+
server: string;
|
|
100
|
+
/** 0 means PBS holds no port. */
|
|
101
|
+
port: number;
|
|
102
|
+
mode: string;
|
|
103
|
+
username: string;
|
|
104
|
+
url: string;
|
|
105
|
+
method: string;
|
|
106
|
+
/** The decoded body template. */
|
|
107
|
+
body: string;
|
|
108
|
+
/** Header NAMES, sorted — presence. */
|
|
109
|
+
header: string;
|
|
110
|
+
/** scrypt digest of the live header pairs, fixed salt per target. */
|
|
111
|
+
headerDigest: string;
|
|
112
|
+
/** Secret NAMES, sorted — all PBS will say. */
|
|
113
|
+
secret: string;
|
|
114
|
+
/**
|
|
115
|
+
* Seal of the secret values and password THIS PROVIDER LAST WROTE; `''` if it never wrote them
|
|
116
|
+
* (an adoption). ⚠️ `''` makes those values presence-only until the first write that carries them.
|
|
117
|
+
*/
|
|
118
|
+
sealed: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface PbsNotificationTarget extends Resource<
|
|
122
|
+
'Pbs.NotificationTarget',
|
|
123
|
+
PbsNotificationTargetProps,
|
|
124
|
+
PbsNotificationTargetAttributes,
|
|
125
|
+
never,
|
|
126
|
+
PveRequirements
|
|
127
|
+
> {}
|
|
128
|
+
|
|
129
|
+
export const PbsNotificationTarget = Resource<PbsNotificationTarget>('Pbs.NotificationTarget');
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* ⛔ `Provider.of` STAYS HERE, for resource.ts's reason: it is the one place the handlers are
|
|
133
|
+
* checked against the concrete resource types, so the lifecycle file cannot drift from them.
|
|
134
|
+
*/
|
|
135
|
+
export const PbsNotificationTargetProvider = () =>
|
|
136
|
+
Provider.effect(
|
|
137
|
+
PbsNotificationTarget,
|
|
138
|
+
Effect.succeed(PbsNotificationTarget.Provider.of(handlers)),
|
|
139
|
+
);
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A target and the matcher that routes to it, declared together the way docs/pbs-notifications.md
|
|
3
|
+
* shows — with the matcher naming the target through its OUTPUT, not a string.
|
|
4
|
+
*
|
|
5
|
+
* ★ WHY THE OUTPUT: PBS refuses to create a matcher naming a target that does not exist, and
|
|
6
|
+
* refuses to delete a target a matcher still names. `targets: [hook.name]` gives Alchemy the
|
|
7
|
+
* dependency edge that orders both; a bare `'alertmanager'` string gives it nothing, and the
|
|
8
|
+
* order is then whatever the engine picks. This pins that the Output resolves inside the list
|
|
9
|
+
* (the matcher's `isResolved` guard) and that the target is written first.
|
|
10
|
+
*/
|
|
11
|
+
import { expect, test } from 'bun:test';
|
|
12
|
+
import * as Effect from 'effect/Effect';
|
|
13
|
+
import * as Layer from 'effect/Layer';
|
|
14
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
15
|
+
import { alertmanagerAlertBody } from './alertmanager-body.ts';
|
|
16
|
+
import type { PbsTarget } from './credentials.ts';
|
|
17
|
+
import { fakeNotify } from './fake-pbs-notify.ts';
|
|
18
|
+
import { withoutBao } from './fake-pve.ts';
|
|
19
|
+
import {
|
|
20
|
+
PbsNotificationMatcher,
|
|
21
|
+
PbsNotificationMatcherProvider,
|
|
22
|
+
} from './pbs-notification-matcher.ts';
|
|
23
|
+
import { PbsNotificationTarget, PbsNotificationTargetProvider } from './pbs-notification-target.ts';
|
|
24
|
+
|
|
25
|
+
const PBS: PbsTarget = { api: 'https://pbs.test:8007/api2/json', mount: 'pbs-test', scheme: 'pbs' };
|
|
26
|
+
|
|
27
|
+
const stack = Effect.gen(function* () {
|
|
28
|
+
const hook = yield* PbsNotificationTarget('alertmanager', {
|
|
29
|
+
body: alertmanagerAlertBody(),
|
|
30
|
+
header: { 'Content-Type': 'application/json' },
|
|
31
|
+
method: 'post',
|
|
32
|
+
name: 'alertmanager',
|
|
33
|
+
target: PBS,
|
|
34
|
+
type: 'webhook',
|
|
35
|
+
url: 'https://alertmanager.example.com/api/v2/alerts',
|
|
36
|
+
});
|
|
37
|
+
yield* PbsNotificationMatcher('page-on-failure', {
|
|
38
|
+
'match-field': ['exact:type=gc,prune,sync,verify'],
|
|
39
|
+
'match-severity': ['error'],
|
|
40
|
+
name: 'page-on-failure',
|
|
41
|
+
target: PBS,
|
|
42
|
+
targets: [hook.name],
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('the matcher resolves the target by Output and is written after it', async () => {
|
|
47
|
+
const fake = fakeNotify();
|
|
48
|
+
const providers = Layer.mergeAll(
|
|
49
|
+
PbsNotificationTargetProvider(),
|
|
50
|
+
PbsNotificationMatcherProvider(),
|
|
51
|
+
);
|
|
52
|
+
await withoutBao(async () => {
|
|
53
|
+
const engine = engineOver(providers.pipe(Layer.provideMerge(fake.layer)));
|
|
54
|
+
await engine.deploy(stack);
|
|
55
|
+
const again = await engine.verify(stack, { all: true });
|
|
56
|
+
expect(again.rows.map((row) => row.diff)).toEqual(['noop', 'noop']);
|
|
57
|
+
});
|
|
58
|
+
expect(fake.writes()).toEqual([
|
|
59
|
+
'POST config/notifications/endpoints/webhook',
|
|
60
|
+
'POST config/notifications/matchers',
|
|
61
|
+
]);
|
|
62
|
+
expect(fake.objects.get('config/notifications/matchers/page-on-failure')?.['target']).toEqual([
|
|
63
|
+
'alertmanager',
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('a new type under the same name replaces delete-first — names are unique across families', async () => {
|
|
68
|
+
const fake = fakeNotify();
|
|
69
|
+
const declare = (type: 'sendmail' | 'webhook') =>
|
|
70
|
+
type === 'webhook'
|
|
71
|
+
? PbsNotificationTarget('pager', {
|
|
72
|
+
method: 'post',
|
|
73
|
+
name: 'pager',
|
|
74
|
+
target: PBS,
|
|
75
|
+
type,
|
|
76
|
+
url: 'https://alertmanager.example.com/api/v2/alerts',
|
|
77
|
+
})
|
|
78
|
+
: PbsNotificationTarget('pager', {
|
|
79
|
+
mailto: ['ops@example.com'],
|
|
80
|
+
name: 'pager',
|
|
81
|
+
target: PBS,
|
|
82
|
+
type,
|
|
83
|
+
});
|
|
84
|
+
await withoutBao(async () => {
|
|
85
|
+
const engine = engineOver(PbsNotificationTargetProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
86
|
+
await engine.deploy(declare('webhook'));
|
|
87
|
+
await engine.deploy(declare('sendmail'));
|
|
88
|
+
});
|
|
89
|
+
expect(fake.writes()).toEqual([
|
|
90
|
+
'POST config/notifications/endpoints/webhook',
|
|
91
|
+
'DELETE config/notifications/endpoints/webhook/pager',
|
|
92
|
+
'POST config/notifications/endpoints/sendmail',
|
|
93
|
+
]);
|
|
94
|
+
});
|
package/src/proxmox/resource.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
20
20
|
import type { Input } from 'alchemy/Input';
|
|
21
21
|
import * as Effect from 'effect/Effect';
|
|
22
22
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
23
|
-
import { pve } from './client.ts';
|
|
23
|
+
import { type PveForm, pve } from './client.ts';
|
|
24
24
|
import type { ApiTarget, PbsTarget, PveRole, PveTarget } from './credentials.ts';
|
|
25
25
|
import { formToSend } from './update-guard.ts';
|
|
26
26
|
|
|
@@ -77,15 +77,15 @@ export type PveSpec<Props extends WithApiTarget, Attributes> = {
|
|
|
77
77
|
readonly collection: (props: Props) => string;
|
|
78
78
|
/** Live JSON to attributes. Returning undefined means "this is not really there". */
|
|
79
79
|
readonly attributes: (live: Record<string, unknown>, props: Props) => Attributes | undefined;
|
|
80
|
-
/** The form PVE wants on create. ⚠️ PVE takes form encoding, not JSON. */
|
|
81
|
-
readonly createForm: (props: Props) =>
|
|
80
|
+
/** The form PVE wants on create. ⚠️ PVE takes form encoding, not JSON. Arrays: client.ts. */
|
|
81
|
+
readonly createForm: (props: Props) => PveForm;
|
|
82
82
|
/**
|
|
83
83
|
* The form for an update, or undefined when the object has no mutable fields.
|
|
84
84
|
*
|
|
85
85
|
* ⚠️ SOME PVE OBJECTS CANNOT BE UPDATED AT ALL. Returning undefined makes a changed prop a
|
|
86
86
|
* REPLACE rather than a silent no-op, which is the honest answer for an immutable object.
|
|
87
87
|
*/
|
|
88
|
-
readonly updateForm?: (props: Props) =>
|
|
88
|
+
readonly updateForm?: (props: Props) => PveForm;
|
|
89
89
|
/** True when live already matches props. Decides noop vs update. */
|
|
90
90
|
readonly matches: (attributes: Attributes, props: Props) => boolean;
|
|
91
91
|
/**
|
|
@@ -105,7 +105,7 @@ export type PveSpec<Props extends WithApiTarget, Attributes> = {
|
|
|
105
105
|
export const pveOperations = <Props extends WithApiTarget, Attributes>(
|
|
106
106
|
spec: PveSpec<Props, Attributes>,
|
|
107
107
|
) => {
|
|
108
|
-
/** The live object, or undefined. ⚠️ A 404
|
|
108
|
+
/** The live object, or undefined. ⚠️ A 404 and `{"data": null}` are ANSWERS, not failures. */
|
|
109
109
|
const read = (props: Props) =>
|
|
110
110
|
pve<Record<string, unknown>>(
|
|
111
111
|
props.target,
|
|
@@ -113,7 +113,7 @@ export const pveOperations = <Props extends WithApiTarget, Attributes>(
|
|
|
113
113
|
'GET',
|
|
114
114
|
spec.path(props),
|
|
115
115
|
).pipe(
|
|
116
|
-
Effect.map((data) => (data
|
|
116
|
+
Effect.map((data) => (data == null ? undefined : spec.attributes(data, props))),
|
|
117
117
|
Effect.orElseSucceed(() => undefined),
|
|
118
118
|
);
|
|
119
119
|
|