@homeflare/alchemy 0.11.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/README.md +3 -3
- package/dist/caddy/admin-guard.d.ts +1 -1
- package/dist/caddy/index.js.map +2 -2
- package/dist/cloudflare/client.d.ts +1 -1
- package/dist/cloudflare/index.js.map +2 -2
- package/dist/cloudflare/lock-rules.d.ts +1 -1
- package/dist/forgejo/branch-protection-form.d.ts +1 -1
- package/dist/forgejo/branch-protection.d.ts +1 -1
- package/dist/forgejo/index.js.map +10 -10
- package/dist/forgejo/org-actions-secrets.d.ts +1 -1
- package/dist/forgejo/org-label.d.ts +1 -1
- package/dist/forgejo/org-team.d.ts +1 -1
- package/dist/forgejo/repo-webhook.d.ts +1 -1
- package/dist/forgejo/repository.d.ts +2 -2
- package/dist/forgejo/repository.d.ts.map +1 -1
- package/dist/forgejo/resource.d.ts +2 -2
- package/dist/forgejo/team-member.d.ts +1 -1
- package/dist/openbao/auth-role.d.ts +1 -1
- package/dist/openbao/bao-http.d.ts +4 -4
- package/dist/openbao/cloudflare-permission-groups.d.ts +1 -1
- package/dist/openbao/cloudflare-role-form.d.ts +2 -2
- package/dist/openbao/cloudflare-role-form.d.ts.map +1 -1
- package/dist/openbao/cloudflare-roles-expand.d.ts +1 -1
- package/dist/openbao/digest.d.ts +1 -1
- package/dist/openbao/forgejo-bootstrap-parse.d.ts +2 -2
- package/dist/openbao/forgejo-bootstrap-parse.d.ts.map +1 -1
- package/dist/openbao/forgejo-bootstrap.d.ts +1 -1
- package/dist/openbao/index.js.map +14 -14
- package/dist/openbao/mount.d.ts +1 -1
- package/dist/openbao/pki-role.d.ts +1 -1
- package/dist/openbao/policy.d.ts +2 -2
- package/dist/openbao/proxmox-role-form.d.ts +2 -2
- package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
- package/dist/openbao/proxmox-role.d.ts +10 -10
- package/dist/openbao/ssh-role.d.ts +3 -3
- package/dist/proxmox/acl.d.ts +2 -2
- package/dist/proxmox/acl.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/api-token-form.d.ts +2 -2
- package/dist/proxmox/api-token.d.ts +11 -11
- package/dist/proxmox/ceph-daemon-form.d.ts +3 -3
- package/dist/proxmox/ceph-daemon.d.ts +19 -19
- package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
- package/dist/proxmox/ceph-flag.d.ts +5 -5
- package/dist/proxmox/ceph-fs-wire.d.ts +5 -5
- package/dist/proxmox/ceph-fs.d.ts +16 -16
- package/dist/proxmox/ceph-osd-tree.d.ts +16 -16
- package/dist/proxmox/ceph-osd.d.ts +5 -5
- package/dist/proxmox/ceph-pool-form.d.ts +13 -13
- package/dist/proxmox/ceph-pool-form.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool.d.ts +3 -3
- package/dist/proxmox/client.d.ts +2 -2
- package/dist/proxmox/credential-literals.d.ts +36 -0
- package/dist/proxmox/credential-literals.d.ts.map +1 -0
- package/dist/proxmox/credentials.d.ts +8 -8
- package/dist/proxmox/credentials.d.ts.map +1 -1
- 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/firewall-alias-form.d.ts +1 -1
- package/dist/proxmox/firewall-alias.d.ts +1 -1
- package/dist/proxmox/generated/pbs.d.ts +279 -1
- package/dist/proxmox/generated/pbs.d.ts.map +1 -1
- package/dist/proxmox/generated/pve.d.ts +58 -1
- package/dist/proxmox/generated/pve.d.ts.map +1 -1
- package/dist/proxmox/group.d.ts +8 -8
- package/dist/proxmox/ha-resource.d.ts +3 -2
- package/dist/proxmox/ha-resource.d.ts.map +1 -1
- package/dist/proxmox/ha-rule-form.d.ts +5 -5
- package/dist/proxmox/ha-rule.d.ts +3 -3
- package/dist/proxmox/index.d.ts +14 -0
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +935 -223
- package/dist/proxmox/index.js.map +69 -56
- package/dist/proxmox/lease-cache.d.ts +4 -4
- package/dist/proxmox/lxc-harness.d.ts +1 -1
- package/dist/proxmox/lxc-props.d.ts +2 -2
- package/dist/proxmox/lxc.d.ts +3 -3
- package/dist/proxmox/members.d.ts +3 -3
- package/dist/proxmox/network-apply-read.d.ts +5 -5
- package/dist/proxmox/network-apply.d.ts +9 -9
- package/dist/proxmox/node-network-form.d.ts +6 -6
- package/dist/proxmox/node-network.d.ts +17 -17
- package/dist/proxmox/node-network.d.ts.map +1 -1
- 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/notification-target.d.ts +3 -2
- package/dist/proxmox/notification-target.d.ts.map +1 -1
- package/dist/proxmox/pbs-datastore-guard.d.ts +1 -1
- package/dist/proxmox/pbs-datastore.d.ts +10 -10
- 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/pbs-prune-job.d.ts +2 -2
- package/dist/proxmox/pbs-sync-job.d.ts +4 -4
- package/dist/proxmox/pbs-verify-job.d.ts +1 -1
- package/dist/proxmox/pool.d.ts +4 -4
- package/dist/proxmox/provision-baseline.d.ts +74 -0
- package/dist/proxmox/provision-baseline.d.ts.map +1 -0
- package/dist/proxmox/provision-bootstrap.d.ts +33 -0
- package/dist/proxmox/provision-bootstrap.d.ts.map +1 -0
- package/dist/proxmox/provision-cli-fake.d.ts +38 -0
- package/dist/proxmox/provision-cli-fake.d.ts.map +1 -0
- package/dist/proxmox/provision-declare.d.ts +25 -0
- package/dist/proxmox/provision-declare.d.ts.map +1 -0
- package/dist/proxmox/replication-job.d.ts +9 -8
- package/dist/proxmox/replication-job.d.ts.map +1 -1
- package/dist/proxmox/resource.d.ts +6 -5
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/sdn-apply-read.d.ts +7 -7
- package/dist/proxmox/sdn-apply.d.ts +1 -1
- package/dist/proxmox/sdn-subnet-form.d.ts +1 -1
- package/dist/proxmox/sdn-subnet.d.ts +5 -5
- package/dist/proxmox/sdn-vnet.d.ts +5 -6
- package/dist/proxmox/sdn-vnet.d.ts.map +1 -1
- package/dist/proxmox/storage.d.ts +4 -3
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/proxmox/user.d.ts +3 -2
- package/dist/proxmox/user.d.ts.map +1 -1
- package/dist/proxmox/values.d.ts +1 -1
- package/dist/proxmox/write-only.d.ts +35 -0
- package/dist/proxmox/write-only.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool-write.d.ts +1 -1
- package/dist/proxmox/zfs-pool.d.ts +5 -5
- package/dist/talos/credentials.d.ts +1 -1
- package/dist/talos/credentials.d.ts.map +1 -1
- package/dist/talos/index.js.map +1 -1
- package/dist/verify/fake-engine.d.ts +5 -0
- package/dist/verify/fake-engine.d.ts.map +1 -1
- package/docs/adopt-verify.md +21 -5
- package/docs/adopted-deploys.md +9 -1
- package/docs/ownership.md +26 -1
- package/docs/pbs-alertmanager-body.md +73 -0
- package/docs/pbs-notifications.md +163 -0
- package/docs/provision-baseline.md +106 -0
- package/docs/proxmox-lxc-adopt.md +2 -2
- package/docs/proxmox-lxc.md +5 -4
- package/docs/proxmox.md +9 -0
- package/package.json +1 -1
- package/src/caddy/admin-guard.ts +1 -1
- package/src/caddy/local-admin.ts +1 -1
- package/src/cloudflare/client.ts +1 -1
- package/src/cloudflare/lock-rules.ts +1 -1
- package/src/forgejo/branch-protection-form.ts +1 -1
- package/src/forgejo/branch-protection.ts +1 -1
- package/src/forgejo/client.ts +1 -1
- package/src/forgejo/org-actions-secrets.ts +1 -1
- package/src/forgejo/org-label.ts +1 -1
- package/src/forgejo/org-team.ts +1 -1
- package/src/forgejo/repo-webhook.ts +1 -1
- package/src/forgejo/repository.ts +2 -2
- package/src/forgejo/resource.ts +2 -2
- package/src/forgejo/team-member.ts +1 -1
- package/src/openbao/README.md +4 -4
- package/src/openbao/auth-role.ts +1 -1
- package/src/openbao/bao-http.test.ts +2 -2
- package/src/openbao/bao-http.ts +6 -6
- package/src/openbao/bao-status.test.ts +1 -1
- package/src/openbao/bao-status.ts +1 -1
- package/src/openbao/cloudflare-group-scope.test.ts +1 -1
- package/src/openbao/cloudflare-permission-groups.test.ts +1 -1
- package/src/openbao/cloudflare-permission-groups.ts +3 -3
- package/src/openbao/cloudflare-policy.ts +1 -1
- package/src/openbao/cloudflare-role-form.ts +2 -2
- package/src/openbao/cloudflare-roles-expand.ts +1 -1
- package/src/openbao/digest.ts +1 -1
- package/src/openbao/forgejo-bootstrap-parse.test.ts +3 -3
- package/src/openbao/forgejo-bootstrap-parse.ts +2 -2
- package/src/openbao/forgejo-bootstrap.ts +4 -4
- package/src/openbao/kubernetes-role-form.test.ts +5 -5
- package/src/openbao/mount.ts +1 -1
- package/src/openbao/pki-role.ts +1 -1
- package/src/openbao/policy-wire.test.ts +3 -3
- package/src/openbao/policy.ts +2 -2
- package/src/openbao/proxmox-role-form.ts +5 -5
- package/src/openbao/proxmox-role.ts +11 -11
- package/src/openbao/ssh-role.ts +3 -3
- package/src/proxmox/acl.ts +4 -4
- package/src/proxmox/adopt-noop.test.ts +6 -6
- package/src/proxmox/alertmanager-body.ts +125 -0
- package/src/proxmox/api-token-form.ts +4 -4
- package/src/proxmox/api-token.ts +13 -13
- package/src/proxmox/ceph-daemon-form.ts +3 -3
- package/src/proxmox/ceph-daemon.ts +20 -20
- package/src/proxmox/ceph-flag.ts +7 -7
- package/src/proxmox/ceph-fs-wire.ts +7 -7
- package/src/proxmox/ceph-fs.ts +18 -18
- package/src/proxmox/ceph-osd-tree.ts +16 -16
- package/src/proxmox/ceph-osd.ts +6 -6
- package/src/proxmox/ceph-pool-adopt.test.ts +14 -14
- package/src/proxmox/ceph-pool-form.ts +13 -13
- package/src/proxmox/ceph-pool.ts +6 -6
- package/src/proxmox/client.test.ts +3 -3
- package/src/proxmox/client.ts +3 -3
- package/src/proxmox/credential-literals.test.ts +148 -0
- package/src/proxmox/credential-literals.ts +74 -0
- package/src/proxmox/credentials.test.ts +6 -8
- package/src/proxmox/credentials.ts +8 -8
- package/src/proxmox/fake-pbs-notify.ts +108 -0
- package/src/proxmox/fake-pve.ts +12 -2
- package/src/proxmox/firewall-alias-form.ts +1 -1
- package/src/proxmox/firewall-alias.ts +1 -1
- package/src/proxmox/generated/pbs.ts +330 -1
- package/src/proxmox/generated/pve.ts +63 -1
- package/src/proxmox/group.ts +10 -10
- package/src/proxmox/ha-resource.ts +3 -2
- package/src/proxmox/ha-rule-form.ts +5 -5
- package/src/proxmox/ha-rule.ts +3 -3
- package/src/proxmox/index.test.ts +10 -0
- package/src/proxmox/index.ts +32 -0
- package/src/proxmox/lease-cache.test.ts +19 -19
- package/src/proxmox/lease-cache.ts +4 -4
- package/src/proxmox/lxc-adopt.test.ts +3 -3
- package/src/proxmox/lxc-create-form.ts +1 -1
- package/src/proxmox/lxc-guards.test.ts +4 -4
- package/src/proxmox/lxc-harness.ts +3 -3
- package/src/proxmox/lxc-judge.test.ts +5 -5
- package/src/proxmox/lxc-lifecycle.ts +1 -1
- package/src/proxmox/lxc-props.ts +2 -2
- package/src/proxmox/lxc-read.test.ts +3 -3
- package/src/proxmox/lxc-strict-adopt.test.ts +1 -1
- package/src/proxmox/lxc-volume.test.ts +9 -9
- package/src/proxmox/lxc-volume.ts +1 -1
- package/src/proxmox/lxc-wire.ts +1 -1
- package/src/proxmox/lxc.ts +3 -3
- package/src/proxmox/members.test.ts +1 -1
- package/src/proxmox/members.ts +3 -3
- package/src/proxmox/metric-server-otel.ts +2 -2
- package/src/proxmox/network-apply-read.ts +10 -10
- package/src/proxmox/network-apply.ts +10 -10
- package/src/proxmox/node-network-form.ts +7 -7
- package/src/proxmox/node-network.ts +18 -18
- 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/notification-target.ts +3 -2
- package/src/proxmox/pbs-datastore-guard.ts +1 -1
- package/src/proxmox/pbs-datastore.ts +10 -10
- 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/pbs-prune-job.ts +2 -2
- package/src/proxmox/pbs-sync-job.ts +4 -4
- package/src/proxmox/pbs-verify-job.ts +1 -1
- package/src/proxmox/pool.test.ts +2 -2
- package/src/proxmox/pool.ts +4 -4
- package/src/proxmox/provision-baseline.test.ts +87 -0
- package/src/proxmox/provision-baseline.ts +162 -0
- package/src/proxmox/provision-bootstrap.test.ts +132 -0
- package/src/proxmox/provision-bootstrap.ts +135 -0
- package/src/proxmox/provision-cli-fake.ts +217 -0
- package/src/proxmox/provision-declare.test.ts +111 -0
- package/src/proxmox/provision-declare.ts +103 -0
- package/src/proxmox/replication-job.ts +9 -8
- package/src/proxmox/resource.ts +9 -9
- package/src/proxmox/sdn-apply-read.test.ts +16 -16
- package/src/proxmox/sdn-apply-read.ts +7 -7
- package/src/proxmox/sdn-apply.ts +3 -3
- package/src/proxmox/sdn-subnet-form.ts +1 -1
- package/src/proxmox/sdn-subnet.ts +5 -5
- package/src/proxmox/sdn-vnet.ts +5 -6
- package/src/proxmox/storage.ts +4 -3
- package/src/proxmox/user.ts +3 -2
- package/src/proxmox/values.test.ts +2 -2
- package/src/proxmox/values.ts +1 -1
- package/src/proxmox/write-only.test.ts +75 -0
- package/src/proxmox/write-only.ts +106 -0
- package/src/proxmox/zfs-pool-write.ts +1 -1
- package/src/proxmox/zfs-pool.ts +6 -6
- package/src/talos/credentials.ts +3 -3
- package/src/talos/values.test.ts +9 -9
- package/src/verify/cli.test.ts +1 -1
- package/src/verify/fake-engine.ts +8 -1
|
@@ -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
|
+
);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Pbs.NotificationTarget` drift that is not about a secret's VALUE: a target switched off by hand,
|
|
3
|
+
* and a secret NAME added to the declaration. Split from pbs-notification-target-state.test.ts for
|
|
4
|
+
* the 250-line cap; the same fake server (fake-pbs-notify.ts) and the same engine.
|
|
5
|
+
*
|
|
6
|
+
* ★ BOTH WERE FOUND BY MUTATION, NOT BY READING. Deleting the `disable` line from `plainMatches`, or
|
|
7
|
+
* the secret-name line from `sealedState`, left every other test green: nothing had declared a
|
|
8
|
+
* target, changed it on the server, and asked the plan.
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, expect, test } from 'bun:test';
|
|
11
|
+
import * as Layer from 'effect/Layer';
|
|
12
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
13
|
+
import type { PbsTarget } from './credentials.ts';
|
|
14
|
+
import { fakeNotify } from './fake-pbs-notify.ts';
|
|
15
|
+
import { withoutBao } from './fake-pve.ts';
|
|
16
|
+
import {
|
|
17
|
+
PbsNotificationTarget,
|
|
18
|
+
type PbsNotificationTargetProps,
|
|
19
|
+
PbsNotificationTargetProvider,
|
|
20
|
+
} from './pbs-notification-target.ts';
|
|
21
|
+
import { keyAndValue } from './pbs-notification-target-wire.ts';
|
|
22
|
+
|
|
23
|
+
const PBS: PbsTarget = { api: 'https://pbs.test:8007/api2/json', mount: 'pbs-test', scheme: 'pbs' };
|
|
24
|
+
const PATH = 'config/notifications/endpoints/webhook/alertmanager';
|
|
25
|
+
|
|
26
|
+
const hook = (over: Partial<PbsNotificationTargetProps> = {}) =>
|
|
27
|
+
PbsNotificationTarget('alertmanager', {
|
|
28
|
+
method: 'post',
|
|
29
|
+
name: 'alertmanager',
|
|
30
|
+
secret: { token: { fromEnv: 'HF_DRIFT_TOKEN' } },
|
|
31
|
+
target: PBS,
|
|
32
|
+
type: 'webhook',
|
|
33
|
+
url: 'https://alertmanager.example.com/api/v2/alerts',
|
|
34
|
+
...over,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
delete process.env['HF_DRIFT_TOKEN'];
|
|
39
|
+
delete process.env['HF_DRIFT_ROUTE'];
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const engineFor = (fake: ReturnType<typeof fakeNotify>) =>
|
|
43
|
+
engineOver(PbsNotificationTargetProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
44
|
+
|
|
45
|
+
test('a target disabled by hand plans an update, and the deploy switches it back on', async () => {
|
|
46
|
+
process.env['HF_DRIFT_TOKEN'] = 'tok-drift-not-real';
|
|
47
|
+
const fake = fakeNotify();
|
|
48
|
+
await withoutBao(async () => {
|
|
49
|
+
const engine = engineFor(fake);
|
|
50
|
+
await engine.deploy(hook());
|
|
51
|
+
fake.objects.set(PATH, { ...fake.objects.get(PATH), disable: true });
|
|
52
|
+
expect((await engine.verify(hook(), { all: true })).rows[0]?.diff).toBe('update');
|
|
53
|
+
await engine.deploy(hook());
|
|
54
|
+
});
|
|
55
|
+
const put = fake.calls.find((call) => call.method === 'PUT');
|
|
56
|
+
expect(put?.pairs).toContainEqual(['disable', '0']);
|
|
57
|
+
expect(put?.pairs.map(([key]) => key)).not.toContain('secret');
|
|
58
|
+
expect(fake.objects.get(PATH)?.['disable']).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a secret name added is stale by PRESENCE — seen with no variables set — and both are sent', async () => {
|
|
62
|
+
process.env['HF_DRIFT_TOKEN'] = 'tok-drift-not-real';
|
|
63
|
+
const fake = fakeNotify();
|
|
64
|
+
const twoSecrets = () =>
|
|
65
|
+
hook({
|
|
66
|
+
secret: { route: { fromEnv: 'HF_DRIFT_ROUTE' }, token: { fromEnv: 'HF_DRIFT_TOKEN' } },
|
|
67
|
+
});
|
|
68
|
+
await withoutBao(async () => {
|
|
69
|
+
const engine = engineFor(fake);
|
|
70
|
+
await engine.deploy(hook());
|
|
71
|
+
// ⚠️ No variable at plan time, so the seal cannot be checked: only the names can say this.
|
|
72
|
+
delete process.env['HF_DRIFT_TOKEN'];
|
|
73
|
+
expect((await engine.verify(twoSecrets(), { all: true })).rows[0]?.diff).toBe('update');
|
|
74
|
+
process.env['HF_DRIFT_TOKEN'] = 'tok-drift-not-real';
|
|
75
|
+
process.env['HF_DRIFT_ROUTE'] = 'route-not-real';
|
|
76
|
+
await engine.deploy(twoSecrets());
|
|
77
|
+
expect(engine.stored()).not.toContain('route-not-real');
|
|
78
|
+
});
|
|
79
|
+
expect((fake.secrets.get(PATH) ?? []).map((item) => keyAndValue(item).name).sort()).toEqual([
|
|
80
|
+
'route',
|
|
81
|
+
'token',
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a PBS notification target has changed, and the forms that change it.
|
|
3
|
+
*
|
|
4
|
+
* ★ THREE QUESTIONS, NOT ONE, because the three kinds of field can be checked three ways:
|
|
5
|
+
* plain every field the server returns in full — compared by value, like any family here
|
|
6
|
+
* header names from the read, values by digest (pbs-notification-target-wire.ts)
|
|
7
|
+
* sealed secret values and the smtp password: the server returns names at best, so values
|
|
8
|
+
* are checked against the seal of what this provider last wrote (write-only.ts)
|
|
9
|
+
* Each answers `match`, `stale` or `unknown`; only `stale` writes. `unknown` is "the plan cannot
|
|
10
|
+
* see it" — a variable missing from the plan's environment, or a secret this provider never
|
|
11
|
+
* wrote (an adoption) — and it is PRESENCE-ONLY by design: a plan never invents drift it
|
|
12
|
+
* cannot see, and adoption stays free.
|
|
13
|
+
*
|
|
14
|
+
* ⛔ A WRITE SENDS ONLY THE GROUPS THAT ARE STALE. Omitting `header` or `secret` from a PUT keeps
|
|
15
|
+
* what the server holds (api/webhook.rs `update_endpoint`), so a changed comment is written
|
|
16
|
+
* without reading a single secret — and a deploy environment without the secrets can still
|
|
17
|
+
* repair the rest. A group that IS stale needs every value it holds; a missing one fails the
|
|
18
|
+
* deploy by variable name before anything is sent.
|
|
19
|
+
*/
|
|
20
|
+
import type { PveForm } from './client.ts';
|
|
21
|
+
import { literalCredentialHeaders, literalCredentialsInUrl } from './credential-literals.ts';
|
|
22
|
+
import { addressList } from './notification-target-form.ts';
|
|
23
|
+
import type {
|
|
24
|
+
PbsNotificationTargetAttributes,
|
|
25
|
+
PbsNotificationTargetProps,
|
|
26
|
+
} from './pbs-notification-target.ts';
|
|
27
|
+
import { base64, headerDigest, names, wireItem } from './pbs-notification-target-wire.ts';
|
|
28
|
+
import { type Environment, type FromEnv, resolveAll, sealMatches } from './write-only.ts';
|
|
29
|
+
|
|
30
|
+
type Props = PbsNotificationTargetProps;
|
|
31
|
+
type Field = keyof Props;
|
|
32
|
+
|
|
33
|
+
/** Which family each optional field belongs to. A field outside its family is refused at plan. */
|
|
34
|
+
const OWNED: Readonly<Partial<Record<Field, readonly Props['type'][]>>> = {
|
|
35
|
+
author: ['sendmail', 'smtp'],
|
|
36
|
+
body: ['webhook'],
|
|
37
|
+
'from-address': ['sendmail', 'smtp'],
|
|
38
|
+
header: ['webhook'],
|
|
39
|
+
mailto: ['sendmail', 'smtp'],
|
|
40
|
+
'mailto-user': ['sendmail', 'smtp'],
|
|
41
|
+
method: ['webhook'],
|
|
42
|
+
mode: ['smtp'],
|
|
43
|
+
password: ['smtp'],
|
|
44
|
+
port: ['smtp'],
|
|
45
|
+
secret: ['webhook'],
|
|
46
|
+
server: ['smtp'],
|
|
47
|
+
url: ['webhook'],
|
|
48
|
+
username: ['smtp'],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const REQUIRED: Readonly<Record<Props['type'], readonly Field[]>> = {
|
|
52
|
+
sendmail: [],
|
|
53
|
+
smtp: ['server', 'from-address'],
|
|
54
|
+
webhook: ['url', 'method'],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** A header or secret name lands inside a property string, where `,` and `=` are syntax. */
|
|
58
|
+
const CLEAN_NAME = /^[^,=\s]+$/;
|
|
59
|
+
|
|
60
|
+
/** How a refused literal credential should be declared instead. */
|
|
61
|
+
const WRITE_ONLY =
|
|
62
|
+
"declare it { fromEnv: 'NAME' }, or keep it in 'secret' and write {{ secrets.<name> }}";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Why this declaration cannot be written, or nothing. Checked at PLAN, so a webhook without a
|
|
66
|
+
* URL fails `alchemy plan` naming the field rather than a deploy failing on a 400.
|
|
67
|
+
* ⚠️ "AT PLAN" MEANS `diff` FOR A TARGET WITH STATE AND THE ADOPTION PROBE (`read`) FOR ONE
|
|
68
|
+
* WITHOUT — pbs-notification-target-lifecycle.ts has why both are needed.
|
|
69
|
+
*/
|
|
70
|
+
export const refusals = (props: Props): string[] => {
|
|
71
|
+
const out: string[] = [];
|
|
72
|
+
for (const [field, types] of Object.entries(OWNED) as [Field, readonly Props['type'][]][]) {
|
|
73
|
+
if (props[field] !== undefined && !types.includes(props.type)) {
|
|
74
|
+
out.push(`'${field}' belongs to ${types.join('/')} targets, not ${props.type}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const field of REQUIRED[props.type]) {
|
|
78
|
+
if (props[field] === undefined || props[field] === '')
|
|
79
|
+
out.push(`${props.type} needs '${field}'`);
|
|
80
|
+
}
|
|
81
|
+
for (const key of [...Object.keys(props.header ?? {}), ...Object.keys(props.secret ?? {})]) {
|
|
82
|
+
if (!CLEAN_NAME.test(key)) out.push(`'${key}' cannot be a header or secret name`);
|
|
83
|
+
}
|
|
84
|
+
// ⛔ credential-literals.ts: a literal credential in a plain prop is a credential in state.
|
|
85
|
+
for (const name of literalCredentialHeaders(props.header)) {
|
|
86
|
+
out.push(`header '${name}' holds a literal credential; ${WRITE_ONLY}`);
|
|
87
|
+
}
|
|
88
|
+
for (const where of literalCredentialsInUrl(props.url)) {
|
|
89
|
+
out.push(`'url' ${where} holds a literal credential; ${WRITE_ONLY}`);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const isFromEnv = (value: string | FromEnv): value is FromEnv => typeof value !== 'string';
|
|
95
|
+
|
|
96
|
+
/** Each group's values, resolved from `env`, plus the variables that were missing. */
|
|
97
|
+
export const resolveGroups = (props: Props, env: Environment = process.env) => {
|
|
98
|
+
const headerRefs: Record<string, FromEnv> = {};
|
|
99
|
+
const headers: Record<string, string> = {};
|
|
100
|
+
for (const [name, value] of Object.entries(props.header ?? {})) {
|
|
101
|
+
if (isFromEnv(value)) headerRefs[name] = value;
|
|
102
|
+
else headers[name] = value;
|
|
103
|
+
}
|
|
104
|
+
const header = resolveAll(headerRefs, env);
|
|
105
|
+
const sealedRefs: Record<string, FromEnv> = {};
|
|
106
|
+
for (const [name, ref] of Object.entries(props.secret ?? {})) sealedRefs[`secret:${name}`] = ref;
|
|
107
|
+
if (props.password !== undefined) sealedRefs['password'] = props.password;
|
|
108
|
+
const sealed = resolveAll(sealedRefs, env);
|
|
109
|
+
return {
|
|
110
|
+
header: { missing: header.missing, values: { ...headers, ...header.values } },
|
|
111
|
+
sealed,
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export type Groups = ReturnType<typeof resolveGroups>;
|
|
116
|
+
export type GroupState = 'match' | 'stale' | 'unknown';
|
|
117
|
+
|
|
118
|
+
const same = (declared: string | undefined, live: string): boolean =>
|
|
119
|
+
declared === undefined || declared === live;
|
|
120
|
+
|
|
121
|
+
/** Every field the server returns in full. `disable` is the one with no undeclared case. */
|
|
122
|
+
export const plainMatches = (live: PbsNotificationTargetAttributes, props: Props): boolean =>
|
|
123
|
+
live.disable === (props.disable === true) &&
|
|
124
|
+
same(props.comment, live.comment) &&
|
|
125
|
+
same(props.author, live.author) &&
|
|
126
|
+
same(props['from-address'], live['from-address']) &&
|
|
127
|
+
same(props.server, live.server) &&
|
|
128
|
+
(props.port === undefined || props.port === live.port) &&
|
|
129
|
+
same(props.mode, live.mode) &&
|
|
130
|
+
same(props.username, live.username) &&
|
|
131
|
+
same(props.url, live.url) &&
|
|
132
|
+
same(props.method, live.method) &&
|
|
133
|
+
same(props.body, live.body) &&
|
|
134
|
+
(props.mailto === undefined || addressList(props.mailto) === live.mailto) &&
|
|
135
|
+
(props['mailto-user'] === undefined || addressList(props['mailto-user']) === live['mailto-user']);
|
|
136
|
+
|
|
137
|
+
export const headerState = (
|
|
138
|
+
live: PbsNotificationTargetAttributes,
|
|
139
|
+
props: Props,
|
|
140
|
+
groups: Groups,
|
|
141
|
+
): GroupState => {
|
|
142
|
+
if (props.header === undefined) return 'match';
|
|
143
|
+
if (names(Object.keys(props.header)) !== live.header) return 'stale';
|
|
144
|
+
if (groups.header.missing.length > 0) return 'unknown';
|
|
145
|
+
return headerDigest(props.name, groups.header.values) === live.headerDigest ? 'match' : 'stale';
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export const sealedState = (
|
|
149
|
+
live: PbsNotificationTargetAttributes,
|
|
150
|
+
props: Props,
|
|
151
|
+
groups: Groups,
|
|
152
|
+
sealed: string,
|
|
153
|
+
): GroupState => {
|
|
154
|
+
if (props.secret === undefined && props.password === undefined) return 'match';
|
|
155
|
+
if (props.secret !== undefined && names(Object.keys(props.secret)) !== live.secret)
|
|
156
|
+
return 'stale';
|
|
157
|
+
if (groups.sealed.missing.length > 0 || sealed === '') return 'unknown';
|
|
158
|
+
return sealMatches(sealed, groups.sealed.values) ? 'match' : 'stale';
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** Which write-only groups a write carries. A create carries every declared one. */
|
|
162
|
+
export interface Carry {
|
|
163
|
+
readonly header: boolean;
|
|
164
|
+
readonly sealed: boolean;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const secretItems = (props: Props, groups: Groups): string[] =>
|
|
168
|
+
Object.keys(props.secret ?? {}).map((name) =>
|
|
169
|
+
wireItem(name, groups.sealed.values[`secret:${name}`] ?? ''),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const headerItems = (props: Props, groups: Groups): string[] =>
|
|
173
|
+
Object.keys(props.header ?? {}).map((name) => wireItem(name, groups.header.values[name] ?? ''));
|
|
174
|
+
|
|
175
|
+
/** `''`/`[]` means "clear": dropped on a create, turned into a `delete` entry on an update. */
|
|
176
|
+
const plainPairs = (props: Props): [string, string | readonly string[]][] => [
|
|
177
|
+
['author', props.author ?? ''],
|
|
178
|
+
['body', props.body === undefined ? '' : base64(props.body)],
|
|
179
|
+
['comment', props.comment ?? ''],
|
|
180
|
+
['from-address', props['from-address'] ?? ''],
|
|
181
|
+
['method', props.method ?? ''],
|
|
182
|
+
['mode', props.mode ?? ''],
|
|
183
|
+
['port', props.port === undefined ? '' : String(props.port)],
|
|
184
|
+
['server', props.server ?? ''],
|
|
185
|
+
['url', props.url ?? ''],
|
|
186
|
+
['username', props.username ?? ''],
|
|
187
|
+
['mailto', props.mailto === undefined ? [] : addressList(props.mailto).split(',')],
|
|
188
|
+
[
|
|
189
|
+
'mailto-user',
|
|
190
|
+
props['mailto-user'] === undefined ? [] : addressList(props['mailto-user']).split(','),
|
|
191
|
+
],
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
const declared = (props: Props, key: string): boolean =>
|
|
195
|
+
(props as unknown as Record<string, unknown>)[key] !== undefined;
|
|
196
|
+
|
|
197
|
+
const empty = (value: string | readonly string[]): boolean =>
|
|
198
|
+
typeof value === 'string' ? value === '' : value.filter((item) => item !== '').length === 0;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The body of a create or an update — keys are the server's own, checked against generated/pbs.ts
|
|
202
|
+
* by pbs-notification-target.test.ts. ⛔ `disable` IS ALWAYS SENT, `0` included — the PVE family's
|
|
203
|
+
* reason: it is always compared, and "undeclared" and "false" must be one state, not a plan that
|
|
204
|
+
* asks for an update the PUT never performs.
|
|
205
|
+
*/
|
|
206
|
+
export const targetForm = (
|
|
207
|
+
props: Props,
|
|
208
|
+
groups: Groups,
|
|
209
|
+
carry: Carry,
|
|
210
|
+
mode: 'create' | 'update',
|
|
211
|
+
): PveForm => {
|
|
212
|
+
const form: Record<string, string | readonly string[]> = {
|
|
213
|
+
disable: props.disable === true ? '1' : '0',
|
|
214
|
+
};
|
|
215
|
+
const cleared: string[] = [];
|
|
216
|
+
const put = (key: string, value: string | readonly string[]) => {
|
|
217
|
+
if (!empty(value)) form[key] = value;
|
|
218
|
+
else if (mode === 'update') cleared.push(key);
|
|
219
|
+
};
|
|
220
|
+
for (const [key, value] of plainPairs(props)) if (declared(props, key)) put(key, value);
|
|
221
|
+
if (carry.header && props.header !== undefined) put('header', headerItems(props, groups));
|
|
222
|
+
if (carry.sealed && props.secret !== undefined) put('secret', secretItems(props, groups));
|
|
223
|
+
if (carry.sealed && props.password !== undefined) {
|
|
224
|
+
form['password'] = groups.sealed.values['password'] ?? '';
|
|
225
|
+
}
|
|
226
|
+
if (mode === 'create') form['name'] = props.name;
|
|
227
|
+
if (cleared.length > 0) form['delete'] = cleared;
|
|
228
|
+
return form;
|
|
229
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lifecycle of a `Pbs.NotificationTarget`: read, diff, reconcile, delete.
|
|
3
|
+
*
|
|
4
|
+
* ★ SPLIT OUT OF pbs-notification-target.ts FOR THE 250-LINE CAP. That file says what a target is
|
|
5
|
+
* and holds `Provider.of`; this one says what each operation does. The `import type` back is
|
|
6
|
+
* erased, so the cycle is on paper only.
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ NOT `pveHandlers`, AND IT SHOULD LOOK DIFFERENT (resource.ts asks for exactly that). The
|
|
9
|
+
* factory's `diff` sees only the live object, and a secret's value is not in the live object:
|
|
10
|
+
* the seal lives in the PREVIOUS STATE, which only a hand-written `diff` and `reconcile` receive
|
|
11
|
+
* (`output`). Everything else — the read, the read-back refusal, the `matches` guard that keeps
|
|
12
|
+
* adoption free — is the factory's behaviour, written out.
|
|
13
|
+
*/
|
|
14
|
+
import { isResolved } from 'alchemy/Diff';
|
|
15
|
+
import type { Input } from 'alchemy/Input';
|
|
16
|
+
import * as Effect from 'effect/Effect';
|
|
17
|
+
import { pve } from './client.ts';
|
|
18
|
+
import type {
|
|
19
|
+
PbsNotificationTargetAttributes,
|
|
20
|
+
PbsNotificationTargetProps,
|
|
21
|
+
} from './pbs-notification-target.ts';
|
|
22
|
+
import {
|
|
23
|
+
type Carry,
|
|
24
|
+
type Groups,
|
|
25
|
+
headerState,
|
|
26
|
+
plainMatches,
|
|
27
|
+
refusals,
|
|
28
|
+
resolveGroups,
|
|
29
|
+
sealedState,
|
|
30
|
+
targetForm,
|
|
31
|
+
} from './pbs-notification-target-form.ts';
|
|
32
|
+
import { targetAttributes } from './pbs-notification-target-wire.ts';
|
|
33
|
+
import { seal } from './write-only.ts';
|
|
34
|
+
|
|
35
|
+
type Props = PbsNotificationTargetProps;
|
|
36
|
+
type Attributes = PbsNotificationTargetAttributes;
|
|
37
|
+
|
|
38
|
+
const path = (props: Props) => `config/notifications/endpoints/${props.type}/${props.name}`;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* ⚠️ A 404 and `{"data": null}` are answers here — and so, resource.ts's ⛔, is a 403. `pve()`
|
|
42
|
+
* returns `null` for the second, not `undefined`, so the test is `== null`.
|
|
43
|
+
*/
|
|
44
|
+
const readLive = (props: Props) =>
|
|
45
|
+
pve<Record<string, unknown>>(props.target, 'read', 'GET', path(props)).pipe(
|
|
46
|
+
Effect.map((data) => (data == null ? undefined : targetAttributes(data, props))),
|
|
47
|
+
Effect.orElseSucceed(() => undefined),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const refuse = (props: Props) => {
|
|
51
|
+
const reasons = refusals(props);
|
|
52
|
+
return reasons.length === 0
|
|
53
|
+
? Effect.void
|
|
54
|
+
: Effect.die(new Error(`${path(props)}: ${reasons.join('; ')}.`));
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** What a write must carry, given what is stale. `undefined` means nothing to write. */
|
|
58
|
+
const toCarry = (live: Attributes, props: Props, groups: Groups, sealed: string) => {
|
|
59
|
+
const header = headerState(live, props, groups) === 'stale';
|
|
60
|
+
const sealedStale = sealedState(live, props, groups, sealed) === 'stale';
|
|
61
|
+
return plainMatches(live, props) && !header && !sealedStale
|
|
62
|
+
? undefined
|
|
63
|
+
: ({ header, sealed: sealedStale } satisfies Carry);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** ⛔ Every variable a carried group needs, or a failure naming the missing ones — never a value. */
|
|
67
|
+
const requireValues = (props: Props, groups: Groups, carry: Carry) => {
|
|
68
|
+
const missing = [
|
|
69
|
+
...(carry.header ? groups.header.missing : []),
|
|
70
|
+
...(carry.sealed ? groups.sealed.missing : []),
|
|
71
|
+
];
|
|
72
|
+
return missing.length === 0
|
|
73
|
+
? Effect.void
|
|
74
|
+
: Effect.die(
|
|
75
|
+
new Error(
|
|
76
|
+
`${path(props)}: this write must send write-only values, and ${missing.join(', ')} ` +
|
|
77
|
+
'is unset or empty in the deploying environment. Export it and deploy again.',
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const handlers = {
|
|
83
|
+
/** ⛔ EMPTY: `mail-to-root` is on every host, and adoption stays an explicit act (resource.ts). */
|
|
84
|
+
list: () => Effect.succeed([]),
|
|
85
|
+
/**
|
|
86
|
+
* ⛔ WITH NO `output` THIS IS ALCHEMY'S ADOPTION PROBE, AND THE ONLY PLAN-TIME HOOK A NEW TARGET
|
|
87
|
+
* GETS — so the refusals run here too. Measured on beta.79 (Plan.ts, the `oldState ===
|
|
88
|
+
* undefined` branch): a declaration with no state row is `read` with `olds: news` and never
|
|
89
|
+
* `diff`ed, and Apply then commits its props as `creating` BEFORE `reconcile` runs. A refusal
|
|
90
|
+
* left to `reconcile` fired after the props — a literal token included — were in the store.
|
|
91
|
+
* ⚠️ ONLY WHEN THE PROPS ARE RESOLVED: Plan skips the probe while any prop is an unresolved
|
|
92
|
+
* Output, and then `reconcile` is the first check. docs/pbs-notifications.md says so.
|
|
93
|
+
* ★ A DEFECT (`Effect.die`), NOT A FAILURE, AND THAT IS WHAT KEEPS IT FROM WEDGING A STAGE. The
|
|
94
|
+
* same `read`-with-no-output shape is Alchemy's RECOVERY read of an interrupted create, at plan
|
|
95
|
+
* and before a delete (Plan.ts, Apply.ts), with the STORED props — and both wrap it in
|
|
96
|
+
* `catchDefect`, degrading to "nothing recovered". A typed failure would pass that catch and
|
|
97
|
+
* block the plan or the delete on props the user already fixed. The cold-start probe has no
|
|
98
|
+
* catch, so there the defect fails the plan, as intended. A refused create sent no request,
|
|
99
|
+
* so "nothing recovered" is also the truth.
|
|
100
|
+
*/
|
|
101
|
+
read: ({ olds, output }: { olds: Props; output: Attributes | undefined }) =>
|
|
102
|
+
(output === undefined ? refuse(olds) : Effect.void).pipe(
|
|
103
|
+
Effect.andThen(readLive(olds)),
|
|
104
|
+
Effect.map((live) =>
|
|
105
|
+
live === undefined ? undefined : { ...live, sealed: output?.sealed ?? '' },
|
|
106
|
+
),
|
|
107
|
+
),
|
|
108
|
+
/**
|
|
109
|
+
* ⚠️ `Input<Props>` and `isResolved`, resource.ts's reason: at plan time a prop can still be an
|
|
110
|
+
* unresolved Output, and comparing a placeholder to a live value reports a phantom update.
|
|
111
|
+
* ⛔ A NEW `type` UNDER THE SAME `name` REPLACES DELETE-FIRST: names are unique across every
|
|
112
|
+
* family on the host (api `ensure_unique`), so creating the new one first would be refused.
|
|
113
|
+
*/
|
|
114
|
+
diff: ({
|
|
115
|
+
news,
|
|
116
|
+
olds,
|
|
117
|
+
output,
|
|
118
|
+
}: {
|
|
119
|
+
news: Input<Props>;
|
|
120
|
+
olds: Props;
|
|
121
|
+
output: Attributes | undefined;
|
|
122
|
+
}) =>
|
|
123
|
+
Effect.gen(function* () {
|
|
124
|
+
if (output === undefined || !isResolved(news)) return undefined;
|
|
125
|
+
if (olds !== undefined && (olds.type !== news.type || olds.name !== news.name)) {
|
|
126
|
+
return { action: 'replace', deleteFirst: olds.name === news.name } as const;
|
|
127
|
+
}
|
|
128
|
+
yield* refuse(news);
|
|
129
|
+
const live = yield* readLive(news);
|
|
130
|
+
if (live === undefined) return { action: 'update' } as const;
|
|
131
|
+
return toCarry(live, news, resolveGroups(news), output.sealed) === undefined
|
|
132
|
+
? ({ action: 'noop' } as const)
|
|
133
|
+
: ({ action: 'update' } as const);
|
|
134
|
+
}),
|
|
135
|
+
reconcile: ({ news, output }: { news: Props; output: Attributes | undefined }) =>
|
|
136
|
+
Effect.gen(function* () {
|
|
137
|
+
yield* refuse(news);
|
|
138
|
+
const groups = resolveGroups(news);
|
|
139
|
+
const live = yield* readLive(news);
|
|
140
|
+
let sealed = output?.sealed ?? '';
|
|
141
|
+
if (live === undefined) {
|
|
142
|
+
const carry = { header: true, sealed: true } as const;
|
|
143
|
+
yield* requireValues(news, groups, carry);
|
|
144
|
+
const collection = `config/notifications/endpoints/${news.type}`;
|
|
145
|
+
yield* pve(
|
|
146
|
+
news.target,
|
|
147
|
+
'provision',
|
|
148
|
+
'POST',
|
|
149
|
+
collection,
|
|
150
|
+
targetForm(news, groups, carry, 'create'),
|
|
151
|
+
);
|
|
152
|
+
sealed = seal(groups.sealed.values);
|
|
153
|
+
} else {
|
|
154
|
+
const carry = toCarry(live, news, groups, sealed);
|
|
155
|
+
if (carry !== undefined) {
|
|
156
|
+
yield* requireValues(news, groups, carry);
|
|
157
|
+
yield* pve(
|
|
158
|
+
news.target,
|
|
159
|
+
'provision',
|
|
160
|
+
'PUT',
|
|
161
|
+
path(news),
|
|
162
|
+
targetForm(news, groups, carry, 'update'),
|
|
163
|
+
);
|
|
164
|
+
if (carry.sealed) sealed = seal(groups.sealed.values);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const after = yield* readLive(news);
|
|
168
|
+
if (after === undefined) {
|
|
169
|
+
// ⛔ resource.ts's rule: "no error" is not evidence of a write. Read back, or refuse.
|
|
170
|
+
return yield* Effect.die(
|
|
171
|
+
new Error(`${path(news)}: the write returned no error but the target is still absent.`),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return { ...after, sealed } satisfies Attributes;
|
|
175
|
+
}),
|
|
176
|
+
delete: ({ olds }: { olds: Props }) => pve(olds.target, 'provision', 'DELETE', path(olds)),
|
|
177
|
+
};
|