@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,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
+ });
@@ -1,19 +1,32 @@
1
1
  /**
2
2
  * Structural guard on the named config-option list itself (mon-transport doc acceptance test #3):
3
- * the list contains none of the lockout classes, and it starts empty (D-C1).
3
+ * the list contains none of the lockout classes, and each entry is one reviewed line (D-C1).
4
4
  */
5
5
  import { describe, expect, test } from 'bun:test';
6
- import { NAMED_CONFIG_OPTIONS, isLockoutConfigOption } from './ceph-config-options.ts';
6
+ import {
7
+ NAMED_CONFIG_OPTIONS,
8
+ allowedWhoFor,
9
+ isLockoutConfigOption,
10
+ } from './ceph-config-options.ts';
7
11
 
8
12
  describe('NAMED_CONFIG_OPTIONS', () => {
9
- test('starts empty', () => {
10
- expect(NAMED_CONFIG_OPTIONS.size).toBe(0);
13
+ test('the first reviewed entry is the mgr prometheus perf-counter switch, for mgr only', () => {
14
+ expect(NAMED_CONFIG_OPTIONS.get('mgr/prometheus/exclude_perf_counters')).toEqual(['mgr']);
15
+ expect(allowedWhoFor('mgr/prometheus/exclude_perf_counters')).toEqual(['mgr']);
16
+ expect(allowedWhoFor('mon_allow_pool_delete')).toEqual([]);
11
17
  });
12
18
 
13
19
  test('contains none of the lockout classes, whatever gets added later', () => {
14
- const bad = [...NAMED_CONFIG_OPTIONS].filter((name) => isLockoutConfigOption(name));
20
+ const bad = [...NAMED_CONFIG_OPTIONS.keys()].filter((name) => isLockoutConfigOption(name));
15
21
  expect(bad).toEqual([]);
16
22
  });
23
+
24
+ test('every entry names at least one bounded <who>', () => {
25
+ for (const [name, whos] of NAMED_CONFIG_OPTIONS) {
26
+ expect([name, whos.length > 0]).toEqual([name, true]);
27
+ for (const who of whos) expect(who).toMatch(/^(global|mon|osd|mgr|mds|client)(\.[\w-]+)?$/);
28
+ }
29
+ });
17
30
  });
18
31
 
19
32
  describe('isLockoutConfigOption', () => {
@@ -31,14 +44,18 @@ describe('isLockoutConfigOption', () => {
31
44
  'keyring_path',
32
45
  'key',
33
46
  'mon_data_key',
47
+ 'mgr/dashboard/key',
48
+ 'mgr/restful/keyring',
34
49
  ])('flags %s', (name) => {
35
50
  expect(isLockoutConfigOption(name)).toBe(true);
36
51
  });
37
52
 
38
- test.each(['mon_allow_pool_delete', 'osd_pool_default_size', 'mon_max_pg_per_osd'])(
39
- 'does not flag an ordinary tuning option like %s',
40
- (name) => {
41
- expect(isLockoutConfigOption(name)).toBe(false);
42
- },
43
- );
53
+ test.each([
54
+ 'mgr/prometheus/exclude_perf_counters',
55
+ 'mon_allow_pool_delete',
56
+ 'osd_pool_default_size',
57
+ 'mon_max_pg_per_osd',
58
+ ])('does not flag an ordinary tuning option like %s', (name) => {
59
+ expect(isLockoutConfigOption(name)).toBe(false);
60
+ });
44
61
  });
@@ -3,10 +3,14 @@
3
3
  * through the mon-command transport (ceph-argv.ts's `configProblem`). Design:
4
4
  * docs/plans/2026-09-26-ceph-config-options.md, D-C1.
5
5
  *
6
- * ⛔ THE LIST STARTS EMPTY, AND EACH ADDITION IS ITS OWN REVIEWED PR LINE. Nothing in K-A4 adds to
7
- * it — `CephConfigOption` (K-D) is a separate, later family; this file exists now only so the
8
- * transport's allowlist has something to bound `config` against from day one, per the accepted
9
- * design ("config get/set for the named option list which starts EMPTY").
6
+ * ⛔ THE LIST STARTED EMPTY, AND EACH ADDITION IS ITS OWN REVIEWED PR LINE. `Ceph.ConfigOption`
7
+ * (K-D, config-option.ts) is the only declarer; it refuses any `{who, name}` this map does not
8
+ * carry before planning, and the transport's allowlist (ceph-argv-config.ts) refuses it again
9
+ * before anything dials. One entry per line, each with a comment saying what it does and why it
10
+ * is managed — never a batch.
11
+ * ★ EACH ENTRY ALSO NAMES THE `<who>` IT MAY BE SET FOR. This is the config-options design's
12
+ * acceptance test #1 ("`<who>` bounded to the declared daemons"): an option listed for `mgr`
13
+ * can never be written to `global`, `client.admin` or any other section through this transport.
10
14
  * ⛔ NEVER ADD ONE OF THE LOCKOUT CLASSES BELOW. `mon_host`, `public_network`, `cluster_network`,
11
15
  * `public_addr`, `cluster_addr`, every `auth_*_required` and every `key`/`keyring`-bearing
12
16
  * option re-address the mons or de-authenticate the whole cluster — cutting every VM disk — and
@@ -17,8 +21,22 @@
17
21
  * never a `ceph config set` argv, which lands in the runner's log and the node's sudo journal.
18
22
  */
19
23
 
20
- /** Starts empty. Add one reviewed option name per PR line, never a batch. */
21
- export const NAMED_CONFIG_OPTIONS: ReadonlySet<string> = new Set([]);
24
+ /**
25
+ * option name -> the `<who>` sections it may be set for. Add one reviewed line per PR, never a
26
+ * batch; the structural test (ceph-config-options.test.ts) runs the lockout guard over every key.
27
+ */
28
+ export const NAMED_CONFIG_OPTIONS: ReadonlyMap<string, readonly string[]> = new Map<
29
+ string,
30
+ readonly string[]
31
+ >([
32
+ // mgr prometheus module: `false` makes it export per-daemon perf counters (ceph_osd_op_*,
33
+ // ceph_rgw_* …) instead of omitting them. Set live 2026-10, codified here so plan reconciles it.
34
+ ['mgr/prometheus/exclude_perf_counters', ['mgr']],
35
+ ]);
36
+
37
+ /** The `<who>` sections `name` may be set for — empty when the option is not on the list. */
38
+ export const allowedWhoFor = (name: string): readonly string[] =>
39
+ NAMED_CONFIG_OPTIONS.get(name) ?? [];
22
40
 
23
41
  const LOCKOUT_EXACT: ReadonlySet<string> = new Set([
24
42
  'mon_host',
@@ -28,8 +46,11 @@ const LOCKOUT_EXACT: ReadonlySet<string> = new Set([
28
46
  'cluster_addr',
29
47
  ]);
30
48
 
31
- /** `auth_*_required` (cephx enforcement) and anything key/keyring-shaped, either side of `_`. */
32
- const LOCKOUT_PATTERN = /^auth_\w*_required$|(^|_)key(ring)?($|_)/;
49
+ /**
50
+ * `auth_*_required` (cephx enforcement) and anything key/keyring-shaped, bounded by `_` or by the
51
+ * `/` that separates an mgr module option (`mgr/<module>/<option>`) — so `mgr/x/key` is caught too.
52
+ */
53
+ const LOCKOUT_PATTERN = /^auth_\w*_required$|(^|[_/])key(ring)?($|[_/])/;
33
54
 
34
55
  /** Whether `name` is a class this transport must never read a value for, adopt, or write. */
35
56
  export const isLockoutConfigOption = (name: string): boolean =>
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `Ceph.ConfigOption` (K-D) — one `{who, name} = value` row of the Ceph mon config db, declared.
3
+ * Design: docs/plans/2026-09-26-ceph-config-options.md. Pure helpers: ceph-config-option-form.ts;
4
+ * the loop: ceph-config-option-reconcile.ts; the allowlist: ceph-argv-config.ts.
5
+ *
6
+ * ★ ONE RESOURCE PER OPTION, AND ONLY OPTIONS ON THE NAMED LIST. `NAMED_CONFIG_OPTIONS`
7
+ * (ceph-config-options.ts) is the gate: each entry is its own reviewed PR line and names the
8
+ * `<who>` it may be set for. Anything else is refused at `diff` (so the plan fails loudly) and
9
+ * again by the transport before anything dials.
10
+ * ★ PLAN READS OVER THE PVE API, NEVER SSH. `read`/`diff` call `GET /nodes/{node}/ceph/cfg/db`
11
+ * (read lease only — "a plan never elevates"). Only `reconcile`/`delete` reach ssh, through
12
+ * ceph-transport.ts — the existing mon-command transport `Ceph.AuthEntity` uses, not a new one.
13
+ * ★ ADOPTION OF AN ALREADY-SET OPTION IS A ZERO-WRITE NO-OP. A cold `read` of a live row answers
14
+ * `Unowned(attrs)` (the house rule for a marker-less API, H1), so a live option needs
15
+ * `adopt(true)` on its row; `reconcile` then observes the matching value and writes nothing.
16
+ * ⛔ Fix-live-then-codify is fine for this family exactly because of that: the plan proves it.
17
+ * ⚠️ PRECONDITION BEFORE DECLARING THE WHOLE CONFIG DB: the dead `public_addr` entries
18
+ * (homeflare-proxmox docs/completeness-tb4-sdn-ceph.md, T3) must be removed by hand with
19
+ * `ceph config rm` first, so fossils are never adopted. `public_addr` is a lockout class and can
20
+ * never be declared here; a single named option (like the first one) is not blocked by it.
21
+ * ⚠️ THE COMPARED VALUE IS THE MON DB's, not the effective one: a ceph.conf override or a compiled
22
+ * default is out of scope (CephConf is its own family).
23
+ * ⚠️ `defaultRemovalPolicy: 'retain'`: removing a row stops managing it; `config rm` runs only when
24
+ * a removal policy of `destroy` is chosen deliberately for that row.
25
+ */
26
+ import * as nodes from '@distilled.cloud/proxmox/nodes';
27
+ import { Resource } from 'alchemy';
28
+ import { Unowned } from 'alchemy/AdoptPolicy';
29
+ import { isResolved } from 'alchemy/Diff';
30
+ import * as Provider from 'alchemy/Provider';
31
+ import * as Effect from 'effect/Effect';
32
+ import { mintTier } from '../proxmox/credentials.ts';
33
+ import { runPve } from '../proxmox/distilled-pve.ts';
34
+ import type { PveRequirements } from '../proxmox/resource-spec.ts';
35
+ import { UNREADABLE, readOrUnreadable, unreadableWarning } from '../proxmox/unreadable-read.ts';
36
+ import {
37
+ type CephConfigOptionAttributes,
38
+ type CephConfigOptionProps,
39
+ configOptionProblem,
40
+ findLive,
41
+ matches,
42
+ } from './ceph-config-option-form.ts';
43
+ import {
44
+ deleteCephConfigOption,
45
+ reconcileCephConfigOption,
46
+ } from './ceph-config-option-reconcile.ts';
47
+
48
+ export type {
49
+ CephConfigOptionAttributes,
50
+ CephConfigOptionProps,
51
+ } from './ceph-config-option-form.ts';
52
+
53
+ export interface CephConfigOption extends Resource<
54
+ 'Ceph.ConfigOption',
55
+ CephConfigOptionProps,
56
+ CephConfigOptionAttributes,
57
+ never,
58
+ PveRequirements
59
+ > {}
60
+
61
+ export const CephConfigOption = Resource<CephConfigOption>('Ceph.ConfigOption', {
62
+ defaultRemovalPolicy: 'retain',
63
+ });
64
+
65
+ /** The whole mon config db, over the PVE API, on a read lease. */
66
+ const readRows = (props: Pick<CephConfigOptionProps, 'target' | 'node'>) =>
67
+ runPve(props.target, 'read', false, nodes.listNodeCephCfgDb({ node: props.node }));
68
+
69
+ const refuse = (props: CephConfigOptionProps) => {
70
+ const problem = configOptionProblem(props);
71
+ return problem === undefined ? Effect.void : Effect.fail(new Error(problem));
72
+ };
73
+
74
+ export const CephConfigOptionProvider = () =>
75
+ Provider.effect(
76
+ CephConfigOption,
77
+ Effect.succeed(
78
+ CephConfigOption.Provider.of({
79
+ list: () => Effect.succeed([]),
80
+
81
+ read: Effect.fn(function* ({ olds, output }) {
82
+ const live = findLive(yield* readRows(olds), olds.who, olds.name);
83
+ if (live === undefined) return undefined;
84
+ return output !== undefined ? live : Unowned(live);
85
+ }),
86
+
87
+ diff: Effect.fn(function* ({ news, output }) {
88
+ if (!isResolved(news)) return undefined;
89
+ yield* refuse(news);
90
+ if (output === undefined) return undefined;
91
+ if (output.who !== news.who || output.name !== news.name)
92
+ return { action: 'replace' } as const;
93
+ const rows = yield* readOrUnreadable(readRows(news));
94
+ if (rows === UNREADABLE) {
95
+ yield* unreadableWarning(
96
+ 'Ceph.ConfigOption',
97
+ `${news.who}/${news.name}`,
98
+ news.target.mount,
99
+ mintTier(news.target, 'read'),
100
+ );
101
+ return { action: 'noop' } as const;
102
+ }
103
+ const live = findLive(rows, news.who, news.name);
104
+ return { action: matches(live, news) ? 'noop' : 'update' } as const;
105
+ }),
106
+
107
+ reconcile: ({ news }) => reconcileCephConfigOption(news, readRows(news), {}),
108
+
109
+ delete: ({ olds }) => deleteCephConfigOption(olds, readRows(olds), {}),
110
+ }),
111
+ ),
112
+ );
package/src/ceph/index.ts CHANGED
@@ -1,12 +1,16 @@
1
1
  /**
2
- * Ceph providers for Alchemy — the mon-command transport and `Ceph.AuthEntity` (K-A4).
2
+ * Ceph providers for Alchemy — the mon-command transport, `Ceph.AuthEntity` (K-A4) and
3
+ * `Ceph.ConfigOption` (K-D).
3
4
  *
4
5
  * ⛔ THIS BARREL IS THE PUBLIC API, DELIBERATELY SMALLER THAN THE DIRECTORY — see
5
6
  * `../openbao/index.ts`'s header for why. The allowlist internals (ceph-argv.ts), the parsing
6
- * (ceph-auth-parse.ts) and the reconcile loop (ceph-auth-reconcile.ts) are exported by path for
7
- * a future `CephConfigOption` (K-D) or a test to reach directly, not published here.
7
+ * (ceph-auth-parse.ts) and the reconcile loops (ceph-auth-reconcile.ts,
8
+ * ceph-config-option-reconcile.ts) are reached by path from tests, not published here.
8
9
  */
9
10
  export type { CephAuthEntityAttributes, CephAuthEntityProps, CephCaps } from './auth-entity.ts';
10
11
  export { CephAuthEntity, CephAuthEntityProvider } from './auth-entity.ts';
11
12
  export type { CephDial, CephExecResult, CephTransportOptions } from './ceph-transport.ts';
12
13
  export { assertFreshQuorum, runCephCommand, sshCephDial } from './ceph-transport.ts';
14
+ export type { CephConfigOptionAttributes, CephConfigOptionProps } from './config-option.ts';
15
+ export { CephConfigOption, CephConfigOptionProvider } from './config-option.ts';
16
+ export { NAMED_CONFIG_OPTIONS } from './ceph-config-options.ts';
@@ -72,9 +72,15 @@ export const canRead = (
72
72
  /**
73
73
  * ⛔ REFUSE A FILE THE DEPLOYING USER COULD NOT READ BACK, before writing it. Both providers read
74
74
  * every write back to verify it (host-file-lifecycle.ts, job-lifecycle.ts) and read it again at
75
- * every plan, as the deploying user; this runner elevates writes, never reads. A root-only
76
- * `0600` would be installed, then fail the read-back with EACCES and stay on disk with no state,
77
- * so every later plan would fail too.
75
+ * every plan, as the deploying user; a runner that elevates writes but no reads strands a
76
+ * root-only `0600` on disk — installed, failing the read-back with EACCES, with no state, so
77
+ * every later plan would fail too.
78
+ * ★ `readAsRoot` IS THE ONE EXCEPTION, and it is not this guard's to grant: the caller passes it
79
+ * only when the RUNNER reads this exact path back through a privileged read (the linux
80
+ * sshSudoRunner's `privilegedReads` list names the file, so its readFile goes through
81
+ * `sudo -n base64`). The read-back then cannot fail with EACCES whatever the mode is, and the
82
+ * refusal would be false; every other check — setuid/setgid, group-writable, the path chain —
83
+ * still applies to that file exactly as before.
78
84
  * ★ An omitted owner is root (under a prefix the runner writes as root). An omitted group is the
79
85
  * parent directory's, which is the group a new file gets on macOS.
80
86
  * ⚠️ Unknown groups never refuse: the read-back after the write stays the final judge.
@@ -84,7 +90,9 @@ export const assertReadableBack = async (
84
90
  path: string,
85
91
  options: WriteOptions,
86
92
  groups: readonly number[] | undefined,
93
+ readAsRoot = false,
87
94
  ): Promise<void> => {
95
+ if (readAsRoot) return;
88
96
  const operator = base.effectiveUid();
89
97
  const parent = path.slice(0, path.lastIndexOf('/')) || '/';
90
98
  const gid = options.gid ?? (await base.stat(parent))?.gid ?? 0;
@@ -13,6 +13,7 @@
13
13
  * separate "staged bytes" side-channel to model, unlike the Mac fake's local temp files.
14
14
  */
15
15
  import type { ExecResult, FileStat } from '../launchd/runner.ts';
16
+ import { BASE64 } from './sudo-runner.ts';
16
17
  import { CHMOD, CHOWN, INSTALL, MKDIR, MV, RM, RMDIR, SYSTEMCTL_ABS } from './sudo-allowlist.ts';
17
18
  import type { fakeLinuxHost } from './fake-linux-host.ts';
18
19
 
@@ -113,6 +114,20 @@ const fileRoot = (
113
114
  if (path !== undefined) fake.files.delete(path);
114
115
  return ok();
115
116
  }
117
+ /**
118
+ * ★ `sudo -n base64 -- <path>` — the elevated read itself (sudo-runner.ts's readFile), so a
119
+ * test can join the two halves: a hidden-mode file just written through the skipped guard
120
+ * reads back AS ROOT here, never EACCES. Refuses exactly what the runner's own pre-elevation
121
+ * check refuses: a root-owned REGULAR file, nothing else.
122
+ */
123
+ if (program === BASE64) {
124
+ const [, path] = args;
125
+ const entry = path === undefined ? undefined : fake.files.get(path);
126
+ if (entry === undefined || entry.kind !== 'file' || entry.uid !== 0) {
127
+ return fail(1, `base64: '${String(path)}': No such file or directory`);
128
+ }
129
+ return ok(`${Buffer.from(entry.bytes).toString('base64')}\n`);
130
+ }
116
131
  return undefined;
117
132
  };
118
133
 
@@ -59,6 +59,14 @@ export type FakeSudoHostOptions = {
59
59
  readonly hostFactory?: (options: FakeLinuxOptions) => ReturnType<typeof fakeLinuxHost>;
60
60
  /** The runner's own declared prefixes. @default PREFIXES */
61
61
  readonly prefixes?: readonly string[];
62
+ /** The runner's exact-path elevated reads (`sshSudoRunner`'s `privilegedReads`). */
63
+ readonly privilegedReads?: readonly string[];
64
+ /**
65
+ * What `/usr/bin/id -G` answers (sudo-guard-mode.ts's `operatorGroups`). Unset, the fake has
66
+ * no `id` and `operatorGroups` reads `undefined` — "unknown groups never refuse" — which is
67
+ * what every pre-existing test relied on; set it to make the read-back refusal decidable.
68
+ */
69
+ readonly operatorGroups?: readonly number[];
62
70
  };
63
71
 
64
72
  /**
@@ -69,7 +77,12 @@ export type FakeSudoHostOptions = {
69
77
  export const fakeSudoHost = (
70
78
  extraDirs: Record<string, number> = {},
71
79
  acl: ReadonlySet<string> = new Set(),
72
- { hostFactory = fakeLinuxHost, prefixes = PREFIXES }: FakeSudoHostOptions = {},
80
+ {
81
+ hostFactory = fakeLinuxHost,
82
+ prefixes = PREFIXES,
83
+ privilegedReads,
84
+ operatorGroups: idGroups,
85
+ }: FakeSudoHostOptions = {},
73
86
  ) => {
74
87
  const dirs: Record<string, number> = {};
75
88
  for (const prefix of prefixes) dirs[prefix] = 0;
@@ -131,6 +144,11 @@ export const fakeSudoHost = (
131
144
  return handleMktemp();
132
145
  }
133
146
  if (argv[0] === 'rm' || argv[0] === '/usr/bin/rm') return handleRm(argv);
147
+ // ★ Only modelled when a test asks for it: unset, `id` stays unknown and the read-back
148
+ // guard keeps its "unknown groups never refuse" behavior (see the option's docs).
149
+ if (argv[0] === '/usr/bin/id' && argv[1] === '-G' && idGroups !== undefined) {
150
+ return ok(`${idGroups.join(' ')}\n`);
151
+ }
134
152
  if (argv[0] === '/usr/bin/systemctl')
135
153
  return fake.runner.exec(['systemctl', ...argv.slice(1)]);
136
154
  if (argv[0] !== SUDO) return fake.runner.exec(argv);
@@ -146,6 +164,7 @@ export const fakeSudoHost = (
146
164
  const runner = makeSshSudoRunner(prefixes, {
147
165
  base,
148
166
  log: (line) => logs.push(line),
167
+ ...(privilegedReads === undefined ? {} : { privilegedReads }),
149
168
  // ★ The real stager, unmodified: it only ever calls `base.exec`/`base.writeFileAtomic`, both
150
169
  // of which this fake's `mktemp`/`ls`/operator-level paths already answer.
151
170
  stage: stageRemote(base),
@@ -121,6 +121,15 @@ export const assertGuardedChain = async (
121
121
  * The mode/owner and read-back checks for a file write under `prefix`, plus the path chain.
122
122
  * ★ Reads only, as the operator — the same checks run at plan time (`checkWrite`) and before the
123
123
  * install/mv pair. `expect` is `'file-or-absent'`: a write may create or replace.
124
+ * ★ `privilegedReads` is the runner's exact-path elevated-read set (sudo-runner.ts): a file on it
125
+ * is read back through `sudo -n base64`, so it cannot be stranded by its own mode — every
126
+ * other refusal below applies to it unchanged.
127
+ * ⛔ A LISTED FILE MAY NEVER BE HANDED TO A NON-ROOT UID, whatever its mode: its read back is
128
+ * the elevated one, which refuses anything but a root-owned regular file, so a handoff with
129
+ * operator-readable other bits (0644 uid 1001) would sail through the plain read-back check,
130
+ * land, and die at EVERY later read — the stranding this guard exists to prevent (glm seat
131
+ * round, 2026-10-18). Refused up front instead. An omitted owner is root under a prefix, so
132
+ * plain root writes are untouched.
124
133
  */
125
134
  export const assertInstallable = async (
126
135
  base: HostRunner,
@@ -128,6 +137,7 @@ export const assertInstallable = async (
128
137
  path: string,
129
138
  options: WriteOptions,
130
139
  groups: readonly number[] | undefined,
140
+ privilegedReads?: ReadonlySet<string>,
131
141
  ): Promise<void> => {
132
142
  const mode = modeProblem(options);
133
143
  if (mode !== undefined) {
@@ -137,6 +147,17 @@ export const assertInstallable = async (
137
147
  'real owner. Nothing ran as root.',
138
148
  );
139
149
  }
150
+ const listed = privilegedReads?.has(path) === true;
151
+ const uid = options.uid ?? 0;
152
+ if (listed && uid !== 0) {
153
+ throw refuse(
154
+ path,
155
+ `is on the runner's privilegedReads list, so its read back is the elevated one — which ` +
156
+ `refuses anything but a root-owned regular file. Handing it to uid ${String(uid)} would ` +
157
+ 'land and then fail every read. Keep it root-owned, or take it off the list. Nothing ' +
158
+ 'ran as root.',
159
+ );
160
+ }
140
161
  await assertGuardedChain(base, prefix, path, 'file-or-absent');
141
- await assertReadableBack(base, path, options, groups);
162
+ await assertReadableBack(base, path, options, groups, listed && uid === 0);
142
163
  };