@homeflare/alchemy 0.39.0 → 0.40.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/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 +1609 -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/proxmox/index.js +15 -6
- package/dist/proxmox/index.js.map +3 -3
- package/dist/proxmox/qemu-volume.d.ts +12 -2
- package/dist/proxmox/qemu-volume.d.ts.map +1 -1
- 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/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 +6 -2
- 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/proxmox/qemu-volume.test.ts +153 -4
- package/src/proxmox/qemu-volume.ts +71 -14
- 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,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Talos.MachineConfig`'s read path — split out of talos-machine-config.ts (2026-09-26, fix-first
|
|
3
|
+
* #1/#2 on PR 307's red team) once the ownership-aware read needed its own header, the same reason
|
|
4
|
+
* machine-config-poll.ts and talos-errors.ts were split out before it. Only `import type` comes
|
|
5
|
+
* back from talos-machine-config.ts, so there is no runtime cycle between the two files.
|
|
6
|
+
*/
|
|
7
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
8
|
+
import { isResolved } from 'alchemy/Diff';
|
|
9
|
+
import type { Input } from 'alchemy/Input';
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
import * as Result from 'effect/Result';
|
|
12
|
+
import { mintTalosconfig } from './credentials.ts';
|
|
13
|
+
import type { MachineConfigAttributes, MachineConfigProps } from './talos-machine-config.ts';
|
|
14
|
+
import { talosctl } from './talosctl.ts';
|
|
15
|
+
import { configDigest, extractMachineConfigSpec } from './values.ts';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Single live read: `talosctl get machineconfig v1alpha1`, extract `spec`, hash it. No retry, no
|
|
19
|
+
* swallow.
|
|
20
|
+
*
|
|
21
|
+
* ⛔ THE ID IS NEVER OMITTED (fix-first #2, PR 307 red team). An unfiltered `get machineconfig`
|
|
22
|
+
* lists `persistent` AND `v1alpha1`, sorted by id — `persistent` first — so a caller that does not
|
|
23
|
+
* name `v1alpha1` gets whichever the SDK's `doc[0]` happens to be, never provably the applied
|
|
24
|
+
* config (values.ts's own multi-document guard is the second half of this fix).
|
|
25
|
+
* ★ `endpoints: [props.node]` (fix-first #5, same review) — without it talosctl falls back to the
|
|
26
|
+
* talosconfig context's own endpoints, which may be a control-plane VIP that only comes up AFTER
|
|
27
|
+
* bootstrap; every pre-bootstrap call in this file targets the node directly instead.
|
|
28
|
+
*/
|
|
29
|
+
export const liveSpecDigest = (props: MachineConfigProps, talosconfigPath: string) =>
|
|
30
|
+
talosctl(['get', 'machineconfig', 'v1alpha1', '-o', 'yaml'], {
|
|
31
|
+
endpoints: [props.node],
|
|
32
|
+
nodes: [props.node],
|
|
33
|
+
talosconfigPath,
|
|
34
|
+
}).pipe(
|
|
35
|
+
Effect.flatMap((wrapperYaml) => {
|
|
36
|
+
const spec = extractMachineConfigSpec(wrapperYaml);
|
|
37
|
+
return spec === undefined
|
|
38
|
+
? Effect.fail(
|
|
39
|
+
new Error(
|
|
40
|
+
`${props.node}: get machineconfig v1alpha1 returned no string 'spec' field — see ` +
|
|
41
|
+
'values.ts extractMachineConfigSpec for the expected shape.',
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
: Effect.succeed(configDigest(spec));
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* ★ MAINTENANCE-MODE PROBE (fix-first #1, PR 307 red team). A freshly booted node has no
|
|
50
|
+
* talosconfig auth yet, so the AUTHENTICATED read above fails there exactly the same way it would
|
|
51
|
+
* against a genuinely unreachable node — the two must not be confused (a real transport failure
|
|
52
|
+
* silently read as "not created" would let a cold-start plan skip straight past a broken node).
|
|
53
|
+
* `--insecure` distinguishes them: REASONED from the published Talos quickstart
|
|
54
|
+
* (`talosctl -n <ip> version --insecure` is the documented way to confirm a maintenance-mode node
|
|
55
|
+
* before its first apply) — not measured on a live cluster. Success here means "nothing applied
|
|
56
|
+
* yet", never "converged", and never returns config content.
|
|
57
|
+
*/
|
|
58
|
+
const probeMaintenanceMode = (props: MachineConfigProps, talosconfigPath: string) =>
|
|
59
|
+
talosctl(['version'], {
|
|
60
|
+
endpoints: [props.node],
|
|
61
|
+
insecure: true,
|
|
62
|
+
nodes: [props.node],
|
|
63
|
+
talosconfigPath,
|
|
64
|
+
}).pipe(
|
|
65
|
+
Effect.as(true),
|
|
66
|
+
Effect.orElseSucceed(() => false),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* ⛔ NO SWALLOWING. A transport failure here propagates as its own error (TalosError or the
|
|
71
|
+
* shape error above) — never as `converged: false`. A false only means "read succeeded, and
|
|
72
|
+
* genuinely differs" (docs/plans/2026-09-26-talos-stack-first-boot.md, acceptance test 3).
|
|
73
|
+
* ⛔ THREE-WAY COLD-START ANSWER, NOT TWO (fix-first #1). Alchemy's engine calls this exact
|
|
74
|
+
* function as its adoption probe with no prior state (alchemy beta.79 Plan.ts:1302-1318) and
|
|
75
|
+
* again to recover an interrupted create (`:1413-1450`) — both hand it nothing to distinguish
|
|
76
|
+
* "not created" from "already configured". Answering only "attrs or error" (the shipped shape)
|
|
77
|
+
* meant a maintenance-mode node's expected auth failure aborted the plan instead of reaching
|
|
78
|
+
* CREATE, and a genuinely different node's matching-cluster-CA-but-wrong-content read landed as a
|
|
79
|
+
* plain, unbranded attrs object — a silent adopt with no `adopt()`, breaking first-boot acceptance
|
|
80
|
+
* test 1 ("zero adopts"). Now: authenticated read fails but the maintenance probe succeeds →
|
|
81
|
+
* `undefined` (not created); authenticated read succeeds and the digest matches → plain attrs
|
|
82
|
+
* (ours); succeeds and differs → `Unowned` (exists, not proven ours — the digest match IS this
|
|
83
|
+
* family's ownership proof, not a state-row lookup like `ownership/probe.ts`'s generic
|
|
84
|
+
* `ownedRead`, which answers a different question: "did OUR state row create this name", not
|
|
85
|
+
* "does this node hold OUR pinned content"); both reads fail → the authenticated error propagates.
|
|
86
|
+
*
|
|
87
|
+
* ★ EXPORTED (like proxmox/ceph-daemon.ts's `reconcileDaemon`) so tests call it directly with a
|
|
88
|
+
* fake `ChildProcessSpawner` instead of driving it through the full Alchemy engine.
|
|
89
|
+
*/
|
|
90
|
+
export const readMachineConfig = (props: MachineConfigProps) =>
|
|
91
|
+
Effect.scoped(
|
|
92
|
+
Effect.gen(function* () {
|
|
93
|
+
const credential = yield* mintTalosconfig(props.target);
|
|
94
|
+
const outcome = yield* Effect.result(liveSpecDigest(props, credential.talosconfigPath));
|
|
95
|
+
if (Result.isFailure(outcome)) {
|
|
96
|
+
const reachable = yield* probeMaintenanceMode(props, credential.talosconfigPath);
|
|
97
|
+
return reachable ? undefined : yield* Effect.fail(outcome.failure);
|
|
98
|
+
}
|
|
99
|
+
const attrs = {
|
|
100
|
+
configDigest: props.configDigest,
|
|
101
|
+
converged: outcome.success === props.configDigest ? ('read-back' as const) : false,
|
|
102
|
+
mode: props.mode ?? 'auto',
|
|
103
|
+
node: props.node,
|
|
104
|
+
} satisfies MachineConfigAttributes;
|
|
105
|
+
return attrs.converged === false ? Unowned(attrs) : attrs;
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
export const diffMachineConfig = (
|
|
110
|
+
news: Input<MachineConfigProps>,
|
|
111
|
+
output: MachineConfigAttributes | undefined,
|
|
112
|
+
) =>
|
|
113
|
+
Effect.gen(function* () {
|
|
114
|
+
if (output === undefined || !isResolved(news)) return undefined;
|
|
115
|
+
const live = yield* readMachineConfig(news);
|
|
116
|
+
if (live !== undefined && live.converged !== false) return { action: 'noop' } as const;
|
|
117
|
+
return { action: 'update' } as const;
|
|
118
|
+
});
|
package/src/talos/resource.ts
CHANGED
|
@@ -29,3 +29,22 @@ export type TalosRequirements = ChildProcessSpawner.ChildProcessSpawner;
|
|
|
29
29
|
|
|
30
30
|
/** Every resource names which cluster's OpenBao mount it uses. */
|
|
31
31
|
export type WithTarget = { target: TalosTarget };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `talosctl apply-config -m/--mode`. Lives here, not in talos-machine-config.ts, so
|
|
35
|
+
* talos-errors.ts can name it in `TalosConvergenceTimeout` without importing that file back
|
|
36
|
+
* (machine-config-poll.ts -> talos-errors.ts -> talos-machine-config.ts would cycle).
|
|
37
|
+
*
|
|
38
|
+
* ★ K-A3 DROPPED THE `insecure` PROP THAT USED TO SIT BESIDE THIS TYPE. A fixed `insecure` prop
|
|
39
|
+
* fails in both directions (`true` breaks every later update, `false` breaks the very first
|
|
40
|
+
* apply against a node with no talosconfig auth yet), so `Talos.MachineConfig` now derives it
|
|
41
|
+
* itself: CREATE (`output === undefined`) applies `--insecure`, UPDATE never does.
|
|
42
|
+
* ⛔ `'staged'` AND `'try'` ARE DROPPED (fix-first #2, PR 307 red team). `try` reverts itself after
|
|
43
|
+
* its timeout, so a bounded poll that later reads the reverted config would see it never match
|
|
44
|
+
* the pin and re-run `apply-config` on every deploy — the poll would be "confirming" a change
|
|
45
|
+
* `try` had already undone. `staged` defers the write to the NEXT reboot rather than applying it,
|
|
46
|
+
* which `isRebootish` (machine-config-poll.ts) cannot honestly claim `'accepted'` for without a
|
|
47
|
+
* reboot ever happening — the reboot for a staged config is a separate, later operator action
|
|
48
|
+
* this package does not drive. Both need their own design work, not a poll-policy guess.
|
|
49
|
+
*/
|
|
50
|
+
export type ApplyMode = 'auto' | 'no-reboot' | 'reboot';
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Acceptance test 2 from docs/plans/2026-09-26-talos-secrets-flow.md: "no talos prop, attribute or
|
|
3
|
+
* argv carries config bytes; state fixtures contain no PEM or token strings (grep gate in the
|
|
4
|
+
* family's tests)." PR 307 shipped every other acceptance test from the design suite but not this
|
|
5
|
+
* one (PR 307 red team, minor #4) — the builder's "every acceptance test covered" claim was wrong.
|
|
6
|
+
*
|
|
7
|
+
* ★ A POSITIVE CONTROL WOULD DEFEAT THE PURPOSE HERE. Unlike lxc-interior.test.ts's "prove PVE
|
|
8
|
+
* really has no such endpoint", this file's job is to prove NOTHING in this family's own source
|
|
9
|
+
* ever grew a byte-carrying field — there is no "real" case where a match should be found. The
|
|
10
|
+
* props/attributes shape checks are the load-bearing half: they fail loudly if a `config` or
|
|
11
|
+
* `configFile` field is ever added back next to `configKey`/`configDigest`.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it } from 'node:test';
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import type { MachineConfigAttributes, MachineConfigProps } from './talos-machine-config.ts';
|
|
18
|
+
|
|
19
|
+
const TALOS_DIR = import.meta.dir;
|
|
20
|
+
|
|
21
|
+
/** Every `.ts` file this family owns — source, tests and fixtures alike. Never node_modules. */
|
|
22
|
+
const talosFiles = () =>
|
|
23
|
+
readdirSync(TALOS_DIR)
|
|
24
|
+
.filter((name) => name.endsWith('.ts'))
|
|
25
|
+
.map((name) => join(TALOS_DIR, name));
|
|
26
|
+
|
|
27
|
+
describe('secrets gate — acceptance test 2', () => {
|
|
28
|
+
it('no file in this family embeds PEM material or a bootstrap-token shape', () => {
|
|
29
|
+
const pem = /-----BEGIN [A-Z ]+-----/;
|
|
30
|
+
// `talosctl gen secrets`' bootstrap token is a `<14>.<28>` lowercase-alnum id.secret pair
|
|
31
|
+
// (REASONED from the published config schema) — never generated anywhere in this family.
|
|
32
|
+
const bootstrapToken = /\b[a-z0-9]{14}\.[a-z0-9]{28}\b/;
|
|
33
|
+
for (const file of talosFiles()) {
|
|
34
|
+
const text = readFileSync(file, 'utf8');
|
|
35
|
+
assert.equal(pem.test(text), false, `${file}: looks like it embeds a PEM block`);
|
|
36
|
+
assert.equal(
|
|
37
|
+
bootstrapToken.test(text),
|
|
38
|
+
false,
|
|
39
|
+
`${file}: looks like it embeds a bootstrap token`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('MachineConfigProps carries a key and a digest, never config bytes', () => {
|
|
45
|
+
const sample: MachineConfigProps = {
|
|
46
|
+
configDigest: 'x',
|
|
47
|
+
configKey: 'nodes/10001',
|
|
48
|
+
node: '198.51.100.10',
|
|
49
|
+
target: { cluster: 'c1', mount: 'talos-c1' },
|
|
50
|
+
};
|
|
51
|
+
assert.deepEqual(Object.keys(sample).sort(), ['configDigest', 'configKey', 'node', 'target']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('MachineConfigAttributes carries a digest and a claim, never config bytes', () => {
|
|
55
|
+
const sample: MachineConfigAttributes = {
|
|
56
|
+
configDigest: 'x',
|
|
57
|
+
converged: false,
|
|
58
|
+
mode: 'auto',
|
|
59
|
+
node: '198.51.100.10',
|
|
60
|
+
};
|
|
61
|
+
assert.deepEqual(Object.keys(sample).sort(), ['configDigest', 'converged', 'mode', 'node']);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Talos.Bootstrap`'s etcd-members probe and split-brain guard — split out of talos-bootstrap.ts
|
|
3
|
+
* (LAND, 2026-09-26, I4 fix) once adding the guard pushed that file over the 250-line cap, the same
|
|
4
|
+
* reason machine-config-read.ts was split out of talos-machine-config.ts before it. Only
|
|
5
|
+
* `import type` comes back from talos-bootstrap.ts, so there is no runtime cycle between the files.
|
|
6
|
+
*/
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import type { BootstrapProps } from './talos-bootstrap.ts';
|
|
9
|
+
import { TalosReBootstrapRefused } from './talos-errors.ts';
|
|
10
|
+
import { talosctl } from './talosctl.ts';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* REASONED: `talosctl get etcdmembers -o json` should list members after bootstrap.
|
|
14
|
+
* ⚠️ NOT MEASURED — first live deploy confirms the resource type name.
|
|
15
|
+
* ⛔ NO SWALLOWING (K-talos-first-boot) — a transport failure propagates as its own error; only a
|
|
16
|
+
* SUCCESSFUL read may conclude presence or absence. Every caller decides for itself what a
|
|
17
|
+
* failure here means (talos-bootstrap.ts's `read`/`reconcile`, and `assertNoLivePeers` below), so
|
|
18
|
+
* it is not decided here.
|
|
19
|
+
*/
|
|
20
|
+
export const isBootstrapped = (props: BootstrapProps, talosconfigPath: string) =>
|
|
21
|
+
talosctl(['get', 'etcdmembers', '-o', 'json'], {
|
|
22
|
+
nodes: [props.node],
|
|
23
|
+
talosconfigPath,
|
|
24
|
+
}).pipe(Effect.map((text) => text.includes('"id"') || text.includes('"member"')));
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* ⛔ I4 FIX (LAND red team, 2026-09-26) — THE CREATE PATH ONLY CHECKED THIS NODE. If the state row
|
|
28
|
+
* is lost (wrong `--stage`, an FQN rename, a wiped state store) while the cluster already lives on
|
|
29
|
+
* OTHER control-plane nodes, the cold-start adoption read on THIS node comes back empty, plan
|
|
30
|
+
* calls `create`, and `talosctl bootstrap` runs here too — a SECOND, isolated etcd cluster: split
|
|
31
|
+
* brain. Before bootstrapping, every OTHER declared control-plane node (`props.peers`) must show a
|
|
32
|
+
* successful, EMPTY etcd-members read — proof no cluster already exists anywhere this row knows
|
|
33
|
+
* about. A peer that already has members, or a peer whose read fails, refuses bootstrap exactly
|
|
34
|
+
* like an already-bootstrapped confirmation failure does (same typed error, same "hand it to a
|
|
35
|
+
* human" posture) — a genuinely fresh cluster has no peers with members yet, so this never blocks
|
|
36
|
+
* a real first boot.
|
|
37
|
+
* ⚠️ `peers` IS OPTIONAL, SO THIS GUARD IS OPT-IN — an existing single-control-plane declaration
|
|
38
|
+
* keeps working unguarded; a consuming stack with more than one control-plane node (decision 66:
|
|
39
|
+
* 3, one per TB4 node) SHOULD list the other two here to get the protection.
|
|
40
|
+
* ★ `Effect.all` over a mapped array, not `Effect.forEach` — oxlint's unicorn/no-array-for-each
|
|
41
|
+
* matches the method NAME and cannot tell Effect's from Array's (same pattern as
|
|
42
|
+
* openbao/cloudflare-permission-groups.ts).
|
|
43
|
+
*/
|
|
44
|
+
export const assertNoLivePeers = (props: BootstrapProps, talosconfigPath: string) =>
|
|
45
|
+
Effect.all(
|
|
46
|
+
(props.peers ?? []).map((peer) =>
|
|
47
|
+
isBootstrapped({ ...props, node: peer }, talosconfigPath).pipe(
|
|
48
|
+
Effect.matchEffect({
|
|
49
|
+
onFailure: (cause) =>
|
|
50
|
+
Effect.fail(
|
|
51
|
+
new TalosReBootstrapRefused({
|
|
52
|
+
detail: `peer ${peer}: live read failed before bootstrap: ${String(cause)}`,
|
|
53
|
+
node: props.node,
|
|
54
|
+
}),
|
|
55
|
+
),
|
|
56
|
+
onSuccess: (hasMembers) =>
|
|
57
|
+
hasMembers
|
|
58
|
+
? Effect.fail(
|
|
59
|
+
new TalosReBootstrapRefused({
|
|
60
|
+
detail:
|
|
61
|
+
`peer ${peer} already reports etcd members — a cluster may already exist; ` +
|
|
62
|
+
'refusing to bootstrap a second one',
|
|
63
|
+
node: props.node,
|
|
64
|
+
}),
|
|
65
|
+
)
|
|
66
|
+
: Effect.void,
|
|
67
|
+
}),
|
|
68
|
+
),
|
|
69
|
+
),
|
|
70
|
+
{ discard: true },
|
|
71
|
+
);
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Talos.Bootstrap` "once means once" — docs/plans/2026-09-26-talos-stack-first-boot.md's
|
|
3
|
+
* acceptance test 4, K-talos-first-boot. Fully offline: fake-process.ts fakes `bao`/`talosctl`,
|
|
4
|
+
* nothing real spawns.
|
|
5
|
+
*/
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import * as ChildProcessSpawner from 'effect/unstable/process/ChildProcessSpawner';
|
|
10
|
+
import { type FakeCall, fakeSpawner } from './fake-process.ts';
|
|
11
|
+
import {
|
|
12
|
+
diffBootstrap as diff,
|
|
13
|
+
readBootstrap as read,
|
|
14
|
+
reconcileBootstrap as reconcile,
|
|
15
|
+
} from './talos-bootstrap.ts';
|
|
16
|
+
|
|
17
|
+
const TARGET = { cluster: 'c1', mount: 'talos-c1' };
|
|
18
|
+
const props = () => ({ node: '198.51.100.10', target: TARGET });
|
|
19
|
+
|
|
20
|
+
const bootstrapped = () => (call: FakeCall) => {
|
|
21
|
+
if (call.command === 'bao')
|
|
22
|
+
return { stdout: JSON.stringify({ data: { data: { talosconfig: 'x' } } }) };
|
|
23
|
+
if (call.args[0] === 'get') return { stdout: '{"id":"member-1"}' };
|
|
24
|
+
throw new Error(`unexpected call ${JSON.stringify(call)}`);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Absent until `bootstrap` is spawned, then present — models the CREATE path's before/after read. */
|
|
28
|
+
const notBootstrapped = () => {
|
|
29
|
+
let ranBootstrap = false;
|
|
30
|
+
return (call: FakeCall) => {
|
|
31
|
+
if (call.command === 'bao')
|
|
32
|
+
return { stdout: JSON.stringify({ data: { data: { talosconfig: 'x' } } }) };
|
|
33
|
+
if (call.args[0] === 'bootstrap') {
|
|
34
|
+
ranBootstrap = true;
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
if (call.args[0] === 'get') return { stdout: ranBootstrap ? '{"id":"member-1"}' : '[]' };
|
|
38
|
+
throw new Error(`unexpected call ${JSON.stringify(call)}`);
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const etcdReadFails = () => (call: FakeCall) => {
|
|
43
|
+
if (call.command === 'bao')
|
|
44
|
+
return { stdout: JSON.stringify({ data: { data: { talosconfig: 'x' } } }) };
|
|
45
|
+
if (call.args[0] === 'get') return { exitCode: 1, stderr: 'dial tcp: connection refused' };
|
|
46
|
+
throw new Error(`unexpected call ${JSON.stringify(call)}`);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const run = <A, E>(
|
|
50
|
+
effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner>,
|
|
51
|
+
handler: (c: FakeCall) => { stdout?: string; stderr?: string; exitCode?: number },
|
|
52
|
+
calls: FakeCall[] = [],
|
|
53
|
+
) =>
|
|
54
|
+
Effect.runPromise(
|
|
55
|
+
Effect.provideService(
|
|
56
|
+
effect,
|
|
57
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
58
|
+
fakeSpawner(handler, calls),
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
describe('read — answers presence/absence, no swallowing', () => {
|
|
63
|
+
it('returns undefined (absence) on a successful read with no members', async () => {
|
|
64
|
+
const result = await run(read(props()), notBootstrapped());
|
|
65
|
+
assert.equal(result, undefined);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns attrs when members are present', async () => {
|
|
69
|
+
const result = await run(read(props()), bootstrapped());
|
|
70
|
+
assert.deepEqual(result, { bootstrapped: true, node: props().node });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('propagates a transport failure instead of reporting absence', async () => {
|
|
74
|
+
await assert.rejects(run(read(props()), etcdReadFails()));
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('diff — trusts state, never touches the live cluster once bootstrapped', () => {
|
|
79
|
+
it('reports noop for output.bootstrapped=true WITHOUT any live call', async () => {
|
|
80
|
+
const calls: FakeCall[] = [];
|
|
81
|
+
const result = await run(
|
|
82
|
+
diff(props(), { bootstrapped: true, node: props().node }),
|
|
83
|
+
etcdReadFails(), // would reject if actually called
|
|
84
|
+
calls,
|
|
85
|
+
);
|
|
86
|
+
assert.deepEqual(result, { action: 'noop' });
|
|
87
|
+
assert.equal(calls.length, 0, 'diff must not call bao/talosctl once bootstrapped is true');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('returns undefined (defer) when there is no prior output', async () => {
|
|
91
|
+
const result = await run(diff(props(), undefined), bootstrapped());
|
|
92
|
+
assert.equal(result, undefined);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('reconcile — once means once (acceptance test 4)', () => {
|
|
97
|
+
it('CREATE path (output undefined): bootstraps once when absent', async () => {
|
|
98
|
+
const calls: FakeCall[] = [];
|
|
99
|
+
const result = await run(reconcile(props(), undefined), notBootstrapped(), calls);
|
|
100
|
+
assert.deepEqual(result, { bootstrapped: true, node: props().node });
|
|
101
|
+
assert.equal(calls.filter((c) => c.args[0] === 'bootstrap').length, 1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('CREATE path: already bootstrapped (adopted) never spawns bootstrap', async () => {
|
|
105
|
+
const calls: FakeCall[] = [];
|
|
106
|
+
await run(reconcile(props(), undefined), bootstrapped(), calls);
|
|
107
|
+
assert.ok(calls.every((c) => c.args[0] !== 'bootstrap'));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('a failing confirmation read against bootstrapped state never spawns bootstrap, and refuses', async () => {
|
|
111
|
+
const calls: FakeCall[] = [];
|
|
112
|
+
await assert.rejects(
|
|
113
|
+
run(reconcile(props(), { bootstrapped: true, node: props().node }), etcdReadFails(), calls),
|
|
114
|
+
(error: unknown) => (error as { _tag?: string } | null)?._tag === 'TalosReBootstrapRefused',
|
|
115
|
+
);
|
|
116
|
+
assert.ok(
|
|
117
|
+
calls.every((c) => c.args[0] !== 'bootstrap'),
|
|
118
|
+
'never re-bootstrap on a failing read',
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('an empty (successful) confirmation read against bootstrapped state never spawns bootstrap, and refuses', async () => {
|
|
123
|
+
const calls: FakeCall[] = [];
|
|
124
|
+
await assert.rejects(
|
|
125
|
+
run(reconcile(props(), { bootstrapped: true, node: props().node }), notBootstrapped(), calls),
|
|
126
|
+
(error: unknown) => (error as { _tag?: string } | null)?._tag === 'TalosReBootstrapRefused',
|
|
127
|
+
);
|
|
128
|
+
assert.ok(
|
|
129
|
+
calls.every((c) => c.args[0] !== 'bootstrap'),
|
|
130
|
+
'never re-bootstrap on an empty read',
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('a successful confirmation read against bootstrapped state confirms without spawning bootstrap', async () => {
|
|
135
|
+
const calls: FakeCall[] = [];
|
|
136
|
+
const result = await run(
|
|
137
|
+
reconcile(props(), { bootstrapped: true, node: props().node }),
|
|
138
|
+
bootstrapped(),
|
|
139
|
+
calls,
|
|
140
|
+
);
|
|
141
|
+
assert.deepEqual(result, { bootstrapped: true, node: props().node });
|
|
142
|
+
assert.ok(calls.every((c) => c.args[0] !== 'bootstrap'));
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('reconcile — I4 fix: split-brain peer guard (acceptance-adjacent, LAND red team)', () => {
|
|
147
|
+
const propsWithPeers = () => ({
|
|
148
|
+
node: '198.51.100.10',
|
|
149
|
+
peers: ['198.51.100.11', '198.51.100.12'],
|
|
150
|
+
target: TARGET,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const nodeArg = (call: FakeCall) => {
|
|
154
|
+
const idx = call.args.indexOf('--nodes');
|
|
155
|
+
return idx === -1 ? undefined : call.args[idx + 1];
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
it('bootstraps once every peer proves genuinely empty', async () => {
|
|
159
|
+
const calls: FakeCall[] = [];
|
|
160
|
+
const handler = (call: FakeCall) => {
|
|
161
|
+
if (call.command === 'bao')
|
|
162
|
+
return { stdout: JSON.stringify({ data: { data: { talosconfig: 'x' } } }) };
|
|
163
|
+
if (call.args[0] === 'bootstrap') return {};
|
|
164
|
+
if (call.args[0] === 'get') {
|
|
165
|
+
const target = nodeArg(call);
|
|
166
|
+
const ranBootstrap = calls.some((c) => c.args[0] === 'bootstrap');
|
|
167
|
+
return target === propsWithPeers().node
|
|
168
|
+
? { stdout: ranBootstrap ? '{"id":"member-1"}' : '[]' }
|
|
169
|
+
: { stdout: '[]' }; // both peers stay empty the whole time
|
|
170
|
+
}
|
|
171
|
+
throw new Error(`unexpected call ${JSON.stringify(call)}`);
|
|
172
|
+
};
|
|
173
|
+
const result = await run(reconcile(propsWithPeers(), undefined), handler, calls);
|
|
174
|
+
assert.deepEqual(result, { bootstrapped: true, node: propsWithPeers().node });
|
|
175
|
+
assert.equal(calls.filter((c) => c.args[0] === 'bootstrap').length, 1);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('refuses, and never spawns bootstrap, when a peer already reports etcd members', async () => {
|
|
179
|
+
const calls: FakeCall[] = [];
|
|
180
|
+
const handler = (call: FakeCall) => {
|
|
181
|
+
if (call.command === 'bao')
|
|
182
|
+
return { stdout: JSON.stringify({ data: { data: { talosconfig: 'x' } } }) };
|
|
183
|
+
if (call.args[0] === 'get') {
|
|
184
|
+
return nodeArg(call) === '198.51.100.11'
|
|
185
|
+
? { stdout: '{"id":"member-1"}' }
|
|
186
|
+
: { stdout: '[]' };
|
|
187
|
+
}
|
|
188
|
+
throw new Error(`unexpected call ${JSON.stringify(call)}`);
|
|
189
|
+
};
|
|
190
|
+
await assert.rejects(
|
|
191
|
+
run(reconcile(propsWithPeers(), undefined), handler, calls),
|
|
192
|
+
(error: unknown) => (error as { _tag?: string } | null)?._tag === 'TalosReBootstrapRefused',
|
|
193
|
+
);
|
|
194
|
+
assert.ok(
|
|
195
|
+
calls.every((c) => c.args[0] !== 'bootstrap'),
|
|
196
|
+
'a peer that may already anchor a cluster must never see a second bootstrap',
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('refuses, and never spawns bootstrap, when a peer read fails', async () => {
|
|
201
|
+
const calls: FakeCall[] = [];
|
|
202
|
+
const handler = (call: FakeCall) => {
|
|
203
|
+
if (call.command === 'bao')
|
|
204
|
+
return { stdout: JSON.stringify({ data: { data: { talosconfig: 'x' } } }) };
|
|
205
|
+
if (call.args[0] === 'get') {
|
|
206
|
+
return nodeArg(call) === '198.51.100.11'
|
|
207
|
+
? { exitCode: 1, stderr: 'dial tcp: connection refused' }
|
|
208
|
+
: { stdout: '[]' };
|
|
209
|
+
}
|
|
210
|
+
throw new Error(`unexpected call ${JSON.stringify(call)}`);
|
|
211
|
+
};
|
|
212
|
+
await assert.rejects(
|
|
213
|
+
run(reconcile(propsWithPeers(), undefined), handler, calls),
|
|
214
|
+
(error: unknown) => (error as { _tag?: string } | null)?._tag === 'TalosReBootstrapRefused',
|
|
215
|
+
);
|
|
216
|
+
assert.ok(calls.every((c) => c.args[0] !== 'bootstrap'));
|
|
217
|
+
});
|
|
218
|
+
});
|