@homeflare/alchemy 0.47.1 → 0.48.1
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/talos/bao-env.d.ts +7 -0
- package/dist/talos/bao-env.d.ts.map +1 -0
- package/dist/talos/cluster-adapter-errors.d.ts +134 -0
- package/dist/talos/cluster-adapter-errors.d.ts.map +1 -0
- package/dist/talos/cluster-adapter.d.ts +116 -0
- package/dist/talos/cluster-adapter.d.ts.map +1 -0
- package/dist/talos/cluster-identity.d.ts +28 -0
- package/dist/talos/cluster-identity.d.ts.map +1 -0
- package/dist/talos/cluster-transport.d.ts +64 -0
- package/dist/talos/cluster-transport.d.ts.map +1 -0
- package/dist/talos/credentials-write.d.ts.map +1 -1
- package/dist/talos/credentials.d.ts +10 -19
- package/dist/talos/credentials.d.ts.map +1 -1
- package/dist/talos/fake-apiserver.d.ts +9 -0
- package/dist/talos/fake-apiserver.d.ts.map +1 -0
- package/dist/talos/index.d.ts +7 -0
- package/dist/talos/index.d.ts.map +1 -1
- package/dist/talos/index.js +683 -170
- package/dist/talos/index.js.map +22 -10
- package/dist/talos/kubeconfig-attrs.d.ts +14 -0
- package/dist/talos/kubeconfig-attrs.d.ts.map +1 -1
- package/dist/talos/kubeconfig-doc.d.ts +23 -0
- package/dist/talos/kubeconfig-doc.d.ts.map +1 -0
- package/dist/talos/kubeconfig.d.ts +0 -12
- package/dist/talos/kubeconfig.d.ts.map +1 -1
- package/dist/talos/literal-connection.d.ts +16 -0
- package/dist/talos/literal-connection.d.ts.map +1 -0
- package/dist/talos/machine-config-read.d.ts +1 -1
- package/dist/talos/machine-config-read.d.ts.map +1 -1
- package/dist/talos/mint-temp-file.d.ts +20 -0
- package/dist/talos/mint-temp-file.d.ts.map +1 -0
- package/dist/talos/node-temp.harness.d.ts +2 -0
- package/dist/talos/node-temp.harness.d.ts.map +1 -0
- package/dist/talos/talos-bootstrap-etcd.d.ts +1 -1
- package/dist/talos/talos-bootstrap-etcd.d.ts.map +1 -1
- package/dist/talos/talos-cluster-health.d.ts +15 -15
- package/dist/talos/talos-cluster-health.d.ts.map +1 -1
- package/dist/talos/talos-cluster-identity.d.ts +65 -0
- package/dist/talos/talos-cluster-identity.d.ts.map +1 -0
- package/dist/talos/talosctl-binary.d.ts +43 -0
- package/dist/talos/talosctl-binary.d.ts.map +1 -0
- package/dist/talos/talosctl.d.ts +29 -3
- package/dist/talos/talosctl.d.ts.map +1 -1
- package/dist/talos/trust-boundary.d.ts +8 -0
- package/dist/talos/trust-boundary.d.ts.map +1 -0
- package/dist/talos/values.d.ts.map +1 -1
- package/dist/talos/yaml-parse.d.ts +3 -0
- package/dist/talos/yaml-parse.d.ts.map +1 -0
- package/dist/verify/fake-engine.d.ts +2 -0
- package/dist/verify/fake-engine.d.ts.map +1 -1
- package/docs/talos-openbao-adapter.md +45 -0
- package/docs/upstream-conformance-bun.md +61 -24
- package/package.json +6 -2
- package/src/talos/bao-env.ts +16 -0
- package/src/talos/bao-vetting.test.ts +97 -0
- package/src/talos/cluster-adapter-deadline.test.ts +110 -0
- package/src/talos/cluster-adapter-errors.ts +193 -0
- package/src/talos/cluster-adapter-move.test.ts +131 -0
- package/src/talos/cluster-adapter-reconcile.test.ts +165 -0
- package/src/talos/cluster-adapter-refusals.test.ts +102 -0
- package/src/talos/cluster-adapter-rename.test.ts +87 -0
- package/src/talos/cluster-adapter-secrecy.test.ts +243 -0
- package/src/talos/cluster-adapter.fixtures.ts +125 -0
- package/src/talos/cluster-adapter.test.ts +240 -0
- package/src/talos/cluster-adapter.ts +250 -0
- package/src/talos/cluster-identity.ts +63 -0
- package/src/talos/cluster-transport.ts +110 -0
- package/src/talos/credentials-write.ts +27 -5
- package/src/talos/credentials.ts +51 -45
- package/src/talos/fake-apiserver.ts +55 -0
- package/src/talos/fake-process.ts +47 -1
- package/src/talos/index.ts +32 -0
- package/src/talos/kubeconfig-adapter.test.ts +37 -0
- package/src/talos/kubeconfig-attrs.ts +12 -28
- package/src/talos/kubeconfig-doc.ts +74 -0
- package/src/talos/kubeconfig.fixtures.ts +52 -0
- package/src/talos/kubeconfig.test.ts +6 -54
- package/src/talos/kubeconfig.ts +18 -34
- package/src/talos/literal-connection.ts +59 -0
- package/src/talos/mint-temp-file.test.ts +44 -0
- package/src/talos/mint-temp-file.ts +68 -0
- package/src/talos/node-connect.harness.ts +78 -0
- package/src/talos/node-connect.test.ts +27 -0
- package/src/talos/node-temp.harness.ts +40 -0
- package/src/talos/node-temp.test.ts +26 -0
- package/src/talos/talos-cluster-health-declare.test.ts +54 -0
- package/src/talos/talos-cluster-health-literal.test.ts +69 -0
- package/src/talos/talos-cluster-health.test.ts +104 -18
- package/src/talos/talos-cluster-health.ts +46 -14
- package/src/talos/talos-cluster-identity.test.ts +96 -0
- package/src/talos/talos-cluster-identity.ts +130 -0
- package/src/talos/talosctl-binary.ts +150 -0
- package/src/talos/talosctl-probe-kill.test.ts +56 -0
- package/src/talos/talosctl-vetting.test.ts +212 -0
- package/src/talos/talosctl.test.ts +163 -0
- package/src/talos/talosctl.ts +133 -15
- package/src/talos/trust-boundary.test.ts +17 -0
- package/src/talos/trust-boundary.ts +45 -0
- package/src/talos/values.ts +5 -12
- package/src/talos/yaml-parse.test.ts +41 -0
- package/src/talos/yaml-parse.ts +28 -0
- package/src/verify/fake-engine.ts +10 -0
- package/dist/talos/fake-process.d.ts +0 -56
- package/dist/talos/fake-process.d.ts.map +0 -1
- package/dist/talos/machine-config-fixtures.d.ts +0 -60
- package/dist/talos/machine-config-fixtures.d.ts.map +0 -1
- package/src/postgres/fixtures/pg-REL_18_6-acl.h.txt +0 -147
- package/src/postgres/fixtures/pg-REL_18_6-aclexplode-func.txt +0 -31
- package/src/postgres/fixtures/pg-REL_18_6-alter-default-privileges-synopsis.txt +0 -82
- package/src/postgres/fixtures/pg-REL_18_6-alter_role.sgml.txt +0 -44
- package/src/postgres/fixtures/pg-REL_18_6-create-database-synopsis.txt +0 -26
- package/src/postgres/fixtures/pg-REL_18_6-create_role.sgml.txt +0 -26
- package/src/postgres/fixtures/pg-REL_18_6-grant-synopsis.txt +0 -95
- package/src/postgres/fixtures/pg-REL_18_6-pg_attribute.h.txt +0 -24
- package/src/postgres/fixtures/pg-REL_18_6-pg_auth_members.h.txt +0 -62
- package/src/postgres/fixtures/pg-REL_18_6-pg_authid.h.txt +0 -70
- package/src/postgres/fixtures/pg-REL_18_6-pg_class.h.txt +0 -33
- package/src/postgres/fixtures/pg-REL_18_6-pg_database.h.txt +0 -67
- package/src/postgres/fixtures/pg-REL_18_6-pg_default_acl.h.txt +0 -81
- package/src/postgres/fixtures/pg-REL_18_6-pg_namespace.h.txt +0 -28
- package/src/postgres/fixtures/pg-REL_18_6-revoke-synopsis.txt +0 -124
- package/src/release/fixtures/openbao/checksums.txt +0 -83
- package/src/release/fixtures/openbao/checksums.txt.gpgsig +0 -0
- package/src/release/fixtures/openbao/darwin-assets.txt +0 -12
- package/src/release/fixtures/openbao/openbao-gpg-pub-20240618.asc +0 -109
- package/src/release/fixtures/victoria/darwin-arm64-assets.txt +0 -22
- package/src/release/fixtures/victoria/victoria-logs-darwin-arm64-v1.52.0_checksums.txt +0 -2
- package/src/release/fixtures/victoria/victoria-metrics-darwin-arm64-v1.151.0_checksums.txt +0 -2
- package/src/release/fixtures/victoria/victoria-traces-darwin-arm64-v0.10.0_checksums.txt +0 -2
- package/src/release/fixtures/victoria/vmutils-darwin-arm64-v1.151.0_checksums.txt +0 -8
|
@@ -4,7 +4,7 @@ Status: extracted from the [conformance ledger](./upstream-conformance.md); its
|
|
|
4
4
|
measurements and open findings are preserved below.
|
|
5
5
|
|
|
6
6
|
These are non-test files under `src/` that the standard (S42) says must be portable. "Exp"
|
|
7
|
-
marks files exported from a subpath `index.ts`. `git grep` on `origin/main` finds
|
|
7
|
+
marks files exported from a subpath `index.ts`. `git grep` on `origin/main` finds 24 non-test
|
|
8
8
|
files under `src/` that call `Bun.*` or import `node:*`/`bun:*`. Six of them are outside
|
|
9
9
|
S42's scope:
|
|
10
10
|
|
|
@@ -13,9 +13,21 @@ S42's scope:
|
|
|
13
13
|
- `proxmox/provision-cli-fake.ts`, which is used only by tests;
|
|
14
14
|
- `verify/args.ts`, which belongs to the CLI and so is tooling under S43.
|
|
15
15
|
|
|
16
|
-
That leaves
|
|
17
|
-
|
|
16
|
+
That leaves 18 files. PR 355 brings the count on this branch to 29 by adding
|
|
17
|
+
`talos/talosctl-binary.ts` and `talos/trust-boundary.ts` (both in scope below), making
|
|
18
|
+
`talos/fake-process.ts` node-loading — on `origin/main` it imported only `effect/*`; here it
|
|
19
|
+
imports `node:fs`, `node:path` and `node:url` — and adding `talos/fake-apiserver.ts`,
|
|
20
|
+
`talos/node-connect.harness.ts` and the test seam `talos/trust-boundary.seam.ts`, three more
|
|
21
|
+
Node-loaded loopback fakes that are test-side under S44 like the other fakes. With those three
|
|
22
|
+
fakes and the seam, ten files on this branch are outside S42's scope, leaving 19 in scope. The
|
|
23
|
+
table lists those 19, plus `provision-cli-fake.ts`, which should move out of `src/`. The
|
|
24
|
+
out-of-scope files other than the seam still ship in the tarball's `src/`, but no export
|
|
18
25
|
reaches them.
|
|
26
|
+
⚠️ PR 355 red team re-measured 2026-10-06: `talos/credentials.ts` and
|
|
27
|
+
`talos/talos-machine-config.ts` no longer call `Bun.*` or import `node:*`/`bun:*` (the temp-file
|
|
28
|
+
mint moved to `talos/mint-temp-file.ts`, and `credentials.ts` reads through `resolveBao`), so
|
|
29
|
+
they left the table and `talos/mint-temp-file.ts` took their row's place; `talos/credentials-write.ts`
|
|
30
|
+
(always node-loaded, previously unlisted) is the +1 that had made the prose count one short.
|
|
19
31
|
⚠️ The first version of this table said 16 files and left out `launchd/job-form.ts`.
|
|
20
32
|
⚠️ Corrected 2026-09-22: 14 of the 17 break upstream's rule. `launchd/job-form.ts`,
|
|
21
33
|
`proxmox/write-only.ts` and `proxmox/pbs-notification-target-wire.ts` use only synchronous
|
|
@@ -24,28 +36,46 @@ reaches them.
|
|
|
24
36
|
among them `Fly/Secret.ts` and `Railway/Variable.ts`. They were listed under a blanket
|
|
25
37
|
`node:*` ban that upstream does not have.
|
|
26
38
|
|
|
27
|
-
| file | API
|
|
28
|
-
| ---------------------------------------------------------- |
|
|
29
|
-
| `openbao/cloudflare-roles-config.ts` | `Bun.YAML`
|
|
30
|
-
| `openbao/digest.ts` (14 importers) | `Bun.CryptoHasher`
|
|
31
|
-
| `openbao/cloudflare-parity-snapshot.ts` | `Bun.file`, `Bun.Glob`
|
|
32
|
-
| `openbao/approle-login-form.ts` | `Bun.file`
|
|
33
|
-
| `openbao/approle-login-result.ts` | `Bun.inspect`
|
|
34
|
-
| `openbao/forgejo-bootstrap.ts` | `Bun.spawn`
|
|
35
|
-
| `talos/credentials.ts`
|
|
36
|
-
| `talos/
|
|
37
|
-
| `talos/
|
|
38
|
-
| `talos/values.ts` | `
|
|
39
|
-
| `
|
|
40
|
-
| `
|
|
41
|
-
| `launchd/
|
|
42
|
-
| `
|
|
43
|
-
| `
|
|
44
|
-
| `
|
|
45
|
-
| `
|
|
39
|
+
| file | API | exp | portable replacement |
|
|
40
|
+
| ---------------------------------------------------------- | --------------------------------------------------------- | --- | -------------------------------------------- |
|
|
41
|
+
| `openbao/cloudflare-roles-config.ts` | `Bun.YAML` | yes | parse on the tooling side; pass data in |
|
|
42
|
+
| `openbao/digest.ts` (14 importers) | `Bun.CryptoHasher` | — | `Crypto.digest` + hex; keep `canonical()` ⚠️ |
|
|
43
|
+
| `openbao/cloudflare-parity-snapshot.ts` | `Bun.file`, `Bun.Glob` | — | `FileSystem` + `Path` |
|
|
44
|
+
| `openbao/approle-login-form.ts` | `Bun.file` | — | `FileSystem.readFileString` |
|
|
45
|
+
| `openbao/approle-login-result.ts` | `Bun.inspect` | — | a plain formatter |
|
|
46
|
+
| `openbao/forgejo-bootstrap.ts` | `Bun.spawn` | — | unreferenced; `ChildProcessSpawner` or drop |
|
|
47
|
+
| `talos/credentials-write.ts` | `node:fs/promises`, `node:crypto`, `node:os`, `node:path` | — | `FileSystem.makeTempFileScoped` |
|
|
48
|
+
| `talos/mint-temp-file.ts` | `node:fs`, `node:crypto`, `node:os`, `node:path` | — | `FileSystem.makeTempFileScoped` |
|
|
49
|
+
| `talos/kubeconfig.ts` | `Bun.file`, `node:fs` | yes | `FileSystem` |
|
|
50
|
+
| `talos/values.ts` | `node:crypto` | — | allowed in `Effect.sync` |
|
|
51
|
+
| `talos/talosctl-binary.ts` | `node:fs`, `node:path` (binary vetting) ⚠️ | — | recorded difference, below |
|
|
52
|
+
| `talos/trust-boundary.ts` | `node:fs` (`realpathSync`, test seam) | — | recorded difference, below |
|
|
53
|
+
| `launchd/local-runner.ts` | `node:child_process`, `node:fs/promises` | yes | H3 above |
|
|
54
|
+
| `launchd/sudo-stage.ts` | `node:fs/promises`, `node:os`, `node:path` | — | `FileSystem`, `Path` |
|
|
55
|
+
| `launchd/job-form.ts` | `node:crypto` (`createHash`) | — | allowed in `Effect.sync` |
|
|
56
|
+
| `linux/ssh-runner.ts` | `node:child_process`, `node:crypto` | yes | H3 above |
|
|
57
|
+
| `caddy/caddy-http-client.ts` | `node:http`, `node:stream` (unix socket) | yes | inherent — an Effect `HttpClient`, not H3 ⚠️ |
|
|
58
|
+
| `proxmox/write-only.ts`, `pbs-notification-target-wire.ts` | `node:crypto`, `node:buffer` | — | allowed in `Effect.sync` ⚠️ |
|
|
59
|
+
| `proxmox/provision-cli-fake.ts` | `Bun.spawn`, `node:fs` | — | test-only; move out of `src/` |
|
|
46
60
|
|
|
47
61
|
The ⚠️ rows:
|
|
48
62
|
|
|
63
|
+
- `talos/talosctl-binary.ts` (added 2026-10-06, hunt round 5; extracted from `talosctl.ts` in
|
|
64
|
+
round 6, where it also walks every ancestor directory): the binary vetting needs `lstat` and a
|
|
65
|
+
synchronous PATH walk. `effect/FileSystem` has `stat` (follows symlinks) and `readLink`
|
|
66
|
+
but no `lstat`, so "not a symlink" cannot be asked of it without a second probe, and the
|
|
67
|
+
Service would become a requirement of every `talosctl` caller and test (the offline fakes
|
|
68
|
+
provide only a `ChildProcessSpawner`). The published `dist/` also runs on Node, where
|
|
69
|
+
`Bun.which` is absent. The calls are synchronous, read-only, inside `Effect.try`; no
|
|
70
|
+
write, no async. Revisit when `FileSystem` gains `lstat`.
|
|
71
|
+
|
|
72
|
+
- `talos/trust-boundary.ts` (added 2026-10-06 by PR 355): the same `node:fs` rationale as
|
|
73
|
+
`talos/talosctl-binary.ts`, which imports it. Its query resolves the directory with
|
|
74
|
+
`realpathSync` so the boundary compare is canonical; the call is synchronous, read-only and
|
|
75
|
+
made inside `Effect.try`. The register function lives in `talos/trust-boundary.seam.ts`
|
|
76
|
+
(test-side, excluded from the tarball), so the shipped module offers no way to stop the walk
|
|
77
|
+
early. Revisit together with `talosctl-binary.ts` when `FileSystem` gains `lstat`.
|
|
78
|
+
|
|
49
79
|
- `openbao/digest.ts`: every `Bao.*` family persists this digest in state, so a swap must
|
|
50
80
|
hash the same bytes (`canonical()`, lowercase hex), or every row plans an update.
|
|
51
81
|
`Crypto.digest` from `effect/Crypto` is in every stack's services and fails with a typed
|
|
@@ -63,5 +93,12 @@ The ⚠️ rows:
|
|
|
63
93
|
more-portable replacement to point to; the file already conforms to S19 (no async/await,
|
|
64
94
|
no raw `Promise` — `Effect.callback`/`Effect.tryPromise` throughout).
|
|
65
95
|
|
|
66
|
-
Test runners:
|
|
67
|
-
talos
|
|
96
|
+
Test runners: 63 files import `node:test`/`node:assert` (openbao 41, proxmox 10, forgejo 1,
|
|
97
|
+
talos 11), and 395 import `bun:test`. S43 says `bun:test`.
|
|
98
|
+
|
|
99
|
+
Measured 2026-10-06 from this worktree with:
|
|
100
|
+
`grep -rl --include='*.ts' -e 'node:test' -e 'node:assert' src/ | wc -l` (per directory, the same
|
|
101
|
+
grep scoped to `src/<area>`), and `grep -rl --include='*.ts' -e 'bun:test' src/ | wc -l`.
|
|
102
|
+
|
|
103
|
+
Re-measured 2026-10-06 (PR 355 red team) — the in-scope table rows above:
|
|
104
|
+
`grep -rlE --include='*.ts' "from ['\"](node|bun):|Bun\\.[A-Za-z]" src/talos/ | grep -v '\\.test\\.ts' | grep -vE 'fake-|harness|\\\\.seam\\\\.'`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.1",
|
|
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",
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
"dist",
|
|
19
19
|
"docs",
|
|
20
20
|
"src",
|
|
21
|
+
"!src/**/fixtures",
|
|
22
|
+
"!src/**/trust-boundary.seam.ts",
|
|
23
|
+
"!dist/**/trust-boundary.seam.*",
|
|
21
24
|
"README.md",
|
|
22
25
|
"LICENSE"
|
|
23
26
|
],
|
|
@@ -139,7 +142,8 @@
|
|
|
139
142
|
"@distilled.cloud/paperless-ngx": "npm:@homeflare/distilled-paperless-ngx@0.3.1",
|
|
140
143
|
"@distilled.cloud/proxmox": "npm:@homeflare/distilled-proxmox@0.3.5",
|
|
141
144
|
"@distilled.cloud/proxmox-backup": "npm:@homeflare/distilled-proxmox-backup@0.3.2",
|
|
142
|
-
"@distilled.cloud/unifi-network": "npm:@homeflare/distilled-unifi-network@0.4.1"
|
|
145
|
+
"@distilled.cloud/unifi-network": "npm:@homeflare/distilled-unifi-network@0.4.1",
|
|
146
|
+
"yaml": "2.9.1"
|
|
143
147
|
},
|
|
144
148
|
"peerDependencies": {
|
|
145
149
|
"@distilled.cloud/argocd": "1.0.0-rc.13",
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⛔ THE CHILD GETS A MINIMAL ENV, NOT `process.env`: `PATH` to find `bao`, the vault address and
|
|
3
|
+
* token, and the optional namespace / CA. `undefined` when BAO_ADDR or BAO_TOKEN is missing —
|
|
4
|
+
* `bao` would then read a cached `~/.vault-token` login and act as whoever last logged in.
|
|
5
|
+
*/
|
|
6
|
+
export const baoEnv = (): Record<string, string> | undefined => {
|
|
7
|
+
const addr = process.env['BAO_ADDR'];
|
|
8
|
+
const token = process.env['BAO_TOKEN'];
|
|
9
|
+
if (addr === undefined || addr === '' || token === undefined || token === '') return undefined;
|
|
10
|
+
const env: Record<string, string> = { BAO_ADDR: addr, BAO_TOKEN: token };
|
|
11
|
+
for (const name of ['PATH', 'BAO_NAMESPACE', 'BAO_CACERT']) {
|
|
12
|
+
const value = process.env[name];
|
|
13
|
+
if (value !== undefined && value !== '') env[name] = value;
|
|
14
|
+
}
|
|
15
|
+
return env;
|
|
16
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bao` vetting (PR 355 red team, round 1): `bao` fetches the cluster credentials, so it must be
|
|
3
|
+
* resolved and vetted exactly like `talosctl` before any environment reaches it. A fake `bao` in a
|
|
4
|
+
* group/world-writable PATH folder (umask 002, the round-10 incident) must be refused, never
|
|
5
|
+
* launched. Fails on a084e18, where `readKvValue`/`writeKvValue` spawned a bare `bao` from PATH
|
|
6
|
+
* with no vetting.
|
|
7
|
+
*/
|
|
8
|
+
import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { expect, test } from 'bun:test';
|
|
12
|
+
import * as Effect from 'effect/Effect';
|
|
13
|
+
import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
|
|
14
|
+
import { readKvValue } from './credentials.ts';
|
|
15
|
+
import { writeKvValue } from './credentials-write.ts';
|
|
16
|
+
import { type FakeCall, fakeSpawner } from './fake-process.ts';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Run `effect` with a fake `bao` planted in a 775 PATH folder prepended to the real PATH, capture
|
|
20
|
+
* every spawn the fake spawner saw, and restore PATH when done. Returns the failure text.
|
|
21
|
+
*/
|
|
22
|
+
const withFakeBaoIn775 = async (
|
|
23
|
+
effect: Effect.Effect<unknown, Error, ChildProcessSpawner.ChildProcessSpawner>,
|
|
24
|
+
): Promise<{ failure: string; calls: FakeCall[]; options: unknown[] }> => {
|
|
25
|
+
const dir = mkdtempSync(join(tmpdir(), 'hf-bao-vet-'));
|
|
26
|
+
const open = join(dir, 'open');
|
|
27
|
+
mkdirSync(open);
|
|
28
|
+
writeFileSync(join(open, 'bao'), '#!/bin/sh\n', { mode: 0o755 });
|
|
29
|
+
chmodSync(join(open, 'bao'), 0o755);
|
|
30
|
+
chmodSync(open, 0o775); // group-writable, like a umask-002 checkout
|
|
31
|
+
const savedPath = process.env['PATH'];
|
|
32
|
+
process.env['PATH'] = `${realpathSync(open)}:${savedPath ?? ''}`;
|
|
33
|
+
const calls: FakeCall[] = [];
|
|
34
|
+
const options: unknown[] = [];
|
|
35
|
+
try {
|
|
36
|
+
const failure = await Effect.runPromise(
|
|
37
|
+
Effect.flip(
|
|
38
|
+
Effect.provideService(
|
|
39
|
+
effect,
|
|
40
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
41
|
+
ChildProcessSpawner.make((cmd) => {
|
|
42
|
+
if (cmd._tag === 'StandardCommand') options.push(cmd.options);
|
|
43
|
+
return fakeSpawner(() => ({}), calls).spawn(cmd);
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
),
|
|
47
|
+
).then((e) => String(e));
|
|
48
|
+
return { failure, calls, options };
|
|
49
|
+
} finally {
|
|
50
|
+
if (savedPath === undefined) delete process.env['PATH'];
|
|
51
|
+
else process.env['PATH'] = savedPath;
|
|
52
|
+
rmSync(dir, { force: true, recursive: true });
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
test('a fake bao in a group/world-writable PATH folder is refused before any env reaches it', async () => {
|
|
57
|
+
const { failure, calls } = await withFakeBaoIn775(
|
|
58
|
+
readKvValue('talos-c1', 'kubeconfig', ['kubeconfig', 'config']),
|
|
59
|
+
);
|
|
60
|
+
expect(failure).toContain('writable directory');
|
|
61
|
+
// ⛔ The refusal happens before the spawn, so the fake `bao` never ran and never saw an env.
|
|
62
|
+
expect(calls).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('writeKvValue vets bao the same way and passes a minimal env with extendEnv off', async () => {
|
|
66
|
+
const { failure, calls } = await withFakeBaoIn775(
|
|
67
|
+
writeKvValue('talos-c1', 'kubeconfig', 'kubeconfig', 'secret-body'),
|
|
68
|
+
);
|
|
69
|
+
expect(failure).toContain('writable directory');
|
|
70
|
+
expect(calls).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('a vetted bao gets baoEnv with extendEnv off and forceKillAfter set', async () => {
|
|
74
|
+
// Uses the committed stub `bao` (fake-process.ts), which passes vetting, and observes the spawn.
|
|
75
|
+
const options: Array<{
|
|
76
|
+
env?: Record<string, string | undefined> | undefined;
|
|
77
|
+
extendEnv?: boolean | undefined;
|
|
78
|
+
forceKillAfter?: unknown;
|
|
79
|
+
}> = [];
|
|
80
|
+
const outcome = await Effect.runPromise(
|
|
81
|
+
Effect.provideService(
|
|
82
|
+
writeKvValue('talos-c1', 'kubeconfig', 'kubeconfig', 'secret-body'),
|
|
83
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
84
|
+
ChildProcessSpawner.make((cmd) => {
|
|
85
|
+
if (cmd._tag === 'StandardCommand') options.push(cmd.options);
|
|
86
|
+
return fakeSpawner(() => ({}), []).spawn(cmd);
|
|
87
|
+
}),
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
expect(outcome).toBeUndefined();
|
|
91
|
+
expect(options.length).toBe(1);
|
|
92
|
+
const opts = options[0];
|
|
93
|
+
expect(opts?.extendEnv).toBe(false);
|
|
94
|
+
expect(opts?.forceKillAfter).toBeDefined();
|
|
95
|
+
expect(Object.keys(opts?.env ?? {})).toContain('BAO_ADDR');
|
|
96
|
+
expect(Object.keys(opts?.env ?? {})).toContain('BAO_TOKEN');
|
|
97
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One deadline over the whole `talos-openbao` connect path, with a REAL child process: a `bao`
|
|
3
|
+
* that never exits, forks a sleeper, or ignores SIGTERM must fail connect with
|
|
4
|
+
* `TalosOpenBaoConnectTimeout` and must not outlive it. The fake `bao` is a throwaway executable
|
|
5
|
+
* in `os.tmpdir()`, removed on every exit path; PATH is the only process-global touched and is
|
|
6
|
+
* restored.
|
|
7
|
+
*/
|
|
8
|
+
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { expect, test } from 'bun:test';
|
|
12
|
+
import * as NodeServices from '@effect/platform-node/NodeServices';
|
|
13
|
+
import * as Duration from 'effect/Duration';
|
|
14
|
+
import * as Effect from 'effect/Effect';
|
|
15
|
+
import { connectTalosOpenBao, talosOpenBaoConnection } from './cluster-adapter.ts';
|
|
16
|
+
import './fake-process.ts';
|
|
17
|
+
|
|
18
|
+
const alive = (pid: number) => {
|
|
19
|
+
try {
|
|
20
|
+
process.kill(pid, 0);
|
|
21
|
+
return true;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const settle = async (pid: number) => {
|
|
28
|
+
for (let i = 0; i < 40 && alive(pid); i++) await Bun.sleep(100);
|
|
29
|
+
return alive(pid);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const config = { c1: { context: 'admin@c1', key: 'kubeconfig', mount: 'talos-c1', uid: 'uid-c1' } };
|
|
33
|
+
const BOUND = Duration.millis(1500);
|
|
34
|
+
// `__DIR__` is declared at the top of each script (the child gets a minimal env, no process.env).
|
|
35
|
+
const recordPid = `require('node:fs').writeFileSync(__DIR__ + '/pid', String(process.pid));\n`;
|
|
36
|
+
const hang = 'setInterval(() => {}, 1000);\n';
|
|
37
|
+
// The sleeper is a node child of `bao`: only a group kill reaches it.
|
|
38
|
+
const forkSleeper = [
|
|
39
|
+
'const sleeper = "require(\'node:fs\').writeFileSync(" + JSON.stringify(__DIR__ + "/grandchild")',
|
|
40
|
+
' + ", String(process.pid)); setInterval(() => {}, 1000)";',
|
|
41
|
+
"require('node:child_process').spawn(process.execPath, ['-e', sleeper], { stdio: 'ignore' });",
|
|
42
|
+
'',
|
|
43
|
+
].join('\n');
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run the production connect path against a throwaway `bao` script on PATH, with only the bound
|
|
47
|
+
* shortened. `pids` reads a pid file the script wrote, while the directory still exists.
|
|
48
|
+
*/
|
|
49
|
+
const connectAgainst = async (
|
|
50
|
+
script: string,
|
|
51
|
+
assert: (run: {
|
|
52
|
+
failure: { _tag: string };
|
|
53
|
+
elapsed: number;
|
|
54
|
+
pid: (name: string) => number;
|
|
55
|
+
}) => Promise<void>,
|
|
56
|
+
) => {
|
|
57
|
+
const dir = mkdtempSync(join(tmpdir(), 'hf-deadline-'));
|
|
58
|
+
const saved = { path: process.env['PATH'] };
|
|
59
|
+
try {
|
|
60
|
+
const header = `#!${process.execPath}\nconst __DIR__ = ${JSON.stringify(dir)};\n`;
|
|
61
|
+
writeFileSync(join(dir, 'bao'), `${header}${script}`);
|
|
62
|
+
chmodSync(join(dir, 'bao'), 0o700);
|
|
63
|
+
process.env['PATH'] = `${dir}:${saved.path ?? ''}`;
|
|
64
|
+
const started = Date.now();
|
|
65
|
+
const failure = await Effect.runPromise(
|
|
66
|
+
connectTalosOpenBao(config, talosOpenBaoConnection('uid-c1'), BOUND).pipe(
|
|
67
|
+
Effect.flip,
|
|
68
|
+
Effect.provide(NodeServices.layer),
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
await assert({
|
|
72
|
+
elapsed: Date.now() - started,
|
|
73
|
+
failure: failure as { _tag: string },
|
|
74
|
+
pid: (name) => Number(readFileSync(join(dir, name), 'utf8')),
|
|
75
|
+
});
|
|
76
|
+
} finally {
|
|
77
|
+
if (saved.path === undefined) delete process.env['PATH'];
|
|
78
|
+
else process.env['PATH'] = saved.path;
|
|
79
|
+
rmSync(dir, { force: true, recursive: true });
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
test('a bao that never exits fails connect with the typed timeout and the child is killed', async () => {
|
|
84
|
+
await connectAgainst(`${recordPid}${hang}`, async (run) => {
|
|
85
|
+
expect(run.failure._tag).toBe('TalosOpenBaoConnectTimeout');
|
|
86
|
+
expect(run.pid('pid')).toBeGreaterThan(0);
|
|
87
|
+
expect(await settle(run.pid('pid'))).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('a bao that forks a sleeper leaves no grandchild alive after the timeout', async () => {
|
|
92
|
+
// ⛔ Killing only the leader (the old `detached: false`) leaves the sleeper running.
|
|
93
|
+
await connectAgainst(`${recordPid}${forkSleeper}${hang}`, async (run) => {
|
|
94
|
+
expect(run.failure._tag).toBe('TalosOpenBaoConnectTimeout');
|
|
95
|
+
await Bun.sleep(300);
|
|
96
|
+
const grandchild = run.pid('grandchild');
|
|
97
|
+
expect(grandchild).toBeGreaterThan(0);
|
|
98
|
+
expect(await settle(grandchild)).toBe(false);
|
|
99
|
+
expect(await settle(run.pid('pid'))).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('a bao that ignores SIGTERM still fails connect within the deadline plus ~2 s', async () => {
|
|
104
|
+
// ⛔ Without `forceKillAfter` the finalizer waits on a leader that never exits.
|
|
105
|
+
await connectAgainst(`${recordPid}process.on('SIGTERM', () => {});\n${hang}`, async (run) => {
|
|
106
|
+
expect(run.failure._tag).toBe('TalosOpenBaoConnectTimeout');
|
|
107
|
+
expect(run.elapsed).toBeLessThan(1500 + 2500);
|
|
108
|
+
expect(await settle(run.pid('pid'))).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed failures of the `talos-openbao` adapter — split out of cluster-adapter.ts to keep each
|
|
3
|
+
* class's own header out of that file's 250-line budget. No message names document bytes.
|
|
4
|
+
*/
|
|
5
|
+
import * as Data from 'effect/Data';
|
|
6
|
+
|
|
7
|
+
/** The connection's uid matches no adapter entry. Uids are public. */
|
|
8
|
+
export class TalosOpenBaoUnknownCluster extends Data.TaggedError('TalosOpenBaoUnknownCluster')<{
|
|
9
|
+
readonly uid: string;
|
|
10
|
+
readonly known: readonly string[];
|
|
11
|
+
}> {
|
|
12
|
+
override get message(): string {
|
|
13
|
+
return (
|
|
14
|
+
`talos-openbao adapter has no entry pinned to cluster uid ${this.uid} ` +
|
|
15
|
+
`(configured uids: ${this.known.join(', ') || 'none'}). Refused rather than reading ` +
|
|
16
|
+
"another cluster's kubeconfig. If the cluster was retired, keep its entry with " +
|
|
17
|
+
'`retired: true` and its uid until its rows are destroyed.'
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Two adapter entries pin the same uid, so which vault key to read is ambiguous. */
|
|
23
|
+
export class TalosOpenBaoAmbiguousUid extends Data.TaggedError('TalosOpenBaoAmbiguousUid')<{
|
|
24
|
+
readonly uid: string;
|
|
25
|
+
readonly aliases: readonly string[];
|
|
26
|
+
}> {
|
|
27
|
+
override get message(): string {
|
|
28
|
+
return (
|
|
29
|
+
`talos-openbao adapter entries ${this.aliases.join(', ')} are all pinned to uid ` +
|
|
30
|
+
`${this.uid}. Refused instead of picking one: a uid names exactly one entry.`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The saved auth block still carries the alias an earlier build of this branch persisted. */
|
|
36
|
+
export class TalosOpenBaoLegacyAuth extends Data.TaggedError('TalosOpenBaoLegacyAuth')<{
|
|
37
|
+
readonly _?: never;
|
|
38
|
+
}> {
|
|
39
|
+
override get message(): string {
|
|
40
|
+
return (
|
|
41
|
+
'talos-openbao connection carries a legacy `cluster` alias in its auth block. Refused ' +
|
|
42
|
+
'before any vault read or request, with no silent migration (old and new auth differ, so ' +
|
|
43
|
+
'upstream would PATCH then DELETE the same object). Remedy: edit the saved state ONCE so ' +
|
|
44
|
+
"each such row's `connection.auth` is `{ kind: 'talos-openbao', uid }` (drop `cluster`), " +
|
|
45
|
+
'then redeploy.'
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `Talos.ClusterIdentity` read a different uid than the one it published. Never an update. */
|
|
51
|
+
export class TalosClusterMoved extends Data.TaggedError('TalosClusterMoved')<{
|
|
52
|
+
readonly cluster: string;
|
|
53
|
+
readonly saved: string;
|
|
54
|
+
readonly live: string;
|
|
55
|
+
}> {
|
|
56
|
+
override get message(): string {
|
|
57
|
+
return (
|
|
58
|
+
`Talos.ClusterIdentity '${this.cluster}' published uid ${this.saved} but the vault key now ` +
|
|
59
|
+
`answers as ${this.live}. Refused, not updated: an update cannot become a replace for the ` +
|
|
60
|
+
'workloads downstream, so the new cluster would be force-applied and the old one orphaned. ' +
|
|
61
|
+
'A move is explicit: declare a NEW Talos.ClusterIdentity for the new cluster and leave this ' +
|
|
62
|
+
'one pointing at the old cluster until its workloads are destroyed.'
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `bao kv get` reported the key unwritten. The message names `mount/key` and no document bytes. */
|
|
68
|
+
export class TalosVaultKeyMissing extends Data.TaggedError('TalosVaultKeyMissing')<{
|
|
69
|
+
readonly mount: string;
|
|
70
|
+
readonly key: string;
|
|
71
|
+
}> {
|
|
72
|
+
override get message(): string {
|
|
73
|
+
return (
|
|
74
|
+
`${this.mount}/${this.key}: OpenBao has no value at this key. Talos.Kubeconfig writes it ` +
|
|
75
|
+
'once at bring-up. Connect refused instead of reading a kubeconfig from disk.'
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Vault bytes exist but are not a kubeconfig for the pinned context. The document is not echoed. */
|
|
81
|
+
export class TalosKubeconfigUnreadable extends Data.TaggedError('TalosKubeconfigUnreadable')<{
|
|
82
|
+
readonly mount: string;
|
|
83
|
+
readonly key: string;
|
|
84
|
+
readonly context: string;
|
|
85
|
+
}> {
|
|
86
|
+
override get message(): string {
|
|
87
|
+
return (
|
|
88
|
+
`${this.mount}/${this.key}: OpenBao kubeconfig does not parse for context ${this.context}. ` +
|
|
89
|
+
'The document stayed in the vault. Connect wrote nothing to disk and nothing to state.'
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The layer was asked to connect a different auth kind. */
|
|
95
|
+
export class TalosOpenBaoAuthKind extends Data.TaggedError('TalosOpenBaoAuthKind')<{
|
|
96
|
+
readonly kind: string;
|
|
97
|
+
}> {
|
|
98
|
+
override get message(): string {
|
|
99
|
+
return (
|
|
100
|
+
`talos-openbao adapter received auth kind '${this.kind}'. This adapter only connects ` +
|
|
101
|
+
'`talos-openbao`.'
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The saved auth block carries no `uid`, so nothing says WHICH physical cluster it meant. */
|
|
107
|
+
export class TalosClusterIdentityMissing extends Data.TaggedError('TalosClusterIdentityMissing')<{
|
|
108
|
+
readonly _?: never;
|
|
109
|
+
}> {
|
|
110
|
+
override get message(): string {
|
|
111
|
+
return (
|
|
112
|
+
'talos-openbao connection carries no uid, so it cannot say which physical cluster it ' +
|
|
113
|
+
'targets. Refused before any vault read or request: pin the uid ' +
|
|
114
|
+
'printed by `Talos.ClusterIdentity` as a literal in `TalosOpenBaoAdapter` and build ' +
|
|
115
|
+
'workloads from `talosOpenBaoConnection(<that literal>)`.'
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The cluster that answered is not the one the saved auth block named. Uids are public. */
|
|
121
|
+
export class TalosClusterIdentityMismatch extends Data.TaggedError('TalosClusterIdentityMismatch')<{
|
|
122
|
+
readonly cluster: string;
|
|
123
|
+
readonly expected: string;
|
|
124
|
+
readonly got: string;
|
|
125
|
+
}> {
|
|
126
|
+
override get message(): string {
|
|
127
|
+
return (
|
|
128
|
+
`talos-openbao cluster '${this.cluster}' answered as kube-system uid ${this.got}, but the ` +
|
|
129
|
+
`saved connection expects ${this.expected}. Refused before any apply or delete: the name ` +
|
|
130
|
+
'now resolves to a different physical cluster. If the original cluster is gone for good, ' +
|
|
131
|
+
'remove its rows from state deliberately.'
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** `GET /api/v1/namespaces/kube-system` answered without a usable `metadata.uid`. */
|
|
137
|
+
export class TalosClusterIdentityUnreadable extends Data.TaggedError(
|
|
138
|
+
'TalosClusterIdentityUnreadable',
|
|
139
|
+
)<{
|
|
140
|
+
readonly cluster: string;
|
|
141
|
+
}> {
|
|
142
|
+
override get message(): string {
|
|
143
|
+
return (
|
|
144
|
+
`talos-openbao cluster '${this.cluster}': kube-system has no readable metadata.uid. ` +
|
|
145
|
+
'Refused rather than assuming the cluster is the expected one.'
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The kube-system GET got no answer in time: a silent apiserver must not hang connect or plan. */
|
|
151
|
+
export class TalosClusterIdentityTimeout extends Data.TaggedError('TalosClusterIdentityTimeout')<{
|
|
152
|
+
readonly cluster: string;
|
|
153
|
+
readonly seconds: number;
|
|
154
|
+
}> {
|
|
155
|
+
override get message(): string {
|
|
156
|
+
return (
|
|
157
|
+
`talos-openbao cluster '${this.cluster}': no answer to the kube-system uid read within ` +
|
|
158
|
+
`${this.seconds}s (the identity read's own deadline, inside the 10 s connect deadline). ` +
|
|
159
|
+
'Refused (failed closed) rather than waiting on a silent apiserver.'
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The whole connect path (vault read, parse, kube-system uid read) did not finish in time. One
|
|
166
|
+
* deadline covers `bao` and the apiserver together: a hung `bao` or a silent apiserver must not
|
|
167
|
+
* hang connect or a plan. The `bao` child is killed when the deadline interrupts the read.
|
|
168
|
+
*/
|
|
169
|
+
export class TalosOpenBaoConnectTimeout extends Data.TaggedError('TalosOpenBaoConnectTimeout')<{
|
|
170
|
+
readonly cluster: string;
|
|
171
|
+
readonly seconds: number;
|
|
172
|
+
}> {
|
|
173
|
+
override get message(): string {
|
|
174
|
+
return (
|
|
175
|
+
`talos-openbao cluster '${this.cluster}': vault read and identity check did not finish ` +
|
|
176
|
+
`within ${this.seconds}s. Refused (failed closed); the bao child was killed.`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A connection was built from a uid that is not a plain string known at plan time. */
|
|
182
|
+
export class TalosUidNotLiteral extends Data.TaggedError('TalosUidNotLiteral')<{
|
|
183
|
+
readonly _?: never;
|
|
184
|
+
}> {
|
|
185
|
+
override get message(): string {
|
|
186
|
+
return (
|
|
187
|
+
'talos-openbao connection uid must be a literal string taken from the adapter config, not ' +
|
|
188
|
+
'an Output (for example a Talos.ClusterIdentity attribute). An unresolved uid hides a ' +
|
|
189
|
+
"cluster change from the plan: upstream would plan an UPDATE and reconcile the old cluster's " +
|
|
190
|
+
'objects on the new one. Pin the uid in `TalosOpenBaoAdapter` and take it from there.'
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|