@homeflare/alchemy 0.54.0 → 0.55.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 (43) 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/linux/index.js +33 -2
  18. package/dist/linux/index.js.map +3 -3
  19. package/dist/linux/sudo-runner.d.ts +12 -0
  20. package/dist/linux/sudo-runner.d.ts.map +1 -1
  21. package/dist/proxmox/index.d.ts +1 -0
  22. package/dist/proxmox/index.d.ts.map +1 -1
  23. package/dist/proxmox/index.js +101 -1
  24. package/dist/proxmox/index.js.map +5 -4
  25. package/dist/proxmox/scoped-api.d.ts +49 -0
  26. package/dist/proxmox/scoped-api.d.ts.map +1 -0
  27. package/docs/pve-scoped-api.md +23 -0
  28. package/package.json +1 -1
  29. package/src/ceph/ceph-argv-config.ts +27 -19
  30. package/src/ceph/ceph-argv.test.ts +37 -1
  31. package/src/ceph/ceph-argv.ts +25 -0
  32. package/src/ceph/ceph-config-option-form.ts +73 -0
  33. package/src/ceph/ceph-config-option-reconcile.ts +88 -0
  34. package/src/ceph/ceph-config-option.test.ts +213 -0
  35. package/src/ceph/ceph-config-options.test.ts +28 -11
  36. package/src/ceph/ceph-config-options.ts +29 -8
  37. package/src/ceph/config-option.ts +112 -0
  38. package/src/ceph/index.ts +7 -3
  39. package/src/linux/sudo-runner-privileged-read.test.ts +92 -0
  40. package/src/linux/sudo-runner.ts +54 -1
  41. package/src/proxmox/index.ts +13 -0
  42. package/src/proxmox/scoped-api.test.ts +52 -0
  43. package/src/proxmox/scoped-api.ts +148 -0
@@ -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,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.55.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
+ });
@@ -0,0 +1,213 @@
1
+ /**
2
+ * `Ceph.ConfigOption` (K-D): validation against the named list, the reconcile/delete loop against
3
+ * fakeCephDial (no ssh) and a canned config-db read (no PVE). Config-options design acceptance
4
+ * tests #1 (allowlist) and #2 (fake-runner loop).
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import * as Effect from 'effect/Effect';
8
+ import type { PveTarget } from '../proxmox/credentials.ts';
9
+ import {
10
+ type CephConfigOptionProps,
11
+ type CfgDbRow,
12
+ configOptionProblem,
13
+ findLive,
14
+ } from './ceph-config-option-form.ts';
15
+ import {
16
+ deleteCephConfigOption,
17
+ reconcileCephConfigOption,
18
+ } from './ceph-config-option-reconcile.ts';
19
+ import { fakeCephDial, fakeCephOk } from './fake-ceph-dial.ts';
20
+
21
+ const MON = 'mon-a.example.test';
22
+ const NAME = 'mgr/prometheus/exclude_perf_counters';
23
+ const TARGET: PveTarget = { members: ['pve-a.example.test'], mount: 'proxmox-test', scheme: 'pve' };
24
+ const PROPS: CephConfigOptionProps = {
25
+ monNodes: [MON],
26
+ name: NAME,
27
+ node: 'pve-a',
28
+ target: TARGET,
29
+ value: 'false',
30
+ who: 'mgr',
31
+ };
32
+ const HEALTHY_QUORUM = '{"quorum":[0,1,2]}';
33
+ const row = (value: string, extra: Partial<CfgDbRow> = {}): CfgDbRow => ({
34
+ mask: '',
35
+ name: NAME,
36
+ section: 'mgr',
37
+ value,
38
+ ...extra,
39
+ });
40
+
41
+ /** A config-db read that answers each scripted snapshot in turn, last one repeating. */
42
+ const rowsSeq = (...snapshots: (readonly CfgDbRow[])[]) => {
43
+ let at = 0;
44
+ return Effect.sync(() => snapshots[Math.min(at++, snapshots.length - 1)] ?? []);
45
+ };
46
+ const argvOf = (seen: readonly { readonly argv: readonly string[] }[]) => seen.map((c) => c.argv);
47
+ const run = <A>(effect: Effect.Effect<A, unknown>) => Effect.runPromise(effect);
48
+
49
+ describe('validation against the named option list', () => {
50
+ test('accepts the first named option for its own <who>', () => {
51
+ expect(configOptionProblem(PROPS)).toBeUndefined();
52
+ });
53
+
54
+ test('rejects a key that is not on the list', () => {
55
+ expect(configOptionProblem({ ...PROPS, name: 'osd_max_backfills', who: 'osd' })).toContain(
56
+ 'not on the named option list',
57
+ );
58
+ });
59
+
60
+ test('rejects a listed key for an unlisted <who>', () => {
61
+ expect(configOptionProblem({ ...PROPS, who: 'global' })).toContain('may only be set for mgr');
62
+ });
63
+
64
+ test('rejects a lockout-class key and a flag-shaped value', () => {
65
+ expect(configOptionProblem({ ...PROPS, name: 'public_addr', who: 'mon' })).toContain('lockout');
66
+ expect(configOptionProblem({ ...PROPS, value: '--conf=/x' })).toContain('safe set');
67
+ });
68
+
69
+ test('rejects an empty mon list', () => {
70
+ expect(configOptionProblem({ ...PROPS, monNodes: [] })).toContain('monNodes');
71
+ });
72
+ });
73
+
74
+ describe('findLive maps the config-db read', () => {
75
+ test('the unmasked row for exactly {who, name}', () => {
76
+ expect(findLive([row('false')], 'mgr', NAME)).toEqual({
77
+ name: NAME,
78
+ value: 'false',
79
+ who: 'mgr',
80
+ });
81
+ });
82
+
83
+ test('a masked row, another section or another name is not this option', () => {
84
+ const rows = [
85
+ row('true', { mask: 'host:a' }),
86
+ row('true', { section: 'global' }),
87
+ row('true', { name: 'mgr/prometheus/x' }),
88
+ ];
89
+ expect(findLive(rows, 'mgr', NAME)).toBeUndefined();
90
+ });
91
+ });
92
+
93
+ describe('reconcile', () => {
94
+ test('live already matches (the codified exclude_perf_counters=false): zero ssh calls', async () => {
95
+ const fake = fakeCephDial({ [MON]: [fakeCephOk('')] });
96
+ const attrs = await run(
97
+ reconcileCephConfigOption(PROPS, rowsSeq([row('false')]), { dial: fake.dial, log: () => {} }),
98
+ );
99
+ expect(attrs).toEqual({ name: NAME, value: 'false', who: 'mgr' });
100
+ expect(fake.seen).toHaveLength(0);
101
+ });
102
+
103
+ test('absent: exactly one config set, then a fresh quorum check, then read back', async () => {
104
+ const fake = fakeCephDial({ [MON]: [fakeCephOk(''), fakeCephOk(HEALTHY_QUORUM)] });
105
+ const attrs = await run(
106
+ reconcileCephConfigOption(PROPS, rowsSeq([], [row('false')]), {
107
+ dial: fake.dial,
108
+ log: () => {},
109
+ }),
110
+ );
111
+ expect(attrs.value).toBe('false');
112
+ expect(argvOf(fake.seen)).toEqual([
113
+ ['/usr/bin/ceph', 'config', 'set', 'mgr', NAME, 'false', '-f', 'json'],
114
+ ['/usr/bin/ceph', 'quorum_status', '-f', 'json'],
115
+ ]);
116
+ });
117
+
118
+ test('drifted value: the update is the same single config set', async () => {
119
+ const fake = fakeCephDial({ [MON]: [fakeCephOk(''), fakeCephOk(HEALTHY_QUORUM)] });
120
+ await run(
121
+ reconcileCephConfigOption(PROPS, rowsSeq([row('true')], [row('false')]), {
122
+ dial: fake.dial,
123
+ log: () => {},
124
+ }),
125
+ );
126
+ expect(argvOf(fake.seen)[0]).toEqual([
127
+ '/usr/bin/ceph',
128
+ 'config',
129
+ 'set',
130
+ 'mgr',
131
+ NAME,
132
+ 'false',
133
+ '-f',
134
+ 'json',
135
+ ]);
136
+ expect(fake.seen).toHaveLength(2);
137
+ });
138
+
139
+ test('a read-back that disagrees is refused, never recorded', async () => {
140
+ const fake = fakeCephDial({ [MON]: [fakeCephOk(''), fakeCephOk(HEALTHY_QUORUM)] });
141
+ await expect(
142
+ run(
143
+ reconcileCephConfigOption(PROPS, rowsSeq([row('true')]), {
144
+ dial: fake.dial,
145
+ log: () => {},
146
+ }),
147
+ ),
148
+ ).rejects.toThrow(/Not recorded/);
149
+ });
150
+
151
+ test('a non-listed key is refused before the read and before any ssh', async () => {
152
+ const fake = fakeCephDial({ [MON]: [fakeCephOk('')] });
153
+ let reads = 0;
154
+ const readRows = Effect.sync(() => {
155
+ reads++;
156
+ return [] as CfgDbRow[];
157
+ });
158
+ await expect(
159
+ run(
160
+ reconcileCephConfigOption({ ...PROPS, name: 'osd_max_backfills', who: 'osd' }, readRows, {
161
+ dial: fake.dial,
162
+ log: () => {},
163
+ }),
164
+ ),
165
+ ).rejects.toThrow(/named option list/);
166
+ expect(reads).toBe(0);
167
+ expect(fake.seen).toHaveLength(0);
168
+ });
169
+
170
+ test('a failed config-db read propagates, never treated as absent', async () => {
171
+ const fake = fakeCephDial({ [MON]: [fakeCephOk('')] });
172
+ await expect(
173
+ run(
174
+ reconcileCephConfigOption(PROPS, Effect.fail(new Error('pve down')), {
175
+ dial: fake.dial,
176
+ log: () => {},
177
+ }),
178
+ ),
179
+ ).rejects.toThrow(/pve down/);
180
+ expect(fake.seen).toHaveLength(0);
181
+ });
182
+
183
+ test('a degraded quorum after the write fails the row', async () => {
184
+ const fake = fakeCephDial({ [MON]: [fakeCephOk(''), fakeCephOk('{"quorum":[]}')] });
185
+ await expect(
186
+ run(
187
+ reconcileCephConfigOption(PROPS, rowsSeq([], [row('false')]), {
188
+ dial: fake.dial,
189
+ log: () => {},
190
+ }),
191
+ ),
192
+ ).rejects.toThrow(/degraded/);
193
+ });
194
+ });
195
+
196
+ describe('delete', () => {
197
+ test('present: exactly one config rm, then a quorum check', async () => {
198
+ const fake = fakeCephDial({ [MON]: [fakeCephOk(''), fakeCephOk(HEALTHY_QUORUM)] });
199
+ await run(
200
+ deleteCephConfigOption(PROPS, rowsSeq([row('false')]), { dial: fake.dial, log: () => {} }),
201
+ );
202
+ expect(argvOf(fake.seen)).toEqual([
203
+ ['/usr/bin/ceph', 'config', 'rm', 'mgr', NAME, '-f', 'json'],
204
+ ['/usr/bin/ceph', 'quorum_status', '-f', 'json'],
205
+ ]);
206
+ });
207
+
208
+ test('already absent: idempotent, nothing sent', async () => {
209
+ const fake = fakeCephDial({ [MON]: [fakeCephOk('')] });
210
+ await run(deleteCephConfigOption(PROPS, rowsSeq([]), { dial: fake.dial, log: () => {} }));
211
+ expect(fake.seen).toHaveLength(0);
212
+ });
213
+ });