@homeflare/alchemy 0.40.0 → 0.42.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/caddy/index.js.map +2 -2
- package/dist/ceph/index.js +2 -1
- package/dist/ceph/index.js.map +3 -3
- package/dist/launchd/fake-runner.d.ts.map +1 -1
- package/dist/launchd/index.d.ts +1 -1
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +7 -1
- package/dist/launchd/index.js.map +5 -5
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +21 -0
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-runner.d.ts.map +1 -1
- package/dist/linux/directory-lifecycle.d.ts.map +1 -1
- package/dist/linux/fake-linux-host.d.ts.map +1 -1
- package/dist/linux/index.d.ts +1 -1
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +16 -4
- package/dist/linux/index.js.map +7 -7
- package/dist/linux/ssh-runner.d.ts.map +1 -1
- package/dist/linux/sudo-runner.d.ts.map +1 -1
- package/dist/opnsense/alias-form.d.ts +14 -1
- package/dist/opnsense/alias-form.d.ts.map +1 -1
- package/dist/opnsense/alias.d.ts +1 -1
- package/dist/opnsense/alias.d.ts.map +1 -1
- package/dist/opnsense/group-form.d.ts +8 -2
- package/dist/opnsense/group-form.d.ts.map +1 -1
- package/dist/opnsense/group.d.ts +1 -1
- package/dist/opnsense/group.d.ts.map +1 -1
- package/dist/opnsense/index.js +15 -7
- package/dist/opnsense/index.js.map +7 -7
- package/dist/opnsense/wire.d.ts +42 -12
- package/dist/opnsense/wire.d.ts.map +1 -1
- package/dist/proxmox/acl.d.ts.map +1 -1
- package/dist/proxmox/api-token.d.ts.map +1 -1
- package/dist/proxmox/credential-errors.d.ts +18 -4
- package/dist/proxmox/credential-errors.d.ts.map +1 -1
- package/dist/proxmox/credentials.d.ts +35 -1
- package/dist/proxmox/credentials.d.ts.map +1 -1
- package/dist/proxmox/group.d.ts.map +1 -1
- package/dist/proxmox/index.js +81 -51
- package/dist/proxmox/index.js.map +19 -17
- package/dist/proxmox/lease-cache.d.ts +67 -5
- package/dist/proxmox/lease-cache.d.ts.map +1 -1
- package/dist/proxmox/mint.d.ts +4 -2
- package/dist/proxmox/mint.d.ts.map +1 -1
- package/dist/proxmox/node-network.d.ts.map +1 -1
- package/dist/proxmox/role.d.ts.map +1 -1
- package/dist/proxmox/storage-form.d.ts +28 -3
- package/dist/proxmox/storage-form.d.ts.map +1 -1
- package/dist/proxmox/storage-plugin-options.d.ts +2 -0
- package/dist/proxmox/storage-plugin-options.d.ts.map +1 -0
- package/dist/proxmox/storage-wire.d.ts +28 -1
- package/dist/proxmox/storage-wire.d.ts.map +1 -1
- package/dist/proxmox/storage.d.ts +6 -1
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/proxmox/unreadable-read.d.ts +8 -1
- package/dist/proxmox/unreadable-read.d.ts.map +1 -1
- package/dist/proxmox/user.d.ts.map +1 -1
- package/dist/proxmox/zfs-pool.d.ts.map +1 -1
- package/dist/release/index.d.ts +1 -1
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js.map +2 -2
- package/docs/credentials.md +14 -1
- package/docs/distilled-interim.md +7 -0
- package/docs/launchd.md +5 -3
- package/docs/provider-standard.md +5 -1
- package/docs/talos-argocd-dogfood.md +160 -0
- package/package.json +2 -2
- package/src/launchd/fake-runner.ts +2 -0
- package/src/launchd/index.ts +8 -1
- package/src/launchd/local-runner.ts +16 -1
- package/src/launchd/runner.ts +22 -0
- package/src/launchd/sudo-runner.ts +3 -0
- package/src/linux/directory-lifecycle.ts +18 -3
- package/src/linux/directory-sudo.test.ts +59 -0
- package/src/linux/directory.test.ts +34 -8
- package/src/linux/fake-linux-host.ts +2 -0
- package/src/linux/index.ts +1 -0
- package/src/linux/ssh-runner.ts +4 -0
- package/src/linux/sudo-runner.ts +3 -0
- package/src/opnsense/alias-form.test.ts +24 -12
- package/src/opnsense/alias-form.ts +20 -7
- package/src/opnsense/alias.test.ts +13 -6
- package/src/opnsense/alias.ts +16 -10
- package/src/opnsense/group-form.test.ts +12 -8
- package/src/opnsense/group-form.ts +13 -4
- package/src/opnsense/group.test.ts +18 -7
- package/src/opnsense/group.ts +10 -3
- package/src/opnsense/wire.ts +57 -12
- package/src/proxmox/acl.ts +7 -1
- package/src/proxmox/api-token.ts +7 -1
- package/src/proxmox/credential-errors.ts +19 -5
- package/src/proxmox/credentials.test.ts +73 -1
- package/src/proxmox/credentials.ts +72 -1
- package/src/proxmox/group.ts +7 -1
- package/src/proxmox/lease-cache-mint.test.ts +70 -0
- package/src/proxmox/lease-cache.test.ts +83 -7
- package/src/proxmox/lease-cache.ts +74 -10
- package/src/proxmox/mint.ts +12 -5
- package/src/proxmox/node-network.ts +7 -1
- package/src/proxmox/role.ts +7 -1
- package/src/proxmox/storage-form.ts +48 -8
- package/src/proxmox/storage-plugin-options.ts +45 -0
- package/src/proxmox/storage-update.test.ts +183 -0
- package/src/proxmox/storage-wire.ts +54 -7
- package/src/proxmox/storage.ts +25 -7
- package/src/proxmox/unreadable-read.ts +16 -4
- package/src/proxmox/user.ts +7 -1
- package/src/proxmox/zfs-pool.ts +7 -1
- package/src/release/index.ts +1 -0
package/src/launchd/index.ts
CHANGED
|
@@ -24,7 +24,14 @@ export { PlistError, renderPlist } from './plist.ts';
|
|
|
24
24
|
export type { PortClaim } from './port-claims.ts';
|
|
25
25
|
export { PortRefused, claimPorts, portClaimProblems } from './port-claims.ts';
|
|
26
26
|
export { launchdProviders } from './providers.ts';
|
|
27
|
-
export type {
|
|
27
|
+
export type {
|
|
28
|
+
ExecResult,
|
|
29
|
+
FileStat,
|
|
30
|
+
HostPlatform,
|
|
31
|
+
HostRunner,
|
|
32
|
+
HostUser,
|
|
33
|
+
WriteOptions,
|
|
34
|
+
} from './runner.ts';
|
|
28
35
|
export { HostRunnerService, canActAsRoot, hostRunnerLayer } from './runner.ts';
|
|
29
36
|
export type { SudoRunnerOptions } from './sudo-runner.ts';
|
|
30
37
|
export { SudoRefusedError, sudoRunner } from './sudo-runner.ts';
|
|
@@ -15,7 +15,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
15
15
|
import { lstat, open, readFile, rename, unlink } from 'node:fs/promises';
|
|
16
16
|
import { basename, dirname, join } from 'node:path';
|
|
17
17
|
import { groupQuery, parseGroup, parseUser, userQuery } from './host-lookup.ts';
|
|
18
|
-
import type { ExecResult, FileStat, HostRunner, WriteOptions } from './runner.ts';
|
|
18
|
+
import type { ExecResult, FileStat, HostPlatform, HostRunner, WriteOptions } from './runner.ts';
|
|
19
19
|
|
|
20
20
|
export type LocalRunnerOptions = {
|
|
21
21
|
/** Kill a spawned program after this long. @default 120_000 */
|
|
@@ -98,6 +98,20 @@ const writeAtomic = async (path: string, bytes: Uint8Array, options: WriteOption
|
|
|
98
98
|
export const localRunner = (options: LocalRunnerOptions = {}): HostRunner => {
|
|
99
99
|
const timeoutMs = options.execTimeoutMs ?? 120_000;
|
|
100
100
|
const platform = process.platform;
|
|
101
|
+
// ★ THE ONE PLACE `process.platform` MAY DECIDE HOST BEHAVIOUR: here, the target IS this
|
|
102
|
+
// process's own machine, so `process.platform` and `HostRunner.platform` name the same host.
|
|
103
|
+
// Everywhere else (chmodChownEnd, host-lookup's callers over ssh) reads `runner.platform`
|
|
104
|
+
// instead — see the incident on `HostRunner.platform`'s own doc comment.
|
|
105
|
+
// ⛔ DECLARED, NEVER GUESSED, past these two: silently folding freebsd/win32/etc. into 'linux'
|
|
106
|
+
// would hand a BSD host GNU's `--`, which macOS's own (BSD-flavoured) chmod/chown already
|
|
107
|
+
// reject — the same shape of bug this PR fixes. sshRunner's own `uname -s` probe refuses a
|
|
108
|
+
// non-Linux target the same way; localRunner refuses here instead of guessing.
|
|
109
|
+
if (platform !== 'darwin' && platform !== 'linux') {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`localRunner(): unsupported process.platform ${JSON.stringify(platform)} — HostRunner only targets darwin and linux.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const hostPlatform: HostPlatform = platform;
|
|
101
115
|
return {
|
|
102
116
|
effectiveUid: () => process.geteuid?.() ?? -1,
|
|
103
117
|
exec: (argv) => run(argv, timeoutMs),
|
|
@@ -109,6 +123,7 @@ export const localRunner = (options: LocalRunnerOptions = {}): HostRunner => {
|
|
|
109
123
|
const result = await run(userQuery(platform, nameOrId), timeoutMs);
|
|
110
124
|
return result.exitCode === 0 ? parseUser(platform, result.stdout) : undefined;
|
|
111
125
|
},
|
|
126
|
+
platform: hostPlatform,
|
|
112
127
|
privileged: false,
|
|
113
128
|
readFile: async (path) => {
|
|
114
129
|
try {
|
package/src/launchd/runner.ts
CHANGED
|
@@ -59,7 +59,29 @@ export type WriteOptions = {
|
|
|
59
59
|
|
|
60
60
|
export type HostUser = { readonly uid: number; readonly gid: number; readonly home: string };
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The two host families a HostRunner ever actually targets. ★ Not `NodeJS.Platform`: nothing
|
|
64
|
+
* here runs against `win32`/`aix`/etc, and a narrower type is what makes `runner.platform ===
|
|
65
|
+
* 'darwin'` exhaustive rather than a string comparison that silently matches nothing.
|
|
66
|
+
*/
|
|
67
|
+
export type HostPlatform = 'darwin' | 'linux';
|
|
68
|
+
|
|
62
69
|
export interface HostRunner {
|
|
70
|
+
/**
|
|
71
|
+
* ⛔ DECLARED, NEVER INFERRED — same rule as `privileged`, and the same failure mode. The OS of
|
|
72
|
+
* the TARGET this runner writes to, never of the process running Alchemy: `localRunner()` may
|
|
73
|
+
* run ON either, but `sshRunner()`/`sshSudoRunner()` always reach a Linux host from wherever
|
|
74
|
+
* Alchemy itself happens to run.
|
|
75
|
+
* 🔴 INCIDENT, homeflare-ct100 2026-09-27: `Host.Directory`'s chmod/chown end-of-options token
|
|
76
|
+
* (directory-lifecycle.ts `chmodChownEnd`) read `process.platform` — the Mac mini's own OS —
|
|
77
|
+
* instead of the runner's target. Deploying from the Mac to CT100 (a Debian LXC over
|
|
78
|
+
* `sshSudoRunner`) dropped GNU's required `--`, and `sudo-allowlist-dir.ts`'s `dirProgramProblem`
|
|
79
|
+
* refused every chown with `SudoRefusedError` even though the path was under a declared prefix.
|
|
80
|
+
* `localRunner()`'s own local deploys never showed it: there, `process.platform` and the
|
|
81
|
+
* target happen to be the same machine. This field is the fix: every call site that needs to
|
|
82
|
+
* know the target's OS reads `runner.platform`, never `process.platform`.
|
|
83
|
+
*/
|
|
84
|
+
readonly platform: HostPlatform;
|
|
63
85
|
/**
|
|
64
86
|
* ⛔ DECLARED, NEVER INFERRED. `true` only when this runner can do every root-needing call the
|
|
65
87
|
* providers make of it: every call already runs as root (a runner that talks to a root helper,
|
|
@@ -162,6 +162,9 @@ export const makeSudoRunner = (prefixList: readonly string[], deps: SudoDeps): H
|
|
|
162
162
|
},
|
|
163
163
|
lookupGroup: (nameOrId) => base.lookupGroup(nameOrId),
|
|
164
164
|
lookupUser: (nameOrId) => base.lookupUser(nameOrId),
|
|
165
|
+
// ★ Forwarded, never hardcoded: this runner elevates on the SAME target `base` already
|
|
166
|
+
// declared (localRunner's own machine), so it carries no opinion of its own.
|
|
167
|
+
platform: base.platform,
|
|
165
168
|
// ★ True, so the providers let a system-domain job through to this runner (canActAsRoot).
|
|
166
169
|
privileged: true,
|
|
167
170
|
readFile: (path) => base.readFile(path),
|
|
@@ -100,8 +100,18 @@ const octal = (mode: number) => mode.toString(8).padStart(3, '0');
|
|
|
100
100
|
* keep it. These paths are absolute, so the token is only an option guard.
|
|
101
101
|
* ⛔ Linux keeps `--`. The sudo allowlist requires that exact token; dropping it there
|
|
102
102
|
* refuses the elevation.
|
|
103
|
+
* 🔴 INCIDENT, homeflare-ct100 2026-09-27: this used to decide on `process.platform` — the OS of
|
|
104
|
+
* the machine RUNNING Alchemy — instead of `runner.platform`, the OS of the TARGET the runner
|
|
105
|
+
* actually writes to. Deploying from the Mac mini (`darwin`) to CT100, a Debian LXC reached
|
|
106
|
+
* over `sshSudoRunner`, dropped the required `--` and `sudo-allowlist-dir.ts`'s
|
|
107
|
+
* `dirProgramProblem` refused every chown with `SudoRefusedError`, even though the path was
|
|
108
|
+
* under a declared prefix — `localRunner()`'s own local (Mac-to-Mac) deploys never showed it,
|
|
109
|
+
* because there `process.platform` and the target happen to be the same machine. Reading
|
|
110
|
+
* `runner.platform` instead (declared by every HostRunner — see its own doc comment) makes the
|
|
111
|
+
* decision follow the target, not the caller.
|
|
103
112
|
*/
|
|
104
|
-
const chmodChownEnd = (): readonly string[] =>
|
|
113
|
+
const chmodChownEnd = (runner: HostRunner): readonly string[] =>
|
|
114
|
+
runner.platform === 'darwin' ? [] : ['--'];
|
|
105
115
|
|
|
106
116
|
/** One program, checked: anything but exit 0 is an Error naming the argv and the host's words. */
|
|
107
117
|
const must = async (runner: HostRunner, path: string, argv: readonly string[]): Promise<void> => {
|
|
@@ -160,7 +170,12 @@ export const reconcileDirectory = async (
|
|
|
160
170
|
}
|
|
161
171
|
await must(runner, props.path, ['mkdir', '-m', octal(want.mode), '--', props.path]);
|
|
162
172
|
} else if (stat.mode !== want.mode) {
|
|
163
|
-
await must(runner, props.path, [
|
|
173
|
+
await must(runner, props.path, [
|
|
174
|
+
'chmod',
|
|
175
|
+
octal(want.mode),
|
|
176
|
+
...chmodChownEnd(runner),
|
|
177
|
+
props.path,
|
|
178
|
+
]);
|
|
164
179
|
}
|
|
165
180
|
/**
|
|
166
181
|
* ★ Only when it is actually wrong. A `chown` that changes nothing is still a write on the host
|
|
@@ -174,7 +189,7 @@ export const reconcileDirectory = async (
|
|
|
174
189
|
? undefined
|
|
175
190
|
: ownerArg(want);
|
|
176
191
|
if (owner !== undefined)
|
|
177
|
-
await must(runner, props.path, ['chown', owner, ...chmodChownEnd(), props.path]);
|
|
192
|
+
await must(runner, props.path, ['chown', owner, ...chmodChownEnd(runner), props.path]);
|
|
178
193
|
const after = await readDirectory(runner, props.path);
|
|
179
194
|
// ⚠️ READ BACK: a runner that ignored the mode shows up here, not as a forever-`update`.
|
|
180
195
|
if (after === undefined || after.mode !== want.mode || !ownerMatches(after, want)) {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host.Directory driven through sshSudoRunner — the exact path the homeflare-ct100 incident
|
|
3
|
+
* (2026-09-27) broke: a deploy running on a Mac (darwin) targeting a Linux host over ssh dropped
|
|
4
|
+
* the sudo allowlist's required `--`, because directory-lifecycle.ts's `chmodChownEnd()` read
|
|
5
|
+
* `process.platform` — the Mac's own OS — instead of the runner's own declared target.
|
|
6
|
+
*
|
|
7
|
+
* ★ WHY A SEPARATE FILE FROM directory.test.ts. Those tests exercise the lifecycle directly
|
|
8
|
+
* against a bare fake host; these go through the REAL `sshSudoRunner` + `sudo-allowlist.ts`
|
|
9
|
+
* layer (`canonicalize`, `dirProgramProblem`), which is what actually enforces the `--` token
|
|
10
|
+
* and is what refused the live deploy with `SudoRefusedError`.
|
|
11
|
+
* ⛔ NEITHER TEST BELOW READS `process.platform`. `fakeLinuxHost()` (via `fakeSudoHost()`)
|
|
12
|
+
* declares `platform: 'linux'` itself — sshRunner's own probe requires it — so this suite
|
|
13
|
+
* catches the regression on every machine that runs `bun test`, regardless of that machine's
|
|
14
|
+
* own OS. (This repo's own `check` job runs on `ubuntu-latest`, not the mini, so CI alone never
|
|
15
|
+
* reproduces the "darwin process, Linux target" split the live incident measured; this suite's
|
|
16
|
+
* fixed, declared target is what actually proves the fix, on any OS.)
|
|
17
|
+
*/
|
|
18
|
+
import { describe, expect, test } from 'bun:test';
|
|
19
|
+
import { reconcileDirectory } from './directory-lifecycle.ts';
|
|
20
|
+
import { fakeSudoHost } from './fake-sudo.ts';
|
|
21
|
+
|
|
22
|
+
describe('Host.Directory through sshSudoRunner (a Linux target)', () => {
|
|
23
|
+
test('the elevated chown and chmod carry -- and the allowlist accepts them', async () => {
|
|
24
|
+
const { privileged, runner } = fakeSudoHost();
|
|
25
|
+
expect(runner.platform).toBe('linux'); // ★ declared by sshRunner's own uname probe.
|
|
26
|
+
|
|
27
|
+
// A fresh create under a declared prefix: mkdir runs as root, then chown (owner/group
|
|
28
|
+
// declared) also runs as root — both routed through routeExec()/elevatedExec().
|
|
29
|
+
const created = await reconcileDirectory(runner, {
|
|
30
|
+
group: 0,
|
|
31
|
+
mode: 0o755,
|
|
32
|
+
owner: 0,
|
|
33
|
+
path: '/etc/systemd/system/hf-test-dir',
|
|
34
|
+
});
|
|
35
|
+
expect(created).toEqual({
|
|
36
|
+
gid: 0,
|
|
37
|
+
mode: 0o755,
|
|
38
|
+
path: '/etc/systemd/system/hf-test-dir',
|
|
39
|
+
uid: 0,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// A mode drift on the next reconcile routes an elevated chmod too.
|
|
43
|
+
await reconcileDirectory(
|
|
44
|
+
runner,
|
|
45
|
+
{ group: 0, mode: 0o750, owner: 0, path: '/etc/systemd/system/hf-test-dir' },
|
|
46
|
+
created,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const chown = privileged().find((call) => call[0] === '/usr/bin/chown');
|
|
50
|
+
const chmod = privileged().find((call) => call[0] === '/usr/bin/chmod');
|
|
51
|
+
// ⛔ REGRESSION: before the fix, these two calls carried no `--` whenever `bun test` ran on a
|
|
52
|
+
// Mac, and `sudo-allowlist-dir.ts`'s `dirProgramProblem` refused them with
|
|
53
|
+
// `SudoRefusedError` — the exact failure measured against homeflare-ct100 2026-09-27.
|
|
54
|
+
expect(chown).toBeDefined();
|
|
55
|
+
expect(chown).toContain('--');
|
|
56
|
+
expect(chmod).toBeDefined();
|
|
57
|
+
expect(chmod).toContain('--');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* to create a parent.
|
|
4
4
|
*/
|
|
5
5
|
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import type { HostRunner } from '../launchd/runner.ts';
|
|
6
7
|
import {
|
|
7
8
|
deleteDirectory,
|
|
8
9
|
diffDirectory,
|
|
@@ -105,8 +106,16 @@ describe('drift', () => {
|
|
|
105
106
|
});
|
|
106
107
|
|
|
107
108
|
describe('argv', () => {
|
|
108
|
-
|
|
109
|
+
// ⛔ REGRESSION, 2026-09-27: this used to read `process.platform !== 'darwin'` to decide what
|
|
110
|
+
// the fake (a LINUX host) should have produced — so it only ever proved the real behaviour
|
|
111
|
+
// when `bun test` itself happened to run on Linux (matching the fake's target by
|
|
112
|
+
// coincidence), and passed just as happily on a Mac while asserting the bug itself (no `--`
|
|
113
|
+
// for a Linux target). `runner.platform` is declared by the fixture instead, so these two
|
|
114
|
+
// tests hold regardless of what OS actually runs the suite — the exact "darwin process,
|
|
115
|
+
// Linux target" split the homeflare-ct100 incident measured.
|
|
116
|
+
test('mkdir keeps --, and chmod/chown carry -- for a Linux target', async () => {
|
|
109
117
|
const fake = host();
|
|
118
|
+
expect(fake.runner.platform).toBe('linux'); // ★ fakeLinuxHost's own declaration.
|
|
110
119
|
const created = await reconcileDirectory(fake.runner, {
|
|
111
120
|
group: 0,
|
|
112
121
|
mode: 0o755,
|
|
@@ -118,13 +127,30 @@ describe('argv', () => {
|
|
|
118
127
|
{ group: 0, mode: 0o750, owner: 0, path: '/opt/app/bin' },
|
|
119
128
|
created,
|
|
120
129
|
);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
130
|
+
expect(fake.calls.find((call) => call[0] === 'mkdir')).toContain('--');
|
|
131
|
+
expect(fake.calls.find((call) => call[0] === 'chown')).toContain('--');
|
|
132
|
+
expect(fake.calls.find((call) => call[0] === 'chmod')).toContain('--');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('mkdir still keeps --, but chmod/chown omit it for a runner declared as Darwin', async () => {
|
|
136
|
+
const fake = host();
|
|
137
|
+
// ★ Same fake host, only the declared target changes — proves chmodChownEnd() reads
|
|
138
|
+
// `runner.platform`, never the machine `bun test` itself happens to run on.
|
|
139
|
+
const darwinRunner: HostRunner = { ...fake.runner, platform: 'darwin' };
|
|
140
|
+
const created = await reconcileDirectory(darwinRunner, {
|
|
141
|
+
group: 0,
|
|
142
|
+
mode: 0o755,
|
|
143
|
+
owner: 0,
|
|
144
|
+
path: '/opt/app/bin',
|
|
145
|
+
});
|
|
146
|
+
await reconcileDirectory(
|
|
147
|
+
darwinRunner,
|
|
148
|
+
{ group: 0, mode: 0o750, owner: 0, path: '/opt/app/bin' },
|
|
149
|
+
created,
|
|
150
|
+
);
|
|
151
|
+
expect(fake.calls.find((call) => call[0] === 'mkdir')).toContain('--');
|
|
152
|
+
expect(fake.calls.find((call) => call[0] === 'chown')?.includes('--')).toBe(false);
|
|
153
|
+
expect(fake.calls.find((call) => call[0] === 'chmod')?.includes('--')).toBe(false);
|
|
128
154
|
});
|
|
129
155
|
});
|
|
130
156
|
|
|
@@ -127,6 +127,8 @@ export const fakeLinuxHost = (options: FakeLinuxOptions = {}) => {
|
|
|
127
127
|
lookupUser: async (nameOrId) =>
|
|
128
128
|
options.users?.[nameOrId] ??
|
|
129
129
|
Object.values(options.users ?? {}).find((user) => String(user.uid) === nameOrId),
|
|
130
|
+
// ★ This fake models sshRunner()'s own target, which is always Linux — fixed, not an option.
|
|
131
|
+
platform: 'linux',
|
|
130
132
|
privileged,
|
|
131
133
|
readFile: async (path) => files.get(path)?.bytes,
|
|
132
134
|
removeFile: async (path) => {
|
package/src/linux/index.ts
CHANGED
package/src/linux/ssh-runner.ts
CHANGED
|
@@ -154,6 +154,10 @@ export const sshRunner = async (options: SshRunnerOptions): Promise<HostRunner>
|
|
|
154
154
|
const result = await run(execScript(userQuery('linux', nameOrId)));
|
|
155
155
|
return result.exitCode === 0 ? parseUser('linux', result.stdout) : undefined;
|
|
156
156
|
},
|
|
157
|
+
// ★ Fixed, not read from the caller's own `process.platform`: the probe above already
|
|
158
|
+
// refused anything but `uname -s` = Linux, so this is the one true answer for this runner —
|
|
159
|
+
// whatever OS is running Alchemy itself is irrelevant (HostRunner.platform's doc comment).
|
|
160
|
+
platform: 'linux',
|
|
157
161
|
privileged: false,
|
|
158
162
|
readFile: async (path) => {
|
|
159
163
|
const result = await run(readScript(path));
|
package/src/linux/sudo-runner.ts
CHANGED
|
@@ -99,6 +99,9 @@ export const makeSshSudoRunner = (prefixList: readonly string[], deps: SshSudoDe
|
|
|
99
99
|
},
|
|
100
100
|
lookupGroup: (nameOrId) => base.lookupGroup(nameOrId),
|
|
101
101
|
lookupUser: (nameOrId) => base.lookupUser(nameOrId),
|
|
102
|
+
// ★ Forwarded, never hardcoded — same reasoning as ../launchd/sudo-runner.ts's own forward:
|
|
103
|
+
// this runner elevates on the SAME target `base` (sshRunner) already declared.
|
|
104
|
+
platform: base.platform,
|
|
102
105
|
privileged: true,
|
|
103
106
|
readFile: (path) => base.readFile(path),
|
|
104
107
|
removeFile: async (path) => {
|
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `alias-form.ts`'s pure functions against literal `
|
|
3
|
-
*
|
|
2
|
+
* `alias-form.ts`'s pure functions against literal `ModelAliasReadItem` fixtures (the WHOLE-MODEL
|
|
3
|
+
* `get()`'s read-shaped item, option maps and all — OPNSENSE-2) — no network, no fake server.
|
|
4
|
+
* Mirrors `../discord/command-form.ts`'s own test file in spirit.
|
|
4
5
|
*/
|
|
5
6
|
import { describe, expect, test } from 'bun:test';
|
|
6
7
|
import type * as alias from '@distilled.cloud/opnsense/firewall_alias';
|
|
7
8
|
import { attributesOf, matches } from './alias-form.ts';
|
|
8
9
|
import type { AliasProps } from './alias.ts';
|
|
10
|
+
import { optionMap } from './wire.ts';
|
|
9
11
|
|
|
10
12
|
const UUID = '11111111-1111-1111-1111-111111111111';
|
|
11
13
|
|
|
12
|
-
const liveHost = (overrides: Partial<alias.
|
|
13
|
-
content: '10.20.10.1',
|
|
14
|
+
const liveHost = (overrides: Partial<alias.ModelAliasReadItem> = {}): alias.ModelAliasReadItem => ({
|
|
15
|
+
content: optionMap('10.20.10.1'),
|
|
14
16
|
enabled: '1',
|
|
15
17
|
name: 'homeflare_edge',
|
|
16
|
-
type: 'host',
|
|
18
|
+
type: optionMap('host'),
|
|
17
19
|
...overrides,
|
|
18
20
|
});
|
|
19
21
|
|
|
@@ -38,10 +40,20 @@ describe('alias-form attributesOf', () => {
|
|
|
38
40
|
expect(attributesOf(UUID, liveHost({ enabled: '0' })).enabled).toBe(false);
|
|
39
41
|
});
|
|
40
42
|
|
|
41
|
-
test('categories are
|
|
42
|
-
const live = liveHost({ categories: 'c-two,c-one
|
|
43
|
+
test('categories are the selected keys, sorted and deduplicated', () => {
|
|
44
|
+
const live = liveHost({ categories: optionMap('c-two', 'c-one') });
|
|
43
45
|
expect(attributesOf(UUID, live).categories).toEqual(['c-one', 'c-two']);
|
|
44
46
|
});
|
|
47
|
+
|
|
48
|
+
test('an unselected option-map entry is not returned (single-select `type`)', () => {
|
|
49
|
+
const live = liveHost({
|
|
50
|
+
type: {
|
|
51
|
+
host: { value: 'Host(s)', selected: 1 },
|
|
52
|
+
network: { value: 'Network(s)', selected: 0 },
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
expect(attributesOf(UUID, live).type).toBe('host');
|
|
56
|
+
});
|
|
45
57
|
});
|
|
46
58
|
|
|
47
59
|
describe('alias-form matches', () => {
|
|
@@ -57,12 +69,12 @@ describe('alias-form matches', () => {
|
|
|
57
69
|
});
|
|
58
70
|
|
|
59
71
|
test('content drift is caught', () => {
|
|
60
|
-
const drifted = attributesOf(UUID, liveHost({ content: '10.20.10.2' }));
|
|
72
|
+
const drifted = attributesOf(UUID, liveHost({ content: optionMap('10.20.10.2') }));
|
|
61
73
|
expect(matches(drifted, props)).toBe(false);
|
|
62
74
|
});
|
|
63
75
|
|
|
64
76
|
test('category order alone is not drift — both sides normalise through the same set', () => {
|
|
65
|
-
const live = attributesOf(UUID, liveHost({ categories: 'b,a' }));
|
|
77
|
+
const live = attributesOf(UUID, liveHost({ categories: optionMap('b', 'a') }));
|
|
66
78
|
const declared: AliasProps = { ...props, categories: ['a', 'b'] };
|
|
67
79
|
expect(matches(live, declared)).toBe(true);
|
|
68
80
|
});
|
|
@@ -79,9 +91,9 @@ describe('propsFromLive round-trip — the declaration renderer whole contract',
|
|
|
79
91
|
// alias.ts re-exports `attributesOf` itself as `propsFromLive`; tested directly here since
|
|
80
92
|
// alias.ts also wires up the Resource machinery this file does not need to import.
|
|
81
93
|
const live = liveHost({
|
|
82
|
-
categories: 'c-two,c-one',
|
|
83
|
-
content: '203.0.113.0/24',
|
|
84
|
-
type: 'network',
|
|
94
|
+
categories: optionMap('c-two', 'c-one'),
|
|
95
|
+
content: optionMap('203.0.113.0/24'),
|
|
96
|
+
type: optionMap('network'),
|
|
85
97
|
});
|
|
86
98
|
const rendered = attributesOf(UUID, live);
|
|
87
99
|
expect(matches(attributesOf(UUID, live), rendered)).toBe(true);
|
|
@@ -18,24 +18,37 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import type { AliasAttributes, AliasProps } from './alias.ts';
|
|
20
20
|
import type * as alias from '@distilled.cloud/opnsense/firewall_alias';
|
|
21
|
-
import { bool01,
|
|
21
|
+
import { bool01, csvSetOf, numField, selectedOf, selectedOneOf } from './wire.ts';
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* ★ ATTRIBUTES AND THE DECLARATION RENDERER ARE ONE FUNCTION. Attributes ARE exactly the props a
|
|
25
25
|
* noop declaration needs — see the exported `propsFromLive` alias in alias.ts's barrel and
|
|
26
26
|
* `alias-form.test.ts`'s round-trip test (`matches(attributesOf(uuid, live), attributesOf(uuid,
|
|
27
27
|
* live))` is always true by construction, and so is the general case).
|
|
28
|
+
*
|
|
29
|
+
* `live` is `ModelAliasReadItem` (the WHOLE-MODEL `get()`'s read-shaped item), not `AliasItem` —
|
|
30
|
+
* `type`/`interface`/`proto`/`categories` decode as option maps, not strings (OPNSENSE-2; see
|
|
31
|
+
* wire.ts's module doc). `selectedOf`/`selectedOneOf` extract the selected key(s) back to the
|
|
32
|
+
* same plain-string/string[] shape `AliasAttributes` already declared.
|
|
33
|
+
*
|
|
34
|
+
* `content` is `.\AliasContentField`, which overrides `getNodeData()` independently of
|
|
35
|
+
* `BaseListField` (`Firewall/FieldTypes/AliasContentField.php:69-76`) — it still decodes as a map,
|
|
36
|
+
* but every entry is unconditionally `selected: 1` (it is a raw newline-separated list, not a
|
|
37
|
+
* real option set), and ITS wire separator is `\n`, not `,`. `Object.keys` preserves the
|
|
38
|
+
* insertion order OPNsense's own `explode()` built the map in, which matters for a host/URL list
|
|
39
|
+
* in a way `selectedOf`'s alphabetical sort does not preserve — so this one field is reconstructed
|
|
40
|
+
* directly rather than through `selectedOf`.
|
|
28
41
|
*/
|
|
29
|
-
export const attributesOf = (uuid: string, live: alias.
|
|
30
|
-
categories:
|
|
31
|
-
content: live.content ?? '',
|
|
42
|
+
export const attributesOf = (uuid: string, live: alias.ModelAliasReadItem): AliasAttributes => ({
|
|
43
|
+
categories: selectedOf(live.categories),
|
|
44
|
+
content: Object.keys(live.content ?? {}).join('\n'),
|
|
32
45
|
counters: bool01(live.counters, false),
|
|
33
46
|
description: live.description ?? '',
|
|
34
47
|
enabled: bool01(live.enabled, true),
|
|
35
|
-
interface: live.interface
|
|
48
|
+
interface: selectedOneOf(live.interface),
|
|
36
49
|
name: live.name,
|
|
37
|
-
proto: live.proto
|
|
38
|
-
type: live.type,
|
|
50
|
+
proto: selectedOf(live.proto).join(','),
|
|
51
|
+
type: selectedOneOf(live.type),
|
|
39
52
|
updatefreq: numField(live.updatefreq, 0),
|
|
40
53
|
uuid,
|
|
41
54
|
});
|
|
@@ -16,6 +16,7 @@ import { spec } from './alias.ts';
|
|
|
16
16
|
import { fakeFailure, fakeOpnsense, fakeOpnsenseLayer, getOnlyOrFail } from './fake-opnsense.ts';
|
|
17
17
|
import type { OpnsenseWriteRefused } from './policy.ts';
|
|
18
18
|
import { opnsenseOperations } from './resource.ts';
|
|
19
|
+
import { optionMap } from './wire.ts';
|
|
19
20
|
|
|
20
21
|
/** `Effect.flip`'s promise resolves to the union of every error this spec can raise; every
|
|
21
22
|
* refusal test below is asserting specifically on the `OpnsenseWriteRefused` member. */
|
|
@@ -30,15 +31,17 @@ const PROPS: AliasProps = {
|
|
|
30
31
|
uuid: UUID,
|
|
31
32
|
};
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
// `liveItem` builds a `ModelAliasReadItem` — the WHOLE-MODEL `get()`'s read-shaped item, whose
|
|
35
|
+
// `content`/`type` decode as option maps, not strings (OPNSENSE-2 — see alias-form.ts).
|
|
36
|
+
const liveItem = (overrides: Partial<alias.ModelAliasReadItem> = {}): alias.ModelAliasReadItem => ({
|
|
37
|
+
content: optionMap('10.20.10.1'),
|
|
35
38
|
enabled: '1',
|
|
36
39
|
name: 'homeflare_edge',
|
|
37
|
-
type: 'host',
|
|
40
|
+
type: optionMap('host'),
|
|
38
41
|
...overrides,
|
|
39
42
|
});
|
|
40
43
|
|
|
41
|
-
const getResponse = (items: Record<string, alias.
|
|
44
|
+
const getResponse = (items: Record<string, alias.ModelAliasReadItem>) =>
|
|
42
45
|
Response.json({ alias: { aliases: { alias: items } } });
|
|
43
46
|
|
|
44
47
|
describe('Opnsense.Firewall.Alias spec.fetchLive', () => {
|
|
@@ -126,7 +129,9 @@ describe('opnsenseOperations(spec).diff', () => {
|
|
|
126
129
|
});
|
|
127
130
|
|
|
128
131
|
test('update when live content drifted from the declaration — never from a read error', async () => {
|
|
129
|
-
const fake = getOnlyOrFail(() =>
|
|
132
|
+
const fake = getOnlyOrFail(() =>
|
|
133
|
+
getResponse({ [UUID]: liveItem({ content: optionMap('10.20.10.9') }) }),
|
|
134
|
+
);
|
|
130
135
|
const result = await Effect.runPromise(
|
|
131
136
|
opnsenseOperations(spec)
|
|
132
137
|
.diff(PROPS, attributesOf(UUID, liveItem()))
|
|
@@ -138,7 +143,9 @@ describe('opnsenseOperations(spec).diff', () => {
|
|
|
138
143
|
|
|
139
144
|
describe('opnsenseOperations(spec).reconcile and destroy — the read-only policy', () => {
|
|
140
145
|
test('reconcile observes with one GET, then refuses — never a write', async () => {
|
|
141
|
-
const fake = getOnlyOrFail(() =>
|
|
146
|
+
const fake = getOnlyOrFail(() =>
|
|
147
|
+
getResponse({ [UUID]: liveItem({ content: optionMap('drifted') }) }),
|
|
148
|
+
);
|
|
142
149
|
const failure = asRefusal(
|
|
143
150
|
await Effect.runPromise(
|
|
144
151
|
Effect.flip(
|
package/src/opnsense/alias.ts
CHANGED
|
@@ -3,15 +3,16 @@
|
|
|
3
3
|
* READ-ONLY BY DESIGN — see policy.ts's header. `reconcile`/`delete` always refuse.
|
|
4
4
|
*
|
|
5
5
|
* ★ READ GOES THROUGH `get()`, NEVER `getItem`/`searchItem`. `get()` (`firewall_alias.ts#get`)
|
|
6
|
-
* returns the WHOLE model tree — `{ alias: { aliases: { alias: { <uuid>:
|
|
7
|
-
* — so finding one item by uuid is an object-key lookup, not a request the vendor
|
|
8
|
-
* ambiguously.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
6
|
+
* returns the WHOLE model tree — `{ alias: { aliases: { alias: { <uuid>: ModelAliasReadItem,
|
|
7
|
+
* ... } } } }` — so finding one item by uuid is an object-key lookup, not a request the vendor
|
|
8
|
+
* could answer ambiguously. `Alias` has no per-item `getAlias` operation at all (`getItemAction`
|
|
9
|
+
* decorates its result — see the distilled clone's package README) — only `get` (whole model)
|
|
10
|
+
* and `searchAlias` (paginated, see below) exist, so this family's whole-model read is the only
|
|
11
|
+
* option here (unlike Category/Group, whose `getCategory`/`getGroup` per-item operations now
|
|
12
|
+
* exist correctly — OPNSENSE-1 — but this file still uses `get()` uniformly across the three
|
|
13
|
+
* resources; switching Category/Group to their per-item operation is a separate, later PR).
|
|
14
|
+
* `ModelAliasReadItem`'s list-shaped fields (`type`/`interface`/`proto`/`categories`) decode as
|
|
15
|
+
* option maps, not strings (OPNSENSE-2) — see `alias-form.ts`'s `attributesOf`.
|
|
15
16
|
*
|
|
16
17
|
* ⛔ NOT `searchAlias`. It is POST-shaped (a read, per OPNsense's own MVC convention — see the
|
|
17
18
|
* family's docs page) and paginated with no typed limit/offset the generated request schema
|
|
@@ -87,7 +88,12 @@ export const isOpnsenseFirewallAlias = (value: unknown): value is OpnsenseFirewa
|
|
|
87
88
|
(value as { Type?: unknown }).Type === 'Opnsense.Firewall.Alias';
|
|
88
89
|
|
|
89
90
|
/** Exported for direct testing against `fake-opnsense.ts` — the same seam `../discord/*.ts` uses. */
|
|
90
|
-
export const spec: OpnsenseSpec<
|
|
91
|
+
export const spec: OpnsenseSpec<
|
|
92
|
+
AliasProps,
|
|
93
|
+
Alias.ModelAliasReadItem,
|
|
94
|
+
AliasAttributes,
|
|
95
|
+
OpnsenseOpError
|
|
96
|
+
> = {
|
|
91
97
|
attributes: attributesOf,
|
|
92
98
|
fetchLive: (props) =>
|
|
93
99
|
Alias.get({}).pipe(Effect.map((res) => res.alias?.aliases?.alias?.[props.uuid])),
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `group-form.ts`'s pure functions against literal `
|
|
2
|
+
* `group-form.ts`'s pure functions against literal `ModelIfgroupentryReadItem` fixtures (the
|
|
3
|
+
* WHOLE-MODEL `get()`'s read-shaped item — `members` is an option map, OPNSENSE-2) — no network.
|
|
3
4
|
*/
|
|
4
5
|
import { describe, expect, test } from 'bun:test';
|
|
5
6
|
import type * as group from '@distilled.cloud/opnsense/firewall_group';
|
|
6
7
|
import { attributesOf, matches } from './group-form.ts';
|
|
7
8
|
import type { GroupProps } from './group.ts';
|
|
9
|
+
import { optionMap } from './wire.ts';
|
|
8
10
|
|
|
9
11
|
const UUID = '33333333-3333-3333-3333-333333333333';
|
|
10
12
|
|
|
11
|
-
const liveGroup = (
|
|
13
|
+
const liveGroup = (
|
|
14
|
+
overrides: Partial<group.ModelIfgroupentryReadItem> = {},
|
|
15
|
+
): group.ModelIfgroupentryReadItem => ({
|
|
12
16
|
ifname: 'IOT_DEVICES',
|
|
13
|
-
members: 'opt1,opt2',
|
|
17
|
+
members: optionMap('opt1', 'opt2'),
|
|
14
18
|
sequence: '1',
|
|
15
19
|
...overrides,
|
|
16
20
|
});
|
|
@@ -27,8 +31,8 @@ describe('group-form attributesOf', () => {
|
|
|
27
31
|
});
|
|
28
32
|
});
|
|
29
33
|
|
|
30
|
-
test('members are
|
|
31
|
-
const live = liveGroup({ members: 'opt2,opt1
|
|
34
|
+
test('members are the selected keys, sorted and deduplicated', () => {
|
|
35
|
+
const live = liveGroup({ members: optionMap('opt2', 'opt1') });
|
|
32
36
|
expect(attributesOf(UUID, live).members).toEqual(['opt1', 'opt2']);
|
|
33
37
|
});
|
|
34
38
|
|
|
@@ -50,13 +54,13 @@ describe('group-form matches', () => {
|
|
|
50
54
|
});
|
|
51
55
|
|
|
52
56
|
test('member-list order alone is not drift', () => {
|
|
53
|
-
const live = attributesOf(UUID, liveGroup({ members: 'opt2,opt1' }));
|
|
57
|
+
const live = attributesOf(UUID, liveGroup({ members: optionMap('opt2', 'opt1') }));
|
|
54
58
|
const declared: GroupProps = { ...props, members: ['opt1', 'opt2'] };
|
|
55
59
|
expect(matches(live, declared)).toBe(true);
|
|
56
60
|
});
|
|
57
61
|
|
|
58
62
|
test('a removed member is drift', () => {
|
|
59
|
-
const live = attributesOf(UUID, liveGroup({ members: 'opt1' }));
|
|
63
|
+
const live = attributesOf(UUID, liveGroup({ members: optionMap('opt1') }));
|
|
60
64
|
expect(matches(live, props)).toBe(false);
|
|
61
65
|
});
|
|
62
66
|
});
|
|
@@ -65,7 +69,7 @@ describe('propsFromLive round-trip', () => {
|
|
|
65
69
|
test('the props it returns plan noop against the exact live object they came from', () => {
|
|
66
70
|
const live = liveGroup({
|
|
67
71
|
descr: 'IoT VLANs',
|
|
68
|
-
members: 'opt3,opt1,opt2',
|
|
72
|
+
members: optionMap('opt3', 'opt1', 'opt2'),
|
|
69
73
|
nogroup: '1',
|
|
70
74
|
sequence: '5',
|
|
71
75
|
});
|
|
@@ -5,13 +5,22 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { GroupAttributes, GroupProps } from './group.ts';
|
|
7
7
|
import type * as group from '@distilled.cloud/opnsense/firewall_group';
|
|
8
|
-
import { bool01,
|
|
8
|
+
import { bool01, csvSetOf, numField, selectedOf } from './wire.ts';
|
|
9
9
|
|
|
10
|
-
/**
|
|
11
|
-
|
|
10
|
+
/**
|
|
11
|
+
* ★ Attributes and the declaration renderer are one function — see alias-form.ts's header note.
|
|
12
|
+
*
|
|
13
|
+
* `live` is `ModelIfgroupentryReadItem` (the WHOLE-MODEL `get()`'s read-shaped item), not
|
|
14
|
+
* `GroupItem` — `members` (`.\InterfaceField`, `Multiple="Y"`) decodes as an option map, not a
|
|
15
|
+
* comma string (OPNSENSE-2; see wire.ts's module doc).
|
|
16
|
+
*/
|
|
17
|
+
export const attributesOf = (
|
|
18
|
+
uuid: string,
|
|
19
|
+
live: group.ModelIfgroupentryReadItem,
|
|
20
|
+
): GroupAttributes => ({
|
|
12
21
|
description: live.descr ?? '',
|
|
13
22
|
ifname: live.ifname,
|
|
14
|
-
members:
|
|
23
|
+
members: selectedOf(live.members),
|
|
15
24
|
nogroup: bool01(live.nogroup, false),
|
|
16
25
|
sequence: numField(live.sequence, 0),
|
|
17
26
|
uuid,
|