@homeflare/alchemy 0.21.0 → 0.22.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/README.md +3 -1
- package/dist/launchd/index.d.ts +2 -0
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +53 -1
- package/dist/launchd/index.js.map +4 -3
- package/dist/launchd/port-claims.d.ts +31 -0
- package/dist/launchd/port-claims.d.ts.map +1 -0
- package/dist/release/catalog.d.ts +37 -0
- package/dist/release/catalog.d.ts.map +1 -1
- package/dist/release/index.d.ts +2 -1
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +75 -15
- package/dist/release/index.js.map +5 -4
- package/dist/release/openbao.d.ts +35 -0
- package/dist/release/openbao.d.ts.map +1 -0
- package/docs/launchd-ports.md +109 -0
- package/docs/launchd.md +5 -0
- package/docs/release-binary-catalogs.md +32 -7
- package/docs/release-binary-openbao.md +151 -0
- package/docs/release-binary.md +13 -2
- package/package.json +1 -1
- package/src/launchd/index.ts +2 -0
- package/src/launchd/port-claims.test.ts +124 -0
- package/src/launchd/port-claims.ts +102 -0
- package/src/launchd/providers.test.ts +3 -0
- package/src/release/catalog.test.ts +71 -0
- package/src/release/catalog.ts +68 -5
- package/src/release/fixtures/openbao/checksums.txt +83 -0
- package/src/release/fixtures/openbao/checksums.txt.gpgsig +0 -0
- package/src/release/fixtures/openbao/darwin-assets.txt +12 -0
- package/src/release/fixtures/openbao/openbao-gpg-pub-20240618.asc +109 -0
- package/src/release/index.ts +3 -0
- package/src/release/openbao.test.ts +120 -0
- package/src/release/openbao.ts +86 -0
- package/src/release/providers.test.ts +1 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Port collisions, before a job is declared — `claimPorts()`
|
|
2
|
+
|
|
3
|
+
Status: active
|
|
4
|
+
Verified: 2026-09-23
|
|
5
|
+
|
|
6
|
+
`claimPorts(claims)` refuses two of a stack's own jobs sharing one port number, in the stack's own
|
|
7
|
+
program, before `alchemy plan` asks a single provider anything. It is not a provider and it is not
|
|
8
|
+
a `LaunchdJob` prop — it is a plain function a host stack calls itself, the same shape as
|
|
9
|
+
[`catalogBinary()`](../src/release/catalog.ts) (see [release-binary-upstream.md](./release-binary-upstream.md)).
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { claimPorts } from '@homeflare/alchemy/launchd';
|
|
13
|
+
|
|
14
|
+
claimPorts([
|
|
15
|
+
{ owner: 'vmalert', port: 8880 },
|
|
16
|
+
{ owner: 'vmalert-logs', port: 8881, address: '127.0.0.1' },
|
|
17
|
+
]);
|
|
18
|
+
// ...then declare the jobs themselves, as normal.
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Why the stack program, not a provider
|
|
22
|
+
|
|
23
|
+
Two structural facts about Alchemy 2.0.0-beta.79 rule out a provider-side check:
|
|
24
|
+
|
|
25
|
+
- **A provider never diffs a first `create` whose props still hold an unresolved `Output`.**
|
|
26
|
+
Every mini job's `programArguments` carries a `HostFile` config digest as an `Output`, so a
|
|
27
|
+
check living in `LaunchdJob`'s `diff` would be skipped on exactly the deploy that matters most —
|
|
28
|
+
the first one.
|
|
29
|
+
- **A provider never sees its sibling resources.** Nothing in the `LaunchdJob` handlers can read
|
|
30
|
+
what port another declared job holds; the stack program is the only place that sees the whole
|
|
31
|
+
set at once.
|
|
32
|
+
|
|
33
|
+
So `claimPorts` runs where the stack itself calls it, ordered however the stack likes (typically
|
|
34
|
+
right before the jobs it describes), and fails the **plan** — nothing is fetched, written, or
|
|
35
|
+
`launchctl`'d — if two claims collide.
|
|
36
|
+
|
|
37
|
+
## Why it keys on the port number alone
|
|
38
|
+
|
|
39
|
+
`house/nix/homeflare-config/hosts/macmini/modules/lib-ports.nix` (a different repo —
|
|
40
|
+
`homeflare/homeflare`, read at `92cff7f`, 2026-09-23) is the check this replaces for a
|
|
41
|
+
kit-declared job. Its `assertNoCollision` is:
|
|
42
|
+
|
|
43
|
+
```nix
|
|
44
|
+
dupes = lib.subtractLists (lib.unique vals) vals; # vals = lib.attrValues ports
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
— every port **value** in the registry, address and protocol never read. `portClaimProblems`
|
|
48
|
+
reproduces that rule rather than a looser one, because that is the strictness the registry already
|
|
49
|
+
enforces across the whole estate, and a kit-side check that disagreed with it would be a second,
|
|
50
|
+
inconsistent source of truth.
|
|
51
|
+
|
|
52
|
+
### The measured bind table
|
|
53
|
+
|
|
54
|
+
What that trades away is real: the OS itself is looser than the registry. Measured on the mini
|
|
55
|
+
(Darwin 27.2.0) with `/usr/bin/python3` sockets, `SO_REUSEADDR` set on both sides, loopback high
|
|
56
|
+
ports — first on 2026-09-23 at 47913–47917, **re-measured independently the same day** at
|
|
57
|
+
47923–47927 with the same result:
|
|
58
|
+
|
|
59
|
+
| first bind | second bind | outcome |
|
|
60
|
+
| ------------------- | ----------- | ------------------------ |
|
|
61
|
+
| `127.0.0.1` | `127.0.0.1` | **refused** — EADDRINUSE |
|
|
62
|
+
| `0.0.0.0` | `127.0.0.1` | both bind |
|
|
63
|
+
| `127.0.0.1` | `0.0.0.0` | both bind |
|
|
64
|
+
| `[::]` (`V6ONLY=0`) | `127.0.0.1` | both bind |
|
|
65
|
+
| `[::1]` | `127.0.0.1` | both bind |
|
|
66
|
+
|
|
67
|
+
Only an **exact** address+port duplicate fails at bind time; a wildcard next to a specific address
|
|
68
|
+
on the same port silently splits traffic between two listeners instead of refusing. `claimPorts`
|
|
69
|
+
refuses that pairing anyway — the port number is the key, exactly like the registry — because
|
|
70
|
+
"the OS allowed it" is not the same claim as "the estate meant two things to share a port."
|
|
71
|
+
|
|
72
|
+
- **Every mini daemon this affects is Go**, and Go sets `SO_REUSEADDR` on **every** darwin
|
|
73
|
+
listener unconditionally: `setDefaultListenerSockopts` in `net/sockopt_bsd.go` (Go 1.26.5,
|
|
74
|
+
installed at `/opt/homebrew/Cellar/go/1.26.5`, `//go:build darwin || …`) —
|
|
75
|
+
`syscall.SetsockoptInt(s, syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1)`, no opt-out. The
|
|
76
|
+
Victoria family, Prometheus-family exporters and OpenBao are all Go, so the table above is their
|
|
77
|
+
actual behaviour, not a worst case.
|
|
78
|
+
- **vector is Rust.** Whether it also sets `SO_REUSEADDR` by default is **unmeasured** — say so
|
|
79
|
+
rather than assume Go's default carries over.
|
|
80
|
+
- **launchd itself has no port concept to collide on.** `man 5 launchd.plist`'s only socket-shaped
|
|
81
|
+
key is `Sockets` (launch-on-demand socket activation via `launch_activate_socket(3)`), and no
|
|
82
|
+
`LaunchdJobProps` today exposes it — a daemon binds its own port itself, launchd never mediates
|
|
83
|
+
it. So this check is squarely about daemons colliding with each other at bind time, not about
|
|
84
|
+
anything launchd enforces.
|
|
85
|
+
|
|
86
|
+
## What it cannot see
|
|
87
|
+
|
|
88
|
+
- **A job the stack does not pass.** `claimPorts` takes exactly the array a stack builds and
|
|
89
|
+
hands it; there is no argv-parsing, no config-file reading and no host inspection here to
|
|
90
|
+
recover a port a stack forgot to include. Leaving a job's claim out of the list is invisible to
|
|
91
|
+
this check, the same way a port left out of `lib-ports.nix` was invisible on 2026-09-02.
|
|
92
|
+
- **A daemon still on Nix**, unless the migrating stack also passes that daemon's port as a claim
|
|
93
|
+
of its own (owner naming the Nix job, so the message is legible during a cutover). Until a job
|
|
94
|
+
moves off Nix, `lib-ports.nix` is still the thing keeping it collision-free; `claimPorts` only
|
|
95
|
+
starts covering a port once a kit stack declares the job that binds it.
|
|
96
|
+
- **Two protocols of the same port number.** Keying on the port alone means a daemon on `tcp/9094`
|
|
97
|
+
and another on `udp/9094` would be refused as a collision, same as the Nix registry. No mini job
|
|
98
|
+
does that today (checked against the registry at `92cff7f`); if one needs to, the key widens
|
|
99
|
+
deliberately, in its own change — not silently here.
|
|
100
|
+
|
|
101
|
+
## The error is a defect, not a typed channel
|
|
102
|
+
|
|
103
|
+
`claimPorts` throws `PortRefused` (a `Data.TaggedError`), and like `catalogBinary()`, that throw
|
|
104
|
+
inside a stack program's `Effect.gen` body is a **defect** — `Effect.catchTag('PortRefused', …)`
|
|
105
|
+
never sees it, only `Effect.runPromiseExit` plus `Cause.hasDies`/`Cause.squash`. This is the exact
|
|
106
|
+
gap [release-binary-upstream.md](./release-binary-upstream.md) names as gap 10 for
|
|
107
|
+
`catalogBinary()`; it applies here unchanged because it is the same shape of function in the same
|
|
108
|
+
kind of caller, not a new hole. `port-claims.test.ts` measures it the same way
|
|
109
|
+
`error-channel.test.ts` measures `catalogBinary()`'s.
|
package/docs/launchd.md
CHANGED
|
@@ -165,6 +165,11 @@ Nix job is booted out first. Per job:
|
|
|
165
165
|
- **Rolling back** is the same three steps reversed: destroy (or remove) the resource, restore the
|
|
166
166
|
job in the Nix config, activate.
|
|
167
167
|
|
|
168
|
+
## Port collisions
|
|
169
|
+
|
|
170
|
+
`claimPorts()` refuses two of a stack's own jobs on one port, in the stack program, before any
|
|
171
|
+
job is declared: [launchd-ports.md](./launchd-ports.md).
|
|
172
|
+
|
|
168
173
|
## The runner
|
|
169
174
|
|
|
170
175
|
`HostRunner` (see `src/launchd/runner.ts`) is `exec(argv)` (never through a shell), `readFile`,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Release binaries — the data sets, and adding one
|
|
2
2
|
|
|
3
3
|
Status: active
|
|
4
|
-
Verified: 2026-09-
|
|
4
|
+
Verified: 2026-09-23
|
|
5
5
|
|
|
6
6
|
`Release.Binary` takes one pinned archive as props and knows no vendor. What
|
|
7
7
|
each vendor published lives in a **data set** beside it (`src/release/<vendor>.ts`),
|
|
@@ -9,7 +9,7 @@ typed `ReleaseCatalog`, and `catalogBinary(dataSet, request)` turns one entry
|
|
|
9
9
|
into props. This page is what is in the data sets, and the rule for adding to
|
|
10
10
|
them. The guide is [release-binary.md](./release-binary.md).
|
|
11
11
|
|
|
12
|
-
## `VICTORIA_RELEASES`
|
|
12
|
+
## `VICTORIA_RELEASES`
|
|
13
13
|
|
|
14
14
|
| package | version | asset (darwin-arm64) | binaries (installed ← member) |
|
|
15
15
|
| ------------------ | ------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
@@ -36,6 +36,31 @@ change that re-pins both digest layers.
|
|
|
36
36
|
A stack that keeps its own digest slots (the mini's `PINS`) can compare them
|
|
37
37
|
with `catalogBinary(...).sha256` in its own tests: two witnesses, one source.
|
|
38
38
|
|
|
39
|
+
## `OPENBAO_RELEASES` — the first data set with a `computed` member
|
|
40
|
+
|
|
41
|
+
| package | version | asset (darwin_arm64) | binaries (installed ← member) |
|
|
42
|
+
| --------- | ------- | ----------------------------------- | ----------------------------------------------------------- |
|
|
43
|
+
| `openbao` | 2.6.2 | `openbao_2.6.2_darwin_arm64.tar.gz` | `bao` ← `bao` (member digest: `computed`, ours — see below) |
|
|
44
|
+
|
|
45
|
+
Walked down 2026-09-23, every command in
|
|
46
|
+
[release-binary-openbao.md](./release-binary-openbao.md).
|
|
47
|
+
|
|
48
|
+
- ★ **Platform key is `darwin_arm64`, underscore** — OpenBao's own asset
|
|
49
|
+
spelling, not Victoria's `darwin-arm64`. Each data set spells the platform
|
|
50
|
+
the way that vendor's asset names do; nothing here picks one estate-wide
|
|
51
|
+
convention (`openbao.ts`'s header has the rule and the one-line change if a
|
|
52
|
+
reviewer wants it unified).
|
|
53
|
+
- ★ **The member digest is `computed`, not `members`.** `checksums.txt` lists
|
|
54
|
+
archives and every `.sbom.json`, never `bao` — the case this page used to
|
|
55
|
+
call out as "that field does not exist yet". It exists now: `catalogBinary`,
|
|
56
|
+
`catalogProblems` and `identifyBinary` all read `members` first, then
|
|
57
|
+
`computed`, and refuse a member pinned in both (`catalog.ts`).
|
|
58
|
+
- ★ **GPG checked, Sigstore read but not verified.** `gpgv` against the key
|
|
59
|
+
published at openbao.org confirmed the signature over `checksums.txt`; the
|
|
60
|
+
key's own fingerprint matches OpenBao's install docs. `cosign` is not on
|
|
61
|
+
the mini, so the Sigstore bundle's workflow identity is recorded as
|
|
62
|
+
unverified, not as a passing check.
|
|
63
|
+
|
|
39
64
|
## Adding a version of a vendor already here
|
|
40
65
|
|
|
41
66
|
Walk it down from the vendor, never from memory:
|
|
@@ -66,9 +91,9 @@ its data module's header with the date:
|
|
|
66
91
|
estate census found (landscape PR 88) list **archives only**: Prometheus's
|
|
67
92
|
`sha256sums.txt`, vector's `-SHA256SUMS`, pyroscope's and OpenBao's
|
|
68
93
|
`checksums.txt`. For those the member digest is not a vendor fact. It is
|
|
69
|
-
computed from a verified archive, and the data set
|
|
70
|
-
|
|
71
|
-
|
|
94
|
+
computed from a verified archive, and the data set says so under
|
|
95
|
+
`computed` rather than filing it beside the vendor's `members` lines
|
|
96
|
+
(`catalog.ts`; `OPENBAO_RELEASES` above is the first consumer).
|
|
72
97
|
- **Its binary's linkage** (`otool -L` on the extracted member, or `ldd`).
|
|
73
98
|
Vendor binaries were measured self-contained **only for the five Victoria
|
|
74
99
|
ones** (system libraries and `/usr/lib/libresolv.9.dylib` only). That is not
|
|
@@ -84,8 +109,8 @@ where `-version` prints, if anything ever runs it (Victoria's goes to stderr).
|
|
|
84
109
|
The queue, from the census, each **not yet walked for this resource**:
|
|
85
110
|
alertmanager, blackbox_exporter, node_exporter and postgres_exporter
|
|
86
111
|
(Prometheus `sha256sums.txt`), unpoller (detached `.sig`; 5.2.7 renames
|
|
87
|
-
`darwin_arm64` to `darwin_all`), pyroscope
|
|
88
|
-
|
|
112
|
+
`darwin_arm64` to `darwin_all`), pyroscope and vector. `bao` (OpenBao) is
|
|
113
|
+
walked — see `OPENBAO_RELEASES` above.
|
|
89
114
|
|
|
90
115
|
⚠️ REASONED NOT MEASURED: the Prometheus-family archives wrap their files in a
|
|
91
116
|
directory, and `tar.ts` refuses a directory entry (and any PAX header) for the
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Release binaries — the OpenBao walk-down
|
|
2
|
+
|
|
3
|
+
Status: active
|
|
4
|
+
Verified: 2026-09-23
|
|
5
|
+
|
|
6
|
+
`OPENBAO_RELEASES` (`src/release/openbao.ts`) is the second data set, and the
|
|
7
|
+
first whose checksum file never lists its own binary. Every command below was
|
|
8
|
+
run 2026-09-23 against `openbao/openbao` v2.6.2, from this worktree, network
|
|
9
|
+
reads only — nothing executed the vendor binary.
|
|
10
|
+
|
|
11
|
+
## The release itself
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
gh api repos/openbao/openbao/releases/tags/v2.6.2
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`published_at` 2026-08-18T16:21:00Z, `immutable: false`, 251 assets.
|
|
18
|
+
`openbao_2.6.2_darwin_arm64.tar.gz`: 76,833,570 bytes,
|
|
19
|
+
`digest: sha256:4e495376174accc0e014d31e9901f518a974f966850c839f626347eaac05fd52`.
|
|
20
|
+
|
|
21
|
+
## checksums.txt
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
curl -sL -o checksums.txt \
|
|
25
|
+
https://github.com/openbao/openbao/releases/download/v2.6.2/checksums.txt
|
|
26
|
+
shasum -a 256 checksums.txt
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
8,683 bytes, `sha256 3d4a19fdc54a86fd94ce59ea91316dbf4304180bb27ae818e78e85c7f2164645`
|
|
30
|
+
— equal to GitHub's own `digest` for the asset. 83 lines, every one matching
|
|
31
|
+
`^[0-9a-f]{64} [^\s/]+$` (`checksums.ts`'s parser), LF endings, a trailing
|
|
32
|
+
newline, no name repeated. Line 20 is the darwin_arm64 archive and equals the
|
|
33
|
+
release API's digest. **41 of the 83 lines are `.sbom.json`; the rest are
|
|
34
|
+
archives, `.deb` or `.rpm` — never `bao` itself**: `grep -n ' bao$'
|
|
35
|
+
checksums.txt` finds nothing. That is why the member digest is `computed`,
|
|
36
|
+
never `members` (checksums.ts's header explains the shape generally; this is
|
|
37
|
+
OpenBao's instance of it).
|
|
38
|
+
|
|
39
|
+
## The signature over checksums.txt
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
curl -sL -o checksums.txt.gpgsig \
|
|
43
|
+
https://github.com/openbao/openbao/releases/download/v2.6.2/checksums.txt.gpgsig
|
|
44
|
+
curl -sL -o openbao-gpg-pub-20240618.asc \
|
|
45
|
+
https://openbao.org/assets/openbao-gpg-pub-20240618.asc
|
|
46
|
+
gpg --dearmor -o openbao-pub.gpg openbao-gpg-pub-20240618.asc
|
|
47
|
+
gpgv --keyring ./openbao-pub.gpg checksums.txt.gpgsig checksums.txt
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
gpgv: Signature made Tue Aug 18 11:57:25 2026 EDT
|
|
52
|
+
gpgv: using RSA key E617DCD4065C2AFC0B2CF7A7BA8BC08C0F691F94
|
|
53
|
+
gpgv: Good signature from "OpenBao <openbao@lists.lfedge.org>"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`gpg --show-keys --with-fingerprint` on the same file: primary
|
|
57
|
+
`66D1 5FDD 8728 7219 C8E1 5478 D200 CD70 2853 E6D0`, signing subkey
|
|
58
|
+
`E617 DCD4 065C 2AFC 0B2C F7A7 BA8B C08C 0F69 1F94` — both match OpenBao's own
|
|
59
|
+
`install.mdx` at this tag. `checksums.txt.gpgsig` is 566 bytes, sha256
|
|
60
|
+
`40f294d642a2b4e9adac766ca6b8b2633e15b3dbfdf63969dedbf46d94df53e8`; the key
|
|
61
|
+
file is 6,896 bytes, sha256
|
|
62
|
+
`1862a196422947124282e026ea4e09d6f7c5d383b628d90c17906936e6e5a8e0`.
|
|
63
|
+
|
|
64
|
+
⚠️ **Trust on first use, across two channels one project runs**: the release
|
|
65
|
+
asset (GitHub) and the key (openbao.org) are both OpenBao's own
|
|
66
|
+
infrastructure. A verified signature says the two agree, not that a third
|
|
67
|
+
party attests to either. `cosign` is not installed on the mini (`which
|
|
68
|
+
cosign` — not found), so the Sigstore bundle alongside `checksums.txt` was
|
|
69
|
+
read, not verified: its certificate names
|
|
70
|
+
`https://github.com/openbao/openbao/.github/workflows/release.yml@refs/heads/release/2.6.x`,
|
|
71
|
+
issuer `https://token.actions.githubusercontent.com` — recorded in
|
|
72
|
+
`openbao.ts` as an **unverified** identity. "GPG or Sigstore" is met by the
|
|
73
|
+
GPG half.
|
|
74
|
+
|
|
75
|
+
Attestations, for completeness: `gh api
|
|
76
|
+
repos/openbao/openbao/attestations/sha256:4e49…` → 404. Positive control
|
|
77
|
+
(`gh api repos/cli/cli/attestations/sha256:<its macOS_arm64 digest>`) → 200,
|
|
78
|
+
2 attestations, same day. A 404 here means "none published", not "the API is
|
|
79
|
+
down".
|
|
80
|
+
|
|
81
|
+
## The archive: download, hash, unpack
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
curl -sL -o openbao_2.6.2_darwin_arm64.tar.gz \
|
|
85
|
+
https://github.com/openbao/openbao/releases/download/v2.6.2/openbao_2.6.2_darwin_arm64.tar.gz
|
|
86
|
+
shasum -a 256 openbao_2.6.2_darwin_arm64.tar.gz
|
|
87
|
+
tar -tvzf openbao_2.6.2_darwin_arm64.tar.gz
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
76,833,570 bytes, sha256
|
|
91
|
+
`4e495376174accc0e014d31e9901f518a974f966850c839f626347eaac05fd52` — equal to
|
|
92
|
+
the pin. `tar -tvzf` (full stream, nothing saved beyond the archive itself):
|
|
93
|
+
four root-level regular files, `ustar`, no PAX header, no directory entry —
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
-rw-r--r-- 0 runner runner 131858 CHANGELOG.md
|
|
97
|
+
-rw-r--r-- 0 runner runner 15958 LICENSE
|
|
98
|
+
-rw-r--r-- 0 runner runner 7136 README.md
|
|
99
|
+
-rwxr-xr-x 0 root root 193769394 bao
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The kit's own `extractMembers` (`archive.ts`) read the same stream without
|
|
103
|
+
refusing it and returned the same four names, in the same order — the
|
|
104
|
+
previous partial-range read (`release-binary-catalogs.md`'s queue note) is
|
|
105
|
+
superseded by this full-stream one, so **no tar-reader change is needed for
|
|
106
|
+
this vendor**.
|
|
107
|
+
|
|
108
|
+
## The member digest — computed, not the vendor's
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
tar -xzOf openbao_2.6.2_darwin_arm64.tar.gz bao | shasum -a 256
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`d476d17e81a35e6d70dd7e86a8ab2a3664313525118f1f1cfe0130e7a2b95f3a` — and the
|
|
115
|
+
kit's `extractMembers` over the same bytes produced the identical digest. Two
|
|
116
|
+
independent readers agree, which is what `computed` in `openbao.ts` records,
|
|
117
|
+
dated, next to a header explaining it is not a vendor fact.
|
|
118
|
+
|
|
119
|
+
## The extracted binary, never run
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
tar -xzf openbao_2.6.2_darwin_arm64.tar.gz bao
|
|
123
|
+
chmod -x bao # never made executable in this worktree
|
|
124
|
+
otool -L bao
|
|
125
|
+
codesign -dv --verbose=2 bao
|
|
126
|
+
xattr -l bao
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`otool -L`: `libSystem.B.dylib`, `libresolv.9.dylib`, CoreFoundation,
|
|
130
|
+
Security — all system frameworks, none from `/nix/store` — the same class as
|
|
131
|
+
Victoria's five. `codesign`: `flags=0x20002(adhoc,linker-signed)`, no
|
|
132
|
+
TeamIdentifier — a stock Go/macOS ad-hoc signature, same as the Victoria
|
|
133
|
+
binaries. `xattr -l`: `com.apple.provenance` only, no quarantine. Build
|
|
134
|
+
provenance (`goreleaser.other.yaml` at v2.6.2, read, not re-measured here):
|
|
135
|
+
`CGO_ENABLED=0`, tag `ui` — the same build class the darwin release ships for
|
|
136
|
+
every OpenBao platform.
|
|
137
|
+
|
|
138
|
+
Both scratch copies (`bao`, the `.tar.gz`) were deleted after these commands;
|
|
139
|
+
nothing was executed and nothing was written outside the scratch directory.
|
|
140
|
+
|
|
141
|
+
## What this does NOT establish
|
|
142
|
+
|
|
143
|
+
- **Sigstore identity is read, not cryptographically checked** — `cosign` was
|
|
144
|
+
not run. Treat the workflow identity above as a claim, not a verification.
|
|
145
|
+
- **The agent never ran `bao`.** Whether it starts under launchd, and whether
|
|
146
|
+
its approle auto-auth / template / exec behaviour matches the Nix build
|
|
147
|
+
the vault server runs today, is unmeasured — expected to be unaffected
|
|
148
|
+
(nothing here suggests otherwise), but nobody has watched it happen.
|
|
149
|
+
- **No adoption or install-path measurement.** This PR adds only the catalog
|
|
150
|
+
entry, its tests and this record — no `ReleaseBinary` declaration, no
|
|
151
|
+
consuming stack change.
|
package/docs/release-binary.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Release binaries — `@homeflare/alchemy/release`
|
|
2
2
|
|
|
3
3
|
Status: active
|
|
4
|
-
Verified: 2026-09-
|
|
4
|
+
Verified: 2026-09-23
|
|
5
5
|
|
|
6
6
|
`Release.Binary` (`ReleaseBinary`) installs one binary out of a pinned release
|
|
7
7
|
archive, verified twice: the archive against its pinned SHA-256 before anything
|
|
@@ -37,9 +37,20 @@ const vmalert = yield* ReleaseBinary('vmalert', {
|
|
|
37
37
|
directory: dir.path, // ★ an Output: the engine creates the directory first
|
|
38
38
|
owner: 0, group: 0, mode: 0o555,
|
|
39
39
|
});
|
|
40
|
-
yield* LaunchdJob('vmalert', { programArguments: [vmalert.path, '--httpListenAddr=127.0.0.1:8880'], … });
|
|
40
|
+
yield* LaunchdJob('vmalert-job', { programArguments: [vmalert.path, '--httpListenAddr=127.0.0.1:8880'], … });
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
+
⚠️ **Give every resource its own logical id, even across different types.**
|
|
44
|
+
alchemy@2.0.0-beta.79's FQN is `namespace + id` alone (`FQN.ts`) — it does
|
|
45
|
+
NOT fold in the resource type. `stack.resources[fqn]` (`Resource.ts`)
|
|
46
|
+
returns the **first** registration for a repeated id whatever type it was:
|
|
47
|
+
`ReleaseBinary('vmalert', …)` then `LaunchdJob('vmalert', …)` in the same
|
|
48
|
+
namespace silently hands the job call back the _binary_ resource, and the
|
|
49
|
+
job is never registered at all — no error, no second row in the plan, just a
|
|
50
|
+
job that does not exist. Measured against the installed alchemy package,
|
|
51
|
+
2026-09-23. Naming them `'vmalert'` and `'vmalert-job'` (as above) avoids it;
|
|
52
|
+
so does any other pair of distinct ids.
|
|
53
|
+
|
|
43
54
|
- **`catalogBinary()` runs in your stack program.** A version, package,
|
|
44
55
|
platform or binary the data set does not pin throws `BinaryRefused` there,
|
|
45
56
|
so `alchemy plan` fails before any resource is asked anything
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
package/src/launchd/index.ts
CHANGED
|
@@ -21,6 +21,8 @@ export type { LocalRunnerOptions } from './local-runner.ts';
|
|
|
21
21
|
export { localRunner } from './local-runner.ts';
|
|
22
22
|
export type { PlistDict, PlistValue } from './plist.ts';
|
|
23
23
|
export { PlistError, renderPlist } from './plist.ts';
|
|
24
|
+
export type { PortClaim } from './port-claims.ts';
|
|
25
|
+
export { PortRefused, claimPorts, portClaimProblems } from './port-claims.ts';
|
|
24
26
|
export { launchdProviders } from './providers.ts';
|
|
25
27
|
export type { ExecResult, FileStat, HostRunner, HostUser, WriteOptions } from './runner.ts';
|
|
26
28
|
export { HostRunnerService, canActAsRoot, hostRunnerLayer } from './runner.ts';
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port collisions, pure and through the engine. Every refusal here happens in the stack's own
|
|
3
|
+
* program (an ordinary `throw` before any resource is even declared), so plan.test.ts's proof for
|
|
4
|
+
* `catalogBinary()` — that the engine never runs a provider or touches a host once the body throws
|
|
5
|
+
* — is re-proved below for `claimPorts`, not assumed to carry over.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import { fakeStack } from '../openbao/fake-stack.ts';
|
|
10
|
+
import { fakeRunner } from './fake-runner.ts';
|
|
11
|
+
import { LaunchdJob } from './job.ts';
|
|
12
|
+
import { type PortClaim, PortRefused, claimPorts, portClaimProblems } from './port-claims.ts';
|
|
13
|
+
import { launchdProviders } from './providers.ts';
|
|
14
|
+
import type { HostRunner } from './runner.ts';
|
|
15
|
+
|
|
16
|
+
describe('portClaimProblems', () => {
|
|
17
|
+
test.each([
|
|
18
|
+
[
|
|
19
|
+
'a port that is not an integer 1–65535 (too low)',
|
|
20
|
+
[{ owner: 'vector', port: 0 }],
|
|
21
|
+
'port must be an integer from 1 to 65535',
|
|
22
|
+
],
|
|
23
|
+
[
|
|
24
|
+
'a port that is not an integer 1–65535 (too high)',
|
|
25
|
+
[{ owner: 'vector', port: 65536 }],
|
|
26
|
+
'port must be an integer from 1 to 65535',
|
|
27
|
+
],
|
|
28
|
+
[
|
|
29
|
+
'a port that is not an integer 1–65535 (fractional)',
|
|
30
|
+
[{ owner: 'vector', port: 9094.5 }],
|
|
31
|
+
'port must be an integer from 1 to 65535',
|
|
32
|
+
],
|
|
33
|
+
['an empty owner', [{ owner: '', port: 9094 }], 'has an empty owner'],
|
|
34
|
+
[
|
|
35
|
+
'one owner claiming a port twice',
|
|
36
|
+
[
|
|
37
|
+
{ owner: 'vector', port: 9094 },
|
|
38
|
+
{ owner: 'vector', port: 9094, address: '127.0.0.1' },
|
|
39
|
+
],
|
|
40
|
+
'vector claims port 9094 more than once',
|
|
41
|
+
],
|
|
42
|
+
[
|
|
43
|
+
'two owners on one port, naming both owners and both addresses',
|
|
44
|
+
[
|
|
45
|
+
{ owner: 'vector', port: 9094, address: '0.0.0.0' },
|
|
46
|
+
{ owner: 'victoria-logs', port: 9094, address: '127.0.0.1' },
|
|
47
|
+
],
|
|
48
|
+
'port 9094 is claimed by more than one owner: vector (0.0.0.0) and victoria-logs (127.0.0.1)',
|
|
49
|
+
],
|
|
50
|
+
] satisfies Array<[string, PortClaim[], string]>)('refuses %s', (_, claims, message) => {
|
|
51
|
+
expect(portClaimProblems(claims).join('; ')).toContain(message);
|
|
52
|
+
expect(() => claimPorts(claims)).toThrow(message);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('distinct ports return no problems', () => {
|
|
56
|
+
const claims: PortClaim[] = [
|
|
57
|
+
{ owner: 'vector', port: 9094 },
|
|
58
|
+
{ owner: 'victoria-logs', port: 9428, address: '127.0.0.1' },
|
|
59
|
+
];
|
|
60
|
+
expect(portClaimProblems(claims)).toEqual([]);
|
|
61
|
+
expect(() => claimPorts(claims)).not.toThrow();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ★ MEASURED (docs/launchd-ports.md): on macOS both of these actually bind — SO_REUSEADDR plus
|
|
65
|
+
// an address mismatch lets a wildcard and a specific address share one port at the OS level.
|
|
66
|
+
// The check refuses it anyway: the key is the port NUMBER alone, like lib-ports.nix.
|
|
67
|
+
test('0.0.0.0:9094 against 127.0.0.1:9094 is refused, though the OS would let both bind', () => {
|
|
68
|
+
const problems = portClaimProblems([
|
|
69
|
+
{ owner: 'vector', port: 9094, address: '0.0.0.0' },
|
|
70
|
+
{ owner: 'victoria-logs', port: 9094, address: '127.0.0.1' },
|
|
71
|
+
]);
|
|
72
|
+
expect(problems).toEqual([
|
|
73
|
+
'port 9094 is claimed by more than one owner: vector (0.0.0.0) and victoria-logs (127.0.0.1)',
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('claimPorts throws PortRefused carrying the house refusal sentence', () => {
|
|
78
|
+
let caught: unknown;
|
|
79
|
+
try {
|
|
80
|
+
claimPorts([
|
|
81
|
+
{ owner: 'a', port: 9094 },
|
|
82
|
+
{ owner: 'b', port: 9094 },
|
|
83
|
+
]);
|
|
84
|
+
} catch (cause) {
|
|
85
|
+
caught = cause;
|
|
86
|
+
}
|
|
87
|
+
expect(caught).toBeInstanceOf(PortRefused);
|
|
88
|
+
expect((caught as Error).message).toMatch(/^Launchd ports: .* Nothing was declared\.$/);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('through the engine', () => {
|
|
93
|
+
test('a collision fails the plan before any provider or host call', async () => {
|
|
94
|
+
const fake = fakeRunner({ euid: 0 });
|
|
95
|
+
const events: string[] = [];
|
|
96
|
+
const runner: HostRunner = {
|
|
97
|
+
...fake.runner,
|
|
98
|
+
exec: (argv) => {
|
|
99
|
+
events.push(`exec ${argv.join(' ')}`);
|
|
100
|
+
return fake.runner.exec(argv);
|
|
101
|
+
},
|
|
102
|
+
writeFileAtomic: (path, bytes, options) => {
|
|
103
|
+
events.push(`write ${path}`);
|
|
104
|
+
return fake.runner.writeFileAtomic(path, bytes, options);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
const stack = fakeStack(launchdProviders(runner), {}, 'PortClaimsStack');
|
|
108
|
+
const body = Effect.gen(function* () {
|
|
109
|
+
claimPorts([
|
|
110
|
+
{ owner: 'vector', port: 9094 },
|
|
111
|
+
{ owner: 'victoria-logs', port: 9094 },
|
|
112
|
+
]);
|
|
113
|
+
// Never reached: proves the collision is caught before this job — or any provider/host
|
|
114
|
+
// call it would make — runs at all.
|
|
115
|
+
yield* LaunchdJob('never-created', {
|
|
116
|
+
domain: 'system',
|
|
117
|
+
label: 'com.example.never-created',
|
|
118
|
+
programArguments: ['/usr/local/bin/never-created'],
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
await expect(stack.deploy(body)).rejects.toThrow('Nothing was declared');
|
|
122
|
+
expect(events).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port collision refusal for a stack's own launchd jobs — pure: no host, no network, and
|
|
3
|
+
* runtime-portable (no `Bun.*`, no `node:*`), so a future Linux consumer (systemd units, under
|
|
4
|
+
* `/linux`) can import it unchanged.
|
|
5
|
+
*
|
|
6
|
+
* ★ MIRRORS house/nix's REGISTRY, NOT A NEW RULE. `lib-ports.nix` (house @ 92cff7f) collides on
|
|
7
|
+
* `lib.attrValues ports` alone — `dupes = lib.subtractLists (lib.unique vals) vals` — keying on
|
|
8
|
+
* the PORT NUMBER by itself; address and protocol are never read. `portClaimProblems` below keys
|
|
9
|
+
* the same way on purpose: it replaces that check for the jobs a kit stack declares, and a looser
|
|
10
|
+
* or stricter rule would silently diverge from the registry it takes over from. What that trades
|
|
11
|
+
* away — a wildcard bind beside a specific one on the same port both succeed, silently splitting
|
|
12
|
+
* traffic — is measured in docs/launchd-ports.md, not assumed.
|
|
13
|
+
* ★ IN THE STACK PROGRAM, NOT A PROVIDER — same shape as `catalogBinary()` (../release/catalog.ts).
|
|
14
|
+
* Two reasons, both structural to Alchemy 2.0.0-beta.79: Alchemy never diffs a first `create`
|
|
15
|
+
* whose props still hold an unresolved `Output`, and every mini job's `programArguments` carries
|
|
16
|
+
* a `HostFile` config digest as an `Output` — so a provider-side check would miss the very first
|
|
17
|
+
* deploy of every job. And a provider never sees its sibling resources, so it could not compare
|
|
18
|
+
* one job's port against another's even if props were resolved. A plain function called from the
|
|
19
|
+
* stack's own `Effect.gen` body sees every claim the stack passes, before anything is applied.
|
|
20
|
+
* ⚠️ IT SEES ONLY WHAT THE STACK PASSES. A job the stack forgets to add to its claim list is
|
|
21
|
+
* invisible to this check — there is no argv-parsing or host inspection here to catch that, and
|
|
22
|
+
* adding one would be guessing at a job's own config shape. docs/launchd-ports.md says what this
|
|
23
|
+
* cannot see, in full.
|
|
24
|
+
*/
|
|
25
|
+
import * as Data from 'effect/Data';
|
|
26
|
+
|
|
27
|
+
/** One job's claim on one port. `address` is informational only — the key is `port` alone. */
|
|
28
|
+
export interface PortClaim {
|
|
29
|
+
/** Whose claim this is, for the refusal message: a job id or label, never empty. */
|
|
30
|
+
readonly owner: string;
|
|
31
|
+
readonly port: number;
|
|
32
|
+
/** What the job binds, if the stack knows it. Omitted claims still collide by port number. */
|
|
33
|
+
readonly address?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const describeAddress = (address: string | undefined): string => address ?? 'unspecified address';
|
|
37
|
+
|
|
38
|
+
const describeOwner = (owner: string): string => (owner === '' ? '(empty owner)' : owner);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Everything wrong with a set of claims; empty when none collide. ★ KEYS ON `port` ALONE, exactly
|
|
42
|
+
* like `lib-ports.nix`'s `assertNoCollision` — see the file header for why.
|
|
43
|
+
*/
|
|
44
|
+
export const portClaimProblems = (claims: readonly PortClaim[]): string[] => {
|
|
45
|
+
const found: string[] = [];
|
|
46
|
+
const byPort = new Map<number, PortClaim[]>();
|
|
47
|
+
for (const claim of claims) {
|
|
48
|
+
if (!Number.isSafeInteger(claim.port) || claim.port < 1 || claim.port > 65535) {
|
|
49
|
+
found.push(
|
|
50
|
+
`${describeOwner(claim.owner)}: port must be an integer from 1 to 65535, got ${String(claim.port)}`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (claim.owner === '') {
|
|
54
|
+
found.push(`a claim on port ${String(claim.port)} has an empty owner`);
|
|
55
|
+
}
|
|
56
|
+
const list = byPort.get(claim.port) ?? [];
|
|
57
|
+
list.push(claim);
|
|
58
|
+
byPort.set(claim.port, list);
|
|
59
|
+
}
|
|
60
|
+
for (const [port, list] of byPort) {
|
|
61
|
+
if (list.length < 2) continue;
|
|
62
|
+
// One owner naming the same port more than once — flagged the first time it repeats.
|
|
63
|
+
const seen = new Set<string>();
|
|
64
|
+
for (const claim of list) {
|
|
65
|
+
if (claim.owner === '') continue; // already reported above
|
|
66
|
+
if (seen.has(claim.owner)) {
|
|
67
|
+
found.push(`${claim.owner} claims port ${String(port)} more than once`);
|
|
68
|
+
}
|
|
69
|
+
seen.add(claim.owner);
|
|
70
|
+
}
|
|
71
|
+
// Two different owners on the same port number — the collision the registry exists to catch.
|
|
72
|
+
const distinctOwners = [...new Set(list.map((claim) => claim.owner).filter((o) => o !== ''))];
|
|
73
|
+
if (distinctOwners.length > 1) {
|
|
74
|
+
const described = list
|
|
75
|
+
.map((claim) => `${describeOwner(claim.owner)} (${describeAddress(claim.address)})`)
|
|
76
|
+
.join(' and ');
|
|
77
|
+
found.push(`port ${String(port)} is claimed by more than one owner: ${described}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return found;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** The declaration itself: a bad port, an empty owner, or two owners sharing one port number. */
|
|
84
|
+
export class PortRefused extends Data.TaggedError('PortRefused')<{
|
|
85
|
+
readonly message: string;
|
|
86
|
+
}> {}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Refuse a colliding set of claims, or do nothing. ⛔ THROWS `PortRefused` — exactly like
|
|
90
|
+
* `catalogBinary()`, this is a DEFECT in a stack program's `Effect.gen` body, not a typed failure
|
|
91
|
+
* a caller can `Effect.catchTag` on: gap 10 of docs/release-binary-upstream.md applies here
|
|
92
|
+
* unchanged (measured the same way, in port-claims.test.ts). The plan still fails before anything
|
|
93
|
+
* is applied; the error itself is just not a typed channel yet.
|
|
94
|
+
*/
|
|
95
|
+
export const claimPorts = (claims: readonly PortClaim[]): void => {
|
|
96
|
+
const problems = portClaimProblems(claims);
|
|
97
|
+
if (problems.length > 0) {
|
|
98
|
+
throw new PortRefused({
|
|
99
|
+
message: `Launchd ports: ${problems.join('; ')}. Nothing was declared.`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
};
|
|
@@ -212,11 +212,14 @@ describe('the barrel', () => {
|
|
|
212
212
|
'LaunchdJob',
|
|
213
213
|
'LaunchdJobProvider',
|
|
214
214
|
'PlistError',
|
|
215
|
+
'PortRefused',
|
|
215
216
|
'SudoRefusedError',
|
|
216
217
|
'canActAsRoot',
|
|
218
|
+
'claimPorts',
|
|
217
219
|
'hostRunnerLayer',
|
|
218
220
|
'launchdProviders',
|
|
219
221
|
'localRunner',
|
|
222
|
+
'portClaimProblems',
|
|
220
223
|
'renderPlist',
|
|
221
224
|
// ★ The opt-in elevating runner; makeSudoRunner and the allowlist internals stay off.
|
|
222
225
|
'sudoRunner',
|