@homeflare/alchemy 0.53.1 → 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.
- 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/github/fork-pr-approval.d.ts +55 -0
- package/dist/github/fork-pr-approval.d.ts.map +1 -0
- package/dist/github/index.d.ts +1 -0
- package/dist/github/index.d.ts.map +1 -1
- package/dist/github/index.js +87 -4
- package/dist/github/index.js.map +6 -5
- package/dist/github/repo-baseline-data.d.ts +3 -2
- package/dist/github/repo-baseline-data.d.ts.map +1 -1
- package/dist/github/repository-ruleset-providers.d.ts +1 -1
- package/dist/github/repository-ruleset-providers.d.ts.map +1 -1
- package/dist/linux/index.js +33 -2
- package/dist/linux/index.js.map +3 -3
- package/dist/linux/sudo-runner.d.ts +12 -0
- package/dist/linux/sudo-runner.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/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/github/fork-pr-approval.test.ts +74 -0
- package/src/github/fork-pr-approval.ts +174 -0
- package/src/github/index.ts +9 -0
- package/src/github/repo-baseline-data.ts +2 -2
- package/src/github/repository-ruleset-providers.ts +6 -1
- package/src/linux/sudo-runner-privileged-read.test.ts +92 -0
- package/src/linux/sudo-runner.ts +54 -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
|
@@ -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';
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import * as Effect from 'effect/Effect';
|
|
3
|
+
import {
|
|
4
|
+
type ForkPrApprovalClient,
|
|
5
|
+
type ForkPrApprovalPolicy,
|
|
6
|
+
ForkPrApprovalReadbackMismatch,
|
|
7
|
+
forkPrApprovalHandlers,
|
|
8
|
+
} from './fork-pr-approval.ts';
|
|
9
|
+
|
|
10
|
+
const fake = (initial: string, store = true) => {
|
|
11
|
+
const state = { policy: initial, puts: [] as string[] };
|
|
12
|
+
const client: ForkPrApprovalClient = {
|
|
13
|
+
get: () => Effect.succeed(state.policy),
|
|
14
|
+
put: (_o, _r, policy: ForkPrApprovalPolicy) =>
|
|
15
|
+
Effect.sync(() => {
|
|
16
|
+
state.puts.push(policy);
|
|
17
|
+
if (store) state.policy = policy;
|
|
18
|
+
}),
|
|
19
|
+
};
|
|
20
|
+
return { state, handlers: forkPrApprovalHandlers(() => client) };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const props = {
|
|
24
|
+
owner: 'taslabs-net',
|
|
25
|
+
repository: 'homeflare-kit',
|
|
26
|
+
approvalPolicy: 'all_external_contributors',
|
|
27
|
+
} as const;
|
|
28
|
+
// oxlint-disable-next-line typescript/no-explicit-any
|
|
29
|
+
const run = <A>(e: Effect.Effect<A, unknown, any>) => Effect.runPromise(e as Effect.Effect<A>);
|
|
30
|
+
// oxlint-disable-next-line typescript/no-explicit-any
|
|
31
|
+
const h = (x: ReturnType<typeof fake>) => x.handlers as any;
|
|
32
|
+
|
|
33
|
+
describe('GitHub.ForkPrApproval', () => {
|
|
34
|
+
test('reconcile PUTs a looser live policy up to the declared one and reads it back', async () => {
|
|
35
|
+
const f = fake('first_time_contributors');
|
|
36
|
+
const out = await run(h(f).reconcile({ news: props, output: undefined }));
|
|
37
|
+
expect(f.state.puts).toEqual(['all_external_contributors']);
|
|
38
|
+
expect(out).toEqual({ ...props, approvalPolicy: 'all_external_contributors' });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('reconcile writes nothing when live already matches', async () => {
|
|
42
|
+
const f = fake('all_external_contributors');
|
|
43
|
+
await run(h(f).reconcile({ news: props, output: undefined }));
|
|
44
|
+
expect(f.state.puts).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a PUT GitHub does not store fails the reconcile', async () => {
|
|
48
|
+
const f = fake('first_time_contributors', false);
|
|
49
|
+
const err = await run(h(f).reconcile({ news: props, output: undefined })).catch(
|
|
50
|
+
(e: unknown) => e,
|
|
51
|
+
);
|
|
52
|
+
expect(String(err)).toContain('read back first_time_contributors');
|
|
53
|
+
expect(ForkPrApprovalReadbackMismatch.name).toBe('ForkPrApprovalReadbackMismatch');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('an unknown policy is refused before any write', async () => {
|
|
57
|
+
const f = fake('first_time_contributors');
|
|
58
|
+
const bad = { ...props, approvalPolicy: 'nobody' };
|
|
59
|
+
await expect(run(h(f).reconcile({ news: bad, output: undefined }))).rejects.toThrow('unknown');
|
|
60
|
+
expect(f.state.puts).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('delete never touches the live setting', async () => {
|
|
64
|
+
const f = fake('all_external_contributors');
|
|
65
|
+
await run(h(f).delete({ olds: props, output: props }));
|
|
66
|
+
expect(f.state.puts).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('read with no state reports the live value as unowned (adopt on purpose)', async () => {
|
|
70
|
+
const f = fake('first_time_contributors');
|
|
71
|
+
const r = await run(h(f).read({ olds: props, output: undefined }));
|
|
72
|
+
expect(JSON.stringify(r)).toContain('first_time_contributors');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GitHub.ForkPrApproval` — a repository's "approval for running fork pull request workflows
|
|
3
|
+
* from contributors" setting, as a declared resource.
|
|
4
|
+
*
|
|
5
|
+
* ★ WHY IT EXISTS (Tim, 2026-10-14 01:28 ET). `PUBLIC_ONLY_UNDECLARABLE` (repo-baseline-data.ts)
|
|
6
|
+
* listed this setting as something no resource could express. homeflare-kit moved onto the
|
|
7
|
+
* self-hosted pve pool, and homeflare-mini's ci-runner serves a public repo only while this
|
|
8
|
+
* setting is `all_external_contributors` (its `PUBLIC_RUNNER_REPOS`, fail-closed) — a fork's
|
|
9
|
+
* code must not reach the LAN runner without a maintainer's click. So the setting is now code.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ ONE ENDPOINT PAIR, NOTHING ELSE. `GET`/`PUT /repos/{owner}/{repo}/actions/permissions/
|
|
12
|
+
* fork-pr-contributor-approval`. The setting always exists, so there is no create and no delete:
|
|
13
|
+
* `reconcile` PUTs the declared policy and reads it back; `delete` leaves the live setting as it
|
|
14
|
+
* is (the default removal policy is `retain`, and dropping a declaration must never loosen it).
|
|
15
|
+
* ⛔ ADOPT, NEVER CLAIM SILENTLY. With no prior state `read` returns the live value as `Unowned`,
|
|
16
|
+
* so a stack has to `adopt(true)` it on purpose — the same convention as `RepositoryRuleset`.
|
|
17
|
+
* ⛔ READ BACK. A PUT that GitHub accepts but does not store is reported, not trusted: the
|
|
18
|
+
* reconcile re-reads and fails on a mismatch.
|
|
19
|
+
*/
|
|
20
|
+
import { Resource } from 'alchemy';
|
|
21
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
22
|
+
import { isResolved } from 'alchemy/Diff';
|
|
23
|
+
import { GitHubCredentials } from 'alchemy/GitHub';
|
|
24
|
+
import * as Provider from 'alchemy/Provider';
|
|
25
|
+
import * as Effect from 'effect/Effect';
|
|
26
|
+
|
|
27
|
+
/** GitHub's three values, most to least permissive. */
|
|
28
|
+
export const FORK_PR_APPROVAL_POLICIES = [
|
|
29
|
+
'first_time_contributors_new_to_github',
|
|
30
|
+
'first_time_contributors',
|
|
31
|
+
'all_external_contributors',
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
export type ForkPrApprovalPolicy = (typeof FORK_PR_APPROVAL_POLICIES)[number];
|
|
35
|
+
|
|
36
|
+
export interface ForkPrApprovalProps {
|
|
37
|
+
readonly owner: string;
|
|
38
|
+
readonly repository: string;
|
|
39
|
+
readonly approvalPolicy: ForkPrApprovalPolicy;
|
|
40
|
+
/** GitHub Enterprise API base; omit for github.com. */
|
|
41
|
+
readonly baseUrl?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ForkPrApprovalAttributes {
|
|
45
|
+
readonly owner: string;
|
|
46
|
+
readonly repository: string;
|
|
47
|
+
readonly approvalPolicy: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ForkPrApproval extends Resource<
|
|
51
|
+
'GitHub.ForkPrApproval',
|
|
52
|
+
ForkPrApprovalProps,
|
|
53
|
+
ForkPrApprovalAttributes,
|
|
54
|
+
never
|
|
55
|
+
> {}
|
|
56
|
+
|
|
57
|
+
export const ForkPrApproval = Resource<ForkPrApproval>('GitHub.ForkPrApproval', {
|
|
58
|
+
defaultRemovalPolicy: 'retain',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** The two calls this resource makes, so tests can hand it a fake. */
|
|
62
|
+
export interface ForkPrApprovalClient<R = never> {
|
|
63
|
+
readonly get: (owner: string, repo: string) => Effect.Effect<string, Error, R>;
|
|
64
|
+
readonly put: (
|
|
65
|
+
owner: string,
|
|
66
|
+
repo: string,
|
|
67
|
+
policy: ForkPrApprovalPolicy,
|
|
68
|
+
) => Effect.Effect<void, Error, R>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A PUT GitHub accepted but did not store. */
|
|
72
|
+
export class ForkPrApprovalReadbackMismatch extends Error {
|
|
73
|
+
readonly _tag = 'ForkPrApprovalReadbackMismatch';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const asError = (e: unknown): Error => (e instanceof Error ? e : new Error(String(e)));
|
|
77
|
+
const PATH = 'actions/permissions/fork-pr-contributor-approval';
|
|
78
|
+
|
|
79
|
+
export const octokitForkPrApprovalClient = (
|
|
80
|
+
baseUrl: string | undefined,
|
|
81
|
+
): ForkPrApprovalClient<GitHubCredentials> => {
|
|
82
|
+
const octokit = Effect.gen(function* () {
|
|
83
|
+
const creds = yield* yield* GitHubCredentials;
|
|
84
|
+
return baseUrl === undefined ? creds.octokit() : creds.octokit({ baseUrl });
|
|
85
|
+
}).pipe(Effect.mapError(asError));
|
|
86
|
+
return {
|
|
87
|
+
get: (owner, repo) =>
|
|
88
|
+
Effect.gen(function* () {
|
|
89
|
+
const o = yield* octokit;
|
|
90
|
+
const res = yield* Effect.tryPromise({
|
|
91
|
+
try: () => o.request(`GET /repos/{owner}/{repo}/${PATH}`, { owner, repo }),
|
|
92
|
+
catch: asError,
|
|
93
|
+
});
|
|
94
|
+
return String((res.data as { approval_policy?: unknown }).approval_policy);
|
|
95
|
+
}),
|
|
96
|
+
put: (owner, repo, policy) =>
|
|
97
|
+
Effect.gen(function* () {
|
|
98
|
+
const o = yield* octokit;
|
|
99
|
+
yield* Effect.tryPromise({
|
|
100
|
+
try: () =>
|
|
101
|
+
o.request(`PUT /repos/{owner}/{repo}/${PATH}`, {
|
|
102
|
+
owner,
|
|
103
|
+
repo,
|
|
104
|
+
approval_policy: policy,
|
|
105
|
+
}),
|
|
106
|
+
catch: asError,
|
|
107
|
+
});
|
|
108
|
+
}),
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const attrs = (
|
|
113
|
+
owner: string,
|
|
114
|
+
repository: string,
|
|
115
|
+
approvalPolicy: string,
|
|
116
|
+
): ForkPrApprovalAttributes => ({
|
|
117
|
+
owner,
|
|
118
|
+
repository,
|
|
119
|
+
approvalPolicy,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/** The provider's handlers over any client — the real one below, a fake in tests. */
|
|
123
|
+
export const forkPrApprovalHandlers = <R>(
|
|
124
|
+
clientFor: (baseUrl: string | undefined) => ForkPrApprovalClient<R>,
|
|
125
|
+
) =>
|
|
126
|
+
ForkPrApproval.Provider.of({
|
|
127
|
+
list: () => Effect.succeed([]),
|
|
128
|
+
nuke: { skip: true },
|
|
129
|
+
|
|
130
|
+
read: ({ output, olds }) =>
|
|
131
|
+
Effect.gen(function* () {
|
|
132
|
+
const live = yield* clientFor(olds.baseUrl).get(olds.owner, olds.repository);
|
|
133
|
+
const found = attrs(olds.owner, olds.repository, live);
|
|
134
|
+
return output === undefined ? Unowned(found) : found;
|
|
135
|
+
}),
|
|
136
|
+
|
|
137
|
+
diff: ({ news, olds }) =>
|
|
138
|
+
Effect.succeed(
|
|
139
|
+
isResolved(news) &&
|
|
140
|
+
olds !== undefined &&
|
|
141
|
+
(news.owner !== olds.owner || news.repository !== olds.repository)
|
|
142
|
+
? ({ action: 'replace' } as const)
|
|
143
|
+
: undefined,
|
|
144
|
+
),
|
|
145
|
+
|
|
146
|
+
reconcile: ({ news }) =>
|
|
147
|
+
Effect.gen(function* () {
|
|
148
|
+
if (!(FORK_PR_APPROVAL_POLICIES as readonly string[]).includes(news.approvalPolicy)) {
|
|
149
|
+
return yield* Effect.fail(
|
|
150
|
+
new Error(`unknown fork-PR approval policy: ${String(news.approvalPolicy)}`),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const client = clientFor(news.baseUrl);
|
|
154
|
+
const before = yield* client.get(news.owner, news.repository);
|
|
155
|
+
if (before !== news.approvalPolicy) {
|
|
156
|
+
yield* client.put(news.owner, news.repository, news.approvalPolicy);
|
|
157
|
+
}
|
|
158
|
+
const after = yield* client.get(news.owner, news.repository);
|
|
159
|
+
if (after !== news.approvalPolicy) {
|
|
160
|
+
return yield* Effect.fail(
|
|
161
|
+
new ForkPrApprovalReadbackMismatch(
|
|
162
|
+
`${news.owner}/${news.repository}: PUT ${news.approvalPolicy}, read back ${after}`,
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return attrs(news.owner, news.repository, after);
|
|
167
|
+
}),
|
|
168
|
+
|
|
169
|
+
// ⛔ Retain: removing the declaration never loosens the live setting.
|
|
170
|
+
delete: () => Effect.void,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
export const ForkPrApprovalProvider = () =>
|
|
174
|
+
Provider.succeed(ForkPrApproval, forkPrApprovalHandlers(octokitForkPrApprovalClient));
|
package/src/github/index.ts
CHANGED
|
@@ -45,6 +45,15 @@ export {
|
|
|
45
45
|
type RepositoryRulesetRules,
|
|
46
46
|
type RepositoryRulesetStatusChecksRule,
|
|
47
47
|
} from './repository-ruleset.ts';
|
|
48
|
+
export {
|
|
49
|
+
FORK_PR_APPROVAL_POLICIES,
|
|
50
|
+
ForkPrApproval,
|
|
51
|
+
type ForkPrApprovalAttributes,
|
|
52
|
+
type ForkPrApprovalPolicy,
|
|
53
|
+
type ForkPrApprovalProps,
|
|
54
|
+
ForkPrApprovalProvider,
|
|
55
|
+
ForkPrApprovalReadbackMismatch,
|
|
56
|
+
} from './fork-pr-approval.ts';
|
|
48
57
|
export { repoPolicyProviders } from './repository-ruleset-providers.ts';
|
|
49
58
|
export { RULE_TYPE_COVERAGE, type RepositoryRuleType } from './repository-ruleset-constraints.ts';
|
|
50
59
|
export {
|
|
@@ -86,10 +86,10 @@ export function repoBaselineRuleset(input: RepoBaselineInput) {
|
|
|
86
86
|
/** Public-only settings this baseline asserts but cannot express through either resource above
|
|
87
87
|
* at `alchemy@2.0.0-beta.79` — recorded for the wave-3 handoff artifact (plan section 2), not
|
|
88
88
|
* declared anywhere. Both already measured ON for `kit` (LIVE SURVEY 2026-09-23); `builds` is
|
|
89
|
-
* the gap.
|
|
89
|
+
* the gap. Fork-PR approval left this list on 2026-10-14: `ForkPrApproval` (fork-pr-approval.ts)
|
|
90
|
+
* declares it. */
|
|
90
91
|
export const PUBLIC_ONLY_UNDECLARABLE = [
|
|
91
92
|
'secret_scanning: enabled',
|
|
92
93
|
'secret_scanning_push_protection: enabled',
|
|
93
94
|
'private_vulnerability_reporting: enabled',
|
|
94
|
-
"fork-PR approval: 'first_time_contributors'",
|
|
95
95
|
] as const;
|
|
@@ -38,9 +38,14 @@
|
|
|
38
38
|
*/
|
|
39
39
|
import * as GitHub from 'alchemy/GitHub';
|
|
40
40
|
import * as Layer from 'effect/Layer';
|
|
41
|
+
import { ForkPrApprovalProvider } from './fork-pr-approval.ts';
|
|
41
42
|
import { RepositoryRulesetProvider } from './repository-ruleset.ts';
|
|
42
43
|
|
|
43
44
|
export const repoPolicyProviders = (options?: GitHub.ProvidersOptions) => {
|
|
44
45
|
const github = GitHub.providers(options);
|
|
45
|
-
return Layer.mergeAll(
|
|
46
|
+
return Layer.mergeAll(
|
|
47
|
+
github,
|
|
48
|
+
RepositoryRulesetProvider().pipe(Layer.provideMerge(github)),
|
|
49
|
+
ForkPrApprovalProvider().pipe(Layer.provideMerge(github)),
|
|
50
|
+
);
|
|
46
51
|
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sshSudoRunner `privilegedReads`: an exact-path, root-owned-regular-file-only sudo read, so a
|
|
3
|
+
* plan can diff a declared 0600 file without a general root-read primitive.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import type { FileStat, HostRunner } from '../launchd/runner.ts';
|
|
7
|
+
import { SUDO } from './sudo-allowlist.ts';
|
|
8
|
+
import {
|
|
9
|
+
BASE64,
|
|
10
|
+
SudoRefusedError,
|
|
11
|
+
checkPrivilegedReads,
|
|
12
|
+
makeSshSudoRunner,
|
|
13
|
+
} from './sudo-runner.ts';
|
|
14
|
+
|
|
15
|
+
const SECRETISH = '/opt/app/config.yaml';
|
|
16
|
+
const file = (uid: number, kind: FileStat['kind'] = 'file'): FileStat =>
|
|
17
|
+
({ kind, mode: 0o600, uid, gid: 0, size: 5 }) as unknown as FileStat;
|
|
18
|
+
|
|
19
|
+
const host = (stat: FileStat | undefined, exitCode = 0) => {
|
|
20
|
+
const execs: string[][] = [];
|
|
21
|
+
const operatorReads: string[] = [];
|
|
22
|
+
const base = {
|
|
23
|
+
platform: 'linux',
|
|
24
|
+
privileged: false,
|
|
25
|
+
effectiveUid: () => 1001,
|
|
26
|
+
stat: async () => stat,
|
|
27
|
+
readFile: async (path: string) => {
|
|
28
|
+
operatorReads.push(path);
|
|
29
|
+
return new TextEncoder().encode('operator');
|
|
30
|
+
},
|
|
31
|
+
exec: async (argv: string[]) => {
|
|
32
|
+
execs.push(argv);
|
|
33
|
+
return {
|
|
34
|
+
exitCode,
|
|
35
|
+
stdout: `${Buffer.from('hello').toString('base64')}\n`,
|
|
36
|
+
stderr: exitCode === 0 ? '' : 'boom',
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
} as unknown as HostRunner;
|
|
40
|
+
const runner = makeSshSudoRunner(['/opt/app/data'], {
|
|
41
|
+
base,
|
|
42
|
+
log: () => undefined,
|
|
43
|
+
stage: async () => {
|
|
44
|
+
throw new Error('unused');
|
|
45
|
+
},
|
|
46
|
+
privilegedReads: [SECRETISH],
|
|
47
|
+
});
|
|
48
|
+
return { execs, operatorReads, runner };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe('privilegedReads', () => {
|
|
52
|
+
test('a listed root-owned file is read with exactly sudo -n -- base64 -- <path>', async () => {
|
|
53
|
+
const { execs, runner } = host(file(0));
|
|
54
|
+
expect(new TextDecoder().decode(await runner.readFile(SECRETISH))).toBe('hello');
|
|
55
|
+
expect(execs).toEqual([[SUDO, '-n', '--', BASE64, '--', SECRETISH]]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('an unlisted path is read as the operator, never elevated', async () => {
|
|
59
|
+
const { execs, operatorReads, runner } = host(file(0));
|
|
60
|
+
await runner.readFile('/etc/caddy/cf-token.env');
|
|
61
|
+
await runner.readFile(`${SECRETISH}.bak`);
|
|
62
|
+
await runner.readFile('/opt/app');
|
|
63
|
+
expect(execs).toEqual([]);
|
|
64
|
+
expect(operatorReads).toEqual(['/etc/caddy/cf-token.env', `${SECRETISH}.bak`, '/opt/app']);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('a symlink, a directory or a non-root file at the listed path is refused', async () => {
|
|
68
|
+
for (const stat of [file(0, 'symlink'), file(0, 'directory'), file(1001)]) {
|
|
69
|
+
const { execs, runner } = host(stat);
|
|
70
|
+
await expect(runner.readFile(SECRETISH)).rejects.toBeInstanceOf(SudoRefusedError);
|
|
71
|
+
expect(execs).toEqual([]);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('an absent listed file reads as absent', async () => {
|
|
76
|
+
const { execs, runner } = host(undefined);
|
|
77
|
+
expect(await runner.readFile(SECRETISH)).toBeUndefined();
|
|
78
|
+
expect(execs).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a failed sudo read throws', async () => {
|
|
82
|
+
const { runner } = host(file(0), 1);
|
|
83
|
+
await expect(runner.readFile(SECRETISH)).rejects.toThrow('-> 1');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('only normalized absolute paths may be listed', () => {
|
|
87
|
+
for (const bad of ['relative', '/opt/../etc/shadow', '/opt//x', '/opt/x/', '/opt/./x', '/']) {
|
|
88
|
+
expect(() => checkPrivilegedReads([bad])).toThrow(SudoRefusedError);
|
|
89
|
+
}
|
|
90
|
+
expect(checkPrivilegedReads([SECRETISH]).has(SECRETISH)).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
});
|