@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
|
@@ -6,6 +6,22 @@
|
|
|
6
6
|
* the bootstrap node. This command must run exactly once per cluster.
|
|
7
7
|
*
|
|
8
8
|
* ⛔ NOT A FACTORY RESOURCE — bootstrap cannot be deleted or updated in place; `delete` is a no-op.
|
|
9
|
+
*
|
|
10
|
+
* ⛔ ONCE MEANS ONCE (K-talos-first-boot, 2026-09-26) — THE SHIPPED SHAPE COULD BOOTSTRAP A SECOND
|
|
11
|
+
* etcd CLUSTER. The original `isBootstrapped` turned every read failure into `false`
|
|
12
|
+
* (`Effect.orElseSucceed`), `diff` then planned `update`, and `reconcile` re-ran `talosctl
|
|
13
|
+
* bootstrap` — Talos's only server-side guard is a non-empty etcd data directory (REASONED,
|
|
14
|
+
* `v1alpha1_server.go:441`), so a reinstalled or reset bootstrap node re-bootstrapped a fresh,
|
|
15
|
+
* isolated single-member cluster: split brain. Fixed per
|
|
16
|
+
* docs/plans/2026-09-26-talos-stack-first-boot.md's "Bootstrap — once means once" section:
|
|
17
|
+
* - `read` now answers presence/absence correctly instead of always returning a defined
|
|
18
|
+
* object — `undefined` only from a SUCCESSFUL read that finds no members (cold-start
|
|
19
|
+
* absence); a failing read propagates its error instead of being reinterpreted as absence.
|
|
20
|
+
* - `diff` trusts `output.bootstrapped` once it is `true` and reports `noop` without touching
|
|
21
|
+
* the live cluster at all — confirmation is `reconcile`'s job now, not plan's.
|
|
22
|
+
* - `reconcile` checks `output?.bootstrapped` FIRST: once true, it NEVER calls `talosctl
|
|
23
|
+
* bootstrap` again, no matter what a live read says. A live read that fails OR comes back
|
|
24
|
+
* empty both raise the same `TalosReBootstrapRefused` — re-bootstrap is a human decision.
|
|
9
25
|
*/
|
|
10
26
|
import { Resource } from 'alchemy';
|
|
11
27
|
import { isResolved } from 'alchemy/Diff';
|
|
@@ -14,11 +30,22 @@ import * as Provider from 'alchemy/Provider';
|
|
|
14
30
|
import * as Effect from 'effect/Effect';
|
|
15
31
|
import { mintTalosconfig } from './credentials.ts';
|
|
16
32
|
import type { TalosRequirements, WithTarget } from './resource.ts';
|
|
17
|
-
import {
|
|
33
|
+
import { assertNoLivePeers, isBootstrapped } from './talos-bootstrap-etcd.ts';
|
|
34
|
+
import { TalosReBootstrapRefused } from './talos-errors.ts';
|
|
35
|
+
import { talosctlOrAlready } from './talosctl.ts';
|
|
18
36
|
|
|
19
37
|
export interface BootstrapProps extends WithTarget {
|
|
20
38
|
/** The ONE control-plane node that receives `talosctl bootstrap`. */
|
|
21
39
|
node: string;
|
|
40
|
+
/**
|
|
41
|
+
* The OTHER declared control-plane nodes — never `node` itself. Optional, but a multi-control-
|
|
42
|
+
* plane cluster (decision 66: 3, one per TB4 node) SHOULD set it: `reconcileBootstrap`'s CREATE
|
|
43
|
+
* path (I4 fix, LAND red team, 2026-09-26) requires every one of these to show a successful,
|
|
44
|
+
* EMPTY etcd-members read before it will bootstrap `node` — the guard against re-bootstrapping a
|
|
45
|
+
* split-brain second cluster when this row's own state was lost. See talos-bootstrap-etcd.ts's
|
|
46
|
+
* `assertNoLivePeers`.
|
|
47
|
+
*/
|
|
48
|
+
peers?: readonly string[];
|
|
22
49
|
/** Machine configs for this node (and peers) must be applied first. */
|
|
23
50
|
after?: readonly unknown[];
|
|
24
51
|
}
|
|
@@ -42,54 +69,127 @@ export const TalosBootstrap = Resource<TalosBootstrap>('Talos.Bootstrap', {
|
|
|
42
69
|
});
|
|
43
70
|
|
|
44
71
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
72
|
+
* ⛔ WRAPPED IN `Effect.scoped` (K-A3, 2026-09-26) — `mintTalosconfig` contributes `Scope.Scope`
|
|
73
|
+
* to its own return type rather than closing its scope internally (the C1 fix, credentials.ts's
|
|
74
|
+
* own header); this is the caller that owns the temp talosconfig's lifetime for the read.
|
|
75
|
+
*
|
|
76
|
+
* ⛔ ANSWERS PRESENCE/ABSENCE, NOT "attrs-or-error" (K-talos-first-boot, fixes the shipped shape).
|
|
77
|
+
* `undefined` only when a read SUCCEEDS and finds no members — the honest "not created yet"
|
|
78
|
+
* Alchemy's engine expects from a cold-start adoption probe. A failing read propagates instead
|
|
79
|
+
* of being reinterpreted as absence — this is the fix `isBootstrapped` no longer hides.
|
|
80
|
+
*
|
|
81
|
+
* ★ EXPORTED (like proxmox/ceph-daemon.ts's `reconcileDaemon`, machine-config-read.ts's
|
|
82
|
+
* `readMachineConfig`) so talos-bootstrap.test.ts calls it directly with a fake spawner instead
|
|
83
|
+
* of driving it through the full Alchemy engine.
|
|
47
84
|
*/
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
85
|
+
export const readBootstrap = (props: BootstrapProps) =>
|
|
86
|
+
Effect.scoped(
|
|
87
|
+
Effect.gen(function* () {
|
|
88
|
+
const credential = yield* mintTalosconfig(props.target);
|
|
89
|
+
const bootstrapped = yield* isBootstrapped(props, credential.talosconfigPath);
|
|
90
|
+
return bootstrapped ? ({ bootstrapped: true, node: props.node } as const) : undefined;
|
|
91
|
+
}),
|
|
55
92
|
);
|
|
56
93
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
94
|
+
/**
|
|
95
|
+
* ⛔ TRUSTS STATE, NEVER TOUCHES THE LIVE CLUSTER (K-talos-first-boot) — once `output.bootstrapped`
|
|
96
|
+
* is `true` this always reports `noop`, unconditionally, with zero live calls.
|
|
97
|
+
*
|
|
98
|
+
* ⛔ I3 CORRECTION (LAND red team, 2026-09-26) — THE OLD COMMENT HERE CLAIMED "confirming the
|
|
99
|
+
* invariant against etcd is reconcile's job (every deploy calls it)". THAT IS FALSE, VERIFIED
|
|
100
|
+
* against the pinned alchemy beta.79 source: Apply never reconciles a node whose diff came back
|
|
101
|
+
* `noop` (`Apply.ts`'s `noop` branch just refreshes row metadata and signals ready — it does not
|
|
102
|
+
* call `provider.reconcile` at all). This file's own `noop` above IS what skips it. So
|
|
103
|
+
* `reconcileBootstrap`'s re-confirm branch (the one that raises `TalosReBootstrapRefused` on a
|
|
104
|
+
* failed or empty read) never runs on an ordinary `plan`/`deploy` once `bootstrapped: true` is
|
|
105
|
+
* recorded — only `--force` reaches it, by forcing a reconcile regardless of the plan action.
|
|
106
|
+
* Safety still holds — `talosctl bootstrap` genuinely never re-runs while state exists — but a
|
|
107
|
+
* reset control-plane node (etcd wiped, VM replaced) stays silently invisible to `plan` and
|
|
108
|
+
* `deploy` alike until an operator runs `--force` or a verify pass.
|
|
109
|
+
* ⚠️ NOT FIXED BY HAVING `diff` READ HERE TOO (the finding's other offered fix) — that would touch
|
|
110
|
+
* the live cluster (and mint a talosconfig) on EVERY plan for an already-bootstrapped node, for a
|
|
111
|
+
* scenario (a reset node) that a `configDigest`-style pinned check can't distinguish from a merely
|
|
112
|
+
* slow/unreachable read; a single transient failure would then abort every future plan for a
|
|
113
|
+
* healthy node, for no benefit — the exact cost the original (correct) half of this comment
|
|
114
|
+
* already named. `MachineConfig`/`ClusterHealth`'s diffs DO mint and read live on every plan
|
|
115
|
+
* (D2 puts that on the admin/operator lane, not agent), so this isn't a lane-access limit; it is
|
|
116
|
+
* this resource's own choice to trust a `true` boolean forever rather than re-derive it from a
|
|
117
|
+
* live signal that cannot yet tell "reset" apart from "briefly unreachable". Recorded as an open
|
|
118
|
+
* gap for whoever builds the operator verify pass, not silently fixed here.
|
|
119
|
+
*/
|
|
120
|
+
export const diffBootstrap = (
|
|
121
|
+
news: Input<BootstrapProps>,
|
|
122
|
+
output: BootstrapAttributes | undefined,
|
|
123
|
+
) =>
|
|
65
124
|
Effect.gen(function* () {
|
|
66
125
|
if (output === undefined || !isResolved(news)) return undefined;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return
|
|
126
|
+
if (output.bootstrapped) return { action: 'noop' } as const;
|
|
127
|
+
const live = yield* readBootstrap(news);
|
|
128
|
+
return live?.bootstrapped === true
|
|
129
|
+
? ({ action: 'noop' } as const)
|
|
130
|
+
: ({ action: 'update' } as const);
|
|
70
131
|
});
|
|
71
132
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
133
|
+
/**
|
|
134
|
+
* ⛔ ONCE MEANS ONCE — see this file's own header. `output?.bootstrapped === true` is checked
|
|
135
|
+
* BEFORE anything else, and that branch never builds a `talosctl bootstrap` argv at all — the
|
|
136
|
+
* confirmation read below can fail, or succeed and say "absent", and EITHER way this raises
|
|
137
|
+
* `TalosReBootstrapRefused` rather than treating "absent" as licence to bootstrap again.
|
|
138
|
+
*/
|
|
139
|
+
export const reconcileBootstrap = (
|
|
140
|
+
props: BootstrapProps,
|
|
141
|
+
output: BootstrapAttributes | undefined,
|
|
142
|
+
) =>
|
|
143
|
+
Effect.scoped(
|
|
144
|
+
Effect.gen(function* () {
|
|
145
|
+
const credential = yield* mintTalosconfig(props.target);
|
|
146
|
+
|
|
147
|
+
if (output?.bootstrapped === true) {
|
|
148
|
+
const stillBootstrapped = yield* isBootstrapped(props, credential.talosconfigPath).pipe(
|
|
149
|
+
Effect.catchIf(
|
|
150
|
+
(): boolean => true,
|
|
151
|
+
(cause) =>
|
|
152
|
+
Effect.fail(
|
|
153
|
+
new TalosReBootstrapRefused({
|
|
154
|
+
detail: `live read failed: ${String(cause)}`,
|
|
155
|
+
node: props.node,
|
|
156
|
+
}),
|
|
157
|
+
),
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
if (!stillBootstrapped) {
|
|
161
|
+
return yield* Effect.fail(
|
|
162
|
+
new TalosReBootstrapRefused({
|
|
163
|
+
detail: 'etcd members are absent on a live read against already-bootstrapped state',
|
|
164
|
+
node: props.node,
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return { bootstrapped: true, node: props.node };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const before = yield* isBootstrapped(props, credential.talosconfigPath);
|
|
172
|
+
if (!before) {
|
|
173
|
+
// ⛔ I4 FIX (LAND red team) — see BootstrapProps.peers's own doc and
|
|
174
|
+
// talos-bootstrap-etcd.ts's assertNoLivePeers header.
|
|
175
|
+
yield* assertNoLivePeers(props, credential.talosconfigPath);
|
|
176
|
+
yield* talosctlOrAlready(['bootstrap'], {
|
|
177
|
+
nodes: [props.node],
|
|
178
|
+
talosconfigPath: credential.talosconfigPath,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const after = yield* isBootstrapped(props, credential.talosconfigPath);
|
|
182
|
+
if (!after) {
|
|
183
|
+
return yield* Effect.die(
|
|
184
|
+
new Error(
|
|
185
|
+
`${props.node}: bootstrap returned no error but etcd members are still absent. ` +
|
|
186
|
+
'Read back rather than trusting the exit code alone.',
|
|
187
|
+
),
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return { bootstrapped: true, node: props.node };
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
93
193
|
|
|
94
194
|
const handlers = {
|
|
95
195
|
delete: () => Effect.void,
|
|
@@ -99,10 +199,16 @@ const handlers = {
|
|
|
99
199
|
}: {
|
|
100
200
|
news: Input<BootstrapProps>;
|
|
101
201
|
output: BootstrapAttributes | undefined;
|
|
102
|
-
}) =>
|
|
202
|
+
}) => diffBootstrap(news, output),
|
|
103
203
|
list: () => Effect.succeed([]),
|
|
104
|
-
read: ({ olds }: { olds: BootstrapProps }) =>
|
|
105
|
-
reconcile: ({
|
|
204
|
+
read: ({ olds }: { olds: BootstrapProps }) => readBootstrap(olds),
|
|
205
|
+
reconcile: ({
|
|
206
|
+
news,
|
|
207
|
+
output,
|
|
208
|
+
}: {
|
|
209
|
+
news: BootstrapProps;
|
|
210
|
+
output: BootstrapAttributes | undefined;
|
|
211
|
+
}) => reconcileBootstrap(news, output),
|
|
106
212
|
};
|
|
107
213
|
|
|
108
214
|
export const TalosBootstrapProvider = () =>
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Talos.ClusterHealth` — no swallowed transport errors (K-talos-first-boot). Fully offline:
|
|
3
|
+
* fake-process.ts fakes `bao`/`talosctl`, nothing real spawns.
|
|
4
|
+
*/
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { describe, it } from 'node:test';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import * as ChildProcessSpawner from 'effect/unstable/process/ChildProcessSpawner';
|
|
9
|
+
import { type FakeCall, fakeSpawner } from './fake-process.ts';
|
|
10
|
+
import {
|
|
11
|
+
diffClusterHealth,
|
|
12
|
+
readClusterHealth,
|
|
13
|
+
reconcileClusterHealth,
|
|
14
|
+
} from './talos-cluster-health.ts';
|
|
15
|
+
|
|
16
|
+
const TARGET = { cluster: 'c1', mount: 'talos-c1' };
|
|
17
|
+
const props = () => ({ controlPlaneNodes: ['198.51.100.10'], target: TARGET });
|
|
18
|
+
|
|
19
|
+
const run = <A, E>(
|
|
20
|
+
effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner>,
|
|
21
|
+
handler: (c: FakeCall) => { stdout?: string; stderr?: string; exitCode?: number },
|
|
22
|
+
calls: FakeCall[] = [],
|
|
23
|
+
) =>
|
|
24
|
+
Effect.runPromise(
|
|
25
|
+
Effect.provideService(
|
|
26
|
+
effect,
|
|
27
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
28
|
+
fakeSpawner(handler, calls),
|
|
29
|
+
),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const baoOk = (call: FakeCall) =>
|
|
33
|
+
call.command === 'bao'
|
|
34
|
+
? { stdout: JSON.stringify({ data: { data: { talosconfig: 'x' } } }) }
|
|
35
|
+
: undefined;
|
|
36
|
+
|
|
37
|
+
/** `talosctl health` ran and reported unhealthy — the one case this family may call "not healthy". */
|
|
38
|
+
const unhealthy = (call: FakeCall) =>
|
|
39
|
+
baoOk(call) ?? { exitCode: 1, stderr: 'kube-proxy not ready' };
|
|
40
|
+
|
|
41
|
+
/** `talosctl health` ran and reported healthy. */
|
|
42
|
+
const healthy = (call: FakeCall) => baoOk(call) ?? {};
|
|
43
|
+
|
|
44
|
+
/** `bao` itself fails — a transport/credential problem, not a cluster-health verdict. */
|
|
45
|
+
const vaultDown = (call: FakeCall) =>
|
|
46
|
+
call.command === 'bao' ? { exitCode: 1, stderr: 'permission denied' } : {};
|
|
47
|
+
|
|
48
|
+
describe('readClusterHealth', () => {
|
|
49
|
+
it('reports healthy:false when talosctl health ran and exited non-zero', async () => {
|
|
50
|
+
const result = await run(readClusterHealth(props()), unhealthy);
|
|
51
|
+
assert.equal(result.healthy, false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('reports healthy:true when talosctl health exits zero', async () => {
|
|
55
|
+
const result = await run(readClusterHealth(props()), healthy);
|
|
56
|
+
assert.equal(result.healthy, true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('propagates a vault/transport failure instead of reporting healthy:false', async () => {
|
|
60
|
+
await assert.rejects(
|
|
61
|
+
run(readClusterHealth(props()), vaultDown),
|
|
62
|
+
(error: unknown) => error instanceof Error && error.message.includes('permission denied'),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('diffClusterHealth', () => {
|
|
68
|
+
it('plans update when the cluster genuinely reports unhealthy', async () => {
|
|
69
|
+
const result = await run(
|
|
70
|
+
diffClusterHealth(props(), {
|
|
71
|
+
controlPlaneNodes: '198.51.100.10',
|
|
72
|
+
healthy: false,
|
|
73
|
+
workerNodes: '',
|
|
74
|
+
}),
|
|
75
|
+
unhealthy,
|
|
76
|
+
);
|
|
77
|
+
assert.equal(result?.action, 'update');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('propagates a vault/transport failure rather than planning update silently', async () => {
|
|
81
|
+
await assert.rejects(
|
|
82
|
+
run(
|
|
83
|
+
diffClusterHealth(props(), {
|
|
84
|
+
controlPlaneNodes: '198.51.100.10',
|
|
85
|
+
healthy: false,
|
|
86
|
+
workerNodes: '',
|
|
87
|
+
}),
|
|
88
|
+
vaultDown,
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('check — I1 fix: --nodes names ONE contact node, not the whole cluster', () => {
|
|
95
|
+
it('targets only the first control-plane node with --nodes/--endpoints, but lists all of them under --control-plane-nodes', async () => {
|
|
96
|
+
const calls: FakeCall[] = [];
|
|
97
|
+
await run(
|
|
98
|
+
readClusterHealth({
|
|
99
|
+
controlPlaneNodes: ['198.51.100.10', '198.51.100.11', '198.51.100.12'],
|
|
100
|
+
target: TARGET,
|
|
101
|
+
}),
|
|
102
|
+
healthy,
|
|
103
|
+
calls,
|
|
104
|
+
);
|
|
105
|
+
const talosctlCall = calls.find((c) => c.command === 'talosctl');
|
|
106
|
+
assert.ok(talosctlCall);
|
|
107
|
+
// ⛔ talosctl v1.13.8, measured: `health --nodes <ip1>,<ip2>` exits with "command \"health\" is
|
|
108
|
+
// not supported with multiple nodes" — exactly one contact node may ever reach --nodes.
|
|
109
|
+
const nodesIdx = talosctlCall.args.indexOf('--nodes');
|
|
110
|
+
assert.equal(talosctlCall.args[nodesIdx + 1], '198.51.100.10');
|
|
111
|
+
const endpointsIdx = talosctlCall.args.indexOf('--endpoints');
|
|
112
|
+
assert.equal(talosctlCall.args[endpointsIdx + 1], '198.51.100.10');
|
|
113
|
+
const cpIdx = talosctlCall.args.indexOf('--control-plane-nodes');
|
|
114
|
+
assert.equal(talosctlCall.args[cpIdx + 1], '198.51.100.10,198.51.100.11,198.51.100.12');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('fails closed instead of spawning talosctl when controlPlaneNodes is empty', async () => {
|
|
118
|
+
await assert.rejects(
|
|
119
|
+
run(readClusterHealth({ controlPlaneNodes: [], target: TARGET }), healthy),
|
|
120
|
+
(error: unknown) =>
|
|
121
|
+
error instanceof Error && error.message.includes('controlPlaneNodes is empty'),
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('reconcileClusterHealth', () => {
|
|
127
|
+
it('wraps a TalosError with a "cluster not healthy" message', async () => {
|
|
128
|
+
await assert.rejects(
|
|
129
|
+
run(reconcileClusterHealth(props()), unhealthy),
|
|
130
|
+
(error: unknown) => error instanceof Error && error.message.includes('cluster not healthy'),
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('propagates a vault/transport failure WITHOUT the "cluster not healthy" mislabel', async () => {
|
|
135
|
+
await assert.rejects(
|
|
136
|
+
run(reconcileClusterHealth(props()), vaultDown),
|
|
137
|
+
(error: unknown) =>
|
|
138
|
+
error instanceof Error &&
|
|
139
|
+
error.message.includes('permission denied') &&
|
|
140
|
+
!error.message.includes('cluster not healthy'),
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -6,6 +6,22 @@
|
|
|
6
6
|
*
|
|
7
7
|
* ⚠️ PLAN READS USE A SHORT TIMEOUT SO `plan` DOES NOT BLOCK TWENTY MINUTES. Reconcile uses the
|
|
8
8
|
* declared timeout — that is the deploy gate, not the plan gate.
|
|
9
|
+
*
|
|
10
|
+
* ⛔ THIS RESOURCE ASSUMES A CNI IS ALREADY RUNNING (K-talos-first-boot, 2026-09-26). The default
|
|
11
|
+
* `talosctl health` suite checks nodes Ready, kube-proxy and CoreDNS, and Talos's own source
|
|
12
|
+
* comment says these wait on the CNI (REASONED, `pkg/cluster/check/default.go:28-60,89-91`;
|
|
13
|
+
* docs/plans/2026-09-26-talos-stack-first-boot.md's "CNI ordering" section). A consuming stack's
|
|
14
|
+
* `after` MUST include the Cilium install step, not just `Talos.Bootstrap`/`Talos.Kubeconfig` —
|
|
15
|
+
* declaring this row directly after bootstrap deadlocks into the `--wait-timeout` on every first
|
|
16
|
+
* deploy, because Argo CD itself needs pod networking before it can install Cilium. Row order:
|
|
17
|
+
* Bootstrap → Kubeconfig → Cilium (inline manifests in the seeded machine config, D-S2) →
|
|
18
|
+
* `Talos.ClusterHealth`.
|
|
19
|
+
* ⛔ NO SWALLOWED TRANSPORT ERRORS (K-talos-first-boot) — the shipped `read` caught EVERY error
|
|
20
|
+
* from `check()`, including a `mintTalosconfig`/`bao` failure, into a plain `healthy: false`. A
|
|
21
|
+
* vault outage would then read as "cluster not healthy yet" — an operator chasing a Cilium
|
|
22
|
+
* rollout would never learn the real problem was vault. Both `read` and `reconcile` now only
|
|
23
|
+
* catch `TalosError` (`talosctl health` itself ran and exited non-zero — the one case this
|
|
24
|
+
* family may honestly call "not healthy"); anything else propagates untouched.
|
|
9
25
|
*/
|
|
10
26
|
import { Resource } from 'alchemy';
|
|
11
27
|
import { isResolved } from 'alchemy/Diff';
|
|
@@ -14,7 +30,7 @@ import * as Provider from 'alchemy/Provider';
|
|
|
14
30
|
import * as Effect from 'effect/Effect';
|
|
15
31
|
import { mintTalosconfig } from './credentials.ts';
|
|
16
32
|
import type { TalosRequirements, WithTarget } from './resource.ts';
|
|
17
|
-
import { talosctl } from './talosctl.ts';
|
|
33
|
+
import { TalosError, talosctl } from './talosctl.ts';
|
|
18
34
|
|
|
19
35
|
export interface ClusterHealthProps extends WithTarget {
|
|
20
36
|
/** Control-plane node IPs passed to `--control-plane-nodes`. */
|
|
@@ -27,7 +43,11 @@ export interface ClusterHealthProps extends WithTarget {
|
|
|
27
43
|
* ⚠️ REASONED default — not measured here.
|
|
28
44
|
*/
|
|
29
45
|
waitTimeout?: string;
|
|
30
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Ordering edge. ⛔ MUST reach past `Talos.Bootstrap`/`Talos.Kubeconfig` all the way through the
|
|
48
|
+
* Cilium CNI install (this file's own header, "CNI ordering") — the default health checks wait
|
|
49
|
+
* on kube-proxy and CoreDNS, both of which wait on a CNI that does not exist yet at bootstrap.
|
|
50
|
+
*/
|
|
31
51
|
after?: readonly unknown[];
|
|
32
52
|
}
|
|
33
53
|
|
|
@@ -59,45 +79,88 @@ const healthArgs = (props: ClusterHealthProps, waitTimeout: string) => {
|
|
|
59
79
|
return args;
|
|
60
80
|
};
|
|
61
81
|
|
|
82
|
+
/**
|
|
83
|
+
* ⛔ WRAPPED IN `Effect.scoped` (K-A3, 2026-09-26) — `mintTalosconfig` contributes `Scope.Scope`
|
|
84
|
+
* to its own return type rather than closing its scope internally (the C1 fix, credentials.ts's
|
|
85
|
+
* own header); both `read` and `reconcile` call this one function, so wrapping it here covers
|
|
86
|
+
* both callers.
|
|
87
|
+
* ⛔ I1 FIX (LAND red team, 2026-09-26) — `--nodes` NAMES ONE CONTACT NODE, NOT THE WHOLE CLUSTER.
|
|
88
|
+
* MEASURED against local talosctl v1.13.8 (unroutable TEST-NET addresses): `talosctl health
|
|
89
|
+
* --nodes <ip1>,<ip2>` fails with `command "health" is not supported with multiple nodes` — the
|
|
90
|
+
* shipped code passed EVERY control-plane node as `--nodes`, so with decision 66's 3-node layout
|
|
91
|
+
* this command could never succeed even against a genuinely healthy cluster: `read` always
|
|
92
|
+
* reported `healthy:false`, `diff` always planned `update`, and `reconcile` always failed with a
|
|
93
|
+
* misleading "not healthy within Nm". Fixed: the first control-plane node is the sole `--nodes`/
|
|
94
|
+
* `--endpoints` contact point (talosctl proxies the actual check through it); the FULL node lists
|
|
95
|
+
* still go to `--control-plane-nodes`/`--worker-nodes` via `healthArgs`, which is what `talosctl
|
|
96
|
+
* health` actually checks the health OF.
|
|
97
|
+
*/
|
|
62
98
|
const check = (props: ClusterHealthProps, waitTimeout: string) =>
|
|
63
|
-
Effect.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
99
|
+
Effect.scoped(
|
|
100
|
+
Effect.gen(function* () {
|
|
101
|
+
const credential = yield* mintTalosconfig(props.target);
|
|
102
|
+
const contact = props.controlPlaneNodes[0];
|
|
103
|
+
if (contact === undefined) {
|
|
104
|
+
return yield* Effect.fail(
|
|
105
|
+
new Error(
|
|
106
|
+
`Talos.ClusterHealth ${props.target.cluster}: controlPlaneNodes is empty — need at ` +
|
|
107
|
+
'least one node to contact.',
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
yield* talosctl(healthArgs(props, waitTimeout), {
|
|
112
|
+
endpoints: [contact],
|
|
113
|
+
nodes: [contact],
|
|
114
|
+
talosconfigPath: credential.talosconfigPath,
|
|
115
|
+
});
|
|
116
|
+
return {
|
|
117
|
+
controlPlaneNodes: nodeCsv(props.controlPlaneNodes),
|
|
118
|
+
healthy: true,
|
|
119
|
+
workerNodes: nodeCsv(props.workerNodes),
|
|
120
|
+
};
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* ⛔ CATCHES ONLY `TalosError` (K-talos-first-boot) — a run of `talosctl health` that itself
|
|
126
|
+
* completed and exited non-zero, which is the only outcome this family may honestly call "not
|
|
127
|
+
* healthy yet". Anything else (a `mintTalosconfig`/`bao` failure, a JS defect) propagates instead
|
|
128
|
+
* of masquerading as an unhealthy cluster — see this file's own header.
|
|
129
|
+
*/
|
|
130
|
+
const isTalosError = (cause: unknown): cause is TalosError => cause instanceof TalosError;
|
|
75
131
|
|
|
76
|
-
|
|
132
|
+
/** ★ EXPORTED for talos-cluster-health.test.ts — see talos-bootstrap.ts's own note on the pattern. */
|
|
133
|
+
export const readClusterHealth = (props: ClusterHealthProps) =>
|
|
77
134
|
check(props, '5s').pipe(
|
|
78
|
-
Effect.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
135
|
+
Effect.catchIf(isTalosError, () =>
|
|
136
|
+
Effect.succeed({
|
|
137
|
+
controlPlaneNodes: nodeCsv(props.controlPlaneNodes),
|
|
138
|
+
healthy: false,
|
|
139
|
+
workerNodes: nodeCsv(props.workerNodes),
|
|
140
|
+
}),
|
|
141
|
+
),
|
|
83
142
|
);
|
|
84
143
|
|
|
85
|
-
const
|
|
144
|
+
export const diffClusterHealth = (
|
|
145
|
+
news: Input<ClusterHealthProps>,
|
|
146
|
+
output: ClusterHealthAttributes | undefined,
|
|
147
|
+
) =>
|
|
86
148
|
Effect.gen(function* () {
|
|
87
149
|
if (output === undefined || !isResolved(news)) return undefined;
|
|
88
|
-
const live = yield*
|
|
150
|
+
const live = yield* readClusterHealth(news);
|
|
89
151
|
if (live.healthy) return { action: 'noop' } as const;
|
|
90
152
|
return { action: 'update' } as const;
|
|
91
153
|
});
|
|
92
154
|
|
|
93
|
-
const
|
|
155
|
+
export const reconcileClusterHealth = (props: ClusterHealthProps) =>
|
|
94
156
|
check(props, props.waitTimeout ?? '20m0s').pipe(
|
|
95
|
-
Effect.
|
|
96
|
-
(
|
|
157
|
+
Effect.catchIf(isTalosError, (cause) =>
|
|
158
|
+
Effect.fail(
|
|
97
159
|
new Error(
|
|
98
160
|
`Talos.ClusterHealth ${props.target.cluster}: cluster not healthy within ` +
|
|
99
161
|
`${props.waitTimeout ?? '20m0s'} — ${String(cause)}`,
|
|
100
162
|
),
|
|
163
|
+
),
|
|
101
164
|
),
|
|
102
165
|
);
|
|
103
166
|
|
|
@@ -109,10 +172,10 @@ const handlers = {
|
|
|
109
172
|
}: {
|
|
110
173
|
news: Input<ClusterHealthProps>;
|
|
111
174
|
output: ClusterHealthAttributes | undefined;
|
|
112
|
-
}) =>
|
|
175
|
+
}) => diffClusterHealth(news, output),
|
|
113
176
|
list: () => Effect.succeed([]),
|
|
114
|
-
read: ({ olds }: { olds: ClusterHealthProps }) =>
|
|
115
|
-
reconcile: ({ news }: { news: ClusterHealthProps }) =>
|
|
177
|
+
read: ({ olds }: { olds: ClusterHealthProps }) => readClusterHealth(olds),
|
|
178
|
+
reconcile: ({ news }: { news: ClusterHealthProps }) => reconcileClusterHealth(news),
|
|
116
179
|
};
|
|
117
180
|
|
|
118
181
|
export const TalosClusterHealthProvider = () =>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Talos.MachineConfig`'s typed failures — split out of talos-machine-config.ts (2026-09-26,
|
|
3
|
+
* K-A3) for the same reason proxmox/credential-errors.ts gives: keep a class's own header out of
|
|
4
|
+
* the 250-line budget of the file that raises it.
|
|
5
|
+
*/
|
|
6
|
+
import * as Data from 'effect/Data';
|
|
7
|
+
import type { ApplyMode } from './resource.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The OpenBao KV content at `mount/key` does not hash to the pinned `configDigest`.
|
|
11
|
+
*
|
|
12
|
+
* ★ FAIL CLOSED, NO SPAWN — docs/plans/2026-09-26-talos-secrets-flow.md's O-A: "a poisoned or
|
|
13
|
+
* fat-fingered KV write cannot silently reach a node". Raised BEFORE any talosctl process
|
|
14
|
+
* starts, so a mismatch never sends an unpinned config anywhere — see reconcile() in
|
|
15
|
+
* talos-machine-config.ts, which checks this ahead of minting the talosconfig credential too.
|
|
16
|
+
* ⛔ NEITHER DIGEST IN THIS MESSAGE IS THE CONFIG. Both are sha256 hex digests — safe to log,
|
|
17
|
+
* safe to persist — never the KV content itself.
|
|
18
|
+
*/
|
|
19
|
+
export class TalosConfigDigestMismatch extends Data.TaggedError('TalosConfigDigestMismatch')<{
|
|
20
|
+
readonly mount: string;
|
|
21
|
+
readonly key: string;
|
|
22
|
+
readonly expected: string;
|
|
23
|
+
readonly got: string;
|
|
24
|
+
}> {
|
|
25
|
+
override get message(): string {
|
|
26
|
+
return (
|
|
27
|
+
`${this.mount}/${this.key}: live content digest ${this.got} does not match the pinned ` +
|
|
28
|
+
`configDigest ${this.expected}. Fail closed — nothing was sent to talosctl. Either the KV ` +
|
|
29
|
+
'value was rewritten without a matching PR, or the pinned digest is stale; check which one ' +
|
|
30
|
+
'moved before re-pinning either side.'
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `apply-config` returned success but the live machineconfig digest never matched the pin within
|
|
37
|
+
* the bounded poll (machine-config-poll.ts). See
|
|
38
|
+
* docs/plans/2026-09-26-talos-stack-first-boot.md's "Converged" section.
|
|
39
|
+
*
|
|
40
|
+
* ⛔ THIS IS THE REPLACEMENT FOR A SILENT `converged: false`. The shipped code let a read
|
|
41
|
+
* failure during convergence checking disguise itself as "not converged yet" forever
|
|
42
|
+
* (`orElseSucceed`); this type makes cap expiry a refusal an operator sees, not a plan that
|
|
43
|
+
* quietly retries the same apply on every future run.
|
|
44
|
+
*/
|
|
45
|
+
export class TalosConvergenceTimeout extends Data.TaggedError('TalosConvergenceTimeout')<{
|
|
46
|
+
readonly node: string;
|
|
47
|
+
readonly mode: ApplyMode;
|
|
48
|
+
readonly attempts: number;
|
|
49
|
+
}> {
|
|
50
|
+
override get message(): string {
|
|
51
|
+
return (
|
|
52
|
+
`${this.node}: apply-config (--mode ${this.mode}) was accepted but the live machineconfig ` +
|
|
53
|
+
`digest never matched the pin within ${this.attempts} bounded poll attempts. Refusing to ` +
|
|
54
|
+
'report converged — hand this node to the operator rather than silently pass.'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `Talos.Bootstrap` reconcile found state already recording `bootstrapped: true`, and a live
|
|
61
|
+
* check against etcd could not reconfirm it — either the read itself failed (`detail` carries the
|
|
62
|
+
* propagated cause), or it succeeded and came back with no members.
|
|
63
|
+
*
|
|
64
|
+
* ★ K-talos-first-boot's "once means once" — docs/plans/2026-09-26-talos-stack-first-boot.md's own
|
|
65
|
+
* section of that name. EITHER cause raises this SAME typed error, and both ways `talosctl
|
|
66
|
+
* bootstrap` is never spawned a second time: re-running it against a node that lost its etcd
|
|
67
|
+
* data (a VM replace, a reset) forms a second, isolated single-member etcd cluster instead of
|
|
68
|
+
* rejoining the existing one (REASONED, `v1alpha1_server.go:441` — Talos's only server-side
|
|
69
|
+
* guard is a non-empty data directory, not "did this cluster already bootstrap"). That is split
|
|
70
|
+
* brain, and nothing here can tell a genuinely reset node (needs its own operator-run recovery)
|
|
71
|
+
* apart from a merely slow or unreachable read (needs a retry) — so both refuse alike and hand
|
|
72
|
+
* the node to a human instead of guessing.
|
|
73
|
+
*/
|
|
74
|
+
export class TalosReBootstrapRefused extends Data.TaggedError('TalosReBootstrapRefused')<{
|
|
75
|
+
readonly node: string;
|
|
76
|
+
readonly detail: string;
|
|
77
|
+
}> {
|
|
78
|
+
override get message(): string {
|
|
79
|
+
return (
|
|
80
|
+
`${this.node}: state already recorded a successful bootstrap, and a live check could not ` +
|
|
81
|
+
`reconfirm it (${this.detail}). Refusing to run 'talosctl bootstrap' again — that risks a ` +
|
|
82
|
+
'second, isolated etcd cluster on a reset node. This needs an operator, not a reconcile.'
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|