@homeflare/alchemy 0.39.0 → 0.41.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 +10 -8
- package/dist/caddy/index.js.map +2 -2
- package/dist/ceph/auth-entity.d.ts +34 -0
- package/dist/ceph/auth-entity.d.ts.map +1 -0
- package/dist/ceph/ceph-argv-config.d.ts +10 -0
- package/dist/ceph/ceph-argv-config.d.ts.map +1 -0
- package/dist/ceph/ceph-argv.d.ts +19 -0
- package/dist/ceph/ceph-argv.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-form.d.ts +54 -0
- package/dist/ceph/ceph-auth-form.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-parse.d.ts +56 -0
- package/dist/ceph/ceph-auth-parse.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-reconcile.d.ts +35 -0
- package/dist/ceph/ceph-auth-reconcile.d.ts.map +1 -0
- package/dist/ceph/ceph-config-options.d.ts +23 -0
- package/dist/ceph/ceph-config-options.d.ts.map +1 -0
- package/dist/ceph/ceph-key-leak-assert.d.ts +25 -0
- package/dist/ceph/ceph-key-leak-assert.d.ts.map +1 -0
- package/dist/ceph/ceph-transport.d.ts +37 -0
- package/dist/ceph/ceph-transport.d.ts.map +1 -0
- package/dist/ceph/fake-ceph-dial.d.ts +35 -0
- package/dist/ceph/fake-ceph-dial.d.ts.map +1 -0
- package/dist/ceph/index.d.ts +13 -0
- package/dist/ceph/index.d.ts.map +1 -0
- package/dist/ceph/index.js +1610 -0
- package/dist/ceph/index.js.map +38 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/fake-runner.d.ts.map +1 -1
- package/dist/launchd/index.d.ts +1 -1
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +7 -1
- package/dist/launchd/index.js.map +5 -5
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +21 -0
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-runner.d.ts.map +1 -1
- package/dist/linux/directory-lifecycle.d.ts.map +1 -1
- package/dist/linux/fake-linux-host.d.ts.map +1 -1
- package/dist/linux/index.d.ts +1 -1
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +16 -4
- package/dist/linux/index.js.map +7 -7
- package/dist/linux/ssh-runner.d.ts.map +1 -1
- package/dist/linux/sudo-runner.d.ts.map +1 -1
- package/dist/opnsense/alias-form.d.ts +14 -1
- package/dist/opnsense/alias-form.d.ts.map +1 -1
- package/dist/opnsense/alias.d.ts +1 -1
- package/dist/opnsense/alias.d.ts.map +1 -1
- package/dist/opnsense/group-form.d.ts +8 -2
- package/dist/opnsense/group-form.d.ts.map +1 -1
- package/dist/opnsense/group.d.ts +1 -1
- package/dist/opnsense/group.d.ts.map +1 -1
- package/dist/opnsense/index.js +15 -7
- package/dist/opnsense/index.js.map +7 -7
- package/dist/opnsense/wire.d.ts +42 -12
- package/dist/opnsense/wire.d.ts.map +1 -1
- package/dist/proxmox/index.js +62 -39
- package/dist/proxmox/index.js.map +8 -7
- package/dist/proxmox/qemu-volume.d.ts +12 -2
- package/dist/proxmox/qemu-volume.d.ts.map +1 -1
- package/dist/proxmox/storage-form.d.ts +28 -3
- package/dist/proxmox/storage-form.d.ts.map +1 -1
- package/dist/proxmox/storage-plugin-options.d.ts +2 -0
- package/dist/proxmox/storage-plugin-options.d.ts.map +1 -0
- package/dist/proxmox/storage-wire.d.ts +28 -1
- package/dist/proxmox/storage-wire.d.ts.map +1 -1
- package/dist/proxmox/storage.d.ts +6 -1
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/release/index.d.ts +1 -1
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js.map +2 -2
- package/dist/talos/credentials-write.d.ts +46 -0
- package/dist/talos/credentials-write.d.ts.map +1 -0
- package/dist/talos/credentials.d.ts +51 -9
- package/dist/talos/credentials.d.ts.map +1 -1
- package/dist/talos/fake-process.d.ts +56 -0
- package/dist/talos/fake-process.d.ts.map +1 -0
- package/dist/talos/index.d.ts +19 -4
- package/dist/talos/index.d.ts.map +1 -1
- package/dist/talos/index.js +357 -173
- package/dist/talos/index.js.map +16 -10
- package/dist/talos/kubeconfig-attrs.d.ts +4 -0
- package/dist/talos/kubeconfig-attrs.d.ts.map +1 -0
- package/dist/talos/kubeconfig.d.ts +41 -23
- package/dist/talos/kubeconfig.d.ts.map +1 -1
- package/dist/talos/machine-config-fixtures.d.ts +60 -0
- package/dist/talos/machine-config-fixtures.d.ts.map +1 -0
- package/dist/talos/machine-config-poll.d.ts +43 -0
- package/dist/talos/machine-config-poll.d.ts.map +1 -0
- package/dist/talos/machine-config-read.d.ts +49 -0
- package/dist/talos/machine-config-read.d.ts.map +1 -0
- package/dist/talos/resource.d.ts +18 -0
- package/dist/talos/resource.d.ts.map +1 -1
- package/dist/talos/talos-bootstrap-etcd.d.ts +38 -0
- package/dist/talos/talos-bootstrap-etcd.d.ts.map +1 -0
- package/dist/talos/talos-bootstrap.d.ts +87 -0
- package/dist/talos/talos-bootstrap.d.ts.map +1 -1
- package/dist/talos/talos-cluster-health.d.ts +43 -1
- package/dist/talos/talos-cluster-health.d.ts.map +1 -1
- package/dist/talos/talos-errors.d.ts +68 -0
- package/dist/talos/talos-errors.d.ts.map +1 -0
- package/dist/talos/talos-machine-config.d.ts +51 -15
- package/dist/talos/talos-machine-config.d.ts.map +1 -1
- package/dist/talos/talosctl.d.ts +7 -0
- package/dist/talos/talosctl.d.ts.map +1 -1
- package/dist/talos/values.d.ts +23 -3
- package/dist/talos/values.d.ts.map +1 -1
- package/dist/unifi/acl-rule-drift.d.ts +22 -0
- package/dist/unifi/acl-rule-drift.d.ts.map +1 -0
- package/dist/unifi/acl-rule-form.d.ts +90 -0
- package/dist/unifi/acl-rule-form.d.ts.map +1 -0
- package/dist/unifi/acl-rule-ordering-form.d.ts +49 -0
- package/dist/unifi/acl-rule-ordering-form.d.ts.map +1 -0
- package/dist/unifi/acl-rule-ordering.d.ts +54 -0
- package/dist/unifi/acl-rule-ordering.d.ts.map +1 -0
- package/dist/unifi/acl-rule.d.ts +49 -0
- package/dist/unifi/acl-rule.d.ts.map +1 -0
- package/dist/unifi/dns-policy-drift.d.ts +21 -0
- package/dist/unifi/dns-policy-drift.d.ts.map +1 -0
- package/dist/unifi/dns-policy-form.d.ts +97 -0
- package/dist/unifi/dns-policy-form.d.ts.map +1 -0
- package/dist/unifi/dns-policy.d.ts +55 -0
- package/dist/unifi/dns-policy.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-drift.d.ts +26 -0
- package/dist/unifi/firewall-policy-drift.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-form.d.ts +102 -0
- package/dist/unifi/firewall-policy-form.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-ordering-form.d.ts +66 -0
- package/dist/unifi/firewall-policy-ordering-form.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-ordering.d.ts +57 -0
- package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -0
- package/dist/unifi/firewall-policy.d.ts +57 -0
- package/dist/unifi/firewall-policy.d.ts.map +1 -0
- package/dist/unifi/index.d.ts +7 -0
- package/dist/unifi/index.d.ts.map +1 -1
- package/dist/unifi/index.js +588 -3
- package/dist/unifi/index.js.map +24 -4
- package/dist/unifi/paginate.d.ts +0 -12
- package/dist/unifi/paginate.d.ts.map +1 -1
- package/dist/unifi/providers.d.ts +1 -1
- package/dist/unifi/providers.d.ts.map +1 -1
- package/dist/unifi/scan-source.d.ts +11 -0
- package/dist/unifi/scan-source.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list-drift.d.ts +22 -0
- package/dist/unifi/traffic-matching-list-drift.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list-form.d.ts +56 -0
- package/dist/unifi/traffic-matching-list-form.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list.d.ts +51 -0
- package/dist/unifi/traffic-matching-list.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast-drift.d.ts +22 -0
- package/dist/unifi/wifi-broadcast-drift.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast-form.d.ts +127 -0
- package/dist/unifi/wifi-broadcast-form.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast.d.ts +73 -0
- package/dist/unifi/wifi-broadcast.d.ts.map +1 -0
- package/docs/distilled-interim.md +7 -0
- package/docs/launchd.md +5 -3
- package/docs/provider-standard.md +5 -1
- package/docs/talos-argocd-dogfood.md +160 -0
- package/docs/unifi-acl-rule.md +122 -0
- package/docs/unifi-dns-policy.md +75 -0
- package/docs/unifi-firewall-policy.md +109 -0
- package/docs/unifi-traffic-matching-list.md +82 -0
- package/docs/unifi-wifi.md +120 -0
- package/docs/unifi.md +10 -5
- package/package.json +7 -3
- package/src/ceph/auth-entity.ts +74 -0
- package/src/ceph/ceph-argv-config.ts +78 -0
- package/src/ceph/ceph-argv.test.ts +226 -0
- package/src/ceph/ceph-argv.ts +189 -0
- package/src/ceph/ceph-auth-form.test.ts +103 -0
- package/src/ceph/ceph-auth-form.ts +107 -0
- package/src/ceph/ceph-auth-parse.test.ts +155 -0
- package/src/ceph/ceph-auth-parse.ts +121 -0
- package/src/ceph/ceph-auth-reconcile-secrecy.test.ts +82 -0
- package/src/ceph/ceph-auth-reconcile.test.ts +233 -0
- package/src/ceph/ceph-auth-reconcile.ts +119 -0
- package/src/ceph/ceph-config-options.test.ts +44 -0
- package/src/ceph/ceph-config-options.ts +36 -0
- package/src/ceph/ceph-key-leak-assert.ts +35 -0
- package/src/ceph/ceph-no-output-logging.test.ts +52 -0
- package/src/ceph/ceph-transport.test.ts +125 -0
- package/src/ceph/ceph-transport.ts +117 -0
- package/src/ceph/fake-ceph-dial.ts +55 -0
- package/src/ceph/index.ts +12 -0
- package/src/index.ts +1 -0
- package/src/launchd/fake-runner.ts +2 -0
- package/src/launchd/index.ts +8 -1
- package/src/launchd/local-runner.ts +16 -1
- package/src/launchd/runner.ts +22 -0
- package/src/launchd/sudo-runner.ts +3 -0
- package/src/linux/directory-lifecycle.ts +18 -3
- package/src/linux/directory-sudo.test.ts +59 -0
- package/src/linux/directory.test.ts +34 -8
- package/src/linux/fake-linux-host.ts +2 -0
- package/src/linux/index.ts +1 -0
- package/src/linux/ssh-runner.ts +4 -0
- package/src/linux/sudo-runner.ts +3 -0
- package/src/opnsense/alias-form.test.ts +24 -12
- package/src/opnsense/alias-form.ts +20 -7
- package/src/opnsense/alias.test.ts +13 -6
- package/src/opnsense/alias.ts +16 -10
- package/src/opnsense/group-form.test.ts +12 -8
- package/src/opnsense/group-form.ts +13 -4
- package/src/opnsense/group.test.ts +18 -7
- package/src/opnsense/group.ts +10 -3
- package/src/opnsense/wire.ts +57 -12
- package/src/proxmox/qemu-volume.test.ts +153 -4
- package/src/proxmox/qemu-volume.ts +71 -14
- package/src/proxmox/storage-form.ts +48 -8
- package/src/proxmox/storage-plugin-options.ts +45 -0
- package/src/proxmox/storage-update.test.ts +183 -0
- package/src/proxmox/storage-wire.ts +54 -7
- package/src/proxmox/storage.ts +18 -6
- package/src/release/index.ts +1 -0
- package/src/talos/credentials-write.test.ts +81 -0
- package/src/talos/credentials-write.ts +95 -0
- package/src/talos/credentials.test.ts +157 -0
- package/src/talos/credentials.ts +160 -90
- package/src/talos/fake-process.ts +94 -0
- package/src/talos/index.ts +39 -4
- package/src/talos/kubeconfig-attrs.ts +55 -0
- package/src/talos/kubeconfig.test.ts +256 -0
- package/src/talos/kubeconfig.ts +170 -90
- package/src/talos/machine-config-fixtures.ts +86 -0
- package/src/talos/machine-config-poll.test.ts +57 -0
- package/src/talos/machine-config-poll.ts +82 -0
- package/src/talos/machine-config-read.ts +118 -0
- package/src/talos/resource.ts +19 -0
- package/src/talos/secrets-gate.test.ts +63 -0
- package/src/talos/talos-bootstrap-etcd.ts +71 -0
- package/src/talos/talos-bootstrap.test.ts +218 -0
- package/src/talos/talos-bootstrap.ts +151 -45
- package/src/talos/talos-cluster-health.test.ts +143 -0
- package/src/talos/talos-cluster-health.ts +91 -28
- package/src/talos/talos-errors.ts +85 -0
- package/src/talos/talos-machine-config.test.ts +189 -0
- package/src/talos/talos-machine-config.ts +102 -88
- package/src/talos/talosctl.ts +8 -0
- package/src/talos/values.test.ts +34 -6
- package/src/talos/values.ts +41 -7
- package/src/unifi/acl-rule-drift.ts +57 -0
- package/src/unifi/acl-rule-form.ts +135 -0
- package/src/unifi/acl-rule-ordering-form.ts +79 -0
- package/src/unifi/acl-rule-ordering.test.ts +141 -0
- package/src/unifi/acl-rule-ordering.ts +70 -0
- package/src/unifi/acl-rule.test.ts +183 -0
- package/src/unifi/acl-rule.ts +61 -0
- package/src/unifi/discriminated-filter-decode.test.ts +134 -0
- package/src/unifi/dns-policy-drift.ts +48 -0
- package/src/unifi/dns-policy-form.ts +142 -0
- package/src/unifi/dns-policy.test.ts +154 -0
- package/src/unifi/dns-policy.ts +67 -0
- package/src/unifi/firewall-policy-drift.test.ts +151 -0
- package/src/unifi/firewall-policy-drift.ts +55 -0
- package/src/unifi/firewall-policy-filter-roundtrip.test.ts +143 -0
- package/src/unifi/firewall-policy-form.ts +146 -0
- package/src/unifi/firewall-policy-ordering-form.ts +111 -0
- package/src/unifi/firewall-policy-ordering.test.ts +185 -0
- package/src/unifi/firewall-policy-ordering.ts +87 -0
- package/src/unifi/firewall-policy.test.ts +183 -0
- package/src/unifi/firewall-policy.ts +73 -0
- package/src/unifi/index.ts +63 -0
- package/src/unifi/paginate.test.ts +24 -0
- package/src/unifi/paginate.ts +15 -3
- package/src/unifi/providers.ts +19 -1
- package/src/unifi/scan-source.ts +64 -0
- package/src/unifi/traffic-matching-list-drift.ts +40 -0
- package/src/unifi/traffic-matching-list-form.ts +77 -0
- package/src/unifi/traffic-matching-list.test.ts +202 -0
- package/src/unifi/traffic-matching-list.ts +78 -0
- package/src/unifi/wifi-broadcast-details-call.test.ts +92 -0
- package/src/unifi/wifi-broadcast-drift.ts +69 -0
- package/src/unifi/wifi-broadcast-form.ts +211 -0
- package/src/unifi/wifi-broadcast-secrets.test.ts +145 -0
- package/src/unifi/wifi-broadcast.test.ts +155 -0
- package/src/unifi/wifi-broadcast.ts +90 -0
- package/src/unifi/write-op-reference.test.ts +8 -54
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The observe -> ensure -> sync loop `Ceph.AuthEntity`'s reconcile runs (mon-transport doc,
|
|
3
|
+
* `Ceph.AuthEntity`). Split from auth-entity.ts so the loop itself — the part a fake-runner test
|
|
4
|
+
* actually exercises — is a plain function, the same split ceph-pool.ts and ssh-role.ts use.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ NEVER ADOPTS. A live entity found present while this stack holds no attributes for it is
|
|
7
|
+
* refused outright, `--adopt` included — the generic `refuseTakeover` (../ownership/adopt.ts)
|
|
8
|
+
* is deliberately NOT used here, because it honours `--adopt` and this family's own design says
|
|
9
|
+
* plainly: "Existing auth entities are recorded as observed inventory only — never adopted:
|
|
10
|
+
* adopting one would mean reading and fingerprinting its key, and a key is only ever handled for
|
|
11
|
+
* an entity this stack owns."
|
|
12
|
+
* ⛔ THE OBSERVE STEP READS `auth get`'S UNFILTERED STDOUT ON EVERY RECONCILE (decision 65,
|
|
13
|
+
* 2026-09-26, amending the design after LAND finding 5 — no node-side filter narrows it first).
|
|
14
|
+
* The key that stdout carries is dropped in the same breath: `parseAuthGet` has no `key` field in
|
|
15
|
+
* its return type at all, so there is no variable here ever holding it past that call. Only `auth
|
|
16
|
+
* get-or-create`, run once on the create branch, yields a `key` value, and it goes straight to
|
|
17
|
+
* OpenBao — never assigned to a variable this function returns, logs, or puts in the attributes
|
|
18
|
+
* it hands back — `attributesOf` takes a fingerprint, never a key.
|
|
19
|
+
* ★ CAPS DRIFT NEVER RE-MINTS THE KEY. `auth caps` changes only what an entity may do; the returned
|
|
20
|
+
* attributes reuse `output.fingerprint` unchanged.
|
|
21
|
+
* ⛔ A MOVED IDENTITY IS REFUSED HERE TOO, BELT AND SUSPENDERS WITH `planCephAuthEntity`'s
|
|
22
|
+
* `replace`. `isResolved(news)` in auth-entity.ts's `diff` can defer to the engine's own
|
|
23
|
+
* `update` fallback while any prop is still an Output (rename.ts's own header documents this
|
|
24
|
+
* exact Alchemy behaviour for every other family this pattern protects) — a case in which
|
|
25
|
+
* `planCephAuthEntity` never even runs. Reached with `output` naming a different entity/mount
|
|
26
|
+
* than `props`, this refuses before any ssh call, the same shape of guard `guardRename`
|
|
27
|
+
* (openbao/rename-identity.ts) runs first in every `Bao.*` reconcile.
|
|
28
|
+
*/
|
|
29
|
+
import * as Effect from 'effect/Effect';
|
|
30
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
31
|
+
import { baoWrite } from '../openbao/bao-http.ts';
|
|
32
|
+
import { sha256 } from '../openbao/digest.ts';
|
|
33
|
+
import { authCapsArgv, authGetArgv, authGetOrCreateArgv } from './ceph-argv.ts';
|
|
34
|
+
import { parseAuthGet, parseAuthGetOrCreate } from './ceph-auth-parse.ts';
|
|
35
|
+
import {
|
|
36
|
+
type CephAuthEntityAttributes,
|
|
37
|
+
type CephAuthEntityProps,
|
|
38
|
+
attributesOf,
|
|
39
|
+
capsEqual,
|
|
40
|
+
cephDataPath,
|
|
41
|
+
identityMoved,
|
|
42
|
+
} from './ceph-auth-form.ts';
|
|
43
|
+
import { type CephTransportOptions, assertFreshQuorum, runCephCommand } from './ceph-transport.ts';
|
|
44
|
+
|
|
45
|
+
export type CephAuthDeps = Pick<CephTransportOptions, 'dial' | 'log'>;
|
|
46
|
+
|
|
47
|
+
const asError = (cause: unknown): Error =>
|
|
48
|
+
cause instanceof Error ? cause : new Error(String(cause));
|
|
49
|
+
|
|
50
|
+
const runCeph = (transport: CephTransportOptions, argv: readonly string[]) =>
|
|
51
|
+
Effect.tryPromise({ catch: asError, try: () => runCephCommand(transport, argv) });
|
|
52
|
+
|
|
53
|
+
const syncQuorum = (transport: CephTransportOptions) =>
|
|
54
|
+
Effect.tryPromise({ catch: asError, try: () => assertFreshQuorum(transport) });
|
|
55
|
+
|
|
56
|
+
const neverAdopted = (entity: string): Error =>
|
|
57
|
+
new Error(
|
|
58
|
+
`Ceph.AuthEntity ${entity}: already exists on the mon cluster and this stack holds no state ` +
|
|
59
|
+
'for it. This family is never adopted — not even under --adopt — because taking it over ' +
|
|
60
|
+
'would mean fingerprinting a key this stack did not mint. Remove it by hand first, or bring ' +
|
|
61
|
+
'it under management through the coordinator’s own read-only inventory process.',
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const movedIdentity = (from: string, to: string): Error =>
|
|
65
|
+
new Error(
|
|
66
|
+
`Ceph.AuthEntity: planned as an update, but its identity moved from ${from} to ${to}. ` +
|
|
67
|
+
`Acting on ${to} now, with ${from}'s attributes, could re-cap an entity this generation ` +
|
|
68
|
+
'never minted. Nothing was sent to any mon. Run the deploy again: once entity/mount fully ' +
|
|
69
|
+
'resolve at plan time this plans as a replace instead.',
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
export const reconcileCephAuthEntity = (
|
|
73
|
+
props: CephAuthEntityProps,
|
|
74
|
+
output: CephAuthEntityAttributes | undefined,
|
|
75
|
+
deps: CephAuthDeps,
|
|
76
|
+
): Effect.Effect<CephAuthEntityAttributes, Error, HttpClient.HttpClient> =>
|
|
77
|
+
Effect.gen(function* () {
|
|
78
|
+
if (identityMoved(props, output))
|
|
79
|
+
return yield* Effect.fail(movedIdentity(output?.entity ?? '', props.entity));
|
|
80
|
+
|
|
81
|
+
const transport: CephTransportOptions = { nodes: props.nodes, ...deps };
|
|
82
|
+
|
|
83
|
+
const observed = yield* runCeph(transport, authGetArgv(props.entity)).pipe(
|
|
84
|
+
Effect.map((result) => parseAuthGet(result, props.entity)),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
if (observed.kind === 'absent') {
|
|
88
|
+
// ⛔ THE VAULT WRITE IS CHECKED BEFORE ANYTHING IS MINTED. LAND red team (2026-09-26),
|
|
89
|
+
// CONFIRMED: minting first and writing second means a write refused by policy (the likely
|
|
90
|
+
// first-deploy shape — the `data/ceph/*` write grant landing after this stack's first
|
|
91
|
+
// run) strands a key that only ever existed in memory. The next reconcile's `auth get`
|
|
92
|
+
// then finds the entity present, `output` is still undefined, and `neverAdopted` refuses
|
|
93
|
+
// it — permanently, since `auth del` is refused too (D3). A placeholder write proves the
|
|
94
|
+
// path is writable first; nothing has touched the mon if it fails, so a retry starts
|
|
95
|
+
// clean. ⚠️ KV-v2 keeps this placeholder as a version in the secret's history (no version
|
|
96
|
+
// is ever skipped) — harmless, since it never carries anything key-shaped, but visible to
|
|
97
|
+
// `bao kv get -version=N` until the mount's `max_versions` rolls it off.
|
|
98
|
+
yield* baoWrite('POST', cephDataPath(props.mount, props.entity), {
|
|
99
|
+
data: { pending: true },
|
|
100
|
+
});
|
|
101
|
+
const result = yield* runCeph(transport, authGetOrCreateArgv(props.entity, props.caps));
|
|
102
|
+
const created = parseAuthGetOrCreate(result, props.entity);
|
|
103
|
+
// ⛔ THE ONE PLACE THE KEY CROSSES INTO A BAO CALL — as the HTTP request body's JSON, never
|
|
104
|
+
// argv, never a temp file. `created.key` is not referenced again after this.
|
|
105
|
+
yield* baoWrite('POST', cephDataPath(props.mount, props.entity), {
|
|
106
|
+
data: { caps: created.caps, key: created.key },
|
|
107
|
+
});
|
|
108
|
+
yield* syncQuorum(transport);
|
|
109
|
+
return attributesOf(props, sha256(created.key), result.node);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (output === undefined) return yield* Effect.fail(neverAdopted(props.entity));
|
|
113
|
+
|
|
114
|
+
if (capsEqual(observed.caps, props.caps)) return output;
|
|
115
|
+
|
|
116
|
+
const result = yield* runCeph(transport, authCapsArgv(props.entity, props.caps));
|
|
117
|
+
yield* syncQuorum(transport);
|
|
118
|
+
return attributesOf(props, output.fingerprint, result.node);
|
|
119
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural guard on the named config-option list itself (mon-transport doc acceptance test #3):
|
|
3
|
+
* the list contains none of the lockout classes, and it starts empty (D-C1).
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import { NAMED_CONFIG_OPTIONS, isLockoutConfigOption } from './ceph-config-options.ts';
|
|
7
|
+
|
|
8
|
+
describe('NAMED_CONFIG_OPTIONS', () => {
|
|
9
|
+
test('starts empty', () => {
|
|
10
|
+
expect(NAMED_CONFIG_OPTIONS.size).toBe(0);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('contains none of the lockout classes, whatever gets added later', () => {
|
|
14
|
+
const bad = [...NAMED_CONFIG_OPTIONS].filter((name) => isLockoutConfigOption(name));
|
|
15
|
+
expect(bad).toEqual([]);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('isLockoutConfigOption', () => {
|
|
20
|
+
test.each([
|
|
21
|
+
'mon_host',
|
|
22
|
+
'public_network',
|
|
23
|
+
'cluster_network',
|
|
24
|
+
'public_addr',
|
|
25
|
+
'cluster_addr',
|
|
26
|
+
'auth_cluster_required',
|
|
27
|
+
'auth_service_required',
|
|
28
|
+
'auth_client_required',
|
|
29
|
+
'keyring',
|
|
30
|
+
'mgr_keyring',
|
|
31
|
+
'keyring_path',
|
|
32
|
+
'key',
|
|
33
|
+
'mon_data_key',
|
|
34
|
+
])('flags %s', (name) => {
|
|
35
|
+
expect(isLockoutConfigOption(name)).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test.each(['mon_allow_pool_delete', 'osd_pool_default_size', 'mon_max_pg_per_osd'])(
|
|
39
|
+
'does not flag an ordinary tuning option like %s',
|
|
40
|
+
(name) => {
|
|
41
|
+
expect(isLockoutConfigOption(name)).toBe(false);
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The named Ceph config-option list — the only options `ceph config get/set/rm` may ever touch
|
|
3
|
+
* through the mon-command transport (ceph-argv.ts's `configProblem`). Design:
|
|
4
|
+
* docs/plans/2026-09-26-ceph-config-options.md, D-C1.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ THE LIST STARTS EMPTY, AND EACH ADDITION IS ITS OWN REVIEWED PR LINE. Nothing in K-A4 adds to
|
|
7
|
+
* it — `CephConfigOption` (K-D) is a separate, later family; this file exists now only so the
|
|
8
|
+
* transport's allowlist has something to bound `config` against from day one, per the accepted
|
|
9
|
+
* design ("config get/set for the named option list which starts EMPTY").
|
|
10
|
+
* ⛔ NEVER ADD ONE OF THE LOCKOUT CLASSES BELOW. `mon_host`, `public_network`, `cluster_network`,
|
|
11
|
+
* `public_addr`, `cluster_addr`, every `auth_*_required` and every `key`/`keyring`-bearing
|
|
12
|
+
* option re-address the mons or de-authenticate the whole cluster — cutting every VM disk — and
|
|
13
|
+
* `isLockoutConfigOption` is a structural guard a test runs against this file's own list, not a
|
|
14
|
+
* suggestion for the reviewer of the next PR line.
|
|
15
|
+
* ⛔ NEVER ADD A SECRET-VALUED OPTION. mgr module options can hold passwords (REASONED — no live
|
|
16
|
+
* cluster to measure this against yet); a secret-bearing setting belongs in a vault-backed flow,
|
|
17
|
+
* never a `ceph config set` argv, which lands in the runner's log and the node's sudo journal.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Starts empty. Add one reviewed option name per PR line, never a batch. */
|
|
21
|
+
export const NAMED_CONFIG_OPTIONS: ReadonlySet<string> = new Set([]);
|
|
22
|
+
|
|
23
|
+
const LOCKOUT_EXACT: ReadonlySet<string> = new Set([
|
|
24
|
+
'mon_host',
|
|
25
|
+
'public_network',
|
|
26
|
+
'cluster_network',
|
|
27
|
+
'public_addr',
|
|
28
|
+
'cluster_addr',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
/** `auth_*_required` (cephx enforcement) and anything key/keyring-shaped, either side of `_`. */
|
|
32
|
+
const LOCKOUT_PATTERN = /^auth_\w*_required$|(^|_)key(ring)?($|_)/;
|
|
33
|
+
|
|
34
|
+
/** Whether `name` is a class this transport must never read a value for, adopt, or write. */
|
|
35
|
+
export const isLockoutConfigOption = (name: string): boolean =>
|
|
36
|
+
LOCKOUT_EXACT.has(name) || LOCKOUT_PATTERN.test(name);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared by the two test files that prove `Ceph.AuthEntity` never leaks a minted key through a
|
|
3
|
+
* parse-error message: ceph-auth-parse.test.ts (direct parser calls) and
|
|
4
|
+
* ceph-auth-reconcile-secrecy.test.ts (through the full reconcile loop). Split out so the leak
|
|
5
|
+
* check and the malformed-stdout fixtures are identical in both places — the property being
|
|
6
|
+
* proved (decision 65) is the same one, and two hand-copied versions are exactly the kind of thing
|
|
7
|
+
* that quietly drifts apart.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ TEST-ONLY. Nothing in `src/` imports this outside a `.test.ts` file.
|
|
10
|
+
* ★ NO HYPHEN IN THE SENTINEL. LAND red team (2026-09-26), CONFIRMED: a JSON lexer's "unexpected
|
|
11
|
+
* identifier" error for a bare (unquoted) token stops at the first character outside
|
|
12
|
+
* `[A-Za-z0-9_$]`, so a hyphenated sentinel (`sentinel-ceph-key-...`) truncates to its first
|
|
13
|
+
* word — `.not.toContain(SENTINEL)` on the FULL string then never fires even when that first
|
|
14
|
+
* word leaked. Cephx keys are base64 (`[A-Za-z0-9+/=]`, no hyphen), so this sentinel is shaped
|
|
15
|
+
* the same way, and every check here compares 8-character slices, not full-string containment.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** A cephx-shaped sentinel: looks like a real minted key, no hyphen anywhere to truncate on. */
|
|
19
|
+
export const KEY_SHAPED_SENTINEL = 'AQDsentinelKEYsentinelKEY0123456789abcd==';
|
|
20
|
+
|
|
21
|
+
/** True if any 8-character window of `key` shows up anywhere in `message`. */
|
|
22
|
+
export const leaksSliceOf = (message: string, key: string = KEY_SHAPED_SENTINEL): boolean => {
|
|
23
|
+
for (let start = 0; start + 8 <= key.length; start += 1) {
|
|
24
|
+
if (message.includes(key.slice(start, start + 8))) return true;
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** `auth get`/`auth get-or-create` stdout with the key left as a bare (unquoted) JSON token. */
|
|
30
|
+
export const unquotedKeyStdout = (entity: string, key: string = KEY_SHAPED_SENTINEL): string =>
|
|
31
|
+
`[{"entity":"${entity}","key":${key}}]`;
|
|
32
|
+
|
|
33
|
+
/** The same shape with a trailing comma after the entry — a different malformed-JSON path. */
|
|
34
|
+
export const trailingCommaKeyStdout = (entity: string, key: string = KEY_SHAPED_SENTINEL): string =>
|
|
35
|
+
`[{"entity":"${entity}","key":"${key}"},]`;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The design doc's own Risks section (docs/plans/2026-09-26-ceph-mon-transport.md) says this
|
|
3
|
+
* family "gets a structural test over its own source (no output-logging)". LAND red team
|
|
4
|
+
* (2026-09-26): no such test existed — the fake-runner tests proved the key never reaches a log
|
|
5
|
+
* line for the SHAPES they happened to exercise, but nothing proved a future call site could not
|
|
6
|
+
* add a new one that does. This is that test, the same grep-gate shape
|
|
7
|
+
* `talos/secrets-gate.test.ts` runs for PEM/bootstrap-token material.
|
|
8
|
+
*
|
|
9
|
+
* ★ A POSITIVE CONTROL WOULD DEFEAT THE PURPOSE HERE, same as secrets-gate.test.ts: there is no
|
|
10
|
+
* "real" case where a hit should be found, so this only has a negative case.
|
|
11
|
+
* ⚠️ THIS IS A TEXT SCAN, NOT A TYPE CHECKER. It cannot prove no code path ever COULD log output —
|
|
12
|
+
* only that today's source doesn't. ceph-transport.ts's own header comment carries the actual
|
|
13
|
+
* invariant ("ONLY ARGV IS EVER LOGGED"); this test is the regression guard for it.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
const CEPH_DIR = import.meta.dir;
|
|
20
|
+
|
|
21
|
+
/** This family's own production source — never a test file, never the fake dial's test double. */
|
|
22
|
+
const productionFiles = () =>
|
|
23
|
+
readdirSync(CEPH_DIR)
|
|
24
|
+
.filter(
|
|
25
|
+
(name) => name.endsWith('.ts') && !name.includes('.test.') && name !== 'fake-ceph-dial.ts',
|
|
26
|
+
)
|
|
27
|
+
.map((name) => join(CEPH_DIR, name));
|
|
28
|
+
|
|
29
|
+
describe('no output-logging — structural test over this family’s own source', () => {
|
|
30
|
+
test('no console.* call anywhere — logging goes only through the injected `log` sink', () => {
|
|
31
|
+
for (const file of productionFiles()) {
|
|
32
|
+
const text = readFileSync(file, 'utf8');
|
|
33
|
+
expect(text).not.toMatch(/\bconsole\.\w+\(/);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('every call to the injected `log` sink logs the attempted argv line only, never stdout/stderr/a key', () => {
|
|
38
|
+
let sawACall = false;
|
|
39
|
+
for (const file of productionFiles()) {
|
|
40
|
+
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
41
|
+
if (!/\blog\(/.test(line)) continue;
|
|
42
|
+
sawACall = true;
|
|
43
|
+
expect(line).not.toMatch(/\.stdout\b/);
|
|
44
|
+
expect(line).not.toMatch(/\.stderr\b/);
|
|
45
|
+
expect(line).not.toMatch(/\bkey\b/i);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// ⛔ A GUARD AGAINST THE GUARD GOING QUIET. If this ever finds zero call sites (the log call
|
|
49
|
+
// renamed, the file moved), the two checks above would trivially pass having proven nothing.
|
|
50
|
+
expect(sawACall).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mon-command transport against `fakeCephDial` — no ssh, no spawned process. Mon-transport doc
|
|
3
|
+
* acceptance tests #1 ("no ssh at all" for a refused shape) and #2 (try-in-order, transport
|
|
4
|
+
* failure never reads as absent, quorum re-checked on a fresh connection after a write).
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { authGetArgv, quorumStatusArgv } from './ceph-argv.ts';
|
|
8
|
+
import { assertFreshQuorum, runCephCommand } from './ceph-transport.ts';
|
|
9
|
+
import { fakeCephDial, fakeCephOk } from './fake-ceph-dial.ts';
|
|
10
|
+
|
|
11
|
+
const ENTITY = 'client.k8s-rbd';
|
|
12
|
+
const MON_A = 'mon-a.example.test';
|
|
13
|
+
const MON_B = 'mon-b.example.test';
|
|
14
|
+
const MON_C = 'mon-c.example.test';
|
|
15
|
+
|
|
16
|
+
describe('runCephCommand', () => {
|
|
17
|
+
test('a refused shape never dials — no mon is ever contacted', async () => {
|
|
18
|
+
const fake = fakeCephDial({});
|
|
19
|
+
await expect(
|
|
20
|
+
runCephCommand({ dial: fake.dial, log: () => {}, nodes: [MON_A] }, [
|
|
21
|
+
'/usr/bin/ceph',
|
|
22
|
+
'auth',
|
|
23
|
+
'del',
|
|
24
|
+
ENTITY,
|
|
25
|
+
'-f',
|
|
26
|
+
'json',
|
|
27
|
+
]),
|
|
28
|
+
).rejects.toThrow(/never auto-delete/);
|
|
29
|
+
expect(fake.seen).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('refuses when no mon nodes are declared, without dialing', async () => {
|
|
33
|
+
const fake = fakeCephDial({});
|
|
34
|
+
await expect(
|
|
35
|
+
runCephCommand({ dial: fake.dial, log: () => {}, nodes: [] }, authGetArgv(ENTITY)),
|
|
36
|
+
).rejects.toThrow(/no mon nodes/);
|
|
37
|
+
expect(fake.seen).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('tries the next mon on a transport failure, in order, and returns the first that answers', async () => {
|
|
41
|
+
const fake = fakeCephDial({
|
|
42
|
+
[MON_B]: [{ kind: 'result', result: { exitCode: 0, stderr: '', stdout: '[]' } }],
|
|
43
|
+
});
|
|
44
|
+
const result = await runCephCommand(
|
|
45
|
+
{ dial: fake.dial, log: () => {}, nodes: [MON_A, MON_B, MON_C] },
|
|
46
|
+
authGetArgv(ENTITY),
|
|
47
|
+
);
|
|
48
|
+
expect(result.node).toBe(MON_B);
|
|
49
|
+
expect(fake.seen.map((call) => call.node)).toEqual([MON_A, MON_B]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('every mon failing at the transport layer propagates as an error, never as absent', async () => {
|
|
53
|
+
const fake = fakeCephDial({});
|
|
54
|
+
const attempt = runCephCommand(
|
|
55
|
+
{ dial: fake.dial, log: () => {}, nodes: [MON_A, MON_B] },
|
|
56
|
+
authGetArgv(ENTITY),
|
|
57
|
+
);
|
|
58
|
+
await expect(attempt).rejects.toThrow(/every mon node failed \(never treated as absent\)/);
|
|
59
|
+
expect(fake.seen.map((call) => call.node)).toEqual([MON_A, MON_B]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a mon that actually answers, even with a nonzero exit, is not retried on another node', async () => {
|
|
63
|
+
const fake = fakeCephDial({
|
|
64
|
+
[MON_A]: [
|
|
65
|
+
{ kind: 'result', result: { exitCode: 2, stderr: 'Error ENOENT: not found', stdout: '' } },
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
const result = await runCephCommand(
|
|
69
|
+
{ dial: fake.dial, log: () => {}, nodes: [MON_A, MON_B] },
|
|
70
|
+
authGetArgv(ENTITY),
|
|
71
|
+
);
|
|
72
|
+
expect(result.node).toBe(MON_A);
|
|
73
|
+
expect(result.exitCode).toBe(2);
|
|
74
|
+
expect(fake.seen.map((call) => call.node)).toEqual([MON_A]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('the log line carries the argv, never any result — including a successful stdout', async () => {
|
|
78
|
+
const lines: string[] = [];
|
|
79
|
+
const fake = fakeCephDial({
|
|
80
|
+
[MON_A]: [
|
|
81
|
+
fakeCephOk('[{"entity":"client.k8s-rbd","key":"fake-key-not-a-secret","caps":{}}]'),
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
await runCephCommand(
|
|
85
|
+
{ dial: fake.dial, log: (line) => lines.push(line), nodes: [MON_A] },
|
|
86
|
+
authGetArgv(ENTITY),
|
|
87
|
+
);
|
|
88
|
+
expect(lines).toHaveLength(1);
|
|
89
|
+
expect(lines[0]).toContain(JSON.stringify(authGetArgv(ENTITY)));
|
|
90
|
+
expect(lines.join('\n')).not.toContain('fake-key-not-a-secret');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('assertFreshQuorum', () => {
|
|
95
|
+
test('passes on a healthy quorum', async () => {
|
|
96
|
+
const fake = fakeCephDial({ [MON_A]: [fakeCephOk('{"quorum":[0,1,2]}')] });
|
|
97
|
+
await expect(
|
|
98
|
+
assertFreshQuorum({ dial: fake.dial, log: () => {}, nodes: [MON_A] }),
|
|
99
|
+
).resolves.toBeUndefined();
|
|
100
|
+
expect(fake.seen).toEqual([{ argv: quorumStatusArgv(), node: MON_A }]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('fails closed on an empty quorum array', async () => {
|
|
104
|
+
const fake = fakeCephDial({ [MON_A]: [fakeCephOk('{"quorum":[]}')] });
|
|
105
|
+
await expect(
|
|
106
|
+
assertFreshQuorum({ dial: fake.dial, log: () => {}, nodes: [MON_A] }),
|
|
107
|
+
).rejects.toThrow(/degraded/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('fails closed on unparseable output — never calls a guess "healthy"', async () => {
|
|
111
|
+
const fake = fakeCephDial({ [MON_A]: [fakeCephOk('not json')] });
|
|
112
|
+
await expect(
|
|
113
|
+
assertFreshQuorum({ dial: fake.dial, log: () => {}, nodes: [MON_A] }),
|
|
114
|
+
).rejects.toThrow(/degraded/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('a nonzero exit from quorum_status itself fails, reporting stderr only', async () => {
|
|
118
|
+
const fake = fakeCephDial({
|
|
119
|
+
[MON_A]: [{ kind: 'result', result: { exitCode: 1, stderr: 'mon down', stdout: '' } }],
|
|
120
|
+
});
|
|
121
|
+
await expect(
|
|
122
|
+
assertFreshQuorum({ dial: fake.dial, log: () => {}, nodes: [MON_A] }),
|
|
123
|
+
).rejects.toThrow(/mon down/);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Ceph mon-command transport (K-A4, T-A): ssh to one of the TB4 mon nodes, `sudo -n
|
|
3
|
+
* /usr/bin/ceph …`, tried in node order. Every call is checked against ceph-argv.ts's allowlist
|
|
4
|
+
* BEFORE anything dials — a refused shape never opens a connection, never mind runs a command.
|
|
5
|
+
*
|
|
6
|
+
* ★ BUILT ON THE SAME WIRE FORMAT sshSudoRunner USES, NOT A SECOND ONE. `runRemote` (the framed
|
|
7
|
+
* script + nonce marker `../linux/ssh-command.ts` and `../linux/ssh-runner.ts` already ship and
|
|
8
|
+
* test) is reused directly: same fail-closed behaviour on a dropped connection, same
|
|
9
|
+
* `BatchMode=yes`, same host-key verification left to the operator's own ssh config.
|
|
10
|
+
* ⛔ THE DIAL IS AN INJECTED SEAM, NOT child_process MOCKED. `CephDial` is the same idea as
|
|
11
|
+
* `HostRunner` (../launchd/runner.ts): production code gets `sshCephDial`, tests hand a fake
|
|
12
|
+
* function directly (fake-ceph-dial.ts) — no ssh binary, no spawned process, ever, in a test.
|
|
13
|
+
* ⛔ A TRANSPORT FAILURE (no mon reachable) IS NEVER "ABSENT". Only a mon that actually ran the
|
|
14
|
+
* command and answered gets to say whether an entity exists; every node failing to connect
|
|
15
|
+
* propagates as an Error, so a down anchor can never read as "recreate it".
|
|
16
|
+
* ⛔ ONLY ARGV IS EVER LOGGED. `log` is called with the shape being attempted, never with a
|
|
17
|
+
* result's stdout or stderr — `auth get`/`auth get-or-create`'s stdout carries the entity's key.
|
|
18
|
+
*/
|
|
19
|
+
import { randomBytes } from 'node:crypto';
|
|
20
|
+
import { quoteArgv } from '../linux/ssh-command.ts';
|
|
21
|
+
import { runRemote } from '../linux/ssh-runner.ts';
|
|
22
|
+
import { SUDO, SudoRefusedError } from '../linux/sudo-allowlist.ts';
|
|
23
|
+
import { cephCommandProblem, quorumStatusArgv } from './ceph-argv.ts';
|
|
24
|
+
import { parseQuorumStatus } from './ceph-auth-parse.ts';
|
|
25
|
+
|
|
26
|
+
export type CephExecResult = {
|
|
27
|
+
readonly exitCode: number;
|
|
28
|
+
readonly stdout: string;
|
|
29
|
+
readonly stderr: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** One attempt against one mon: run `argv` (already sudo-wrapped by the caller) and report. */
|
|
33
|
+
export type CephDial = (node: string, argv: readonly string[]) => Promise<CephExecResult>;
|
|
34
|
+
|
|
35
|
+
const CONNECT_TIMEOUT_SEC = 10;
|
|
36
|
+
const EXEC_TIMEOUT_MS = 30_000;
|
|
37
|
+
|
|
38
|
+
/** ★ A fresh nonce per attempt — this dial never reuses a connection or a marker across calls. */
|
|
39
|
+
export const sshCephDial: CephDial = async (node, argv) => {
|
|
40
|
+
const nonce = randomBytes(8).toString('hex');
|
|
41
|
+
const script = `${SUDO} -n -- ${quoteArgv(argv)}`;
|
|
42
|
+
return runRemote(
|
|
43
|
+
{ connectTimeoutSec: CONNECT_TIMEOUT_SEC, execTimeoutMs: EXEC_TIMEOUT_MS, host: node },
|
|
44
|
+
nonce,
|
|
45
|
+
script,
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type CephTransportOptions = {
|
|
50
|
+
/** TB4 mon ssh destinations, tried in this order. Required; at least one. */
|
|
51
|
+
readonly nodes: readonly string[];
|
|
52
|
+
readonly dial?: CephDial;
|
|
53
|
+
/** One line per attempted argv, before it dials. @default a line on stderr */
|
|
54
|
+
readonly log?: (line: string) => void;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const defaultLog = (line: string) => process.stderr.write(`${line}\n`);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Run one allowed ceph shape against the first mon that answers.
|
|
61
|
+
*
|
|
62
|
+
* ⛔ THE ALLOWLIST CHECK HAPPENS BEFORE THE FIRST NODE IS EVEN TRIED — a refused argv dials no
|
|
63
|
+
* ssh at all, so a bug that builds a bad shape cannot leak so much as a connection attempt.
|
|
64
|
+
*/
|
|
65
|
+
export const runCephCommand = async (
|
|
66
|
+
options: CephTransportOptions,
|
|
67
|
+
argv: readonly string[],
|
|
68
|
+
): Promise<CephExecResult & { readonly node: string }> => {
|
|
69
|
+
const problem = cephCommandProblem(argv);
|
|
70
|
+
if (problem !== undefined) {
|
|
71
|
+
throw new SudoRefusedError(`ceph transport refused ${JSON.stringify(argv)}: ${problem}`);
|
|
72
|
+
}
|
|
73
|
+
if (options.nodes.length === 0) throw new Error('ceph transport: no mon nodes were declared');
|
|
74
|
+
const dial = options.dial ?? sshCephDial;
|
|
75
|
+
const log = options.log ?? defaultLog;
|
|
76
|
+
const attempts: { readonly node: string; readonly message: string }[] = [];
|
|
77
|
+
for (const node of options.nodes) {
|
|
78
|
+
log(`homeflare/ceph sudo -n ${JSON.stringify(argv)} @ ${node}`);
|
|
79
|
+
try {
|
|
80
|
+
const result = await dial(node, argv);
|
|
81
|
+
return { ...result, node };
|
|
82
|
+
} catch (cause) {
|
|
83
|
+
attempts.push({ message: cause instanceof Error ? cause.message : String(cause), node });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// ⛔ NEVER "ABSENT". Every mon this call knew about failed at the transport layer; that is a
|
|
87
|
+
// down anchor (or a network problem), not proof the entity does not exist.
|
|
88
|
+
const detail = attempts.map((a) => `${a.node}: ${a.message}`).join('; ');
|
|
89
|
+
throw new Error(`ceph transport: every mon node failed (never treated as absent) — ${detail}`);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* ★ LOCKOUT SAFETY, THE UFW-ESTABLISHED PATTERN: after every write, re-check quorum on a FRESH
|
|
94
|
+
* connection (a new dial, never the one the write used) and fail the row if it degraded. Never
|
|
95
|
+
* auto-repair, never continue — the caller's write is reported as failed even though it may
|
|
96
|
+
* itself have succeeded, because a write that broke quorum is not a success this transport will
|
|
97
|
+
* call one.
|
|
98
|
+
* ⚠️ THE SHAPE OF `quorum_status -f json` IS REASONED FROM PUBLIC CEPH DOCS, NOT MEASURED against
|
|
99
|
+
* this estate's Ceph release — there is no mon yet. An unparseable or missing `quorum` array
|
|
100
|
+
* fails closed as "degraded", never as "healthy"; see ceph-auth-parse.ts.
|
|
101
|
+
*/
|
|
102
|
+
export const assertFreshQuorum = async (options: CephTransportOptions): Promise<void> => {
|
|
103
|
+
const result = await runCephCommand(options, quorumStatusArgv());
|
|
104
|
+
if (result.exitCode !== 0) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`ceph quorum_status failed after a write (mon ${result.node}): ` +
|
|
107
|
+
`${result.stderr.trim().slice(0, 300)}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const quorum = parseQuorumStatus(result.stdout);
|
|
111
|
+
if (!quorum.healthy) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`ceph quorum degraded after a write (mon ${result.node}): ${quorum.reason} — the write is ` +
|
|
114
|
+
'reported as failed even if it landed; nothing here auto-repairs a quorum problem.',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fake `CephDial` for the tests beside it — no ssh, no spawned process, ever. The same idea as
|
|
3
|
+
* `../linux/fake-sudo.ts` and `../openbao/fake-bao.ts`: production code is handed the real
|
|
4
|
+
* implementation, a test hands this instead, and neither one's internals change to make room for
|
|
5
|
+
* the other.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ TEST-ONLY. Nothing in `src/` imports this outside a `.test.ts` file.
|
|
8
|
+
*/
|
|
9
|
+
import type { CephDial, CephExecResult } from './ceph-transport.ts';
|
|
10
|
+
|
|
11
|
+
/** One call this fake received. */
|
|
12
|
+
export type SeenCephCall = { readonly node: string; readonly argv: readonly string[] };
|
|
13
|
+
|
|
14
|
+
/** What a scripted node answers: a result, or a thrown transport failure. */
|
|
15
|
+
export type CephAnswer =
|
|
16
|
+
| { readonly kind: 'result'; readonly result: CephExecResult }
|
|
17
|
+
| { readonly kind: 'transport-error'; readonly message: string };
|
|
18
|
+
|
|
19
|
+
export type FakeCephDial = {
|
|
20
|
+
readonly dial: CephDial;
|
|
21
|
+
readonly seen: SeenCephCall[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const ok = (stdout: string, exitCode = 0, stderr = ''): CephAnswer => ({
|
|
25
|
+
kind: 'result',
|
|
26
|
+
result: { exitCode, stderr, stdout },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `script` maps a node to the fixed sequence of answers it gives, one per call that reaches it —
|
|
31
|
+
* the last entry repeats once exhausted. A node with no entry always transport-fails, which is
|
|
32
|
+
* exactly "an unreachable mon" without a special case.
|
|
33
|
+
*/
|
|
34
|
+
export const fakeCephDial = (
|
|
35
|
+
script: Readonly<Record<string, readonly CephAnswer[]>>,
|
|
36
|
+
): FakeCephDial => {
|
|
37
|
+
const seen: SeenCephCall[] = [];
|
|
38
|
+
const counts = new Map<string, number>();
|
|
39
|
+
const dial: CephDial = async (node, argv) => {
|
|
40
|
+
seen.push({ argv, node });
|
|
41
|
+
const answers = script[node];
|
|
42
|
+
if (answers === undefined || answers.length === 0) {
|
|
43
|
+
throw new Error(`fakeCephDial: ${node} is not reachable (no script entry)`);
|
|
44
|
+
}
|
|
45
|
+
const at = counts.get(node) ?? 0;
|
|
46
|
+
counts.set(node, at + 1);
|
|
47
|
+
const answer = answers[Math.min(at, answers.length - 1)];
|
|
48
|
+
if (answer === undefined) throw new Error(`fakeCephDial: ${node} has no answer`);
|
|
49
|
+
if (answer.kind === 'transport-error') throw new Error(answer.message);
|
|
50
|
+
return answer.result;
|
|
51
|
+
};
|
|
52
|
+
return { dial, seen };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export { ok as fakeCephOk };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ceph providers for Alchemy — the mon-command transport and `Ceph.AuthEntity` (K-A4).
|
|
3
|
+
*
|
|
4
|
+
* ⛔ THIS BARREL IS THE PUBLIC API, DELIBERATELY SMALLER THAN THE DIRECTORY — see
|
|
5
|
+
* `../openbao/index.ts`'s header for why. The allowlist internals (ceph-argv.ts), the parsing
|
|
6
|
+
* (ceph-auth-parse.ts) and the reconcile loop (ceph-auth-reconcile.ts) are exported by path for
|
|
7
|
+
* a future `CephConfigOption` (K-D) or a test to reach directly, not published here.
|
|
8
|
+
*/
|
|
9
|
+
export type { CephAuthEntityAttributes, CephAuthEntityProps, CephCaps } from './auth-entity.ts';
|
|
10
|
+
export { CephAuthEntity, CephAuthEntityProvider } from './auth-entity.ts';
|
|
11
|
+
export type { CephDial, CephExecResult, CephTransportOptions } from './ceph-transport.ts';
|
|
12
|
+
export { assertFreshQuorum, runCephCommand, sshCephDial } from './ceph-transport.ts';
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* import { BaoMount } from '@homeflare/alchemy/openbao';
|
|
11
11
|
* import { TalosCluster } from '@homeflare/alchemy/talos';
|
|
12
12
|
* import { ProxmoxAcl } from '@homeflare/alchemy/proxmox';
|
|
13
|
+
* import { CephAuthEntity } from '@homeflare/alchemy/ceph';
|
|
13
14
|
* import { LaunchdJob } from '@homeflare/alchemy/launchd';
|
|
14
15
|
* import { ReleaseBinary } from '@homeflare/alchemy/release';
|
|
15
16
|
* import { CaddyConfig } from '@homeflare/alchemy/caddy';
|
|
@@ -156,6 +156,8 @@ export const fakeRunner = (options: FakeOptions = {}) => {
|
|
|
156
156
|
lookupUser: async (nameOrId) =>
|
|
157
157
|
options.users?.[nameOrId] ??
|
|
158
158
|
Object.values(options.users ?? {}).find((user) => String(user.uid) === nameOrId),
|
|
159
|
+
// ★ This fake models launchd, which only ever exists on a Mac — fixed, not an option.
|
|
160
|
+
platform: 'darwin',
|
|
159
161
|
privileged,
|
|
160
162
|
readFile: async (path) => files.get(path)?.bytes,
|
|
161
163
|
removeFile: async (path) => {
|
package/src/launchd/index.ts
CHANGED
|
@@ -24,7 +24,14 @@ export { PlistError, renderPlist } from './plist.ts';
|
|
|
24
24
|
export type { PortClaim } from './port-claims.ts';
|
|
25
25
|
export { PortRefused, claimPorts, portClaimProblems } from './port-claims.ts';
|
|
26
26
|
export { launchdProviders } from './providers.ts';
|
|
27
|
-
export type {
|
|
27
|
+
export type {
|
|
28
|
+
ExecResult,
|
|
29
|
+
FileStat,
|
|
30
|
+
HostPlatform,
|
|
31
|
+
HostRunner,
|
|
32
|
+
HostUser,
|
|
33
|
+
WriteOptions,
|
|
34
|
+
} from './runner.ts';
|
|
28
35
|
export { HostRunnerService, canActAsRoot, hostRunnerLayer } from './runner.ts';
|
|
29
36
|
export type { SudoRunnerOptions } from './sudo-runner.ts';
|
|
30
37
|
export { SudoRefusedError, sudoRunner } from './sudo-runner.ts';
|