@homeflare/alchemy 0.54.0 → 0.56.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.
Files changed (62) hide show
  1. package/dist/ceph/ceph-argv-config.d.ts +6 -3
  2. package/dist/ceph/ceph-argv-config.d.ts.map +1 -1
  3. package/dist/ceph/ceph-argv.d.ts +8 -0
  4. package/dist/ceph/ceph-argv.d.ts.map +1 -1
  5. package/dist/ceph/ceph-config-option-form.d.ts +48 -0
  6. package/dist/ceph/ceph-config-option-form.d.ts.map +1 -0
  7. package/dist/ceph/ceph-config-option-reconcile.d.ts +22 -0
  8. package/dist/ceph/ceph-config-option-reconcile.d.ts.map +1 -0
  9. package/dist/ceph/ceph-config-options.d.ts +15 -6
  10. package/dist/ceph/ceph-config-options.d.ts.map +1 -1
  11. package/dist/ceph/config-option.d.ts +10 -0
  12. package/dist/ceph/config-option.d.ts.map +1 -0
  13. package/dist/ceph/index.d.ts +7 -3
  14. package/dist/ceph/index.d.ts.map +1 -1
  15. package/dist/ceph/index.js +363 -8
  16. package/dist/ceph/index.js.map +17 -7
  17. package/dist/launchd/index.js +4 -2
  18. package/dist/launchd/index.js.map +3 -3
  19. package/dist/launchd/sudo-guard-mode.d.ts +10 -4
  20. package/dist/launchd/sudo-guard-mode.d.ts.map +1 -1
  21. package/dist/linux/fake-sudo-root.d.ts.map +1 -1
  22. package/dist/linux/fake-sudo.d.ts +9 -1
  23. package/dist/linux/fake-sudo.d.ts.map +1 -1
  24. package/dist/linux/index.js +47 -9
  25. package/dist/linux/index.js.map +6 -6
  26. package/dist/linux/sudo-guard.d.ts +10 -1
  27. package/dist/linux/sudo-guard.d.ts.map +1 -1
  28. package/dist/linux/sudo-runner.d.ts +15 -1
  29. package/dist/linux/sudo-runner.d.ts.map +1 -1
  30. package/dist/linux/sudo-write.d.ts +4 -1
  31. package/dist/linux/sudo-write.d.ts.map +1 -1
  32. package/dist/proxmox/index.d.ts +1 -0
  33. package/dist/proxmox/index.d.ts.map +1 -1
  34. package/dist/proxmox/index.js +101 -1
  35. package/dist/proxmox/index.js.map +5 -4
  36. package/dist/proxmox/scoped-api.d.ts +49 -0
  37. package/dist/proxmox/scoped-api.d.ts.map +1 -0
  38. package/docs/linux-sudo-review-2026-09-23.md +27 -0
  39. package/docs/linux-sudo.md +28 -40
  40. package/docs/pve-scoped-api.md +23 -0
  41. package/package.json +1 -1
  42. package/src/ceph/ceph-argv-config.ts +27 -19
  43. package/src/ceph/ceph-argv.test.ts +37 -1
  44. package/src/ceph/ceph-argv.ts +25 -0
  45. package/src/ceph/ceph-config-option-form.ts +73 -0
  46. package/src/ceph/ceph-config-option-reconcile.ts +88 -0
  47. package/src/ceph/ceph-config-option.test.ts +213 -0
  48. package/src/ceph/ceph-config-options.test.ts +28 -11
  49. package/src/ceph/ceph-config-options.ts +29 -8
  50. package/src/ceph/config-option.ts +112 -0
  51. package/src/ceph/index.ts +7 -3
  52. package/src/launchd/sudo-guard-mode.ts +11 -3
  53. package/src/linux/fake-sudo-root.ts +15 -0
  54. package/src/linux/fake-sudo.ts +20 -1
  55. package/src/linux/sudo-guard.ts +22 -1
  56. package/src/linux/sudo-runner-privileged-read.test.ts +200 -0
  57. package/src/linux/sudo-runner.ts +59 -4
  58. package/src/linux/sudo-write.ts +5 -1
  59. package/src/proxmox/index.ts +13 -0
  60. package/src/proxmox/scoped-api.test.ts +52 -0
  61. package/src/proxmox/scoped-api.ts +148 -0
  62. package/src/talos/talosctl.test.ts +6 -4
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `pveScoped`: the agent helper for Ceph and container operations over the PVE API when SSH to a
3
+ * node is not available (2026-10-14, "PVE API path").
4
+ *
5
+ * ★ EVERY CALL MINTS A SCOPED TIER, NEVER `provision`. A call names its tier (`ceph-ops` or
6
+ * `ct-config`); `scopedTarget` pins BOTH of the target's roles to that tier (credentials.ts's
7
+ * "set both roles or neither"), so no read or write in it can fall back to the estate-wide tier.
8
+ * In the estate those tiers are `proxmox-tb4/creds/{ceph-ops,ct-config}` → hf-ceph-ops@pve
9
+ * (HFCephOps on `/`) and hf-ct-config@pve (HFCTConfig on `/vms`); the PVE ACL is the boundary.
10
+ * ★ A CLOSED SET OF OPERATIONS. Each builder returns a fixed method + path, with every path
11
+ * segment validated, and `ctSetConfig` accepts only the keys HFCTConfig can change anyway.
12
+ * No free-form path, so an agent cannot turn this into a general PVE client.
13
+ * ⛔ The minted secret never leaves `mint`/`pve()` (mint.ts's ⛔). Results are PVE's `data`.
14
+ * ⛔ Not a substitute for Alchemy: a change that should persist (a declared CT's cores, a Ceph
15
+ * flag that a stack owns) still lands as a reviewed PR. This is the break-glass read/act path.
16
+ */
17
+ import type * as Effect from 'effect/Effect';
18
+ import type * as HttpClient from 'effect/http/HttpClient';
19
+ import type { PveCredentialDenied } from './credential-errors.ts';
20
+ import { type PveForm } from './client.ts';
21
+ import type { PveRole, PveTarget } from './credentials.ts';
22
+ export type ScopedTier = 'ceph-ops' | 'ct-config';
23
+ export type ScopedCall = {
24
+ readonly tier: ScopedTier;
25
+ readonly role: PveRole;
26
+ readonly method: 'GET' | 'POST' | 'PUT';
27
+ readonly path: string;
28
+ readonly form?: PveForm;
29
+ };
30
+ /** The target with BOTH roles pinned to `tier`: nothing here can mint `read`/`provision`. */
31
+ export declare const scopedTarget: (target: PveTarget, tier: ScopedTier) => PveTarget;
32
+ /** Flags an operator sets around maintenance. `pause`, `full` and friends are deliberately absent. */
33
+ export declare const CEPH_FLAGS: readonly ['noout', 'norebalance', 'norecover', 'nobackfill', 'noscrub', 'nodeep-scrub'];
34
+ export type CephFlag = (typeof CEPH_FLAGS)[number];
35
+ /** The CT config keys `ctSetConfig` may write: exactly what HFCTConfig's VM.Config.* privs cover. */
36
+ export declare const CT_CONFIG_KEYS: readonly ['cores', 'cpulimit', 'memory', 'swap', 'onboot', 'description', 'tags'];
37
+ export type CtConfigPatch = Partial<Record<(typeof CT_CONFIG_KEYS)[number], string | number | boolean>>;
38
+ export declare const scopedCalls: {
39
+ readonly cephStatus: () => ScopedCall;
40
+ readonly cephFlags: () => ScopedCall;
41
+ readonly cephSetFlag: (flag: CephFlag, on: boolean) => ScopedCall;
42
+ readonly cephOsd: (n: string, id: number, to: 'in' | 'out') => ScopedCall;
43
+ readonly ctConfig: (n: string, id: number) => ScopedCall;
44
+ readonly ctSetConfig: (n: string, id: number, patch: CtConfigPatch) => ScopedCall;
45
+ readonly ctPower: (n: string, id: number, action: 'start' | 'shutdown' | 'reboot') => ScopedCall;
46
+ };
47
+ /** Run one scoped call: mint its tier (only), call PVE, return `data`. */
48
+ export declare const pveScoped: <T = unknown>(target: PveTarget, call: ScopedCall) => Effect.Effect<T | undefined, Error | PveCredentialDenied, HttpClient.HttpClient>;
49
+ //# sourceMappingURL=scoped-api.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scoped-api.d.ts","sourceRoot":"","sources":["../../src/proxmox/scoped-api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,KAAK,MAAM,MAAM,eAAe,CAAC;AAC7C,OAAO,KAAK,KAAK,UAAU,MAAM,wBAAwB,CAAC;AAC1D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAClE,OAAO,EAAE,KAAK,OAAO,EAAO,MAAM,aAAa,CAAC;AAChD,OAAO,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAE3D,MAAM,MAAM,UAAU,GAAG,UAAU,GAAG,WAAW,CAAC;AAElD,MAAM,MAAM,UAAU,GAAG;IACvB,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,CAAC;IACxC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF,6FAA6F;AAC7F,eAAO,MAAM,YAAY,WAAY,SAAS,QAAQ,UAAU,KAAG,SAGjE,CAAC;AAgBH,sGAAsG;AACtG,eAAO,MAAM,UAAU,YACrB,OAAO,EACP,aAAa,EACb,WAAW,EACX,YAAY,EACZ,SAAS,EACT,cAAc,CACN,CAAC;AACX,MAAM,MAAM,QAAQ,GAAG,CAAC,OAAO,UAAU,CAAC,CAAC,MAAM,CAAC,CAAC;AAEnD,qGAAqG;AACrG,eAAO,MAAM,cAAc,YACzB,OAAO,EACP,UAAU,EACV,QAAQ,EACR,MAAM,EACN,QAAQ,EACR,aAAa,EACb,MAAM,CACE,CAAC;AACX,MAAM,MAAM,aAAa,GAAG,OAAO,CACjC,MAAM,CAAC,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,CACnE,CAAC;AAEF,eAAO,MAAM,WAAW;aACtB,UAAU,QAAM,UAAU;aAM1B,SAAS,QAAM,UAAU;aAMzB,WAAW,SAAS,QAAQ,MAAM,OAAO,KAAG,UAAU;aAUtD,OAAO,MAAM,MAAM,MAAM,MAAM,MAAM,IAAI,GAAG,KAAK,KAAG,UAAU;aAS9D,QAAQ,MAAM,MAAM,MAAM,MAAM,KAAG,UAAU;aAM7C,WAAW,MAAM,MAAM,MAAM,MAAM,SAAS,aAAa,KAAG,UAAU;aAgBtE,OAAO,MAAM,MAAM,MAAM,MAAM,UAAU,OAAO,GAAG,UAAU,GAAG,QAAQ,KAAG,UAAU;CAS7E,CAAC;AAEX,0EAA0E;AAC1E,eAAO,MAAM,SAAS,GAAI,CAAC,GAAG,OAAO,UAC3B,SAAS,QACX,UAAU,KACf,MAAM,CAAC,MAAM,CAAC,CAAC,GAAG,SAAS,EAAE,KAAK,GAAG,mBAAmB,EAAE,UAAU,CAAC,UAAU,CACK,CAAC"}
@@ -0,0 +1,27 @@
1
+ # The 2026-09-23 adversarial review of the linux sudo runner
2
+
3
+ Extracted from [linux-sudo.md](./linux-sudo.md) to keep that doc under the 200-line
4
+ cap. The findings it records are live rules of the runner, not history.
5
+
6
+ 🔴 **Adversarial review, 2026-09-23, two rounds — everything found is fixed
7
+ and tested.** Round 1: a directory this runner elevates was checked for
8
+ SHAPE only, never mode or owner. `mkdir -m 0777` and `chown <non-root uid>`
9
+ both passed untouched, so a stack could declare `HostDirectory({ path:
10
+ '/etc/systemd/system/x.service.d', mode: 0o777 })`, drop an `ExecStart=`
11
+ override into it, and this runner's own next `daemon-reload` + `restart`
12
+ would run it as root. Fixed: a directory this runner elevates is now always
13
+ root-owned and never setuid/setgid or group/other-writable, unconditionally
14
+ — a directory's OWNER always has write access through the owner bits alone,
15
+ so no mode restriction alone could make a non-root-owned directory safe
16
+ here. The same round tightened the shared `modeProblem` check
17
+ (`../launchd/sudo-guard.ts`, used by both platforms): it exempted a file
18
+ merely because its `uid` was non-root, missing that `{uid: 501, gid: 0,
19
+ mode: 0o2775}` — setgid to root's own group — reaches the same escalation
20
+ through the group instead. Round 2 found the round-1 `modeProblem` fix was
21
+ itself incomplete (an OMITTED `gid`, not just an explicit `0`, defaults to
22
+ root's own group under `install` — see the section above), the masked-unit
23
+ and FragmentPath-scope gaps documented above, `install`'s own numeric-id
24
+ ambiguity (also above), and a swallowed cleanup-`rm` failure (now included
25
+ in the thrown error, with the leftover path named). All covered by tests:
26
+ `sudo-allowlist.test.ts`, `sudo-lifecycle.test.ts`, `sudo-runner.test.ts`,
27
+ `../launchd/sudo-modes.test.ts`.
@@ -60,18 +60,19 @@ staging directory is removed either way.
60
60
 
61
61
  Each is `/usr/bin/sudo -n -- <argv>`, every program by absolute path.
62
62
 
63
- | when | argv |
64
- | ------------------------------------ | ----------------------------------------------------------------------------------- |
65
- | a file under a prefix — stage→temp | `/usr/bin/install -m <0644> -T -- <staged> <temp>` |
66
- | its owner, if not root:root | `/usr/bin/chown +<uid>[:+<gid>] -- <temp>` — numeric, `+`-forced, bound to `<temp>` |
67
- | a file under a prefix — temp→dest | `/usr/bin/mv -f -T -- <temp> <dest>` |
68
- | a file under a prefix is removed | `/usr/bin/rm -f -- <path>` |
69
- | a directory under a prefix is made | `/usr/bin/mkdir -m <octal> -- <path>` — never setuid/setgid or group/other-write |
70
- | its mode is fixed | `/usr/bin/chmod <octal> -- <path>` — same restriction |
71
- | its owner is fixed | `/usr/bin/chown <owner> -- <path>` — **root only**: `+0`, `:+0` or `+0:+0` |
72
- | it is removed | `/usr/bin/rmdir -- <path>` |
73
- | after any unit-file write/removal | `/usr/bin/systemctl daemon-reload` |
74
- | a unit is enabled/disabled/started/… | `/usr/bin/systemctl <verb> -- <unit>` |
63
+ | when | argv |
64
+ | ------------------------------------ | --------------------------------------------------------------------------------------------- |
65
+ | a file under a prefix — stage→temp | `/usr/bin/install -m <0644> -T -- <staged> <temp>` |
66
+ | its owner, if not root:root | `/usr/bin/chown +<uid>[:+<gid>] -- <temp>` — numeric, `+`-forced, bound to `<temp>` |
67
+ | a file under a prefix — temp→dest | `/usr/bin/mv -f -T -- <temp> <dest>` |
68
+ | a file under a prefix is removed | `/usr/bin/rm -f -- <path>` |
69
+ | a directory under a prefix is made | `/usr/bin/mkdir -m <octal> -- <path>` — never setuid/setgid or group/other-write |
70
+ | its mode is fixed | `/usr/bin/chmod <octal> -- <path>` — same restriction |
71
+ | its owner is fixed | `/usr/bin/chown <owner> -- <path>` — **root only**: `+0`, `:+0` or `+0:+0` |
72
+ | it is removed | `/usr/bin/rmdir -- <path>` |
73
+ | after any unit-file write/removal | `/usr/bin/systemctl daemon-reload` |
74
+ | a unit is enabled/disabled/started/… | `/usr/bin/systemctl <verb> -- <unit>` |
75
+ | a `privilegedReads` file is read | `/usr/bin/base64 -- <path>` — exact listed path, root-owned regular file, or the read refuses |
75
76
 
76
77
  `enable`/`disable`/`start`/`stop`/`restart` elevate only when the unit is not
77
78
  **masked** (`LoadState`, read alongside `FragmentPath` in the same call —
@@ -103,7 +104,7 @@ know that syntax exists.
103
104
 
104
105
  ## What stays as the operator
105
106
 
106
- - Every read: `stat`, `readFile`, user/group lookups, `systemctl show`.
107
+ - Every read: `stat`, `readFile`, user/group lookups, `systemctl show` — with one exact exception: a file on the runner's `privilegedReads` list is read with `sudo -n base64` (the plan-diff read a declared root-owned file needs; see the table above). Every other path, and every `checkWrite` host probe (`id -G`, `ls`, `stat`), stays the operator's own.
107
108
  - `checkWrite` — the plan-time half of a write, run at every plan so a
108
109
  refusal surfaces before any resource is applied, never at the write.
109
110
  - Files and directories outside every prefix (a foreign owner there is a
@@ -111,10 +112,11 @@ know that syntax exists.
111
112
  - `ls -ldn` and `mktemp -d` — the guard's chain read and the write's own
112
113
  staging, both unprivileged (`src/linux/sudo-listing.ts`, `sudo-stage.ts`).
113
114
 
114
- ★ **So a plan never calls sudo**, and construction never does either:
115
- `sshSudoRunner()` runs one combined, read-only `test -x` for the nine
116
- absolute programs above, so a missing coreutils package or a systemd-free
117
- container fails with a clear message at construction — never a sudo probe.
115
+ ★ **So a plan calls sudo for exactly one thing** — reading a `privilegedReads`
116
+ file — and construction never does: `sshSudoRunner()` runs one combined,
117
+ read-only `test -x` for the nine absolute programs above, so a missing
118
+ coreutils package or a systemd-free container fails with a clear message at
119
+ construction — never a sudo probe.
118
120
 
119
121
  ## The host guard
120
122
 
@@ -144,31 +146,17 @@ prefix-sibling (`/usr/local/bin-x` is not under `/usr/local/bin`), or a
144
146
  `chown` by name, a bare digit, or any non-root id; `systemctl mask`/`--user`;
145
147
  a masked unit, any verb; `enable`/`start`/`restart` on a unit with no unit
146
148
  file; a root-owned file that would be group- or world-writable, setuid or
147
- setgid; a file the operator could not read back (both `Systemd.Unit` and
148
- `Remote.File` read every write back to compare digests).
149
+ setgid; a file NOT on the `privilegedReads` list that the operator could not
150
+ read back (both `Systemd.Unit` and `Remote.File` read every write back to
151
+ compare digests); a listed file handed to a non-root uid, whatever its mode
152
+ — the elevated read-back refuses anything but a root-owned regular file, so
153
+ the handoff would land and then fail every read.
149
154
 
150
155
  🔴 **Adversarial review, 2026-09-23, two rounds — everything found is fixed
151
- and tested.** Round 1: a directory this runner elevates was checked for
152
- SHAPE only, never mode or owner. `mkdir -m 0777` and `chown <non-root uid>`
153
- both passed untouched, so a stack could declare `HostDirectory({ path:
154
- '/etc/systemd/system/x.service.d', mode: 0o777 })`, drop an `ExecStart=`
155
- override into it, and this runner's own next `daemon-reload` + `restart`
156
- would run it as root. Fixed: a directory this runner elevates is now always
157
- root-owned and never setuid/setgid or group/other-writable, unconditionally
158
- — a directory's OWNER always has write access through the owner bits alone,
159
- so no mode restriction alone could make a non-root-owned directory safe
160
- here. The same round tightened the shared `modeProblem` check
161
- (`../launchd/sudo-guard.ts`, used by both platforms): it exempted a file
162
- merely because its `uid` was non-root, missing that `{uid: 501, gid: 0,
163
- mode: 0o2775}` — setgid to root's own group — reaches the same escalation
164
- through the group instead. Round 2 found the round-1 `modeProblem` fix was
165
- itself incomplete (an OMITTED `gid`, not just an explicit `0`, defaults to
166
- root's own group under `install` — see the section above), the masked-unit
167
- and FragmentPath-scope gaps documented above, `install`'s own numeric-id
168
- ambiguity (also above), and a swallowed cleanup-`rm` failure (now included
169
- in the thrown error, with the leftover path named). All covered by tests:
170
- `sudo-allowlist.test.ts`, `sudo-lifecycle.test.ts`, `sudo-runner.test.ts`,
171
- `../launchd/sudo-modes.test.ts`.
156
+ and tested** — the full record (the directory shape-only gap, the shared `modeProblem`
157
+ escapes, the omitted-`gid` default, the masked-unit and `FragmentPath` scope gaps,
158
+ `install`'s numeric-id ambiguity, the swallowed cleanup failure) lives in
159
+ [linux-sudo-review-2026-09-23.md](./linux-sudo-review-2026-09-23.md).
172
160
 
173
161
  Every refusal throws `SudoRefusedError`: nothing ran as root. A privileged
174
162
  command that ran and failed throws a plain `Error` with its exit code.
@@ -0,0 +1,23 @@
1
+ # Scoped PVE API helper (`pveScoped`)
2
+
3
+ Break-glass Ceph and container operations over the PVE API when SSH to a node is unavailable.
4
+
5
+ ```ts
6
+ import { pveScoped, scopedCalls } from '@homeflare/alchemy/proxmox';
7
+
8
+ const target = { mount: 'proxmox-tb4', members: ['n2.mgmt.homeflare.dev'], scheme: 'pve' } as const;
9
+ const status = await Effect.runPromise(
10
+ pveScoped(target, scopedCalls.cephStatus()).pipe(Effect.provide(FetchHttpClient.layer)),
11
+ );
12
+ ```
13
+
14
+ - Every call mints its own scoped tier from OpenBao: `<mount>/creds/ceph-ops` or `<mount>/creds/ct-config`.
15
+ `scopedTarget` pins both roles to that tier, so nothing can fall back to `read` or `provision`.
16
+ - In the estate, `ceph-ops` is `hf-ceph-ops@pve` (HFCephOps on `/`), and `ct-config` is
17
+ `hf-ct-config@pve` (HFCTConfig on `/vms`). Both are declared in homeflare-proxmox
18
+ `src/access-scoped-tiers.ts`. The OpenBao roles and the proxmox-deploy grant are declared in homeflare-openbao.
19
+ - The operations are a closed set: `cephStatus`, `cephFlags`, `cephSetFlag` (maintenance flags only),
20
+ `cephOsd` (in/out), `ctConfig`, `ctSetConfig` (cores, cpulimit, memory, swap, onboot, description, tags) and
21
+ `ctPower` (start, shutdown, reboot). Every path segment is validated.
22
+ - OpenBao is resolved the same way as `mint` (`BAO_ADDR`/`BAO_TOKEN` or an agent). The secret is never returned.
23
+ - Not a substitute for Alchemy: a change a stack owns still lands as a reviewed PR.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.54.0",
3
+ "version": "0.56.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",
@@ -4,19 +4,26 @@
4
4
  * split follows the same line the design docs already draw (2026-09-26-ceph-config-options.md is
5
5
  * its own companion to 2026-09-26-ceph-mon-transport.md).
6
6
  *
7
- * ⚠️ `<who>` IS NOT YET BOUNDED TO K-D's "declared daemons" (the config-options design's own
8
- * acceptance test #1). `client.admin` still matches this shape. Latent today:
9
- * `NAMED_CONFIG_OPTIONS` starts empty, so `namedOptionProblem` refuses every `config get/set/rm`
10
- * regardless of `who` — LAND red team, 2026-09-26, flagged this as armed only once K-D's first
11
- * named option lands, and narrowing it needs that family's own declared-daemon list, which does
12
- * not exist yet. Tracked for `CephConfigOption` (K-D), not fixed here.
7
+ * ★ `<who>` IS BOUNDED PER OPTION (the config-options design's acceptance test #1). LAND red team,
8
+ * 2026-09-26, flagged the shape-only `WHO` check as armed the moment K-D's first named option
9
+ * landed (`client.admin` matches it). That first option lands in the same change as this guard:
10
+ * each `NAMED_CONFIG_OPTIONS` entry carries the sections it may be set for, and a `<who>` outside
11
+ * that list is refused before anything dials.
13
12
  */
14
- import { NAMED_CONFIG_OPTIONS, isLockoutConfigOption } from './ceph-config-options.ts';
13
+ import {
14
+ NAMED_CONFIG_OPTIONS,
15
+ allowedWhoFor,
16
+ isLockoutConfigOption,
17
+ } from './ceph-config-options.ts';
15
18
 
16
19
  /** `config`'s `<who>`: a daemon class, optionally `.id` — never a path, never free text. */
17
20
  const WHO = /^(global|mon|osd|mgr|mds|client)(\.[A-Za-z0-9_-]{1,32})?$/;
18
- /** A ceph option name's own shape — snake_case, lowercase. Independent of the named list below. */
19
- const OPTION_NAME = /^[a-z][a-z0-9_]{0,63}$/;
21
+ /**
22
+ * A ceph option name's own shape — snake_case, lowercase — or an mgr module option, which the mon
23
+ * config db keys as `mgr/<module>/<option>` (e.g. `mgr/prometheus/exclude_perf_counters`).
24
+ * Independent of the named list below; never a path beyond those exactly-three segments.
25
+ */
26
+ const OPTION_NAME = /^(?:[a-z][a-z0-9_]{0,63}|mgr\/[a-z][a-z0-9_]{0,31}\/[a-z][a-z0-9_]{0,63})$/;
20
27
  /**
21
28
  * No shell metacharacters; the named list (below) is what actually decides secrecy, not this.
22
29
  * ⛔ NO LEADING `-` EXCEPT A NEGATIVE INTEGER, AND NEVER EMPTY. LAND red team (2026-09-26),
@@ -27,15 +34,13 @@ const OPTION_NAME = /^[a-z][a-z0-9_]{0,63}$/;
27
34
  * `--admin-daemon=/var/run/ceph/ceph-mon.a.asok`, `--conf=/tmp/x`, `-n`, and the empty string.
28
35
  * The sudoers entry has no argument restriction of its own (that is D4, a later step), so this
29
36
  * allowlist is the only thing standing between a config value and an arbitrary ceph CLI flag.
30
- * Latent while `NAMED_CONFIG_OPTIONS` is empty; K-D's first entry arms it.
37
+ * Armed since K-D's first named entry (`mgr/prometheus/exclude_perf_counters`).
31
38
  */
32
39
  const CONFIG_VALUE = /^(?:[A-Za-z0-9][A-Za-z0-9 ._:/=,-]{0,255}|-[0-9]{1,255})$/;
33
40
 
34
41
  /**
35
42
  * `undefined` when `value` is a safe ceph config value — exported (like `boundedEntityProblem`)
36
- * so the shape itself is directly testable, since `NAMED_CONFIG_OPTIONS` being empty today means
37
- * `cephCommandProblem` never reaches this check on any real argv (`namedOptionProblem` refuses
38
- * first) — this stays exercised until K-D's first named option arms the path for real.
43
+ * so the shape itself is directly testable independently of whichever options are named.
39
44
  */
40
45
  export const configValueProblem = (value: string): string | undefined =>
41
46
  CONFIG_VALUE.test(value)
@@ -43,16 +48,19 @@ export const configValueProblem = (value: string): string | undefined =>
43
48
  : `config value ${JSON.stringify(value)} contains characters outside the safe set`;
44
49
 
45
50
  /**
46
- * `undefined` when `name` may be declared, adopted, read by value or written — see
51
+ * `undefined` when `name` may be declared for `who`, adopted, read by value or written — see
47
52
  * ceph-config-options.ts's header for why the list starts empty and stays reviewed line by line.
48
53
  */
49
- const namedOptionProblem = (name: string): string | undefined => {
54
+ export const namedOptionProblem = (who: string, name: string): string | undefined => {
50
55
  if (!OPTION_NAME.test(name)) return `${JSON.stringify(name)} is not a plain ceph option name`;
51
56
  if (isLockoutConfigOption(name))
52
57
  return `${name} is a lockout-class option and can never be declared`;
53
- return NAMED_CONFIG_OPTIONS.has(name)
58
+ if (!NAMED_CONFIG_OPTIONS.has(name))
59
+ return `${name} is not on the named option list (each entry is a reviewed PR line — see ceph-config-options.ts)`;
60
+ const allowed = allowedWhoFor(name);
61
+ return allowed.includes(who)
54
62
  ? undefined
55
- : `${name} is not on the named option list (it starts empty — see ceph-config-options.ts)`;
63
+ : `${name} may only be set for ${allowed.join('/')} on the named option list, not ${JSON.stringify(who)}`;
56
64
  };
57
65
 
58
66
  /** `config get|set|rm <who> <name> [value]`, called with the operands after `config` stripped. */
@@ -64,14 +72,14 @@ export const configProblem = (rest: readonly string[]): string | undefined => {
64
72
  if (name === undefined) return 'config takes a `<name>` from the named option list';
65
73
  if (verb === 'get' || verb === 'rm') {
66
74
  return extra.length === 0
67
- ? namedOptionProblem(name)
75
+ ? namedOptionProblem(who, name)
68
76
  : `config ${String(verb)} takes exactly \`<who> <name>\``;
69
77
  }
70
78
  if (verb === 'set') {
71
79
  const [value, ...more] = extra;
72
80
  if (value === undefined || more.length !== 0)
73
81
  return 'config set takes exactly `<who> <name> <value>`';
74
- const named = namedOptionProblem(name);
82
+ const named = namedOptionProblem(who, name);
75
83
  return named !== undefined ? named : configValueProblem(value);
76
84
  }
77
85
  return `ceph config ${String(verb)} is not on the allowlist`;
@@ -161,7 +161,7 @@ describe('shape mismatches', () => {
161
161
  });
162
162
  });
163
163
 
164
- describe('config: the named list starts empty, so every shape is refused today', () => {
164
+ describe('config: only options on the named list are accepted', () => {
165
165
  test.each([
166
166
  ['get', [CEPH_BIN, 'config', 'get', 'global', 'mon_allow_pool_delete', '-f', 'json']],
167
167
  ['set', [CEPH_BIN, 'config', 'set', 'global', 'mon_allow_pool_delete', 'true', '-f', 'json']],
@@ -197,6 +197,42 @@ describe('config: the named list starts empty, so every shape is refused today',
197
197
  expect(problem).toBeString();
198
198
  });
199
199
 
200
+ const OPT = 'mgr/prometheus/exclude_perf_counters';
201
+ test.each([
202
+ ['get', [CEPH_BIN, 'config', 'get', 'mgr', OPT, '-f', 'json']],
203
+ ['set', [CEPH_BIN, 'config', 'set', 'mgr', OPT, 'false', '-f', 'json']],
204
+ ['rm', [CEPH_BIN, 'config', 'rm', 'mgr', OPT, '-f', 'json']],
205
+ ])('config %s of the first named option, for its own <who>, is accepted', (_verb, argv) => {
206
+ expect(cephCommandProblem(argv)).toBeUndefined();
207
+ });
208
+
209
+ test.each(['global', 'client.admin', 'mon', 'mgr.a'])(
210
+ 'the named option is refused for a <who> it is not listed for (%s)',
211
+ (who) => {
212
+ const problem = cephCommandProblem([
213
+ CEPH_BIN,
214
+ 'config',
215
+ 'set',
216
+ who,
217
+ OPT,
218
+ 'false',
219
+ '-f',
220
+ 'json',
221
+ ]);
222
+ expect(problem).toContain('may only be set for mgr');
223
+ },
224
+ );
225
+
226
+ test('an mgr-module-shaped name not on the list is refused', () => {
227
+ const argv = [CEPH_BIN, 'config', 'set', 'mgr', 'mgr/dashboard/ssl', 'false', '-f', 'json'];
228
+ expect(cephCommandProblem(argv)).toContain('named option list');
229
+ });
230
+
231
+ test('a path-like name beyond mgr/<module>/<option> is refused by shape', () => {
232
+ const argv = [CEPH_BIN, 'config', 'get', 'mgr', 'mgr/prometheus/../x', '-f', 'json'];
233
+ expect(cephCommandProblem(argv)).toContain('not a plain ceph option name');
234
+ });
235
+
200
236
  test('a config value with a shell metacharacter is refused', () => {
201
237
  const argv = [CEPH_BIN, 'config', 'set', 'global', 'x', 'true; rm -rf /', '-f', 'json'];
202
238
  expect(cephCommandProblem(argv)).toBeString();
@@ -187,3 +187,28 @@ export const quorumStatusArgv = (): readonly string[] => [
187
187
  'quorum_status',
188
188
  ...JSON_FORMAT,
189
189
  ];
190
+
191
+ /**
192
+ * `config set <who> <name> <value>` — `Ceph.ConfigOption`'s write. ⚠️ The VALUE is in argv, so it
193
+ * lands in the runner's log and the node's sudo journal; acceptable only because the named option
194
+ * list carries no secret-valued option (ceph-config-options.ts).
195
+ */
196
+ export const configSetArgv = (who: string, name: string, value: string): readonly string[] => [
197
+ CEPH_BIN,
198
+ 'config',
199
+ 'set',
200
+ who,
201
+ name,
202
+ value,
203
+ ...JSON_FORMAT,
204
+ ];
205
+
206
+ /** `config rm <who> <name>` — `Ceph.ConfigOption`'s delete; idempotent on the ceph side. */
207
+ export const configRmArgv = (who: string, name: string): readonly string[] => [
208
+ CEPH_BIN,
209
+ 'config',
210
+ 'rm',
211
+ who,
212
+ name,
213
+ ...JSON_FORMAT,
214
+ ];
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Props, attributes and the pure helpers for `Ceph.ConfigOption` (K-D) — split from
3
+ * config-option.ts the same way ceph-auth-form.ts is split from auth-entity.ts: nothing here
4
+ * talks to a cluster, so every rule is unit-tested without PVE or ssh.
5
+ * Design: docs/plans/2026-09-26-ceph-config-options.md.
6
+ *
7
+ * ⛔ VALIDATION IS THE TRANSPORT'S OWN ALLOWLIST, NOT A SECOND COPY OF IT. `configOptionProblem`
8
+ * builds the exact `config set` argv the write would run and asks `cephCommandProblem` — so the
9
+ * named list (ceph-config-options.ts), the per-option `<who>` bound, the lockout classes and the
10
+ * flag-shaped-value refusal can never disagree between plan time and write time.
11
+ */
12
+ import type { ListNodeCephCfgDbResponseBodyItem } from '@distilled.cloud/proxmox/nodes';
13
+ import type { WithTarget } from '../proxmox/resource-spec.ts';
14
+ import { cephCommandProblem, configSetArgv } from './ceph-argv.ts';
15
+
16
+ export interface CephConfigOptionProps extends WithTarget {
17
+ /**
18
+ * The PVE node whose `GET /nodes/{node}/ceph/cfg/db` answers the mon config db — the plan-time
19
+ * read. Any node of the Ceph cluster answers the same db.
20
+ */
21
+ node: string;
22
+ /** ssh destinations of the mon hosts the write (`config set`/`rm`) is tried against, in order. */
23
+ monNodes: readonly string[];
24
+ /** The config section: `global`, `mon`, `osd`, `mgr`, `mds`, `client`, optionally `.<id>`. */
25
+ who: string;
26
+ /** The option name — must be on `NAMED_CONFIG_OPTIONS` for exactly this `who`. */
27
+ name: string;
28
+ /**
29
+ * The value as ceph stores it — a string, compared byte-for-byte with what the config db reports.
30
+ * ⚠️ Spell it the way ceph echoes it back (`false`, not `0`), or every plan is an `update`.
31
+ */
32
+ value: string;
33
+ }
34
+
35
+ export interface CephConfigOptionAttributes {
36
+ who: string;
37
+ name: string;
38
+ /** The mon-db value observed after the last reconcile. The only field `matches` compares. */
39
+ value: string;
40
+ }
41
+
42
+ /** One row of the mon config db, as the PVE API reports it (`ceph config dump`'s shape). */
43
+ export type CfgDbRow = Pick<
44
+ ListNodeCephCfgDbResponseBodyItem,
45
+ 'section' | 'name' | 'value' | 'mask'
46
+ >;
47
+
48
+ /** `undefined` when props may be declared at all; else why not. Runs before any read or write. */
49
+ export const configOptionProblem = (props: CephConfigOptionProps): string | undefined => {
50
+ if (props.monNodes.length === 0) return 'monNodes must name at least one mon ssh destination';
51
+ const problem = cephCommandProblem(configSetArgv(props.who, props.name, props.value));
52
+ return problem === undefined
53
+ ? undefined
54
+ : `Ceph.ConfigOption ${props.who}/${props.name}: ${problem}`;
55
+ };
56
+
57
+ /**
58
+ * The live row for exactly `{who, name}` with NO mask. ⚠️ A masked row (`osd/class:ssd`) is a
59
+ * different setting the bare `config set <who>` neither writes nor removes, so it is never matched.
60
+ */
61
+ export const findLive = (
62
+ rows: readonly CfgDbRow[],
63
+ who: string,
64
+ name: string,
65
+ ): CephConfigOptionAttributes | undefined => {
66
+ const row = rows.find((r) => r.section === who && r.name === name && (r.mask ?? '') === '');
67
+ return row === undefined ? undefined : { name, value: String(row.value), who };
68
+ };
69
+
70
+ export const matches = (
71
+ live: CephConfigOptionAttributes | undefined,
72
+ props: CephConfigOptionProps,
73
+ ) => live !== undefined && live.value === props.value;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The observe -> ensure -> sync loop and the delete for `Ceph.ConfigOption` (K-D) — plain
3
+ * functions, so a fake-runner test drives the real loop (same split as ceph-auth-reconcile.ts).
4
+ *
5
+ * ★ OBSERVE IS THE PVE API, WRITES ARE THE SSH MON TRANSPORT. The PVE API is read-only for the
6
+ * config db (`GET /nodes/{node}/ceph/cfg/db`; no write endpoint exists), so writes go through
7
+ * ceph-transport.ts's `runCephCommand` — the one transport every Ceph family in this kit uses,
8
+ * allowlist-checked before it dials — and each write is followed by `assertFreshQuorum`.
9
+ * ★ A DEPLOY WITH NO DRIFT MAKES ZERO WRITES (S10). If the observed mon-db value already equals
10
+ * props — an adopted, already-set option included — nothing reaches ssh at all.
11
+ * ⛔ A FAILED READ IS NEVER "ABSENT". The caller's `readRows` propagates its own failure; only a
12
+ * successful read with no matching row means the option is unset.
13
+ * ⛔ READ BACK AFTER EVERY WRITE and refuse to record a value the mon db does not report.
14
+ */
15
+ import * as Effect from 'effect/Effect';
16
+ import { configRmArgv, configSetArgv } from './ceph-argv.ts';
17
+ import {
18
+ type CephConfigOptionAttributes,
19
+ type CephConfigOptionProps,
20
+ type CfgDbRow,
21
+ configOptionProblem,
22
+ findLive,
23
+ matches,
24
+ } from './ceph-config-option-form.ts';
25
+ import { type CephTransportOptions, assertFreshQuorum, runCephCommand } from './ceph-transport.ts';
26
+
27
+ export type CephConfigDeps = Pick<CephTransportOptions, 'dial' | 'log'>;
28
+
29
+ const asError = (cause: unknown): Error =>
30
+ cause instanceof Error ? cause : new Error(String(cause));
31
+
32
+ const runWrite = (transport: CephTransportOptions, argv: readonly string[]) =>
33
+ Effect.gen(function* () {
34
+ const result = yield* Effect.tryPromise({
35
+ catch: asError,
36
+ try: () => runCephCommand(transport, argv),
37
+ });
38
+ if (result.exitCode !== 0) {
39
+ return yield* Effect.fail(
40
+ new Error(
41
+ `ceph ${argv.slice(1, 3).join(' ')} failed on mon ${result.node} (exit ` +
42
+ `${String(result.exitCode)}): ${result.stderr.trim().slice(0, 300)}`,
43
+ ),
44
+ );
45
+ }
46
+ yield* Effect.tryPromise({ catch: asError, try: () => assertFreshQuorum(transport) });
47
+ });
48
+
49
+ export const reconcileCephConfigOption = <E, R>(
50
+ props: CephConfigOptionProps,
51
+ readRows: Effect.Effect<readonly CfgDbRow[], E, R>,
52
+ deps: CephConfigDeps,
53
+ ): Effect.Effect<CephConfigOptionAttributes, E | Error, R> =>
54
+ Effect.gen(function* () {
55
+ const problem = configOptionProblem(props);
56
+ if (problem !== undefined) return yield* Effect.fail(new Error(problem));
57
+
58
+ const live = findLive(yield* readRows, props.who, props.name);
59
+ if (live !== undefined && matches(live, props)) return live;
60
+
61
+ yield* runWrite(
62
+ { nodes: props.monNodes, ...deps },
63
+ configSetArgv(props.who, props.name, props.value),
64
+ );
65
+ const after = findLive(yield* readRows, props.who, props.name);
66
+ if (after === undefined || !matches(after, props)) {
67
+ return yield* Effect.fail(
68
+ new Error(
69
+ `Ceph.ConfigOption ${props.who}/${props.name}: 'ceph config set' succeeded but the mon ` +
70
+ `config db reports ${after === undefined ? 'no value' : JSON.stringify(after.value)}, ` +
71
+ `not ${JSON.stringify(props.value)}. Not recorded — check \`ceph config dump\`.`,
72
+ ),
73
+ );
74
+ }
75
+ return after;
76
+ });
77
+
78
+ /** Idempotent: an option already absent from the mon db is success, with nothing sent. */
79
+ export const deleteCephConfigOption = <E, R>(
80
+ props: Pick<CephConfigOptionProps, 'who' | 'name' | 'monNodes'>,
81
+ readRows: Effect.Effect<readonly CfgDbRow[], E, R>,
82
+ deps: CephConfigDeps,
83
+ ): Effect.Effect<void, E | Error, R> =>
84
+ Effect.gen(function* () {
85
+ const live = findLive(yield* readRows, props.who, props.name);
86
+ if (live === undefined) return;
87
+ yield* runWrite({ nodes: props.monNodes, ...deps }, configRmArgv(props.who, props.name));
88
+ });