@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 @@
|
|
|
1
|
+
{"version":3,"file":"write-only.d.ts","sourceRoot":"","sources":["../../src/proxmox/write-only.ts"],"names":[],"mappings":"AA2BA,+FAA+F;AAC/F,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,+FAA+F;AAC/F,MAAM,MAAM,WAAW,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;AAEvE;;;;;;;;GAQG;AACH,eAAO,MAAM,UAAU,SACf,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,QAClC,WAAW,KACf;IAAE,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;CAS1F,CAAC;AAqBF;;;;;;GAMG;AACH,eAAO,MAAM,IAAI,WAAY,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,SAAS,MAAM,KAAG,MAI9E,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,WAAW,WAAY,MAAM,UAAU,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,KAAG,OAStF,CAAC"}
|
|
@@ -53,6 +53,11 @@ export interface FakeEngine {
|
|
|
53
53
|
readonly deploy: (body: Body) => Promise<Record<string, string>>;
|
|
54
54
|
/** Verify `body` against what the earlier deploys recorded. */
|
|
55
55
|
readonly verify: (body: Body, options?: VerifyOptions) => Promise<AdoptReport>;
|
|
56
|
+
/**
|
|
57
|
+
* Every state row the store holds, as JSON — what a Postgres store would persist. ★ For tests
|
|
58
|
+
* that must prove a value NEVER reaches state (proxmox/pbs-notification-target-state.test.ts).
|
|
59
|
+
*/
|
|
60
|
+
readonly stored: () => string;
|
|
56
61
|
}
|
|
57
62
|
/**
|
|
58
63
|
* One stack over one state store and any providers, shared by every call — a family's own test
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fake-engine.d.ts","sourceRoot":"","sources":["../../src/verify/fake-engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,OAAO,MAAM,SAAS,CAAC;AAKnC,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,aAAa,EAAiB,MAAM,aAAa,CAAC;AAElF,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AACD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,KAAM,SAAQ,OAAO,CAAC,QAAQ,CAAC,YAAY,EAAE,UAAU,EAAE,eAAe,CAAC;CAAG;AAC7F,eAAO,MAAM,KAAK,8BAAwC,CAAC;AAE3D,4EAA4E;AAC5E,MAAM,WAAW,KAAM,SAAQ,OAAO,CAAC,QAAQ,CAAC,YAAY,EAAE,UAAU,EAAE,eAAe,CAAC;CAAG;AAC7F,eAAO,MAAM,KAAK,8BAAwC,CAAC;AAE3D;;;;GAIG;AACH,MAAM,WAAW,QAAS,SAAQ,OAAO,CAAC,QAAQ,CAAC,eAAe,EAAE,UAAU,EAAE,eAAe,CAAC;CAAG;AACnG,eAAO,MAAM,QAAQ,iCAA8C,CAAC;AAEpE,gGAAgG;AAChG,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC9B,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED,eAAO,MAAM,OAAO,UAAU,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAQ,KAI1D,CAAC;AAEH,8CAA8C;AAC9C,eAAO,MAAM,QAAQ,UAAW,KAAK,KAAG,MAAM,EAC2C,CAAC;AAyB1F,4FAA4F;AAC5F,eAAO,MAAM,cAAc,UAAW,KAAK,8GAmCxC,CAAC;AAEJ,oFAAoF;AACpF,MAAM,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;AAK5D,MAAM,WAAW,UAAU;IACzB,iGAAiG;IACjG,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACjE,+DAA+D;IAC/D,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,CAAC,EAAE,aAAa,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"fake-engine.d.ts","sourceRoot":"","sources":["../../src/verify/fake-engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,OAAO,MAAM,SAAS,CAAC;AAKnC,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,aAAa,EAAiB,MAAM,aAAa,CAAC;AAElF,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AACD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,KAAM,SAAQ,OAAO,CAAC,QAAQ,CAAC,YAAY,EAAE,UAAU,EAAE,eAAe,CAAC;CAAG;AAC7F,eAAO,MAAM,KAAK,8BAAwC,CAAC;AAE3D,4EAA4E;AAC5E,MAAM,WAAW,KAAM,SAAQ,OAAO,CAAC,QAAQ,CAAC,YAAY,EAAE,UAAU,EAAE,eAAe,CAAC;CAAG;AAC7F,eAAO,MAAM,KAAK,8BAAwC,CAAC;AAE3D;;;;GAIG;AACH,MAAM,WAAW,QAAS,SAAQ,OAAO,CAAC,QAAQ,CAAC,eAAe,EAAE,UAAU,EAAE,eAAe,CAAC;CAAG;AACnG,eAAO,MAAM,QAAQ,iCAA8C,CAAC;AAEpE,gGAAgG;AAChG,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC9B,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED,eAAO,MAAM,OAAO,UAAU,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAQ,KAI1D,CAAC;AAEH,8CAA8C;AAC9C,eAAO,MAAM,QAAQ,UAAW,KAAK,KAAG,MAAM,EAC2C,CAAC;AAyB1F,4FAA4F;AAC5F,eAAO,MAAM,cAAc,UAAW,KAAK,8GAmCxC,CAAC;AAEJ,oFAAoF;AACpF,MAAM,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;AAK5D,MAAM,WAAW,UAAU;IACzB,iGAAiG;IACjG,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACjE,+DAA+D;IAC/D,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,CAAC,EAAE,aAAa,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC;IAC/E;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,MAAM,CAAC;CAC/B;AAED;;;;GAIG;AACH,eAAO,MAAM,UAAU,GAAI,IAAI,EAAE,CAAC,EAAE,GAAG,SAAS,KAAK,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,EAAE,GAAG,CAAC,KAAG,UAyC3E,CAAC;AAEF,oCAAoC;AACpC,eAAO,MAAM,UAAU,UAAW,KAAK,KAAG,UAA+C,CAAC"}
|
package/docs/adopted-deploys.md
CHANGED
|
@@ -38,6 +38,7 @@ API token on the cluster, just as it does for `alchemy plan`.
|
|
|
38
38
|
| `Proxmox.BackupJob` | `cluster/backup/{id}` | read | shared path — no write |
|
|
39
39
|
| `Proxmox.MetricServer` | `cluster/metrics/server/{id}` | read | shared path — no write |
|
|
40
40
|
| `Proxmox.NotificationTarget` ² | `cluster/notifications/endpoints/{type}/{name}` | read | shared path — no write |
|
|
41
|
+
| `Proxmox.NotificationMatcher` | `cluster/notifications/matchers/{name}` | read | shared path — no write |
|
|
41
42
|
| `Proxmox.HaResource` | `cluster/ha/resources/{sid}` | read | shared path — no write |
|
|
42
43
|
| `Proxmox.HaRule` | `cluster/ha/rules/{rule}` | read | shared path — no write |
|
|
43
44
|
| `Proxmox.SdnZone` | `cluster/sdn/zones/{zone}` | provision ¹ | shared path — no write |
|
|
@@ -47,6 +48,8 @@ API token on the cluster, just as it does for `alchemy plan`.
|
|
|
47
48
|
| `Pbs.PruneJob` | `config/prune/{id}` | read (PBS) | shared path — no write |
|
|
48
49
|
| `Pbs.SyncJob` | `config/sync/{id}` | read (PBS) | shared path — no write |
|
|
49
50
|
| `Pbs.VerifyJob` | `config/verify/{id}` | read (PBS) | shared path — no write |
|
|
51
|
+
| `Pbs.NotificationMatcher` | `config/notifications/matchers/{name}` | read (PBS) | shared path — no write |
|
|
52
|
+
| `Pbs.NotificationTarget` ⁵ | `config/notifications/endpoints/{type}/{name}` | read (PBS) | own: GET, PUT only for a stale group, GET — no write |
|
|
50
53
|
| `Proxmox.Acl` | `access/acl` (whole list, filtered) | provision ¹ | shared path, then its own `bound` check — no write |
|
|
51
54
|
| `Pbs.Datastore` | `config/datastore/{name}` | read (PBS) | own: GET, path/backend guards, PUT skipped on `matches`, settle GET — no write |
|
|
52
55
|
| `Proxmox.SdnApply` | 5 × `cluster/sdn/*?pending=1` + fabrics `?pending` / `?running` | read | own: counts staged objects; zero returns at once — **no `PUT /cluster/sdn`** |
|
|
@@ -65,6 +68,11 @@ no-write row applies. Pinned by `src/proxmox/lxc-adopt.test.ts` ("deploys with n
|
|
|
65
68
|
declared ratio that differs from the live one is never written on a no-op adoption and never
|
|
66
69
|
reported as a diff.
|
|
67
70
|
|
|
71
|
+
⁵ Its own `diff` and `reconcile`, because a secret's value is not in the live object: the plan
|
|
72
|
+
compares a seal kept in the previous state ([pbs-notifications.md](./pbs-notifications.md)). An
|
|
73
|
+
adopted target has no seal, so its secret values are presence-only and adoption writes nothing.
|
|
74
|
+
Pinned by `src/proxmox/pbs-notification-target-state.test.ts`.
|
|
75
|
+
|
|
68
76
|
## Before the fix: `Proxmox.CephPool`
|
|
69
77
|
|
|
70
78
|
`reconcile` read the pool and, whenever it existed, PUT the declared `size`, `min_size`,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# A PBS webhook body that posts an Alertmanager alert
|
|
2
|
+
|
|
3
|
+
`alertmanagerAlertBody()` ([alertmanager-body.ts](../src/proxmox/alertmanager-body.ts)) is the
|
|
4
|
+
body of a PBS webhook target that posts one alert to Alertmanager's `POST /api/v2/alerts`.
|
|
5
|
+
Declaring the target and the matcher that routes to it: [pbs-notifications.md](./pbs-notifications.md).
|
|
6
|
+
|
|
7
|
+
With the default options it returns exactly this text:
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
[
|
|
11
|
+
{
|
|
12
|
+
"labels": {
|
|
13
|
+
{{#if fields.type}}"job_type": {{ json fields.type }},{{/if}}
|
|
14
|
+
{{#if fields.job-id}}"job_id": {{ json fields.job-id }},{{/if}}
|
|
15
|
+
{{#if fields.datastore}}"datastore": {{ json fields.datastore }},{{/if}}
|
|
16
|
+
{{#if fields.hostname}}"hostname": {{ json fields.hostname }},{{/if}}
|
|
17
|
+
"severity": {{ json severity }},
|
|
18
|
+
"alertname": "PbsNotification",
|
|
19
|
+
"source": "pbs"
|
|
20
|
+
},
|
|
21
|
+
"annotations": {
|
|
22
|
+
"summary": {{ json title }},
|
|
23
|
+
"description": {{ json message }},
|
|
24
|
+
"timestamp": "{{ timestamp }}"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The fence is `text` on purpose. As a `handlebars` fence the formatter reflowed it into
|
|
31
|
+
something the function does not return.
|
|
32
|
+
|
|
33
|
+
Options: `alertname`, `source` and an optional `generatorURL`. Braces and backslashes are
|
|
34
|
+
refused in all three. `generatorURL` must also be an absolute `http` or `https` URL, because
|
|
35
|
+
Alertmanager validates it as a URI and rejects the whole post if it is not one.
|
|
36
|
+
|
|
37
|
+
## Why it is valid JSON for every PBS notification
|
|
38
|
+
|
|
39
|
+
Each point is from the proxmox-notify, proxmox-backup and Alertmanager source, read 2026-09-22.
|
|
40
|
+
|
|
41
|
+
- **The webhook renderer has no escaping** (`register_escape_fn(no_escape)`). Every value that
|
|
42
|
+
comes from the notification goes through `json`, which is `serde_json::to_string`. It writes
|
|
43
|
+
a complete, quoted JSON string with quotes, backslashes and control characters escaped. A task
|
|
44
|
+
log with a quote, a tab or a newline stays valid JSON.
|
|
45
|
+
- **`escape` on a missing field fails the whole render**, and the notification is lost.
|
|
46
|
+
`json` on a missing field writes `null`, which is valid JSON but not a label value. So every
|
|
47
|
+
`fields.*` label sits inside `{{#if}}` and ends with its own comma, and the constant labels
|
|
48
|
+
close the object. The UI's **Test** button sends a notification with no fields at all.
|
|
49
|
+
- **Every field value is a string.** proxmox-notify turns each metadata field into a JSON string
|
|
50
|
+
before rendering. `severity` serialises as `info`, `notice`, `warning`, `error` or `unknown`,
|
|
51
|
+
and `timestamp` is an integer (epoch seconds). Nothing else in the data is rendered.
|
|
52
|
+
- **The helpers are `json`, `escape` and `url-encode`, and nothing else.** The mail templates'
|
|
53
|
+
`timestamp` and `duration` helpers are not registered for webhooks, so `timestamp` is sent as
|
|
54
|
+
epoch seconds in an annotation.
|
|
55
|
+
- **Alertmanager takes an array** (`postableAlerts`). `labels` is required and must not be empty.
|
|
56
|
+
Every label and annotation value must be a string. It drops empty labels.
|
|
57
|
+
|
|
58
|
+
`tests/alertmanager-body.test.ts` renders the template with handlebars.js set up the way
|
|
59
|
+
proxmox-notify sets up its renderer. It covers the field set of every event type in PBS's
|
|
60
|
+
`src/server/notifications/mod.rs`, the Test notification, forwarded system mail, and hostile
|
|
61
|
+
strings (quotes, backslashes, control characters, `{{`, U+2028). Each result must parse as an
|
|
62
|
+
Alertmanager v2 alert array. A scratch build of handlebars-rust 5.1.2, the major version
|
|
63
|
+
proxmox-notify pins, rendered the same cases to valid JSON. The first real notification is
|
|
64
|
+
still the check that counts: press **Test** on the target, then read what Alertmanager received.
|
|
65
|
+
|
|
66
|
+
## What it does not do
|
|
67
|
+
|
|
68
|
+
- **With no `endsAt`, Alertmanager resolves the alert itself** after `resolve_timeout` (5 min by
|
|
69
|
+
default), and PBS never sends a resolve. Treat a "resolved" notification as a timeout, not a
|
|
70
|
+
recovery.
|
|
71
|
+
- **`severity` uses PBS's names.** Route on `severity="error"`, or relabel in Alertmanager.
|
|
72
|
+
- **Alertmanager deduplicates by label set.** Two failures of the same job within
|
|
73
|
+
`resolve_timeout` are one alert, not two.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# PBS and PVE notifications — targets, matchers, and an Alertmanager webhook
|
|
2
|
+
|
|
3
|
+
`@homeflare/alchemy/proxmox` declares both halves of the Proxmox notification system:
|
|
4
|
+
|
|
5
|
+
| family | what it is | source |
|
|
6
|
+
| ----------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------- |
|
|
7
|
+
| `Pbs.NotificationTarget` | where PBS sends: `webhook`, `smtp`, `sendmail` | [pbs-notification-target.ts](../src/proxmox/pbs-notification-target.ts) |
|
|
8
|
+
| `Pbs.NotificationMatcher` | which PBS notifications go to which targets | [pbs-notification-matcher.ts](../src/proxmox/pbs-notification-matcher.ts) |
|
|
9
|
+
| `Proxmox.NotificationMatcher` | the same on a PVE cluster | [notification-matcher.ts](../src/proxmox/notification-matcher.ts) |
|
|
10
|
+
| `Proxmox.NotificationTarget` | PVE targets (existing; declares no secret) | [notification-target.ts](../src/proxmox/notification-target.ts) |
|
|
11
|
+
| `alertmanagerAlertBody()` | a webhook body that posts one Alertmanager v2 alert | [pbs-alertmanager-body.md](./pbs-alertmanager-body.md) |
|
|
12
|
+
|
|
13
|
+
A target that no matcher names receives nothing. Declare both.
|
|
14
|
+
|
|
15
|
+
## Paging on a failed verify, sync, prune or garbage collection
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import * as Effect from 'effect/Effect';
|
|
19
|
+
import {
|
|
20
|
+
PbsNotificationMatcher,
|
|
21
|
+
PbsNotificationTarget,
|
|
22
|
+
alertmanagerAlertBody,
|
|
23
|
+
} from '@homeflare/alchemy/proxmox';
|
|
24
|
+
|
|
25
|
+
const pbs = {
|
|
26
|
+
api: 'https://pbs.example.com:8007/api2/json',
|
|
27
|
+
mount: 'pbs-mount',
|
|
28
|
+
scheme: 'pbs',
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
export const notifications = Effect.gen(function* () {
|
|
32
|
+
const hook = yield* PbsNotificationTarget('alertmanager', {
|
|
33
|
+
target: pbs,
|
|
34
|
+
type: 'webhook',
|
|
35
|
+
name: 'alertmanager',
|
|
36
|
+
method: 'post',
|
|
37
|
+
url: 'https://alertmanager.example.com/api/v2/alerts',
|
|
38
|
+
header: {
|
|
39
|
+
'Content-Type': 'application/json',
|
|
40
|
+
Authorization: 'Bearer {{ secrets.token }}', // template text, not the token
|
|
41
|
+
},
|
|
42
|
+
secret: { token: { fromEnv: 'PBS_ALERTMANAGER_TOKEN' } }, // the token, by variable name
|
|
43
|
+
body: alertmanagerAlertBody(),
|
|
44
|
+
});
|
|
45
|
+
yield* PbsNotificationMatcher('page-on-failure', {
|
|
46
|
+
target: pbs,
|
|
47
|
+
name: 'page-on-failure',
|
|
48
|
+
targets: [hook.name], // the Output, not a string: it orders create and delete
|
|
49
|
+
'match-severity': ['error'],
|
|
50
|
+
'match-field': ['exact:type=gc,prune,sync,verify'],
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Drop the `Authorization` header and the `secret` when Alertmanager takes unauthenticated posts
|
|
56
|
+
on a trusted network. The provider layer is `PbsNotificationTargetProvider()` and
|
|
57
|
+
`PbsNotificationMatcherProvider()`. `targets: [hook.name]` matters: PBS refuses a matcher that
|
|
58
|
+
names a missing target, and refuses to delete a target a matcher still names. The Output gives
|
|
59
|
+
Alchemy that ordering; a bare string does not (`pbs-notifications-stack.test.ts`).
|
|
60
|
+
|
|
61
|
+
⛔ **A datastore in `legacy-sendmail` mode never reaches a matcher.** PBS mails its `notify-user`
|
|
62
|
+
directly. `notification-system` is the default, but a datastore created before PBS 3.2 can say
|
|
63
|
+
otherwise. Check with `proxmox-backup-manager datastore show <name>`.
|
|
64
|
+
|
|
65
|
+
⚠️ **A verification job's event `type` is `verify`**, not `verification`. PBS's own event table
|
|
66
|
+
says `verification`, and the source (`send_verify_status`) sets `verify`. The other types are
|
|
67
|
+
`gc`, `prune`, `sync`, `tape-backup`, `tape-load`, `package-updates`, `acme`, `thresholds` and
|
|
68
|
+
`system-mail`. Garbage collection has no `job-id`.
|
|
69
|
+
|
|
70
|
+
⚠️ **`match-severity: ['warning', 'error']` under the default `mode: 'all'` matches nothing.**
|
|
71
|
+
Each item is combined with `mode`. The severities inside one item are OR-ed, so write
|
|
72
|
+
`['warning,error']`.
|
|
73
|
+
|
|
74
|
+
## Write-only values: `{ fromEnv }`
|
|
75
|
+
|
|
76
|
+
Alchemy stores props and attributes unencrypted, and a `Redacted` value is tagged, not
|
|
77
|
+
encrypted. A secret prop would be a secret in the state database. So `secret`, the smtp
|
|
78
|
+
`password` and any header value that is a credential are declared as `{ fromEnv: 'NAME' }`.
|
|
79
|
+
The deploying process reads the variable at call time.
|
|
80
|
+
|
|
81
|
+
| what | in state | how a plan compares it |
|
|
82
|
+
| --------------- | ---------------------------------------------- | -------------------------------------------------------------------------------- |
|
|
83
|
+
| secret values | names (PBS returns names only), and a **seal** | seal of what this provider last wrote, checked against the env value |
|
|
84
|
+
| smtp password | the seal only (PBS returns nothing) | the same seal |
|
|
85
|
+
| header values | names, and a scrypt digest of the live pairs | digest of the declared values (env-resolved) against the digest of the live ones |
|
|
86
|
+
| everything else | the value | by value, like every family here |
|
|
87
|
+
|
|
88
|
+
The seal is `scrypt:<salt>:<digest>`, with a random salt per write. It proves "unchanged" and
|
|
89
|
+
holds nothing a reader could send to PBS. A guessable secret stays guessable, so use a random
|
|
90
|
+
token.
|
|
91
|
+
|
|
92
|
+
- **A plan without the variables is presence-only.** Names are compared and values are
|
|
93
|
+
`unknown`. `unknown` plans `noop`, never an invented update.
|
|
94
|
+
- **A write carries only the stale groups.** A PUT without `secret` or `header` leaves those on
|
|
95
|
+
the server as they are, so a changed comment deploys without the secrets.
|
|
96
|
+
- **A write that needs a missing variable fails by name**, before any request is sent.
|
|
97
|
+
- **Adoption is free.** A webhook adopted with secrets has no seal. Its values stay unverified
|
|
98
|
+
(presence-only) until the first write that carries them: a create, a changed secret name, or
|
|
99
|
+
a value rotated after that first write. A value the server already holds is never replaced by
|
|
100
|
+
adoption alone. To write yours, rename the secret once, together with its `{{ secrets.* }}`
|
|
101
|
+
references.
|
|
102
|
+
- **Rotation.** Change the variable's value and plan: the seal no longer matches, and the deploy
|
|
103
|
+
PUTs the secret group alone.
|
|
104
|
+
|
|
105
|
+
⛔ **A literal credential in a plain prop is refused.** A plain prop is stored as written, so an
|
|
106
|
+
`Authorization`, `Proxy-Authorization` or `Cookie` header, a header or URL query parameter
|
|
107
|
+
named like `token`, `key`, `secret`, `password` or `signature`, or a password in the URL's
|
|
108
|
+
userinfo must be `{ fromEnv }` or read `{{ secrets.<name> }}`. The plan fails and names the
|
|
109
|
+
field, never the value.
|
|
110
|
+
|
|
111
|
+
⚠️ **What the guard cannot see.** A token in a URL path (Slack-style webhooks), in `body` or in
|
|
112
|
+
`comment` is still a plain prop. Put it in `secret` and reference it from the template. The
|
|
113
|
+
check runs at plan only while every prop is resolved. If a prop is an unresolved Output, Alchemy
|
|
114
|
+
records the props before `reconcile` refuses them.
|
|
115
|
+
|
|
116
|
+
⚠️ **Prefer `secret` over a `{ fromEnv }` header for a credential.** PBS returns header values
|
|
117
|
+
to anyone with `Sys.Audit`, and prints a header's value unmasked when it fails to render. It
|
|
118
|
+
masks only `secret` values.
|
|
119
|
+
|
|
120
|
+
## The body template
|
|
121
|
+
|
|
122
|
+
`alertmanagerAlertBody()` has its own page, with the exact text it returns and why it is valid
|
|
123
|
+
JSON for every notification PBS sends: [pbs-alertmanager-body.md](./pbs-alertmanager-body.md).
|
|
124
|
+
|
|
125
|
+
## Adopting the built-in `default-matcher`
|
|
126
|
+
|
|
127
|
+
A matcher is compared as its whole rule: `match-*`, `mode`, `invert-match` and `disable`,
|
|
128
|
+
declared or not. Only `comment` is left alone when undeclared. Adopting a built-in means
|
|
129
|
+
declaring what it holds. Measured 2026-09-22:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
// PVE 9.2.11: everything to mail-to-root
|
|
133
|
+
ProxmoxNotificationMatcher('default-matcher', {
|
|
134
|
+
target: cluster,
|
|
135
|
+
name: 'default-matcher',
|
|
136
|
+
targets: ['mail-to-root'],
|
|
137
|
+
});
|
|
138
|
+
// PBS 4.2: everything except successful prune jobs
|
|
139
|
+
PbsNotificationMatcher('default-matcher', {
|
|
140
|
+
target: pbs,
|
|
141
|
+
name: 'default-matcher',
|
|
142
|
+
targets: ['mail-to-root'],
|
|
143
|
+
'invert-match': true,
|
|
144
|
+
'match-field': ['exact:type=prune'],
|
|
145
|
+
'match-severity': ['info'],
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Both plan `noop` and deploy with no write (`notification-matcher.test.ts`). ⛔ **Deleting a
|
|
150
|
+
built-in reverts it to the shipped rule**, and the server reports success. The attributes show
|
|
151
|
+
`origin` so you can see which kind you have.
|
|
152
|
+
|
|
153
|
+
## Privileges
|
|
154
|
+
|
|
155
|
+
| server | read | write |
|
|
156
|
+
| ------ | --------------------------------------------------------------- | -------------------------------------------- |
|
|
157
|
+
| PBS | `Sys.Audit` on `/system/notifications` | `Sys.Modify` on `/system/notifications` |
|
|
158
|
+
| PVE | `Mapping.Audit` or `Mapping.Modify` on `/mapping/notifications` | `Mapping.Modify` on `/mapping/notifications` |
|
|
159
|
+
|
|
160
|
+
⚠️ A missing read privilege looks like "absent". The plan says `create` and the server answers
|
|
161
|
+
that the object already exists. Grant the read before changing anything else. On PVE,
|
|
162
|
+
`Mapping.Use` is not enough: it opens the matcher list, not the per-matcher read this package
|
|
163
|
+
makes (`get_matcher` in pve-manager's `Notifications.pm`).
|
package/docs/proxmox.md
CHANGED
|
@@ -15,6 +15,9 @@ or creates one from a template.
|
|
|
15
15
|
- ★ **One provisioning baseline for every cluster and node**: the privilege list, a helper that
|
|
16
16
|
declares it, and the one-time root commands that bootstrap it
|
|
17
17
|
([provision-baseline.md](./provision-baseline.md)).
|
|
18
|
+
- ★ **Notifications that page**: PBS webhook, smtp and sendmail targets with write-only secrets
|
|
19
|
+
(`{ fromEnv }`, never in state), PBS and PVE matchers, and a webhook body that posts an
|
|
20
|
+
Alertmanager v2 alert ([pbs-notifications.md](./pbs-notifications.md)).
|
|
18
21
|
- ★ **Measurements in the source name a reference cluster with placeholders**: `C1`, three nodes
|
|
19
22
|
`node-b`, `node-c` and `node-d` with Ceph on a Thunderbolt mesh, and documentation addresses
|
|
20
23
|
(RFC 5737). The shapes and counts are measured; the names and addresses are not the real ones.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A PBS webhook body that posts one Alertmanager v2 alert — `POST /api/v2/alerts` takes a JSON
|
|
3
|
+
* ARRAY of `postableAlert`, each with a required `labels` map and an optional `annotations` map,
|
|
4
|
+
* every value a string (Alertmanager's api/v2/openapi.yaml, read at main 2026-09-22).
|
|
5
|
+
*
|
|
6
|
+
* ★ PURE TEXT, NOT A RENDERER. PBS renders it, with Handlebars, at send time: `{{ severity }}`,
|
|
7
|
+
* `{{ title }}`, `{{ message }}`, `{{ timestamp }}` and `{{ fields.<name> }}` are the
|
|
8
|
+
* notification, and `json`, `escape` and `url-encode` are the only helpers
|
|
9
|
+
* (proxmox-notify endpoints/webhook.rs `setup_handlebars`, read at HEAD 2026-09-22).
|
|
10
|
+
*
|
|
11
|
+
* ⛔ THERE IS NO ESCAPING UNLESS THE TEMPLATE ASKS FOR IT. The webhook's Handlebars registers
|
|
12
|
+
* `no_escape` ("There is no escape."), so `"{{ message }}"` breaks the JSON the first time a
|
|
13
|
+
* task log holds a quote or a newline — and a failed job's message is exactly a task log. Every
|
|
14
|
+
* value below goes through `json`, which emits a complete, quoted JSON string.
|
|
15
|
+
* ⛔ A FIELD THE NOTIFICATION DOES NOT CARRY MUST NOT BE REACHED FOR BARE. `escape` on a missing
|
|
16
|
+
* field fails the whole render, and the notification is then lost — not degraded. MEASURED
|
|
17
|
+
* (2026-09-22) with handlebars-rust 5.1.2 and the webhook's three helpers restated from source:
|
|
18
|
+
* `{{ escape fields.job-id }}` over no fields errors "param at index 0 required but not found";
|
|
19
|
+
* `{{ json fields.job-id }}` renders `null`, valid JSON but not a label value. Garbage
|
|
20
|
+
* collection has no `job-id`, and the UI's "Test" button sends a notification with NO fields at
|
|
21
|
+
* all (lib.rs `test_target`). So every field-derived label sits inside
|
|
22
|
+
* `{{#if}}`, ends with its own comma, and the labels after them are always there: whichever
|
|
23
|
+
* fields are present, the object stays valid JSON.
|
|
24
|
+
*
|
|
25
|
+
* ⚠️ ALERTMANAGER RESOLVES IT BY ITSELF. With no `endsAt` it sets `now + resolve_timeout` (5m by
|
|
26
|
+
* default, api/v2/api.go `postAlertsHandler`), and PBS never sends a resolve — so a receiver
|
|
27
|
+
* with `send_resolved: true` reports "resolved" five minutes after a failure that nobody fixed.
|
|
28
|
+
* Page on the firing notification; do not read the resolve as a recovery.
|
|
29
|
+
* ⚠️ `severity` IS PBS'S WORD, not Alertmanager convention: `info`, `notice`, `warning`, `error`,
|
|
30
|
+
* `unknown`. Route on `severity="error"`, or relabel it in the Alertmanager config.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export interface AlertmanagerBodyOptions {
|
|
34
|
+
/** The `alertname` label. Default `PbsNotification`; `job_type` says which job it was. */
|
|
35
|
+
readonly alertname?: string;
|
|
36
|
+
/** The `source` label. Default `pbs`. */
|
|
37
|
+
readonly source?: string;
|
|
38
|
+
/** Optional `generatorURL` — an absolute link back to the PBS UI. */
|
|
39
|
+
readonly generatorURL?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* ⛔ AN OPTION IS PASTED INTO THE TEMPLATE, SO IT MAY NOT CARRY TEMPLATE SYNTAX. A `{{` in an
|
|
44
|
+
* alert name would be rendered by PBS as an expression; a brace or backslash is refused here
|
|
45
|
+
* rather than escaped, because Handlebars' escape rules are the renderer's, not this file's.
|
|
46
|
+
*/
|
|
47
|
+
const literal = (label: string, value: string): string => {
|
|
48
|
+
if (/[{}\\]/.test(value) || value === '') {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`alertmanagerAlertBody: ${label} must be non-empty and hold no '{', '}' or '\\'`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return JSON.stringify(value);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* ⛔ AN ABSOLUTE http(s) URL OR NOTHING. Alertmanager validates `generatorURL` as `format: uri`
|
|
58
|
+
* (api/v2/openapi.yaml) and answers 422 for the WHOLE post — so a typo here would not degrade
|
|
59
|
+
* one field, it would lose every notification, and only at send time.
|
|
60
|
+
*/
|
|
61
|
+
const absoluteUrl = (value: string): string => {
|
|
62
|
+
const text = literal('generatorURL', value);
|
|
63
|
+
let protocol = '';
|
|
64
|
+
try {
|
|
65
|
+
protocol = new URL(value).protocol;
|
|
66
|
+
} catch {
|
|
67
|
+
// not a URL at all: refused below, like a relative one
|
|
68
|
+
}
|
|
69
|
+
if (protocol !== 'https:' && protocol !== 'http:') {
|
|
70
|
+
throw new Error('alertmanagerAlertBody: generatorURL must be an absolute http(s) URL');
|
|
71
|
+
}
|
|
72
|
+
return text;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Label name → notification field. ⚠️ Label names must match `[a-zA-Z_][a-zA-Z0-9_]*`. */
|
|
76
|
+
const FIELD_LABELS: readonly (readonly [label: string, field: string])[] = [
|
|
77
|
+
['job_type', 'type'],
|
|
78
|
+
['job_id', 'job-id'],
|
|
79
|
+
['datastore', 'datastore'],
|
|
80
|
+
['hostname', 'hostname'],
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The body template. Declare it on a webhook target together with `Content-Type:
|
|
85
|
+
* application/json`, method `post`, and the URL `<alertmanager>/api/v2/alerts`.
|
|
86
|
+
*
|
|
87
|
+
* ★ `fields.job-id` IS A VALID PATH: Handlebars-rust 5 (the version proxmox-notify pins) allows
|
|
88
|
+
* `-` in an identifier (grammar.pest `symbol_char`), as handlebars.js does. The same scratch
|
|
89
|
+
* build rendered this template for a GC failure, a verify failure and a field-less test
|
|
90
|
+
* notification, and each parsed as JSON with string-only labels. The adversarial review re-ran
|
|
91
|
+
* it over the test file's whole matrix — every PBS event's fields x five severities x hostile
|
|
92
|
+
* text, 840 renders with and without `generatorURL` — and every one parsed (2026-09-22).
|
|
93
|
+
* ⚠️ THAT BUILD IS NOT PBS. The first real notification is the measurement that counts: send one
|
|
94
|
+
* with the target's "Test" button and read what Alertmanager received.
|
|
95
|
+
*/
|
|
96
|
+
export const alertmanagerAlertBody = (options: AlertmanagerBodyOptions = {}): string => {
|
|
97
|
+
const alertname = literal('alertname', options.alertname ?? 'PbsNotification');
|
|
98
|
+
const source = literal('source', options.source ?? 'pbs');
|
|
99
|
+
const labels = FIELD_LABELS.map(
|
|
100
|
+
([label, field]) =>
|
|
101
|
+
` {{#if fields.${field}}}"${label}": {{ json fields.${field} }},{{/if}}`,
|
|
102
|
+
);
|
|
103
|
+
const link =
|
|
104
|
+
options.generatorURL === undefined
|
|
105
|
+
? ''
|
|
106
|
+
: `,\n "generatorURL": ${absoluteUrl(options.generatorURL)}`;
|
|
107
|
+
return [
|
|
108
|
+
'[',
|
|
109
|
+
' {',
|
|
110
|
+
' "labels": {',
|
|
111
|
+
...labels,
|
|
112
|
+
' "severity": {{ json severity }},',
|
|
113
|
+
` "alertname": ${alertname},`,
|
|
114
|
+
` "source": ${source}`,
|
|
115
|
+
' },',
|
|
116
|
+
' "annotations": {',
|
|
117
|
+
' "summary": {{ json title }},',
|
|
118
|
+
' "description": {{ json message }},',
|
|
119
|
+
' "timestamp": "{{ timestamp }}"',
|
|
120
|
+
` }${link}`,
|
|
121
|
+
' }',
|
|
122
|
+
']',
|
|
123
|
+
'',
|
|
124
|
+
].join('\n');
|
|
125
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A literal credential in a plain `Pbs.NotificationTarget` prop is refused — and refused BEFORE
|
|
3
|
+
* Alchemy writes the props to the state store, which is the whole point.
|
|
4
|
+
*
|
|
5
|
+
* ★ THE END-TO-END CASES RUN ALCHEMY'S OWN PLAN AND APPLY (verify/fake-engine.ts) and dump the
|
|
6
|
+
* store, because the failure being pinned is not "a refusal exists" but "the token never
|
|
7
|
+
* reached the row": before the fix a refused create had already committed its props.
|
|
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 {
|
|
14
|
+
isCredentialName,
|
|
15
|
+
literalCredentialHeaders,
|
|
16
|
+
literalCredentialsInUrl,
|
|
17
|
+
} from './credential-literals.ts';
|
|
18
|
+
import { fakeNotify } from './fake-pbs-notify.ts';
|
|
19
|
+
import { withoutBao } from './fake-pve.ts';
|
|
20
|
+
import {
|
|
21
|
+
PbsNotificationTarget,
|
|
22
|
+
type PbsNotificationTargetProps,
|
|
23
|
+
PbsNotificationTargetProvider,
|
|
24
|
+
} from './pbs-notification-target.ts';
|
|
25
|
+
import { refusals } from './pbs-notification-target-form.ts';
|
|
26
|
+
|
|
27
|
+
const PBS: PbsTarget = { api: 'https://pbs.test:8007/api2/json', mount: 'pbs-test', scheme: 'pbs' };
|
|
28
|
+
const LITERAL = 'lit-7a1c-not-real';
|
|
29
|
+
const AM = 'https://alertmanager.example.com/api/v2/alerts';
|
|
30
|
+
|
|
31
|
+
const hook = (over: Partial<PbsNotificationTargetProps> = {}): PbsNotificationTargetProps => ({
|
|
32
|
+
method: 'post',
|
|
33
|
+
name: 'alertmanager',
|
|
34
|
+
target: PBS,
|
|
35
|
+
type: 'webhook',
|
|
36
|
+
url: AM,
|
|
37
|
+
...over,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('which names are credentials', () => {
|
|
41
|
+
test('authorization, cookie and credential words — whole names or dash/underscore words', () => {
|
|
42
|
+
for (const name of ['Authorization', 'proxy-authorization', 'Cookie', 'X-Api-Key', 'api_key']) {
|
|
43
|
+
expect(isCredentialName(name)).toBe(true);
|
|
44
|
+
}
|
|
45
|
+
for (const name of ['X-Auth-Token', 'Private-Token', 'X-Gotify-Key', 'token', 'sig', 'code']) {
|
|
46
|
+
expect(isCredentialName(name)).toBe(true);
|
|
47
|
+
}
|
|
48
|
+
for (const name of ['Content-Type', 'X-Scope-OrgID', 'Author', 'Keyword', 'Passthrough']) {
|
|
49
|
+
expect(isCredentialName(name)).toBe(false);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('literal credentials are found by where they sit', () => {
|
|
55
|
+
test('headers: a literal is refused; a secrets template, `{ fromEnv }` and `""` are not', () => {
|
|
56
|
+
expect(
|
|
57
|
+
literalCredentialHeaders({
|
|
58
|
+
'Content-Type': 'application/json',
|
|
59
|
+
Cookie: `session=${LITERAL}`,
|
|
60
|
+
'X-Api-Key': { fromEnv: 'HOOK_KEY' },
|
|
61
|
+
Authorization: `Bearer ${LITERAL}`,
|
|
62
|
+
'X-Auth-Token': '{{secrets.token}}',
|
|
63
|
+
'X-Empty-Token': '',
|
|
64
|
+
}),
|
|
65
|
+
).toEqual(['Authorization', 'Cookie']);
|
|
66
|
+
expect(literalCredentialHeaders({ Authorization: 'Bearer {{ secrets.token }}' })).toEqual([]);
|
|
67
|
+
expect(literalCredentialHeaders(undefined)).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('a URL: a userinfo password and credential-named query parameters, by name', () => {
|
|
71
|
+
expect(literalCredentialsInUrl(`https://pbs:${LITERAL}@am.example.com/x`)).toEqual([
|
|
72
|
+
'userinfo',
|
|
73
|
+
]);
|
|
74
|
+
expect(literalCredentialsInUrl(`${AM}?token=${LITERAL}&team=ops&api_key=${LITERAL}`)).toEqual([
|
|
75
|
+
'?token',
|
|
76
|
+
'?api_key',
|
|
77
|
+
]);
|
|
78
|
+
expect(literalCredentialsInUrl(`${AM}?code=${LITERAL}#token=${LITERAL}`)).toEqual([
|
|
79
|
+
'?code',
|
|
80
|
+
'?token',
|
|
81
|
+
]);
|
|
82
|
+
expect(literalCredentialsInUrl(`${AM}#team=ops&sig=${LITERAL}`)).toEqual(['?sig']);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a URL that references its secrets, or has no password, is clean', () => {
|
|
86
|
+
for (const url of [
|
|
87
|
+
AM,
|
|
88
|
+
`${AM}?team=ops`,
|
|
89
|
+
`${AM}?token={{ url-encode secrets.token }}`,
|
|
90
|
+
'https://pbs:{{ secrets.pw }}@am.example.com/',
|
|
91
|
+
'https://pbs@am.example.com/',
|
|
92
|
+
`${AM}?token=`,
|
|
93
|
+
undefined,
|
|
94
|
+
]) {
|
|
95
|
+
expect(literalCredentialsInUrl(url)).toEqual([]);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('refusals name the header or the URL part — never the value', () => {
|
|
100
|
+
const reasons = refusals(
|
|
101
|
+
hook({ header: { Authorization: `Bearer ${LITERAL}` }, url: `${AM}?token=${LITERAL}` }),
|
|
102
|
+
);
|
|
103
|
+
expect(reasons).toHaveLength(2);
|
|
104
|
+
expect(reasons[0]).toStartWith("header 'Authorization' holds a literal credential");
|
|
105
|
+
expect(reasons[1]).toStartWith("'url' ?token holds a literal credential");
|
|
106
|
+
expect(reasons.join(' ')).not.toContain(LITERAL);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('through Alchemy: refused at plan, before anything is stored or sent', () => {
|
|
111
|
+
const run = async (props: PbsNotificationTargetProps) => {
|
|
112
|
+
const fake = fakeNotify();
|
|
113
|
+
let failure = '';
|
|
114
|
+
let stored = '';
|
|
115
|
+
await withoutBao(async () => {
|
|
116
|
+
const engine = engineOver(
|
|
117
|
+
PbsNotificationTargetProvider().pipe(Layer.provideMerge(fake.layer)),
|
|
118
|
+
);
|
|
119
|
+
await engine.deploy(PbsNotificationTarget('alertmanager', props)).catch((error: unknown) => {
|
|
120
|
+
failure = String(error);
|
|
121
|
+
});
|
|
122
|
+
stored = engine.stored();
|
|
123
|
+
});
|
|
124
|
+
return { failure, stored, writes: fake.writes() };
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
test('a literal Authorization header on a NEW target never reaches the store', async () => {
|
|
128
|
+
const { failure, stored, writes } = await run(
|
|
129
|
+
hook({ header: { Authorization: `Bearer ${LITERAL}` } }),
|
|
130
|
+
);
|
|
131
|
+
expect(failure).toContain("header 'Authorization' holds a literal credential");
|
|
132
|
+
expect(stored).not.toContain(LITERAL);
|
|
133
|
+
expect(writes).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('a literal query token on a NEW target never reaches the store', async () => {
|
|
137
|
+
const { failure, stored, writes } = await run(hook({ url: `${AM}?token=${LITERAL}` }));
|
|
138
|
+
expect(failure).toContain("'url' ?token holds a literal credential");
|
|
139
|
+
expect(stored).not.toContain(LITERAL);
|
|
140
|
+
expect(writes).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('any other refusal on a new target also fails before the row is written', async () => {
|
|
144
|
+
const { failure, stored } = await run(hook({ comment: LITERAL, mailto: ['x@example.com'] }));
|
|
145
|
+
expect(failure).toContain("'mailto' belongs to sendmail/smtp targets, not webhook");
|
|
146
|
+
expect(stored).not.toContain(LITERAL);
|
|
147
|
+
});
|
|
148
|
+
});
|