@homeflare/alchemy 0.39.0 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -8
- package/dist/caddy/index.js.map +2 -2
- package/dist/ceph/auth-entity.d.ts +34 -0
- package/dist/ceph/auth-entity.d.ts.map +1 -0
- package/dist/ceph/ceph-argv-config.d.ts +10 -0
- package/dist/ceph/ceph-argv-config.d.ts.map +1 -0
- package/dist/ceph/ceph-argv.d.ts +19 -0
- package/dist/ceph/ceph-argv.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-form.d.ts +54 -0
- package/dist/ceph/ceph-auth-form.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-parse.d.ts +56 -0
- package/dist/ceph/ceph-auth-parse.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-reconcile.d.ts +35 -0
- package/dist/ceph/ceph-auth-reconcile.d.ts.map +1 -0
- package/dist/ceph/ceph-config-options.d.ts +23 -0
- package/dist/ceph/ceph-config-options.d.ts.map +1 -0
- package/dist/ceph/ceph-key-leak-assert.d.ts +25 -0
- package/dist/ceph/ceph-key-leak-assert.d.ts.map +1 -0
- package/dist/ceph/ceph-transport.d.ts +37 -0
- package/dist/ceph/ceph-transport.d.ts.map +1 -0
- package/dist/ceph/fake-ceph-dial.d.ts +35 -0
- package/dist/ceph/fake-ceph-dial.d.ts.map +1 -0
- package/dist/ceph/index.d.ts +13 -0
- package/dist/ceph/index.d.ts.map +1 -0
- package/dist/ceph/index.js +1610 -0
- package/dist/ceph/index.js.map +38 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/fake-runner.d.ts.map +1 -1
- package/dist/launchd/index.d.ts +1 -1
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +7 -1
- package/dist/launchd/index.js.map +5 -5
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +21 -0
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-runner.d.ts.map +1 -1
- package/dist/linux/directory-lifecycle.d.ts.map +1 -1
- package/dist/linux/fake-linux-host.d.ts.map +1 -1
- package/dist/linux/index.d.ts +1 -1
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +16 -4
- package/dist/linux/index.js.map +7 -7
- package/dist/linux/ssh-runner.d.ts.map +1 -1
- package/dist/linux/sudo-runner.d.ts.map +1 -1
- package/dist/opnsense/alias-form.d.ts +14 -1
- package/dist/opnsense/alias-form.d.ts.map +1 -1
- package/dist/opnsense/alias.d.ts +1 -1
- package/dist/opnsense/alias.d.ts.map +1 -1
- package/dist/opnsense/group-form.d.ts +8 -2
- package/dist/opnsense/group-form.d.ts.map +1 -1
- package/dist/opnsense/group.d.ts +1 -1
- package/dist/opnsense/group.d.ts.map +1 -1
- package/dist/opnsense/index.js +15 -7
- package/dist/opnsense/index.js.map +7 -7
- package/dist/opnsense/wire.d.ts +42 -12
- package/dist/opnsense/wire.d.ts.map +1 -1
- package/dist/proxmox/index.js +62 -39
- package/dist/proxmox/index.js.map +8 -7
- package/dist/proxmox/qemu-volume.d.ts +12 -2
- package/dist/proxmox/qemu-volume.d.ts.map +1 -1
- package/dist/proxmox/storage-form.d.ts +28 -3
- package/dist/proxmox/storage-form.d.ts.map +1 -1
- package/dist/proxmox/storage-plugin-options.d.ts +2 -0
- package/dist/proxmox/storage-plugin-options.d.ts.map +1 -0
- package/dist/proxmox/storage-wire.d.ts +28 -1
- package/dist/proxmox/storage-wire.d.ts.map +1 -1
- package/dist/proxmox/storage.d.ts +6 -1
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/release/index.d.ts +1 -1
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js.map +2 -2
- package/dist/talos/credentials-write.d.ts +46 -0
- package/dist/talos/credentials-write.d.ts.map +1 -0
- package/dist/talos/credentials.d.ts +51 -9
- package/dist/talos/credentials.d.ts.map +1 -1
- package/dist/talos/fake-process.d.ts +56 -0
- package/dist/talos/fake-process.d.ts.map +1 -0
- package/dist/talos/index.d.ts +19 -4
- package/dist/talos/index.d.ts.map +1 -1
- package/dist/talos/index.js +357 -173
- package/dist/talos/index.js.map +16 -10
- package/dist/talos/kubeconfig-attrs.d.ts +4 -0
- package/dist/talos/kubeconfig-attrs.d.ts.map +1 -0
- package/dist/talos/kubeconfig.d.ts +41 -23
- package/dist/talos/kubeconfig.d.ts.map +1 -1
- package/dist/talos/machine-config-fixtures.d.ts +60 -0
- package/dist/talos/machine-config-fixtures.d.ts.map +1 -0
- package/dist/talos/machine-config-poll.d.ts +43 -0
- package/dist/talos/machine-config-poll.d.ts.map +1 -0
- package/dist/talos/machine-config-read.d.ts +49 -0
- package/dist/talos/machine-config-read.d.ts.map +1 -0
- package/dist/talos/resource.d.ts +18 -0
- package/dist/talos/resource.d.ts.map +1 -1
- package/dist/talos/talos-bootstrap-etcd.d.ts +38 -0
- package/dist/talos/talos-bootstrap-etcd.d.ts.map +1 -0
- package/dist/talos/talos-bootstrap.d.ts +87 -0
- package/dist/talos/talos-bootstrap.d.ts.map +1 -1
- package/dist/talos/talos-cluster-health.d.ts +43 -1
- package/dist/talos/talos-cluster-health.d.ts.map +1 -1
- package/dist/talos/talos-errors.d.ts +68 -0
- package/dist/talos/talos-errors.d.ts.map +1 -0
- package/dist/talos/talos-machine-config.d.ts +51 -15
- package/dist/talos/talos-machine-config.d.ts.map +1 -1
- package/dist/talos/talosctl.d.ts +7 -0
- package/dist/talos/talosctl.d.ts.map +1 -1
- package/dist/talos/values.d.ts +23 -3
- package/dist/talos/values.d.ts.map +1 -1
- package/dist/unifi/acl-rule-drift.d.ts +22 -0
- package/dist/unifi/acl-rule-drift.d.ts.map +1 -0
- package/dist/unifi/acl-rule-form.d.ts +90 -0
- package/dist/unifi/acl-rule-form.d.ts.map +1 -0
- package/dist/unifi/acl-rule-ordering-form.d.ts +49 -0
- package/dist/unifi/acl-rule-ordering-form.d.ts.map +1 -0
- package/dist/unifi/acl-rule-ordering.d.ts +54 -0
- package/dist/unifi/acl-rule-ordering.d.ts.map +1 -0
- package/dist/unifi/acl-rule.d.ts +49 -0
- package/dist/unifi/acl-rule.d.ts.map +1 -0
- package/dist/unifi/dns-policy-drift.d.ts +21 -0
- package/dist/unifi/dns-policy-drift.d.ts.map +1 -0
- package/dist/unifi/dns-policy-form.d.ts +97 -0
- package/dist/unifi/dns-policy-form.d.ts.map +1 -0
- package/dist/unifi/dns-policy.d.ts +55 -0
- package/dist/unifi/dns-policy.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-drift.d.ts +26 -0
- package/dist/unifi/firewall-policy-drift.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-form.d.ts +102 -0
- package/dist/unifi/firewall-policy-form.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-ordering-form.d.ts +66 -0
- package/dist/unifi/firewall-policy-ordering-form.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-ordering.d.ts +57 -0
- package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -0
- package/dist/unifi/firewall-policy.d.ts +57 -0
- package/dist/unifi/firewall-policy.d.ts.map +1 -0
- package/dist/unifi/index.d.ts +7 -0
- package/dist/unifi/index.d.ts.map +1 -1
- package/dist/unifi/index.js +588 -3
- package/dist/unifi/index.js.map +24 -4
- package/dist/unifi/paginate.d.ts +0 -12
- package/dist/unifi/paginate.d.ts.map +1 -1
- package/dist/unifi/providers.d.ts +1 -1
- package/dist/unifi/providers.d.ts.map +1 -1
- package/dist/unifi/scan-source.d.ts +11 -0
- package/dist/unifi/scan-source.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list-drift.d.ts +22 -0
- package/dist/unifi/traffic-matching-list-drift.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list-form.d.ts +56 -0
- package/dist/unifi/traffic-matching-list-form.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list.d.ts +51 -0
- package/dist/unifi/traffic-matching-list.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast-drift.d.ts +22 -0
- package/dist/unifi/wifi-broadcast-drift.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast-form.d.ts +127 -0
- package/dist/unifi/wifi-broadcast-form.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast.d.ts +73 -0
- package/dist/unifi/wifi-broadcast.d.ts.map +1 -0
- package/docs/distilled-interim.md +7 -0
- package/docs/launchd.md +5 -3
- package/docs/provider-standard.md +5 -1
- package/docs/talos-argocd-dogfood.md +160 -0
- package/docs/unifi-acl-rule.md +122 -0
- package/docs/unifi-dns-policy.md +75 -0
- package/docs/unifi-firewall-policy.md +109 -0
- package/docs/unifi-traffic-matching-list.md +82 -0
- package/docs/unifi-wifi.md +120 -0
- package/docs/unifi.md +10 -5
- package/package.json +7 -3
- package/src/ceph/auth-entity.ts +74 -0
- package/src/ceph/ceph-argv-config.ts +78 -0
- package/src/ceph/ceph-argv.test.ts +226 -0
- package/src/ceph/ceph-argv.ts +189 -0
- package/src/ceph/ceph-auth-form.test.ts +103 -0
- package/src/ceph/ceph-auth-form.ts +107 -0
- package/src/ceph/ceph-auth-parse.test.ts +155 -0
- package/src/ceph/ceph-auth-parse.ts +121 -0
- package/src/ceph/ceph-auth-reconcile-secrecy.test.ts +82 -0
- package/src/ceph/ceph-auth-reconcile.test.ts +233 -0
- package/src/ceph/ceph-auth-reconcile.ts +119 -0
- package/src/ceph/ceph-config-options.test.ts +44 -0
- package/src/ceph/ceph-config-options.ts +36 -0
- package/src/ceph/ceph-key-leak-assert.ts +35 -0
- package/src/ceph/ceph-no-output-logging.test.ts +52 -0
- package/src/ceph/ceph-transport.test.ts +125 -0
- package/src/ceph/ceph-transport.ts +117 -0
- package/src/ceph/fake-ceph-dial.ts +55 -0
- package/src/ceph/index.ts +12 -0
- package/src/index.ts +1 -0
- package/src/launchd/fake-runner.ts +2 -0
- package/src/launchd/index.ts +8 -1
- package/src/launchd/local-runner.ts +16 -1
- package/src/launchd/runner.ts +22 -0
- package/src/launchd/sudo-runner.ts +3 -0
- package/src/linux/directory-lifecycle.ts +18 -3
- package/src/linux/directory-sudo.test.ts +59 -0
- package/src/linux/directory.test.ts +34 -8
- package/src/linux/fake-linux-host.ts +2 -0
- package/src/linux/index.ts +1 -0
- package/src/linux/ssh-runner.ts +4 -0
- package/src/linux/sudo-runner.ts +3 -0
- package/src/opnsense/alias-form.test.ts +24 -12
- package/src/opnsense/alias-form.ts +20 -7
- package/src/opnsense/alias.test.ts +13 -6
- package/src/opnsense/alias.ts +16 -10
- package/src/opnsense/group-form.test.ts +12 -8
- package/src/opnsense/group-form.ts +13 -4
- package/src/opnsense/group.test.ts +18 -7
- package/src/opnsense/group.ts +10 -3
- package/src/opnsense/wire.ts +57 -12
- package/src/proxmox/qemu-volume.test.ts +153 -4
- package/src/proxmox/qemu-volume.ts +71 -14
- package/src/proxmox/storage-form.ts +48 -8
- package/src/proxmox/storage-plugin-options.ts +45 -0
- package/src/proxmox/storage-update.test.ts +183 -0
- package/src/proxmox/storage-wire.ts +54 -7
- package/src/proxmox/storage.ts +18 -6
- package/src/release/index.ts +1 -0
- package/src/talos/credentials-write.test.ts +81 -0
- package/src/talos/credentials-write.ts +95 -0
- package/src/talos/credentials.test.ts +157 -0
- package/src/talos/credentials.ts +160 -90
- package/src/talos/fake-process.ts +94 -0
- package/src/talos/index.ts +39 -4
- package/src/talos/kubeconfig-attrs.ts +55 -0
- package/src/talos/kubeconfig.test.ts +256 -0
- package/src/talos/kubeconfig.ts +170 -90
- package/src/talos/machine-config-fixtures.ts +86 -0
- package/src/talos/machine-config-poll.test.ts +57 -0
- package/src/talos/machine-config-poll.ts +82 -0
- package/src/talos/machine-config-read.ts +118 -0
- package/src/talos/resource.ts +19 -0
- package/src/talos/secrets-gate.test.ts +63 -0
- package/src/talos/talos-bootstrap-etcd.ts +71 -0
- package/src/talos/talos-bootstrap.test.ts +218 -0
- package/src/talos/talos-bootstrap.ts +151 -45
- package/src/talos/talos-cluster-health.test.ts +143 -0
- package/src/talos/talos-cluster-health.ts +91 -28
- package/src/talos/talos-errors.ts +85 -0
- package/src/talos/talos-machine-config.test.ts +189 -0
- package/src/talos/talos-machine-config.ts +102 -88
- package/src/talos/talosctl.ts +8 -0
- package/src/talos/values.test.ts +34 -6
- package/src/talos/values.ts +41 -7
- package/src/unifi/acl-rule-drift.ts +57 -0
- package/src/unifi/acl-rule-form.ts +135 -0
- package/src/unifi/acl-rule-ordering-form.ts +79 -0
- package/src/unifi/acl-rule-ordering.test.ts +141 -0
- package/src/unifi/acl-rule-ordering.ts +70 -0
- package/src/unifi/acl-rule.test.ts +183 -0
- package/src/unifi/acl-rule.ts +61 -0
- package/src/unifi/discriminated-filter-decode.test.ts +134 -0
- package/src/unifi/dns-policy-drift.ts +48 -0
- package/src/unifi/dns-policy-form.ts +142 -0
- package/src/unifi/dns-policy.test.ts +154 -0
- package/src/unifi/dns-policy.ts +67 -0
- package/src/unifi/firewall-policy-drift.test.ts +151 -0
- package/src/unifi/firewall-policy-drift.ts +55 -0
- package/src/unifi/firewall-policy-filter-roundtrip.test.ts +143 -0
- package/src/unifi/firewall-policy-form.ts +146 -0
- package/src/unifi/firewall-policy-ordering-form.ts +111 -0
- package/src/unifi/firewall-policy-ordering.test.ts +185 -0
- package/src/unifi/firewall-policy-ordering.ts +87 -0
- package/src/unifi/firewall-policy.test.ts +183 -0
- package/src/unifi/firewall-policy.ts +73 -0
- package/src/unifi/index.ts +63 -0
- package/src/unifi/paginate.test.ts +24 -0
- package/src/unifi/paginate.ts +15 -3
- package/src/unifi/providers.ts +19 -1
- package/src/unifi/scan-source.ts +64 -0
- package/src/unifi/traffic-matching-list-drift.ts +40 -0
- package/src/unifi/traffic-matching-list-form.ts +77 -0
- package/src/unifi/traffic-matching-list.test.ts +202 -0
- package/src/unifi/traffic-matching-list.ts +78 -0
- package/src/unifi/wifi-broadcast-details-call.test.ts +92 -0
- package/src/unifi/wifi-broadcast-drift.ts +69 -0
- package/src/unifi/wifi-broadcast-form.ts +211 -0
- package/src/unifi/wifi-broadcast-secrets.test.ts +145 -0
- package/src/unifi/wifi-broadcast.test.ts +155 -0
- package/src/unifi/wifi-broadcast.ts +90 -0
- package/src/unifi/write-op-reference.test.ts +8 -54
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Talos.Kubeconfig` landing in OpenBao, written once at bring-up (K-talos-first-boot). Fully
|
|
3
|
+
* offline: fake-process.ts fakes `bao`/`talosctl`; the fake `talosctl kubeconfig` handler writes a
|
|
4
|
+
* fixture file to the temp path it was given, standing in for what the real binary would do.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ FIXTURES ONLY — see values.test.ts's own header for why the base64 fields are built from
|
|
7
|
+
* plain words at run time rather than written out literally.
|
|
8
|
+
*/
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { describe, it } from 'node:test';
|
|
12
|
+
import * as Effect from 'effect/Effect';
|
|
13
|
+
import * as ChildProcessSpawner from 'effect/unstable/process/ChildProcessSpawner';
|
|
14
|
+
import { type FakeCall, fakeSpawner } from './fake-process.ts';
|
|
15
|
+
import {
|
|
16
|
+
type KubeconfigAttributes,
|
|
17
|
+
type KubeconfigProps,
|
|
18
|
+
diffKubeconfig as diff,
|
|
19
|
+
readKubeconfig as read,
|
|
20
|
+
reconcileKubeconfig as reconcile,
|
|
21
|
+
} from './kubeconfig.ts';
|
|
22
|
+
|
|
23
|
+
const b64 = (words: string) => Buffer.from(words).toString('base64');
|
|
24
|
+
|
|
25
|
+
const fixtureKubeconfig = (marker: string) => `
|
|
26
|
+
apiVersion: v1
|
|
27
|
+
kind: Config
|
|
28
|
+
clusters:
|
|
29
|
+
- name: hf-c1
|
|
30
|
+
cluster:
|
|
31
|
+
server: https://192.0.2.50:6443
|
|
32
|
+
certificate-authority-data: ${b64(`not a ca ${marker}`)}
|
|
33
|
+
contexts:
|
|
34
|
+
- name: admin@hf-c1
|
|
35
|
+
context: { cluster: hf-c1, user: admin@hf-c1 }
|
|
36
|
+
users:
|
|
37
|
+
- name: admin@hf-c1
|
|
38
|
+
user:
|
|
39
|
+
client-certificate-data: ${b64(`not a certificate ${marker}`)}
|
|
40
|
+
client-key-data: ${b64(`not a key ${marker}`)}
|
|
41
|
+
current-context: admin@hf-c1
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
const TARGET = { cluster: 'c1', mount: 'talos-c1' };
|
|
45
|
+
const props = (): KubeconfigProps => ({
|
|
46
|
+
context: 'admin@hf-c1',
|
|
47
|
+
node: '198.51.100.10',
|
|
48
|
+
target: TARGET,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const run = <A, E>(
|
|
52
|
+
effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner>,
|
|
53
|
+
handler: (c: FakeCall) => { stdout?: string; stderr?: string; exitCode?: number },
|
|
54
|
+
calls: FakeCall[] = [],
|
|
55
|
+
) =>
|
|
56
|
+
Effect.runPromise(
|
|
57
|
+
Effect.provideService(
|
|
58
|
+
effect,
|
|
59
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
60
|
+
fakeSpawner(handler, calls),
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
describe('reconcile — CREATE writes talosctl kubeconfig output into the vault, never a host path', () => {
|
|
65
|
+
it('mints the temp file, writes its content to vault via stdin, cleans up the temp file', async () => {
|
|
66
|
+
const calls: FakeCall[] = [];
|
|
67
|
+
let outPath = '';
|
|
68
|
+
const handler = (call: FakeCall) => {
|
|
69
|
+
if (call.command === 'bao' && call.args[1] === 'get') {
|
|
70
|
+
return { stdout: JSON.stringify({ data: { data: { talosconfig: 'fake-talosconfig' } } }) };
|
|
71
|
+
}
|
|
72
|
+
if (call.command === 'bao' && call.args[1] === 'put') return {};
|
|
73
|
+
if (call.command === 'talosctl' && call.args[0] === 'kubeconfig') {
|
|
74
|
+
outPath = call.args[1] ?? '';
|
|
75
|
+
writeFileSync(outPath, fixtureKubeconfig('create'));
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
throw new Error(`unexpected call ${JSON.stringify(call)}`);
|
|
79
|
+
};
|
|
80
|
+
const result = (await run(
|
|
81
|
+
reconcile(props(), undefined),
|
|
82
|
+
handler,
|
|
83
|
+
calls,
|
|
84
|
+
)) as KubeconfigAttributes;
|
|
85
|
+
|
|
86
|
+
assert.equal(result.endpoint, 'https://192.0.2.50:6443');
|
|
87
|
+
assert.equal(result.context, 'admin@hf-c1');
|
|
88
|
+
assert.equal(result.connection.auth.kind, 'kubeconfig');
|
|
89
|
+
// ⛔ I2 (LAND red team): NEVER undefined — an undefined `path` here would let the stock
|
|
90
|
+
// KubeConfigAdapter fall back to $KUBECONFIG/~/.kube/config. It must be a path that can never
|
|
91
|
+
// resolve, so a consumer that isn't yet wired to mintKubeconfig fails loudly instead of quietly
|
|
92
|
+
// reaching the operator's own cluster.
|
|
93
|
+
const authPath = (result.connection.auth as { path?: string }).path;
|
|
94
|
+
assert.ok(typeof authPath === 'string' && authPath.length > 0);
|
|
95
|
+
|
|
96
|
+
const put = calls.find((c) => c.command === 'bao' && c.args[1] === 'put');
|
|
97
|
+
assert.ok(put);
|
|
98
|
+
// ⛔ C2 (LAND red team): `=-`, never `=@-` — see credentials-write.ts's own header.
|
|
99
|
+
assert.deepEqual(put.args, ['kv', 'put', 'talos-c1/kubeconfig', 'kubeconfig=-']);
|
|
100
|
+
assert.equal(put.stdin, fixtureKubeconfig('create'));
|
|
101
|
+
assert.ok(!put.args.some((a) => a.includes('not a key')), 'never in argv');
|
|
102
|
+
|
|
103
|
+
assert.equal(existsSync(outPath), false, 'the captured temp file must be gone after reconcile');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('reconcile — write-once: a second reconcile never re-runs talosctl kubeconfig', () => {
|
|
108
|
+
it('confirms from the vault copy instead of minting a new admin cert', async () => {
|
|
109
|
+
const calls: FakeCall[] = [];
|
|
110
|
+
const priorOutput: KubeconfigAttributes = {
|
|
111
|
+
certificateAuthorityFingerprint: 'x',
|
|
112
|
+
clientCertificateFingerprint: 'x',
|
|
113
|
+
connection: { auth: { context: 'admin@hf-c1', kind: 'kubeconfig' } },
|
|
114
|
+
context: 'admin@hf-c1',
|
|
115
|
+
credentialGeneration: 'x',
|
|
116
|
+
endpoint: 'https://192.0.2.50:6443',
|
|
117
|
+
};
|
|
118
|
+
const handler = (call: FakeCall) => {
|
|
119
|
+
if (call.command === 'bao' && call.args[1] === 'get') {
|
|
120
|
+
return {
|
|
121
|
+
stdout: JSON.stringify({ data: { data: { config: fixtureKubeconfig('confirm') } } }),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
throw new Error(`unexpected call ${JSON.stringify(call)}`);
|
|
125
|
+
};
|
|
126
|
+
const result = (await run(
|
|
127
|
+
reconcile(props(), priorOutput),
|
|
128
|
+
handler,
|
|
129
|
+
calls,
|
|
130
|
+
)) as KubeconfigAttributes;
|
|
131
|
+
|
|
132
|
+
assert.equal(result.endpoint, 'https://192.0.2.50:6443');
|
|
133
|
+
assert.ok(
|
|
134
|
+
calls.every(
|
|
135
|
+
(c) => !(c.command === 'talosctl') && !(c.command === 'bao' && c.args[1] === 'put'),
|
|
136
|
+
),
|
|
137
|
+
'a confirm-only reconcile never spawns talosctl or writes the vault again',
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('read — C1 fix: undefined means genuinely absent, never "some read failed"', () => {
|
|
143
|
+
it('returns undefined (never a defined empty object) when the vault key was never written', async () => {
|
|
144
|
+
// ⛔ MEASURED against OpenBao v2.6.2: this exit code + stderr text is exactly what `bao kv get`
|
|
145
|
+
// prints for a genuinely unwritten KV-v2 key — see kubeconfig.ts's `isVaultKeyAbsent` header.
|
|
146
|
+
const result = await run(read(props()), () => ({
|
|
147
|
+
exitCode: 2,
|
|
148
|
+
stderr: 'No value found at talos-c1/data/kubeconfig',
|
|
149
|
+
}));
|
|
150
|
+
assert.equal(result, undefined);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('propagates a genuine vault/transport failure instead of reading it as absence', async () => {
|
|
154
|
+
await assert.rejects(
|
|
155
|
+
run(read(props()), () => ({ exitCode: 2, stderr: 'permission denied' })),
|
|
156
|
+
(error: unknown) => error instanceof Error && error.message.includes('permission denied'),
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('read → reconcile chained the way the Alchemy engine drives a cold start (C1 regression)', () => {
|
|
162
|
+
it('a cold-start read (undefined) followed by reconcile(undefined) actually creates', async () => {
|
|
163
|
+
// ⛔ Alchemy beta.79 Plan.ts:1303-1352 always calls `read` first when there is no state row; a
|
|
164
|
+
// DEFINED result there is adopted and forces `update`, so this is the exact sequence that made
|
|
165
|
+
// Talos.Kubeconfig un-creatable before the C1 fix — see kubeconfig.ts's own `readKubeconfig`
|
|
166
|
+
// header.
|
|
167
|
+
const calls: FakeCall[] = [];
|
|
168
|
+
const coldHandler = () => ({
|
|
169
|
+
exitCode: 2,
|
|
170
|
+
stderr: 'No value found at talos-c1/data/kubeconfig',
|
|
171
|
+
});
|
|
172
|
+
const readResult = await run(read(props()), coldHandler, calls);
|
|
173
|
+
assert.equal(
|
|
174
|
+
readResult,
|
|
175
|
+
undefined,
|
|
176
|
+
"the engine's adoption probe must see a genuine cold start",
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const createHandler = (call: FakeCall) => {
|
|
180
|
+
if (call.command === 'bao' && call.args[1] === 'get') {
|
|
181
|
+
// ★ mintTalosconfig reads the `talosconfig` key first — only the `kubeconfig` key itself
|
|
182
|
+
// is genuinely absent at a cold start.
|
|
183
|
+
return call.args[3]?.endsWith('/kubeconfig')
|
|
184
|
+
? coldHandler()
|
|
185
|
+
: { stdout: JSON.stringify({ data: { data: { talosconfig: 'fake-talosconfig' } } }) };
|
|
186
|
+
}
|
|
187
|
+
if (call.command === 'bao' && call.args[1] === 'put') return {};
|
|
188
|
+
if (call.command === 'talosctl' && call.args[0] === 'kubeconfig') {
|
|
189
|
+
writeFileSync(call.args[1] ?? '', fixtureKubeconfig('engine-order'));
|
|
190
|
+
return {};
|
|
191
|
+
}
|
|
192
|
+
throw new Error(`unexpected call ${JSON.stringify(call)}`);
|
|
193
|
+
};
|
|
194
|
+
const created = (await run(
|
|
195
|
+
reconcile(props(), readResult),
|
|
196
|
+
createHandler,
|
|
197
|
+
)) as KubeconfigAttributes;
|
|
198
|
+
assert.equal(created.endpoint, 'https://192.0.2.50:6443');
|
|
199
|
+
assert.notEqual(created.credentialGeneration, '');
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('reconcile — belt-and-suspenders: a defined-but-empty output is treated as absent (C1)', () => {
|
|
204
|
+
it('a pre-fix-shaped empty output still runs CREATE instead of failing to confirm', async () => {
|
|
205
|
+
const staleEmptyOutput: KubeconfigAttributes = {
|
|
206
|
+
certificateAuthorityFingerprint: '',
|
|
207
|
+
clientCertificateFingerprint: '',
|
|
208
|
+
connection: { auth: { context: 'admin@hf-c1', kind: 'kubeconfig' } },
|
|
209
|
+
context: 'admin@hf-c1',
|
|
210
|
+
credentialGeneration: '',
|
|
211
|
+
endpoint: '',
|
|
212
|
+
};
|
|
213
|
+
const handler = (call: FakeCall) => {
|
|
214
|
+
if (call.command === 'bao' && call.args[1] === 'get') {
|
|
215
|
+
return { stdout: JSON.stringify({ data: { data: { talosconfig: 'x' } } }) };
|
|
216
|
+
}
|
|
217
|
+
if (call.command === 'bao' && call.args[1] === 'put') return {};
|
|
218
|
+
if (call.command === 'talosctl' && call.args[0] === 'kubeconfig') {
|
|
219
|
+
writeFileSync(call.args[1] ?? '', fixtureKubeconfig('stale-empty'));
|
|
220
|
+
return {};
|
|
221
|
+
}
|
|
222
|
+
throw new Error(`unexpected call ${JSON.stringify(call)}`);
|
|
223
|
+
};
|
|
224
|
+
const result = (await run(
|
|
225
|
+
reconcile(props(), staleEmptyOutput),
|
|
226
|
+
handler,
|
|
227
|
+
)) as KubeconfigAttributes;
|
|
228
|
+
assert.notEqual(result.credentialGeneration, '');
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('diff', () => {
|
|
233
|
+
it('plans noop when the vault copy matches state', async () => {
|
|
234
|
+
const output: KubeconfigAttributes = (await run(
|
|
235
|
+
reconcile(props(), undefined),
|
|
236
|
+
(call: FakeCall) => {
|
|
237
|
+
if (call.command === 'bao' && call.args[1] === 'get') {
|
|
238
|
+
return { stdout: JSON.stringify({ data: { data: { talosconfig: 'x' } } }) };
|
|
239
|
+
}
|
|
240
|
+
if (call.command === 'bao' && call.args[1] === 'put') return {};
|
|
241
|
+
if (call.command === 'talosctl') {
|
|
242
|
+
writeFileSync(call.args[1] ?? '', fixtureKubeconfig('diff'));
|
|
243
|
+
return {};
|
|
244
|
+
}
|
|
245
|
+
throw new Error('unexpected');
|
|
246
|
+
},
|
|
247
|
+
)) as KubeconfigAttributes;
|
|
248
|
+
|
|
249
|
+
const result = await run(diff(props(), output), (call: FakeCall) =>
|
|
250
|
+
call.args[1] === 'get'
|
|
251
|
+
? { stdout: JSON.stringify({ data: { data: { config: fixtureKubeconfig('diff') } } }) }
|
|
252
|
+
: {},
|
|
253
|
+
);
|
|
254
|
+
assert.equal(result?.action, 'noop');
|
|
255
|
+
});
|
|
256
|
+
});
|
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;
|