@homeflare/alchemy 0.21.0 → 0.23.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/proxmox/api-token-form.d.ts +12 -1
- package/dist/proxmox/api-token-form.d.ts.map +1 -1
- package/dist/proxmox/api-token.d.ts +8 -3
- package/dist/proxmox/api-token.d.ts.map +1 -1
- package/dist/proxmox/generated/pve/cluster-sdn-fabrics.d.ts +13 -13
- package/dist/proxmox/generated/pve/cluster-sdn-fabrics.d.ts.map +1 -1
- package/dist/proxmox/index.d.ts +9 -2
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +17 -9
- package/dist/proxmox/index.js.map +8 -7
- package/dist/proxmox/zfs-pool-spec.d.ts +27 -0
- package/dist/proxmox/zfs-pool-spec.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool-write.d.ts +48 -7
- package/dist/proxmox/zfs-pool-write.d.ts.map +1 -1
- package/dist/proxmox/zfs-pool.d.ts +42 -20
- package/dist/proxmox/zfs-pool.d.ts.map +1 -1
- 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/adopted-deploys.md +14 -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/proxmox/api-token-adopt.test.ts +138 -0
- package/src/proxmox/api-token-form.ts +19 -2
- package/src/proxmox/api-token.ts +8 -3
- package/src/proxmox/generated/pve/cluster-sdn-fabrics.ts +23 -13
- package/src/proxmox/index.test.ts +5 -2
- package/src/proxmox/index.ts +9 -2
- package/src/proxmox/zfs-pool-adopt.test.ts +141 -0
- package/src/proxmox/zfs-pool-spec.ts +76 -0
- package/src/proxmox/zfs-pool-write.ts +63 -9
- package/src/proxmox/zfs-pool.ts +45 -95
- 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,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.23.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',
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.ApiToken` adopted, undeclared and drifted through Alchemy's own Plan and Apply, over a
|
|
3
|
+
* fake cluster — pinning the metadata-only shape decision 9 (2026-09-23) builds: adopt and manage
|
|
4
|
+
* `comment`/`expire`/`privsep`, never mint a token this resource cannot hand anybody the secret
|
|
5
|
+
* for (api-token.ts's header).
|
|
6
|
+
*
|
|
7
|
+
* ★ FOUR LIVE SHAPES: `metrics@pve!exporter` and `iac@pve!apply` are the no-comment / commented
|
|
8
|
+
* shapes named in api-token.ts's own header; `svc@pve!lease1` and `svc@pve!lease2` are
|
|
9
|
+
* PLACEHOLDERS (never an estate mint-user account — see the ⛔ on declaring under one in
|
|
10
|
+
* api-token.ts's header) exercising a real non-zero `expire` offered as BOTH a JSON number and
|
|
11
|
+
* a JSON string, which PVE does depending on path — `int()` (values.ts) must not care which.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
import * as Effect from 'effect/Effect';
|
|
15
|
+
import * as Layer from 'effect/Layer';
|
|
16
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
17
|
+
import { type ApiTokenProps, ProxmoxApiToken, ProxmoxApiTokenProvider } from './api-token.ts';
|
|
18
|
+
import { shape } from './api-token-form.ts';
|
|
19
|
+
import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
|
|
20
|
+
import type {
|
|
21
|
+
AccessUsersUseridTokenTokenidPostParams,
|
|
22
|
+
AccessUsersUseridTokenTokenidPutParams,
|
|
23
|
+
} from './generated/pve.ts';
|
|
24
|
+
|
|
25
|
+
type Live = { comment?: string; expire: number | string; privsep: number };
|
|
26
|
+
|
|
27
|
+
/** `access/users/{userid}/token/{tokenid}`, mutable so the PUT test can watch it change. */
|
|
28
|
+
const tokens = new Map<string, Live>([
|
|
29
|
+
['access/users/metrics@pve/token/exporter', { expire: 0, privsep: 0 }],
|
|
30
|
+
['access/users/iac@pve/token/apply', { comment: 'automation', expire: 0, privsep: 0 }],
|
|
31
|
+
['access/users/svc@pve/token/lease1', { expire: 1789327175, privsep: 0 }],
|
|
32
|
+
['access/users/svc@pve/token/lease2', { comment: 'lease', expire: '1789327175', privsep: 0 }],
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const cluster = () =>
|
|
36
|
+
fakePve((call: PveCall) => {
|
|
37
|
+
if (call.method === 'GET') return tokens.get(call.path);
|
|
38
|
+
if (call.method === 'PUT') {
|
|
39
|
+
const live = tokens.get(call.path);
|
|
40
|
+
if (live !== undefined) tokens.set(call.path, { ...live, ...call.form });
|
|
41
|
+
return call.form;
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** The declared props matching a live fixture exactly — `expire` always as a `number` prop. */
|
|
47
|
+
const propsFor = (
|
|
48
|
+
userid: string,
|
|
49
|
+
tokenid: string,
|
|
50
|
+
over: { comment?: string; privsep?: boolean },
|
|
51
|
+
): ApiTokenProps => ({
|
|
52
|
+
expire: Number(tokens.get(`access/users/${userid}/token/${tokenid}`)?.expire ?? 0),
|
|
53
|
+
privsep: over.privsep ?? false,
|
|
54
|
+
target: FAKE_TARGET,
|
|
55
|
+
tokenid,
|
|
56
|
+
userid,
|
|
57
|
+
...(over.comment === undefined ? {} : { comment: over.comment }),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const token = (userid: string, tokenid: string, over: { comment?: string; privsep?: boolean }) =>
|
|
61
|
+
ProxmoxApiToken(`${userid.replace('@', '-')}-${tokenid}`, propsFor(userid, tokenid, over));
|
|
62
|
+
|
|
63
|
+
const engineFor = (fake: ReturnType<typeof cluster>) =>
|
|
64
|
+
engineOver(ProxmoxApiTokenProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
65
|
+
|
|
66
|
+
const declared = () =>
|
|
67
|
+
Effect.all([
|
|
68
|
+
token('metrics@pve', 'exporter', {}),
|
|
69
|
+
token('iac@pve', 'apply', { comment: 'automation' }),
|
|
70
|
+
token('svc@pve', 'lease1', {}),
|
|
71
|
+
token('svc@pve', 'lease2', { comment: 'lease' }),
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
describe('adopting a metadata-only ApiToken declaration', () => {
|
|
75
|
+
test('no comment, commented, and a real expire as both a number and a string: noop, adopted, no writes', async () => {
|
|
76
|
+
const fake = cluster();
|
|
77
|
+
await withoutBao(async () => {
|
|
78
|
+
const engine = engineFor(fake);
|
|
79
|
+
const report = await engine.verify(declared());
|
|
80
|
+
expect(report.rows).toHaveLength(4);
|
|
81
|
+
for (const row of report.rows) {
|
|
82
|
+
expect(row).toMatchObject({ changed: [], diff: 'noop', ok: true, planned: 'adopted' });
|
|
83
|
+
}
|
|
84
|
+
const planned = await engine.deploy(declared());
|
|
85
|
+
expect(new Set(Object.values(planned))).toEqual(new Set(['adopted']));
|
|
86
|
+
expect(Object.keys(planned)).toHaveLength(4);
|
|
87
|
+
});
|
|
88
|
+
expect(fake.writes()).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('undeclaring all four sends no DELETE (retain is the default)', async () => {
|
|
92
|
+
const fake = cluster();
|
|
93
|
+
await withoutBao(async () => {
|
|
94
|
+
const engine = engineFor(fake);
|
|
95
|
+
await engine.deploy(declared());
|
|
96
|
+
expect(await engine.deploy(Effect.void)).toEqual({});
|
|
97
|
+
});
|
|
98
|
+
expect(fake.writes()).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('an absent token: the deploy refuses with the mint-elsewhere message, and writes nothing', async () => {
|
|
102
|
+
const fake = cluster();
|
|
103
|
+
const gone = () =>
|
|
104
|
+
ProxmoxApiToken('gone', {
|
|
105
|
+
expire: 0,
|
|
106
|
+
privsep: false,
|
|
107
|
+
target: FAKE_TARGET,
|
|
108
|
+
tokenid: 'missing',
|
|
109
|
+
userid: 'nope@pve',
|
|
110
|
+
});
|
|
111
|
+
await withoutBao(async () => {
|
|
112
|
+
const engine = engineFor(fake);
|
|
113
|
+
await expect(engine.deploy(gone())).rejects.toBeDefined();
|
|
114
|
+
});
|
|
115
|
+
expect(fake.writes()).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('a privsep flip: verify shows changed, and the deploy sends exactly one PUT of comment/expire/privsep', async () => {
|
|
119
|
+
const fake = cluster();
|
|
120
|
+
const flipped = () => token('iac@pve', 'apply', { comment: 'automation', privsep: true });
|
|
121
|
+
await withoutBao(async () => {
|
|
122
|
+
const engine = engineFor(fake);
|
|
123
|
+
const report = await engine.verify(flipped());
|
|
124
|
+
expect(report.rows[0]).toMatchObject({ changed: ['privsep'], diff: 'update', ok: false });
|
|
125
|
+
expect(await engine.deploy(flipped())).toEqual({ 'iac-pve-apply': 'adopted' });
|
|
126
|
+
});
|
|
127
|
+
const put = fake.calls.find((call) => call.method === 'PUT');
|
|
128
|
+
expect(fake.writes()).toEqual(['PUT access/users/iac@pve/token/apply']);
|
|
129
|
+
expect(put?.pairs.map(([name]) => name)).toEqual(['comment', 'expire', 'privsep']);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('the form keys typecheck against both generated PVE params, with no cast', () => {
|
|
133
|
+
const props = propsFor('iac@pve', 'apply', { comment: 'automation' });
|
|
134
|
+
const post: AccessUsersUseridTokenTokenidPostParams = shape(props);
|
|
135
|
+
const put: AccessUsersUseridTokenTokenidPutParams = shape(props);
|
|
136
|
+
expect(post).toEqual(put);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -16,6 +16,10 @@ import type { ApiTokenAttributes, ApiTokenProps } from './api-token.ts';
|
|
|
16
16
|
* that costs a forever-diff: the schema gives `expire` the default "same as user", and the code
|
|
17
17
|
* never implements it. api-token.ts records what that means.
|
|
18
18
|
*/
|
|
19
|
+
import type {
|
|
20
|
+
AccessUsersUseridTokenTokenidPostParams,
|
|
21
|
+
AccessUsersUseridTokenTokenidPutParams,
|
|
22
|
+
} from './generated/pve.ts';
|
|
19
23
|
import type { PveSpec } from './resource.ts';
|
|
20
24
|
import { bool, int, text } from './values.ts';
|
|
21
25
|
|
|
@@ -56,10 +60,23 @@ export const fullTokenid = (props: ApiTokenProps) => `${props.userid}!${props.to
|
|
|
56
60
|
* required prop precisely so that it is never undeclared, so there is nothing for it to do and
|
|
57
61
|
* its `string | undefined` return would have to be asserted away to fit `Record<string,string>`.
|
|
58
62
|
* An assertion here would be a claim about the prop that the type already makes properly.
|
|
63
|
+
*
|
|
64
|
+
* ★ TYPED AGAINST THE GENERATED PUT AND POST PARAMS, NOT `Record<string, string>`, since
|
|
65
|
+
* 2026-09-23 — the `notification-matcher-form.ts` pattern: `Pick` rather than the whole type,
|
|
66
|
+
* because neither `AccessUsersUseridTokenTokenidPutParams` nor …`PostParams` (generated/pve.ts)
|
|
67
|
+
* carries `userid`/`tokenid` (they are path segments — see the ⛔ above) and Put alone also
|
|
68
|
+
* carries `delete`/`regenerate`, which this family never sends. A schema drift on any of the
|
|
69
|
+
* three fields this function DOES send fails `tsc` here rather than surfacing as a 400 on a live
|
|
70
|
+
* cluster; api-token.test.ts pins the assignment both ways (Put and Post) without a cast.
|
|
71
|
+
* `expire` is written as a template literal, not `String(...)`, because the generated type is
|
|
72
|
+
* `` `${number}` ``, not `string` — `String(n)` widens to plain `string` and would not typecheck.
|
|
59
73
|
*/
|
|
60
|
-
export const shape = (
|
|
74
|
+
export const shape = (
|
|
75
|
+
props: ApiTokenProps,
|
|
76
|
+
): Pick<AccessUsersUseridTokenTokenidPutParams, 'comment' | 'expire' | 'privsep'> &
|
|
77
|
+
Pick<AccessUsersUseridTokenTokenidPostParams, 'comment' | 'expire' | 'privsep'> => ({
|
|
61
78
|
comment: props.comment ?? '',
|
|
62
|
-
expire:
|
|
79
|
+
expire: `${props.expire}`,
|
|
63
80
|
privsep: props.privsep ? '1' : '0',
|
|
64
81
|
});
|
|
65
82
|
|
package/src/proxmox/api-token.ts
CHANGED
|
@@ -20,9 +20,14 @@
|
|
|
20
20
|
* whole of a token's policy, and declaring them is real work: it is how `expire` stops being
|
|
21
21
|
* whatever somebody typed in 2024. Mint NEW tokens where the secret can be caught — OpenBao's
|
|
22
22
|
* `proxmox-c1` mount, or a human at `pveum user token add`.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* ⛔ ★ CORRECTED 2026-09-23 — THIS PARAGRAPH PREVIOUSLY SAID "THE CREATE PATH IS LEFT REACHABLE
|
|
24
|
+
* RATHER THAN STUBBED… the honest arrangement is a create that works". THAT CONTRADICTED THE
|
|
25
|
+
* `reconcile` BELOW, WHICH REFUSES: `handlers.reconcile` calls `Effect.die` by name when `read`
|
|
26
|
+
* answers absent, before any POST — metadata-only per decision 9 (2026-09-23), exported on the
|
|
27
|
+
* barrel as an adopt-and-manage-only Resource. A create that silently did nothing WOULD be the
|
|
28
|
+
* lie resource.ts's ★ describes for a stubbed delete; refusing loudly, by name, with the two
|
|
29
|
+
* ways to get a usable token, is the honest arrangement instead — see the ★ further down and
|
|
30
|
+
* api-token-adopt.test.ts's absent-token row, which pins the refusal and zero writes.
|
|
26
31
|
*
|
|
27
32
|
* ⛔ THERE IS NO `regenerate` PROP, AND IT IS NOT AN OVERSIGHT. PVE's own description: "All users
|
|
28
33
|
* of the previous secret will lose access after this operation." A prop for it would revoke a
|