@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.
- package/dist/ceph/ceph-argv-config.d.ts +6 -3
- package/dist/ceph/ceph-argv-config.d.ts.map +1 -1
- package/dist/ceph/ceph-argv.d.ts +8 -0
- package/dist/ceph/ceph-argv.d.ts.map +1 -1
- package/dist/ceph/ceph-config-option-form.d.ts +48 -0
- package/dist/ceph/ceph-config-option-form.d.ts.map +1 -0
- package/dist/ceph/ceph-config-option-reconcile.d.ts +22 -0
- package/dist/ceph/ceph-config-option-reconcile.d.ts.map +1 -0
- package/dist/ceph/ceph-config-options.d.ts +15 -6
- package/dist/ceph/ceph-config-options.d.ts.map +1 -1
- package/dist/ceph/config-option.d.ts +10 -0
- package/dist/ceph/config-option.d.ts.map +1 -0
- package/dist/ceph/index.d.ts +7 -3
- package/dist/ceph/index.d.ts.map +1 -1
- package/dist/ceph/index.js +363 -8
- package/dist/ceph/index.js.map +17 -7
- package/dist/launchd/index.js +4 -2
- package/dist/launchd/index.js.map +3 -3
- package/dist/launchd/sudo-guard-mode.d.ts +10 -4
- package/dist/launchd/sudo-guard-mode.d.ts.map +1 -1
- package/dist/linux/fake-sudo-root.d.ts.map +1 -1
- package/dist/linux/fake-sudo.d.ts +9 -1
- package/dist/linux/fake-sudo.d.ts.map +1 -1
- package/dist/linux/index.js +47 -9
- package/dist/linux/index.js.map +6 -6
- package/dist/linux/sudo-guard.d.ts +10 -1
- package/dist/linux/sudo-guard.d.ts.map +1 -1
- package/dist/linux/sudo-runner.d.ts +15 -1
- package/dist/linux/sudo-runner.d.ts.map +1 -1
- package/dist/linux/sudo-write.d.ts +4 -1
- package/dist/linux/sudo-write.d.ts.map +1 -1
- package/dist/proxmox/index.d.ts +1 -0
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +101 -1
- package/dist/proxmox/index.js.map +5 -4
- package/dist/proxmox/scoped-api.d.ts +49 -0
- package/dist/proxmox/scoped-api.d.ts.map +1 -0
- package/docs/linux-sudo-review-2026-09-23.md +27 -0
- package/docs/linux-sudo.md +28 -40
- package/docs/pve-scoped-api.md +23 -0
- package/package.json +1 -1
- package/src/ceph/ceph-argv-config.ts +27 -19
- package/src/ceph/ceph-argv.test.ts +37 -1
- package/src/ceph/ceph-argv.ts +25 -0
- package/src/ceph/ceph-config-option-form.ts +73 -0
- package/src/ceph/ceph-config-option-reconcile.ts +88 -0
- package/src/ceph/ceph-config-option.test.ts +213 -0
- package/src/ceph/ceph-config-options.test.ts +28 -11
- package/src/ceph/ceph-config-options.ts +29 -8
- package/src/ceph/config-option.ts +112 -0
- package/src/ceph/index.ts +7 -3
- package/src/launchd/sudo-guard-mode.ts +11 -3
- package/src/linux/fake-sudo-root.ts +15 -0
- package/src/linux/fake-sudo.ts +20 -1
- package/src/linux/sudo-guard.ts +22 -1
- package/src/linux/sudo-runner-privileged-read.test.ts +200 -0
- package/src/linux/sudo-runner.ts +59 -4
- package/src/linux/sudo-write.ts +5 -1
- package/src/proxmox/index.ts +13 -0
- package/src/proxmox/scoped-api.test.ts +52 -0
- package/src/proxmox/scoped-api.ts +148 -0
- 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
|
|
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 {
|
|
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('
|
|
10
|
-
expect(NAMED_CONFIG_OPTIONS.
|
|
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([
|
|
39
|
-
'
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
7
|
-
*
|
|
8
|
-
* transport's allowlist
|
|
9
|
-
*
|
|
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
|
-
/**
|
|
21
|
-
|
|
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
|
-
/**
|
|
32
|
-
|
|
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
|
|
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
|
|
7
|
-
*
|
|
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;
|
|
76
|
-
* `0600`
|
|
77
|
-
*
|
|
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
|
|
package/src/linux/fake-sudo.ts
CHANGED
|
@@ -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
|
-
{
|
|
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),
|
package/src/linux/sudo-guard.ts
CHANGED
|
@@ -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
|
};
|