@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
package/src/talos/kubeconfig.ts
CHANGED
|
@@ -5,37 +5,53 @@
|
|
|
5
5
|
* `--merge=false` keeps it isolated; `-f/--force` overwrites an existing file.
|
|
6
6
|
*
|
|
7
7
|
* ⛔ THE KUBECONFIG FILE CONTAINS A CLIENT CERTIFICATE — IT MUST NOT BE AN ATTRIBUTE. Alchemy
|
|
8
|
-
* persists attributes unencrypted. This resource
|
|
9
|
-
*
|
|
10
|
-
* - persists ONLY public metadata + fingerprints,
|
|
11
|
-
* - exposes a `connection` field referencing the runtime path for `Kubernetes.ClusterAdapter`.
|
|
8
|
+
* persists attributes unencrypted. This resource persists ONLY public metadata + fingerprints,
|
|
9
|
+
* never the file's own bytes.
|
|
12
10
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* ⛔ LANDS IN OPENBAO, NOT A HOST `runtimePath` (K-talos-first-boot, 2026-09-26). The shipped shape
|
|
12
|
+
* wrote a cluster-admin kubeconfig to un-vaulted host disk and never removed it — the exact class
|
|
13
|
+
* of exposure `talos/credentials.ts`'s own header exists to prevent — and its own `read` depended
|
|
14
|
+
* on that file's continued presence, so verifying from any other host planned `update` forever
|
|
15
|
+
* (docs/plans/2026-09-26-talos-secrets-flow.md's "Measured today" section). Fixed:
|
|
16
|
+
* - CREATE (`output === undefined`) runs `talosctl kubeconfig` into a throwaway, unguessable
|
|
17
|
+
* temp path (this file's own `reservedTempPath`), reads it back, and writes its bytes into
|
|
18
|
+
* OpenBao at `target.mount`/`kubeconfigKey` via stdin (`credentials-write.ts`'s
|
|
19
|
+
* `writeKvValue` — ⛔ never argv) — never to any path a caller could depend on.
|
|
20
|
+
* - ⛔ WRITTEN ONCE, NOT RE-MINTED EVERY DEPLOY. `talosctl kubeconfig` always issues a FRESH
|
|
21
|
+
* admin client certificate; re-running it every reconcile would rotate credentials for no
|
|
22
|
+
* reason and violate O1's write-once-at-bring-up grant on this one key
|
|
23
|
+
* (docs/plans/2026-09-26-talos-secrets-flow.md, O1). Once `output` is defined, reconcile only
|
|
24
|
+
* reads the vault copy back to confirm it — the same "once" shape `talos-bootstrap.ts` uses
|
|
25
|
+
* for a boolean, here for a value.
|
|
26
|
+
* - The persisted `connection` carries no usable host path — a consumer materializes its own
|
|
27
|
+
* temp file via `credentials.ts`'s `mintKubeconfig`, the same pattern `mintTalosconfig`
|
|
28
|
+
* already established. Wiring `Kubernetes.ClusterAdapter` to call it is separate, later work
|
|
29
|
+
* (named, not built, in the secrets-flow doc); this only makes the vault-backed read+mint
|
|
30
|
+
* step exist for it to call. ⛔ Until that wiring lands, `connection.auth.path` is a sentinel
|
|
31
|
+
* that can never resolve to a real file (LAND red team I2, `kubeconfig-attrs.ts`'s own
|
|
32
|
+
* header) — an ABSENT path here would let the stock `Kubernetes.KubeConfigAdapter` silently
|
|
33
|
+
* fall back to `$KUBECONFIG`/`~/.kube/config` instead of failing loudly.
|
|
16
34
|
*/
|
|
35
|
+
import { chmodSync } from 'node:fs';
|
|
17
36
|
import { Resource } from 'alchemy';
|
|
18
37
|
import { isResolved } from 'alchemy/Diff';
|
|
19
38
|
import type { Input } from 'alchemy/Input';
|
|
20
39
|
import type { Connection } from 'alchemy/Kubernetes/Connection';
|
|
21
40
|
import * as Provider from 'alchemy/Provider';
|
|
22
41
|
import * as Effect from 'effect/Effect';
|
|
23
|
-
import { mintTalosconfig } from './credentials.ts';
|
|
42
|
+
import { DEFAULT_KUBECONFIG_KEY, mintTalosconfig, readKvValue } from './credentials.ts';
|
|
43
|
+
import { reservedTempPath, writeKvValue } from './credentials-write.ts';
|
|
44
|
+
import { buildAttrs } from './kubeconfig-attrs.ts';
|
|
24
45
|
import type { TalosRequirements, WithTarget } from './resource.ts';
|
|
25
46
|
import { talosctl } from './talosctl.ts';
|
|
26
|
-
import { kubeconfigMetadata, sha256 } from './values.ts';
|
|
27
47
|
|
|
28
48
|
export interface KubeconfigProps extends WithTarget {
|
|
29
49
|
/** Node that serves `talosctl kubeconfig`. */
|
|
30
50
|
node: string;
|
|
31
51
|
/** Context name inside the generated kubeconfig — not secret. */
|
|
32
52
|
context: string;
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
*
|
|
36
|
-
* ⛔ Must be outside Alchemy state and rotated by redeploy, never copied into attributes.
|
|
37
|
-
*/
|
|
38
|
-
runtimePath: string;
|
|
53
|
+
/** OpenBao KV path under `target.mount` for the written kubeconfig. Default `'kubeconfig'`. */
|
|
54
|
+
kubeconfigKey?: string;
|
|
39
55
|
after?: readonly unknown[];
|
|
40
56
|
}
|
|
41
57
|
|
|
@@ -50,7 +66,11 @@ export interface KubeconfigAttributes {
|
|
|
50
66
|
* Serializable `Kubernetes.Connection` for downstream workloads.
|
|
51
67
|
*
|
|
52
68
|
* ★ auth.kind `kubeconfig` is the stock ClusterAdapter — provider-roadmap.md names this seam.
|
|
53
|
-
*
|
|
69
|
+
* `path` is a sentinel that can never resolve (LAND red team I2, `kubeconfig-attrs.ts`'s own
|
|
70
|
+
* `toConnection` header): the content lives in OpenBao, not at a fixed host path, and a real
|
|
71
|
+
* `Kubernetes.ClusterAdapter` wiring is later work, so this must fail rather than silently
|
|
72
|
+
* reach the operator's own kubeconfig. A consumer materializes its own scoped temp file via
|
|
73
|
+
* `credentials.ts`'s `mintKubeconfig` in the meantime.
|
|
54
74
|
*/
|
|
55
75
|
connection: Connection;
|
|
56
76
|
}
|
|
@@ -65,92 +85,146 @@ export interface TalosKubeconfig extends Resource<
|
|
|
65
85
|
|
|
66
86
|
export const TalosKubeconfig = Resource<TalosKubeconfig>('Talos.Kubeconfig');
|
|
67
87
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
88
|
+
/**
|
|
89
|
+
* ★ MEASURED AGAINST OPENBAO v2.6.2 (C1 fix, LAND red team, 2026-09-26 — isolated in-memory dev
|
|
90
|
+
* server, `env -i`, scratch HOME, no estate credentials; server removed after): `bao kv get` on a
|
|
91
|
+
* genuinely unwritten KV-v2 key exits 2 with stderr exactly `No value found at <mount>/data/<key>`
|
|
92
|
+
* — `readKvValue`'s Error message embeds that text verbatim. This is the ONLY signal this file
|
|
93
|
+
* trusts as "not written yet"; every other failure (a wrong exit code, a different stderr such as
|
|
94
|
+
* a permission denial or a network failure) propagates as a real error instead of being read as
|
|
95
|
+
* absence.
|
|
96
|
+
*/
|
|
97
|
+
const isVaultKeyAbsent = (error: unknown): boolean =>
|
|
98
|
+
error instanceof Error && /no value found at/i.test(error.message);
|
|
74
99
|
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
100
|
+
const readVaultMeta = (props: KubeconfigProps, key: string) =>
|
|
101
|
+
readKvValue(props.target.mount, key, ['kubeconfig', 'config']).pipe(
|
|
102
|
+
Effect.catchIf(isVaultKeyAbsent, () => Effect.succeed(undefined)),
|
|
103
|
+
);
|
|
78
104
|
|
|
79
|
-
|
|
105
|
+
/**
|
|
106
|
+
* ★ EXPORTED for kubeconfig.test.ts — see talos-bootstrap.ts's own note on the pattern.
|
|
107
|
+
*
|
|
108
|
+
* ⛔ C1 FIX (LAND red team, 2026-09-26) — `undefined` NOW MEANS ONLY "the key has never been
|
|
109
|
+
* written", NEVER "some read failed". The shipped `Effect.orElseSucceed` folded EVERY failure —
|
|
110
|
+
* including a vault outage or wrong mount — into a defined, empty-fingerprint object. Alchemy's
|
|
111
|
+
* engine always adopts a defined cold-start `read` result and forces `update` (alchemy beta.79
|
|
112
|
+
* `Plan.ts:1303-1352`), so `reconcileKubeconfig`'s write-once branch (`output !== undefined`)
|
|
113
|
+
* then tried to CONFIRM a key that had never actually been written, and failed every time —
|
|
114
|
+
* `Talos.Kubeconfig` could never be created. Vault content that EXISTS but does not parse as a
|
|
115
|
+
* kubeconfig is a different, fail-closed case (below), never treated as absence.
|
|
116
|
+
*/
|
|
117
|
+
export const readKubeconfig = (props: KubeconfigProps) =>
|
|
80
118
|
Effect.gen(function* () {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
};
|
|
119
|
+
const key = props.kubeconfigKey ?? DEFAULT_KUBECONFIG_KEY;
|
|
120
|
+
const raw = yield* readVaultMeta(props, key);
|
|
121
|
+
if (raw === undefined) return undefined;
|
|
122
|
+
const meta = buildAttrs(raw, props);
|
|
123
|
+
if (meta === undefined) {
|
|
124
|
+
return yield* Effect.fail(
|
|
125
|
+
new Error(
|
|
126
|
+
`${props.target.mount}/${key}: vault content exists but does not parse as a kubeconfig ` +
|
|
127
|
+
`for context ${props.context} — not a cold start. Fix the KV entry or the pinned ` +
|
|
128
|
+
'context rather than trusting an empty read.',
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return meta;
|
|
96
133
|
});
|
|
97
134
|
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
? {
|
|
103
|
-
certificateAuthorityFingerprint: '',
|
|
104
|
-
clientCertificateFingerprint: '',
|
|
105
|
-
connection: toConnection(props),
|
|
106
|
-
context: props.context,
|
|
107
|
-
credentialGeneration: '',
|
|
108
|
-
endpoint: '',
|
|
109
|
-
}
|
|
110
|
-
: meta,
|
|
111
|
-
),
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
const diff = (news: Input<KubeconfigProps>, output: KubeconfigAttributes | undefined) =>
|
|
135
|
+
export const diffKubeconfig = (
|
|
136
|
+
news: Input<KubeconfigProps>,
|
|
137
|
+
output: KubeconfigAttributes | undefined,
|
|
138
|
+
) =>
|
|
115
139
|
Effect.gen(function* () {
|
|
116
140
|
if (output === undefined || !isResolved(news)) return undefined;
|
|
117
|
-
const live = yield*
|
|
141
|
+
const live = yield* readKubeconfig(news);
|
|
118
142
|
if (
|
|
119
|
-
live
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
143
|
+
live !== undefined &&
|
|
144
|
+
live.credentialGeneration === output.credentialGeneration &&
|
|
145
|
+
live.context === output.context &&
|
|
146
|
+
live.endpoint === output.endpoint
|
|
123
147
|
) {
|
|
124
148
|
return { action: 'noop' } as const;
|
|
125
149
|
}
|
|
126
150
|
return { action: 'update' } as const;
|
|
127
151
|
});
|
|
128
152
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
153
|
+
/**
|
|
154
|
+
* ⛔ WRAPPED IN `Effect.scoped` — same C1-lifetime reasoning as every other Talos resource file:
|
|
155
|
+
* `mintTalosconfig` and `reservedTempPath` both contribute `Scope.Scope`, and this is the caller
|
|
156
|
+
* that owns their lifetime for the whole reconcile.
|
|
157
|
+
* ⛔ WRITE-ONCE — see this file's own header. A bring-up already ran; this branch never spawns
|
|
158
|
+
* `talosctl kubeconfig` or writes to the vault again.
|
|
159
|
+
* ⛔ GATED ON `credentialGeneration`, NOT JUST `output !== undefined` (C1 fix, LAND red team,
|
|
160
|
+
* belt-and-suspenders alongside the `read` fix above) — `output`'s own type still allows a
|
|
161
|
+
* defined-but-empty value (state persisted by a pre-fix build's `emptyAttrs()`, or any future
|
|
162
|
+
* caller that hands this function a stale row). Treat that exactly like `undefined` — mint and
|
|
163
|
+
* write once — rather than take the confirm-only branch and fail trying to read back a key that
|
|
164
|
+
* was never actually written.
|
|
165
|
+
*/
|
|
166
|
+
export const reconcileKubeconfig = (
|
|
167
|
+
props: KubeconfigProps,
|
|
168
|
+
output: KubeconfigAttributes | undefined,
|
|
169
|
+
) =>
|
|
170
|
+
Effect.scoped(
|
|
171
|
+
Effect.gen(function* () {
|
|
172
|
+
const key = props.kubeconfigKey ?? DEFAULT_KUBECONFIG_KEY;
|
|
173
|
+
|
|
174
|
+
if (output !== undefined && output.credentialGeneration !== '') {
|
|
175
|
+
const raw = yield* readKvValue(props.target.mount, key, ['kubeconfig', 'config']);
|
|
176
|
+
const meta = buildAttrs(raw, props);
|
|
177
|
+
if (meta === undefined) {
|
|
178
|
+
return yield* Effect.die(
|
|
179
|
+
new Error(
|
|
180
|
+
`${props.target.mount}/${key}: vault content no longer parses as a kubeconfig for ` +
|
|
181
|
+
`context ${props.context}. Read back rather than trusting the write once.`,
|
|
182
|
+
),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return meta;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const credential = yield* mintTalosconfig(props.target);
|
|
189
|
+
const { path: outPath } = yield* reservedTempPath(
|
|
190
|
+
`${props.target.cluster}-${props.node}-kubeconfig-out`,
|
|
150
191
|
);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
192
|
+
yield* talosctl(
|
|
193
|
+
['kubeconfig', outPath, '--merge=false', '--force', '--force-context-name', props.context],
|
|
194
|
+
{ nodes: [props.node], talosconfigPath: credential.talosconfigPath },
|
|
195
|
+
);
|
|
196
|
+
const raw = yield* Effect.tryPromise({
|
|
197
|
+
catch: (cause) => new Error(`${outPath}: reading generated kubeconfig: ${String(cause)}`),
|
|
198
|
+
try: () => Bun.file(outPath).text(),
|
|
199
|
+
});
|
|
200
|
+
if (raw.trim() === '') {
|
|
201
|
+
return yield* Effect.die(
|
|
202
|
+
new Error(
|
|
203
|
+
`${props.node}: talosctl kubeconfig returned no error but wrote an empty file. Read ` +
|
|
204
|
+
'back rather than trusting the exit code alone.',
|
|
205
|
+
),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
// ⚠️ REASONED, not measured: talosctl's own writer likely already uses 0600 like most Go
|
|
209
|
+
// credential writers, but this chmods defensively regardless — same posture as
|
|
210
|
+
// credentials.ts's mintKvTempFile.
|
|
211
|
+
yield* Effect.try({
|
|
212
|
+
catch: (cause) => new Error(`${outPath}: chmod 0600: ${String(cause)}`),
|
|
213
|
+
try: () => chmodSync(outPath, 0o600),
|
|
214
|
+
});
|
|
215
|
+
const meta = buildAttrs(raw, props);
|
|
216
|
+
if (meta === undefined) {
|
|
217
|
+
return yield* Effect.die(
|
|
218
|
+
new Error(
|
|
219
|
+
`${props.node}: talosctl kubeconfig wrote a file that does not parse for context ` +
|
|
220
|
+
`${props.context}.`,
|
|
221
|
+
),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
yield* writeKvValue(props.target.mount, key, 'kubeconfig', raw);
|
|
225
|
+
return meta;
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
154
228
|
|
|
155
229
|
const handlers = {
|
|
156
230
|
delete: () => Effect.void,
|
|
@@ -160,10 +234,16 @@ const handlers = {
|
|
|
160
234
|
}: {
|
|
161
235
|
news: Input<KubeconfigProps>;
|
|
162
236
|
output: KubeconfigAttributes | undefined;
|
|
163
|
-
}) =>
|
|
237
|
+
}) => diffKubeconfig(news, output),
|
|
164
238
|
list: () => Effect.succeed([]),
|
|
165
|
-
read: ({ olds }: { olds: KubeconfigProps }) =>
|
|
166
|
-
reconcile: ({
|
|
239
|
+
read: ({ olds }: { olds: KubeconfigProps }) => readKubeconfig(olds),
|
|
240
|
+
reconcile: ({
|
|
241
|
+
news,
|
|
242
|
+
output,
|
|
243
|
+
}: {
|
|
244
|
+
news: KubeconfigProps;
|
|
245
|
+
output: KubeconfigAttributes | undefined;
|
|
246
|
+
}) => reconcileKubeconfig(news, output),
|
|
167
247
|
};
|
|
168
248
|
|
|
169
249
|
export const TalosKubeconfigProvider = () =>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared fixtures for talos-machine-config.test.ts and machine-config-poll.test.ts — split out
|
|
3
|
+
* so neither test file needs to duplicate the fake `bao`/`talosctl` dispatcher (2026-09-26, K-A3).
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ FIXTURES ONLY. `CONFIG_TEXT` is a plain marker string, never anything resembling a real
|
|
6
|
+
* Talos machine config, CA key or bootstrap token. No provider imports this file.
|
|
7
|
+
*/
|
|
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 type { MachineConfigProps } from './talos-machine-config.ts';
|
|
12
|
+
import { configDigest } from './values.ts';
|
|
13
|
+
|
|
14
|
+
export const CONFIG_TEXT = 'machine:\n type: worker\n';
|
|
15
|
+
export const PIN = configDigest(CONFIG_TEXT);
|
|
16
|
+
export const TARGET = { cluster: 'c1', mount: 'talos-c1' };
|
|
17
|
+
export const FAST_POLL = {
|
|
18
|
+
immediate: { intervalMs: 0, maxAttempts: 3 },
|
|
19
|
+
rebootish: { intervalMs: 0, maxAttempts: 4 },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const props = (overrides: Partial<MachineConfigProps> = {}): MachineConfigProps => ({
|
|
23
|
+
configDigest: PIN,
|
|
24
|
+
configKey: 'nodes/10001',
|
|
25
|
+
node: '198.51.100.10',
|
|
26
|
+
target: TARGET,
|
|
27
|
+
...overrides,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** Shape REASONED from talosctl's `get machineconfig -o yaml` — see values.ts's own header. */
|
|
31
|
+
export const wrapperFor = (specText: string, version = '1') =>
|
|
32
|
+
`node: 198.51.100.10\nmetadata:\n version: "${version}"\nspec: "${specText.replace(/\n/g, '\\n')}"\n`;
|
|
33
|
+
|
|
34
|
+
/** Routes fake `bao kv get` (config + talosconfig keys) and `talosctl` calls in one place. */
|
|
35
|
+
export const dispatcher = (opts: {
|
|
36
|
+
configText?: string;
|
|
37
|
+
liveWrapper?: () => { stdout?: string; exitCode?: number };
|
|
38
|
+
applyExitCode?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Controls the fix-first #1 maintenance-mode probe (`talosctl version --insecure`), reached only
|
|
41
|
+
* when the authenticated `get machineconfig` above fails. Defaults to `false` so a bare failing
|
|
42
|
+
* `liveWrapper` (most tests) still exercises the "both reads fail → propagate" path rather than
|
|
43
|
+
* silently succeeding at the probe.
|
|
44
|
+
*/
|
|
45
|
+
maintenanceReachable?: boolean;
|
|
46
|
+
}) => {
|
|
47
|
+
const configText = opts.configText ?? CONFIG_TEXT;
|
|
48
|
+
return (call: FakeCall) => {
|
|
49
|
+
if (call.command === 'bao') {
|
|
50
|
+
const key = call.args[3] ?? '';
|
|
51
|
+
if (key.endsWith('/talosconfig')) {
|
|
52
|
+
return {
|
|
53
|
+
stdout: JSON.stringify({ data: { data: { talosconfig: 'fake-talosconfig-body' } } }),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return { stdout: JSON.stringify({ data: { data: { config: configText } } }) };
|
|
57
|
+
}
|
|
58
|
+
if (call.command === 'talosctl') {
|
|
59
|
+
if (call.args[0] === 'apply-config') return { exitCode: opts.applyExitCode ?? 0 };
|
|
60
|
+
if (call.args[0] === 'version')
|
|
61
|
+
return (opts.maintenanceReachable ?? false)
|
|
62
|
+
? { stdout: 'Client: v1.13.10\nServer: v1.13.10\n' }
|
|
63
|
+
: { exitCode: 1, stderr: 'dial tcp: connection refused' };
|
|
64
|
+
if (call.args[0] === 'get')
|
|
65
|
+
return opts.liveWrapper?.() ?? { stdout: wrapperFor(CONFIG_TEXT) };
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`dispatcher: unexpected call ${JSON.stringify(call)}`);
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const run = <A, E>(
|
|
72
|
+
effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner>,
|
|
73
|
+
handler: (c: FakeCall) => { stdout?: string; stderr?: string; exitCode?: number },
|
|
74
|
+
calls: FakeCall[] = [],
|
|
75
|
+
) =>
|
|
76
|
+
Effect.runPromise(
|
|
77
|
+
Effect.provideService(
|
|
78
|
+
effect,
|
|
79
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
80
|
+
fakeSpawner(handler, calls),
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
/** `Data.TaggedError` stamps `_tag`, not `.message` — see talos-errors.ts. */
|
|
85
|
+
export const hasTag = (tag: string) => (error: unknown) =>
|
|
86
|
+
(error as { _tag?: string } | null)?._tag === tag;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded-poll-per-mode acceptance tests from docs/plans/2026-09-26-talos-stack-first-boot.md's
|
|
3
|
+
* "Converged" section — exercised through `reconcileMachineConfig` with `FAST_POLL` (a test-only
|
|
4
|
+
* seam, see machine-config-poll.ts's own header) so the ~10s/~2m production caps never run here.
|
|
5
|
+
*/
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import {
|
|
9
|
+
CONFIG_TEXT,
|
|
10
|
+
FAST_POLL,
|
|
11
|
+
dispatcher,
|
|
12
|
+
hasTag,
|
|
13
|
+
props,
|
|
14
|
+
run,
|
|
15
|
+
wrapperFor,
|
|
16
|
+
} from './machine-config-fixtures.ts';
|
|
17
|
+
import type { ApplyMode } from './resource.ts';
|
|
18
|
+
import { reconcileMachineConfig } from './talos-machine-config.ts';
|
|
19
|
+
|
|
20
|
+
describe('reconcileMachineConfig — bounded poll per mode', () => {
|
|
21
|
+
const rebootish: ApplyMode = 'reboot';
|
|
22
|
+
const immediate: ApplyMode = 'no-reboot';
|
|
23
|
+
|
|
24
|
+
it("no-reboot converges via a single successful read-back ('read-back')", async () => {
|
|
25
|
+
const out = await run(
|
|
26
|
+
reconcileMachineConfig(props({ mode: immediate }), undefined, FAST_POLL),
|
|
27
|
+
dispatcher({}),
|
|
28
|
+
);
|
|
29
|
+
assert.equal(out.converged, 'read-back');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("reboot/auto tolerate a transient miss then converge ('accepted')", async () => {
|
|
33
|
+
let attempt = 0;
|
|
34
|
+
const out = await run(
|
|
35
|
+
reconcileMachineConfig(props({ mode: rebootish }), undefined, FAST_POLL),
|
|
36
|
+
dispatcher({
|
|
37
|
+
liveWrapper: () => {
|
|
38
|
+
attempt += 1;
|
|
39
|
+
return attempt < 2
|
|
40
|
+
? { exitCode: 1, stderr: 'node rebooting' }
|
|
41
|
+
: { stdout: wrapperFor(CONFIG_TEXT) };
|
|
42
|
+
},
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
assert.equal(out.converged, 'accepted');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('raises TalosConvergenceTimeout, never a silent pass, when the cap expires', async () => {
|
|
49
|
+
await assert.rejects(
|
|
50
|
+
run(
|
|
51
|
+
reconcileMachineConfig(props({ mode: rebootish }), undefined, FAST_POLL),
|
|
52
|
+
dispatcher({ liveWrapper: () => ({ exitCode: 1, stderr: 'still unreachable' }) }),
|
|
53
|
+
),
|
|
54
|
+
hasTag('TalosConvergenceTimeout'),
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-apply convergence for `Talos.MachineConfig` — split out of talos-machine-config.ts
|
|
3
|
+
* (2026-09-26, K-A3) once the bounded-poll logic needed its own header. See
|
|
4
|
+
* docs/plans/2026-09-26-talos-stack-first-boot.md, "Converged" and "Bootstrap" sections.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ NO SILENT `converged: false` HERE. A single attempt's own transport failure is swallowed
|
|
7
|
+
* ONLY inside the bounded loop below — a rebooting node's API is legitimately unreachable for a
|
|
8
|
+
* while — but the loop AS A WHOLE never swallows: running out of attempts always raises
|
|
9
|
+
* `TalosConvergenceTimeout`, never a quiet `false`. That distinction is read()'s job, not
|
|
10
|
+
* reconcile's — see talos-machine-config.ts.
|
|
11
|
+
* ⚠️ SHORT INTERVALS, A LOW CAP, NEVER ONE LONG SLEEP — the house timeout rule, and the specific
|
|
12
|
+
* reason talosctl's own `health` gate has a 20-minute `--wait-timeout`: a single blocking wait
|
|
13
|
+
* that long would hide whether THIS process is still alive from anything watching it.
|
|
14
|
+
*/
|
|
15
|
+
import * as Effect from 'effect/Effect';
|
|
16
|
+
import type { ApplyMode } from './resource.ts';
|
|
17
|
+
import { TalosConvergenceTimeout } from './talos-errors.ts';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Modes whose apply can trigger a full install + reboot. REASONED, Talos v1.13 CLI reference.
|
|
21
|
+
* ⛔ `'staged'` is deliberately absent — resource.ts's own header explains why (fix-first #2).
|
|
22
|
+
*/
|
|
23
|
+
const isRebootish = (mode: ApplyMode) => mode === 'reboot' || mode === 'auto';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* ★ TWO POLICIES, ONE PER MODE CLASS — REASONED, no live cluster to time this against yet.
|
|
27
|
+
* `no-reboot` never drops the API, so a handful of quick retries covers only ordinary
|
|
28
|
+
* config-propagation lag. `reboot`/`auto` can drop the API for a full boot cycle, so the cap is
|
|
29
|
+
* longer and the interval coarser. Both stay well inside the house rule above.
|
|
30
|
+
*/
|
|
31
|
+
export type PollPolicy = { readonly intervalMs: number; readonly maxAttempts: number };
|
|
32
|
+
|
|
33
|
+
const POLICY: { readonly immediate: PollPolicy; readonly rebootish: PollPolicy } = {
|
|
34
|
+
immediate: { intervalMs: 2_000, maxAttempts: 5 }, // ~10s cap
|
|
35
|
+
rebootish: { intervalMs: 5_000, maxAttempts: 24 }, // ~2m cap
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Run `check` until it reports `true` or `maxAttempts` is spent, sleeping `intervalMs` between
|
|
40
|
+
* attempts. A per-attempt failure counts as "not yet" and does not end the loop early.
|
|
41
|
+
*/
|
|
42
|
+
const pollUntilMatch = <E, R>(
|
|
43
|
+
check: Effect.Effect<boolean, E, R>,
|
|
44
|
+
policy: { readonly intervalMs: number; readonly maxAttempts: number },
|
|
45
|
+
): Effect.Effect<boolean, never, R> =>
|
|
46
|
+
Effect.gen(function* () {
|
|
47
|
+
for (let attempt = 1; attempt <= policy.maxAttempts; attempt++) {
|
|
48
|
+
const matched = yield* check.pipe(Effect.orElseSucceed(() => false));
|
|
49
|
+
if (matched) return true;
|
|
50
|
+
if (attempt < policy.maxAttempts) yield* Effect.sleep(policy.intervalMs);
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Confirm convergence per `mode`'s policy, or fail with `TalosConvergenceTimeout`.
|
|
57
|
+
*
|
|
58
|
+
* Returns which CLAIM was proven, never a boolean — `'read-back'` for the immediate policy
|
|
59
|
+
* (no-reboot), `'accepted'` for the reboot-tolerant one (reboot/auto): state records which kind of
|
|
60
|
+
* confirmation actually happened, per the design doc's `converged` field.
|
|
61
|
+
*
|
|
62
|
+
* ⚠️ `policies` IS A TEST SEAM, NOT A PROP. `talos-machine-config.ts` never passes it, so
|
|
63
|
+
* production always runs the REASONED defaults above; tests pass tiny intervals so a bounded
|
|
64
|
+
* poll test finishes in milliseconds instead of the real ~10s/~2m caps.
|
|
65
|
+
*/
|
|
66
|
+
export const confirmConverged = <E, R>(
|
|
67
|
+
node: string,
|
|
68
|
+
mode: ApplyMode,
|
|
69
|
+
check: Effect.Effect<boolean, E, R>,
|
|
70
|
+
policies: { readonly immediate: PollPolicy; readonly rebootish: PollPolicy } = POLICY,
|
|
71
|
+
): Effect.Effect<'read-back' | 'accepted', TalosConvergenceTimeout, R> =>
|
|
72
|
+
Effect.gen(function* () {
|
|
73
|
+
const rebootish = isRebootish(mode);
|
|
74
|
+
const policy = rebootish ? policies.rebootish : policies.immediate;
|
|
75
|
+
const matched = yield* pollUntilMatch(check, policy);
|
|
76
|
+
if (!matched) {
|
|
77
|
+
return yield* Effect.fail(
|
|
78
|
+
new TalosConvergenceTimeout({ attempts: policy.maxAttempts, mode, node }),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return rebootish ? 'accepted' : 'read-back';
|
|
82
|
+
});
|