@homeflare/alchemy 0.39.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -8
- package/dist/ceph/auth-entity.d.ts +34 -0
- package/dist/ceph/auth-entity.d.ts.map +1 -0
- package/dist/ceph/ceph-argv-config.d.ts +10 -0
- package/dist/ceph/ceph-argv-config.d.ts.map +1 -0
- package/dist/ceph/ceph-argv.d.ts +19 -0
- package/dist/ceph/ceph-argv.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-form.d.ts +54 -0
- package/dist/ceph/ceph-auth-form.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-parse.d.ts +56 -0
- package/dist/ceph/ceph-auth-parse.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-reconcile.d.ts +35 -0
- package/dist/ceph/ceph-auth-reconcile.d.ts.map +1 -0
- package/dist/ceph/ceph-config-options.d.ts +23 -0
- package/dist/ceph/ceph-config-options.d.ts.map +1 -0
- package/dist/ceph/ceph-key-leak-assert.d.ts +25 -0
- package/dist/ceph/ceph-key-leak-assert.d.ts.map +1 -0
- package/dist/ceph/ceph-transport.d.ts +37 -0
- package/dist/ceph/ceph-transport.d.ts.map +1 -0
- package/dist/ceph/fake-ceph-dial.d.ts +35 -0
- package/dist/ceph/fake-ceph-dial.d.ts.map +1 -0
- package/dist/ceph/index.d.ts +13 -0
- package/dist/ceph/index.d.ts.map +1 -0
- package/dist/ceph/index.js +1609 -0
- package/dist/ceph/index.js.map +38 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +15 -6
- package/dist/proxmox/index.js.map +3 -3
- package/dist/proxmox/qemu-volume.d.ts +12 -2
- package/dist/proxmox/qemu-volume.d.ts.map +1 -1
- package/dist/talos/credentials-write.d.ts +46 -0
- package/dist/talos/credentials-write.d.ts.map +1 -0
- package/dist/talos/credentials.d.ts +51 -9
- package/dist/talos/credentials.d.ts.map +1 -1
- package/dist/talos/fake-process.d.ts +56 -0
- package/dist/talos/fake-process.d.ts.map +1 -0
- package/dist/talos/index.d.ts +19 -4
- package/dist/talos/index.d.ts.map +1 -1
- package/dist/talos/index.js +357 -173
- package/dist/talos/index.js.map +16 -10
- package/dist/talos/kubeconfig-attrs.d.ts +4 -0
- package/dist/talos/kubeconfig-attrs.d.ts.map +1 -0
- package/dist/talos/kubeconfig.d.ts +41 -23
- package/dist/talos/kubeconfig.d.ts.map +1 -1
- package/dist/talos/machine-config-fixtures.d.ts +60 -0
- package/dist/talos/machine-config-fixtures.d.ts.map +1 -0
- package/dist/talos/machine-config-poll.d.ts +43 -0
- package/dist/talos/machine-config-poll.d.ts.map +1 -0
- package/dist/talos/machine-config-read.d.ts +49 -0
- package/dist/talos/machine-config-read.d.ts.map +1 -0
- package/dist/talos/resource.d.ts +18 -0
- package/dist/talos/resource.d.ts.map +1 -1
- package/dist/talos/talos-bootstrap-etcd.d.ts +38 -0
- package/dist/talos/talos-bootstrap-etcd.d.ts.map +1 -0
- package/dist/talos/talos-bootstrap.d.ts +87 -0
- package/dist/talos/talos-bootstrap.d.ts.map +1 -1
- package/dist/talos/talos-cluster-health.d.ts +43 -1
- package/dist/talos/talos-cluster-health.d.ts.map +1 -1
- package/dist/talos/talos-errors.d.ts +68 -0
- package/dist/talos/talos-errors.d.ts.map +1 -0
- package/dist/talos/talos-machine-config.d.ts +51 -15
- package/dist/talos/talos-machine-config.d.ts.map +1 -1
- package/dist/talos/talosctl.d.ts +7 -0
- package/dist/talos/talosctl.d.ts.map +1 -1
- package/dist/talos/values.d.ts +23 -3
- package/dist/talos/values.d.ts.map +1 -1
- package/dist/unifi/acl-rule-drift.d.ts +22 -0
- package/dist/unifi/acl-rule-drift.d.ts.map +1 -0
- package/dist/unifi/acl-rule-form.d.ts +90 -0
- package/dist/unifi/acl-rule-form.d.ts.map +1 -0
- package/dist/unifi/acl-rule-ordering-form.d.ts +49 -0
- package/dist/unifi/acl-rule-ordering-form.d.ts.map +1 -0
- package/dist/unifi/acl-rule-ordering.d.ts +54 -0
- package/dist/unifi/acl-rule-ordering.d.ts.map +1 -0
- package/dist/unifi/acl-rule.d.ts +49 -0
- package/dist/unifi/acl-rule.d.ts.map +1 -0
- package/dist/unifi/dns-policy-drift.d.ts +21 -0
- package/dist/unifi/dns-policy-drift.d.ts.map +1 -0
- package/dist/unifi/dns-policy-form.d.ts +97 -0
- package/dist/unifi/dns-policy-form.d.ts.map +1 -0
- package/dist/unifi/dns-policy.d.ts +55 -0
- package/dist/unifi/dns-policy.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-drift.d.ts +26 -0
- package/dist/unifi/firewall-policy-drift.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-form.d.ts +102 -0
- package/dist/unifi/firewall-policy-form.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-ordering-form.d.ts +66 -0
- package/dist/unifi/firewall-policy-ordering-form.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-ordering.d.ts +57 -0
- package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -0
- package/dist/unifi/firewall-policy.d.ts +57 -0
- package/dist/unifi/firewall-policy.d.ts.map +1 -0
- package/dist/unifi/index.d.ts +7 -0
- package/dist/unifi/index.d.ts.map +1 -1
- package/dist/unifi/index.js +588 -3
- package/dist/unifi/index.js.map +24 -4
- package/dist/unifi/paginate.d.ts +0 -12
- package/dist/unifi/paginate.d.ts.map +1 -1
- package/dist/unifi/providers.d.ts +1 -1
- package/dist/unifi/providers.d.ts.map +1 -1
- package/dist/unifi/scan-source.d.ts +11 -0
- package/dist/unifi/scan-source.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list-drift.d.ts +22 -0
- package/dist/unifi/traffic-matching-list-drift.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list-form.d.ts +56 -0
- package/dist/unifi/traffic-matching-list-form.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list.d.ts +51 -0
- package/dist/unifi/traffic-matching-list.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast-drift.d.ts +22 -0
- package/dist/unifi/wifi-broadcast-drift.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast-form.d.ts +127 -0
- package/dist/unifi/wifi-broadcast-form.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast.d.ts +73 -0
- package/dist/unifi/wifi-broadcast.d.ts.map +1 -0
- package/docs/unifi-acl-rule.md +122 -0
- package/docs/unifi-dns-policy.md +75 -0
- package/docs/unifi-firewall-policy.md +109 -0
- package/docs/unifi-traffic-matching-list.md +82 -0
- package/docs/unifi-wifi.md +120 -0
- package/docs/unifi.md +10 -5
- package/package.json +6 -2
- package/src/ceph/auth-entity.ts +74 -0
- package/src/ceph/ceph-argv-config.ts +78 -0
- package/src/ceph/ceph-argv.test.ts +226 -0
- package/src/ceph/ceph-argv.ts +189 -0
- package/src/ceph/ceph-auth-form.test.ts +103 -0
- package/src/ceph/ceph-auth-form.ts +107 -0
- package/src/ceph/ceph-auth-parse.test.ts +155 -0
- package/src/ceph/ceph-auth-parse.ts +121 -0
- package/src/ceph/ceph-auth-reconcile-secrecy.test.ts +82 -0
- package/src/ceph/ceph-auth-reconcile.test.ts +233 -0
- package/src/ceph/ceph-auth-reconcile.ts +119 -0
- package/src/ceph/ceph-config-options.test.ts +44 -0
- package/src/ceph/ceph-config-options.ts +36 -0
- package/src/ceph/ceph-key-leak-assert.ts +35 -0
- package/src/ceph/ceph-no-output-logging.test.ts +52 -0
- package/src/ceph/ceph-transport.test.ts +125 -0
- package/src/ceph/ceph-transport.ts +117 -0
- package/src/ceph/fake-ceph-dial.ts +55 -0
- package/src/ceph/index.ts +12 -0
- package/src/index.ts +1 -0
- package/src/proxmox/qemu-volume.test.ts +153 -4
- package/src/proxmox/qemu-volume.ts +71 -14
- package/src/talos/credentials-write.test.ts +81 -0
- package/src/talos/credentials-write.ts +95 -0
- package/src/talos/credentials.test.ts +157 -0
- package/src/talos/credentials.ts +160 -90
- package/src/talos/fake-process.ts +94 -0
- package/src/talos/index.ts +39 -4
- package/src/talos/kubeconfig-attrs.ts +55 -0
- package/src/talos/kubeconfig.test.ts +256 -0
- package/src/talos/kubeconfig.ts +170 -90
- package/src/talos/machine-config-fixtures.ts +86 -0
- package/src/talos/machine-config-poll.test.ts +57 -0
- package/src/talos/machine-config-poll.ts +82 -0
- package/src/talos/machine-config-read.ts +118 -0
- package/src/talos/resource.ts +19 -0
- package/src/talos/secrets-gate.test.ts +63 -0
- package/src/talos/talos-bootstrap-etcd.ts +71 -0
- package/src/talos/talos-bootstrap.test.ts +218 -0
- package/src/talos/talos-bootstrap.ts +151 -45
- package/src/talos/talos-cluster-health.test.ts +143 -0
- package/src/talos/talos-cluster-health.ts +91 -28
- package/src/talos/talos-errors.ts +85 -0
- package/src/talos/talos-machine-config.test.ts +189 -0
- package/src/talos/talos-machine-config.ts +102 -88
- package/src/talos/talosctl.ts +8 -0
- package/src/talos/values.test.ts +34 -6
- package/src/talos/values.ts +41 -7
- package/src/unifi/acl-rule-drift.ts +57 -0
- package/src/unifi/acl-rule-form.ts +135 -0
- package/src/unifi/acl-rule-ordering-form.ts +79 -0
- package/src/unifi/acl-rule-ordering.test.ts +141 -0
- package/src/unifi/acl-rule-ordering.ts +70 -0
- package/src/unifi/acl-rule.test.ts +183 -0
- package/src/unifi/acl-rule.ts +61 -0
- package/src/unifi/discriminated-filter-decode.test.ts +134 -0
- package/src/unifi/dns-policy-drift.ts +48 -0
- package/src/unifi/dns-policy-form.ts +142 -0
- package/src/unifi/dns-policy.test.ts +154 -0
- package/src/unifi/dns-policy.ts +67 -0
- package/src/unifi/firewall-policy-drift.test.ts +151 -0
- package/src/unifi/firewall-policy-drift.ts +55 -0
- package/src/unifi/firewall-policy-filter-roundtrip.test.ts +143 -0
- package/src/unifi/firewall-policy-form.ts +146 -0
- package/src/unifi/firewall-policy-ordering-form.ts +111 -0
- package/src/unifi/firewall-policy-ordering.test.ts +185 -0
- package/src/unifi/firewall-policy-ordering.ts +87 -0
- package/src/unifi/firewall-policy.test.ts +183 -0
- package/src/unifi/firewall-policy.ts +73 -0
- package/src/unifi/index.ts +63 -0
- package/src/unifi/paginate.test.ts +24 -0
- package/src/unifi/paginate.ts +15 -3
- package/src/unifi/providers.ts +19 -1
- package/src/unifi/scan-source.ts +64 -0
- package/src/unifi/traffic-matching-list-drift.ts +40 -0
- package/src/unifi/traffic-matching-list-form.ts +77 -0
- package/src/unifi/traffic-matching-list.test.ts +202 -0
- package/src/unifi/traffic-matching-list.ts +78 -0
- package/src/unifi/wifi-broadcast-details-call.test.ts +92 -0
- package/src/unifi/wifi-broadcast-drift.ts +69 -0
- package/src/unifi/wifi-broadcast-form.ts +211 -0
- package/src/unifi/wifi-broadcast-secrets.test.ts +145 -0
- package/src/unifi/wifi-broadcast.test.ts +155 -0
- package/src/unifi/wifi-broadcast.ts +90 -0
- package/src/unifi/write-op-reference.test.ts +8 -54
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mon-command transport against `fakeCephDial` — no ssh, no spawned process. Mon-transport doc
|
|
3
|
+
* acceptance tests #1 ("no ssh at all" for a refused shape) and #2 (try-in-order, transport
|
|
4
|
+
* failure never reads as absent, quorum re-checked on a fresh connection after a write).
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { authGetArgv, quorumStatusArgv } from './ceph-argv.ts';
|
|
8
|
+
import { assertFreshQuorum, runCephCommand } from './ceph-transport.ts';
|
|
9
|
+
import { fakeCephDial, fakeCephOk } from './fake-ceph-dial.ts';
|
|
10
|
+
|
|
11
|
+
const ENTITY = 'client.k8s-rbd';
|
|
12
|
+
const MON_A = 'mon-a.example.test';
|
|
13
|
+
const MON_B = 'mon-b.example.test';
|
|
14
|
+
const MON_C = 'mon-c.example.test';
|
|
15
|
+
|
|
16
|
+
describe('runCephCommand', () => {
|
|
17
|
+
test('a refused shape never dials — no mon is ever contacted', async () => {
|
|
18
|
+
const fake = fakeCephDial({});
|
|
19
|
+
await expect(
|
|
20
|
+
runCephCommand({ dial: fake.dial, log: () => {}, nodes: [MON_A] }, [
|
|
21
|
+
'/usr/bin/ceph',
|
|
22
|
+
'auth',
|
|
23
|
+
'del',
|
|
24
|
+
ENTITY,
|
|
25
|
+
'-f',
|
|
26
|
+
'json',
|
|
27
|
+
]),
|
|
28
|
+
).rejects.toThrow(/never auto-delete/);
|
|
29
|
+
expect(fake.seen).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('refuses when no mon nodes are declared, without dialing', async () => {
|
|
33
|
+
const fake = fakeCephDial({});
|
|
34
|
+
await expect(
|
|
35
|
+
runCephCommand({ dial: fake.dial, log: () => {}, nodes: [] }, authGetArgv(ENTITY)),
|
|
36
|
+
).rejects.toThrow(/no mon nodes/);
|
|
37
|
+
expect(fake.seen).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('tries the next mon on a transport failure, in order, and returns the first that answers', async () => {
|
|
41
|
+
const fake = fakeCephDial({
|
|
42
|
+
[MON_B]: [{ kind: 'result', result: { exitCode: 0, stderr: '', stdout: '[]' } }],
|
|
43
|
+
});
|
|
44
|
+
const result = await runCephCommand(
|
|
45
|
+
{ dial: fake.dial, log: () => {}, nodes: [MON_A, MON_B, MON_C] },
|
|
46
|
+
authGetArgv(ENTITY),
|
|
47
|
+
);
|
|
48
|
+
expect(result.node).toBe(MON_B);
|
|
49
|
+
expect(fake.seen.map((call) => call.node)).toEqual([MON_A, MON_B]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('every mon failing at the transport layer propagates as an error, never as absent', async () => {
|
|
53
|
+
const fake = fakeCephDial({});
|
|
54
|
+
const attempt = runCephCommand(
|
|
55
|
+
{ dial: fake.dial, log: () => {}, nodes: [MON_A, MON_B] },
|
|
56
|
+
authGetArgv(ENTITY),
|
|
57
|
+
);
|
|
58
|
+
await expect(attempt).rejects.toThrow(/every mon node failed \(never treated as absent\)/);
|
|
59
|
+
expect(fake.seen.map((call) => call.node)).toEqual([MON_A, MON_B]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a mon that actually answers, even with a nonzero exit, is not retried on another node', async () => {
|
|
63
|
+
const fake = fakeCephDial({
|
|
64
|
+
[MON_A]: [
|
|
65
|
+
{ kind: 'result', result: { exitCode: 2, stderr: 'Error ENOENT: not found', stdout: '' } },
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
const result = await runCephCommand(
|
|
69
|
+
{ dial: fake.dial, log: () => {}, nodes: [MON_A, MON_B] },
|
|
70
|
+
authGetArgv(ENTITY),
|
|
71
|
+
);
|
|
72
|
+
expect(result.node).toBe(MON_A);
|
|
73
|
+
expect(result.exitCode).toBe(2);
|
|
74
|
+
expect(fake.seen.map((call) => call.node)).toEqual([MON_A]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('the log line carries the argv, never any result — including a successful stdout', async () => {
|
|
78
|
+
const lines: string[] = [];
|
|
79
|
+
const fake = fakeCephDial({
|
|
80
|
+
[MON_A]: [
|
|
81
|
+
fakeCephOk('[{"entity":"client.k8s-rbd","key":"fake-key-not-a-secret","caps":{}}]'),
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
await runCephCommand(
|
|
85
|
+
{ dial: fake.dial, log: (line) => lines.push(line), nodes: [MON_A] },
|
|
86
|
+
authGetArgv(ENTITY),
|
|
87
|
+
);
|
|
88
|
+
expect(lines).toHaveLength(1);
|
|
89
|
+
expect(lines[0]).toContain(JSON.stringify(authGetArgv(ENTITY)));
|
|
90
|
+
expect(lines.join('\n')).not.toContain('fake-key-not-a-secret');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('assertFreshQuorum', () => {
|
|
95
|
+
test('passes on a healthy quorum', async () => {
|
|
96
|
+
const fake = fakeCephDial({ [MON_A]: [fakeCephOk('{"quorum":[0,1,2]}')] });
|
|
97
|
+
await expect(
|
|
98
|
+
assertFreshQuorum({ dial: fake.dial, log: () => {}, nodes: [MON_A] }),
|
|
99
|
+
).resolves.toBeUndefined();
|
|
100
|
+
expect(fake.seen).toEqual([{ argv: quorumStatusArgv(), node: MON_A }]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('fails closed on an empty quorum array', async () => {
|
|
104
|
+
const fake = fakeCephDial({ [MON_A]: [fakeCephOk('{"quorum":[]}')] });
|
|
105
|
+
await expect(
|
|
106
|
+
assertFreshQuorum({ dial: fake.dial, log: () => {}, nodes: [MON_A] }),
|
|
107
|
+
).rejects.toThrow(/degraded/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('fails closed on unparseable output — never calls a guess "healthy"', async () => {
|
|
111
|
+
const fake = fakeCephDial({ [MON_A]: [fakeCephOk('not json')] });
|
|
112
|
+
await expect(
|
|
113
|
+
assertFreshQuorum({ dial: fake.dial, log: () => {}, nodes: [MON_A] }),
|
|
114
|
+
).rejects.toThrow(/degraded/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('a nonzero exit from quorum_status itself fails, reporting stderr only', async () => {
|
|
118
|
+
const fake = fakeCephDial({
|
|
119
|
+
[MON_A]: [{ kind: 'result', result: { exitCode: 1, stderr: 'mon down', stdout: '' } }],
|
|
120
|
+
});
|
|
121
|
+
await expect(
|
|
122
|
+
assertFreshQuorum({ dial: fake.dial, log: () => {}, nodes: [MON_A] }),
|
|
123
|
+
).rejects.toThrow(/mon down/);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Ceph mon-command transport (K-A4, T-A): ssh to one of the TB4 mon nodes, `sudo -n
|
|
3
|
+
* /usr/bin/ceph …`, tried in node order. Every call is checked against ceph-argv.ts's allowlist
|
|
4
|
+
* BEFORE anything dials — a refused shape never opens a connection, never mind runs a command.
|
|
5
|
+
*
|
|
6
|
+
* ★ BUILT ON THE SAME WIRE FORMAT sshSudoRunner USES, NOT A SECOND ONE. `runRemote` (the framed
|
|
7
|
+
* script + nonce marker `../linux/ssh-command.ts` and `../linux/ssh-runner.ts` already ship and
|
|
8
|
+
* test) is reused directly: same fail-closed behaviour on a dropped connection, same
|
|
9
|
+
* `BatchMode=yes`, same host-key verification left to the operator's own ssh config.
|
|
10
|
+
* ⛔ THE DIAL IS AN INJECTED SEAM, NOT child_process MOCKED. `CephDial` is the same idea as
|
|
11
|
+
* `HostRunner` (../launchd/runner.ts): production code gets `sshCephDial`, tests hand a fake
|
|
12
|
+
* function directly (fake-ceph-dial.ts) — no ssh binary, no spawned process, ever, in a test.
|
|
13
|
+
* ⛔ A TRANSPORT FAILURE (no mon reachable) IS NEVER "ABSENT". Only a mon that actually ran the
|
|
14
|
+
* command and answered gets to say whether an entity exists; every node failing to connect
|
|
15
|
+
* propagates as an Error, so a down anchor can never read as "recreate it".
|
|
16
|
+
* ⛔ ONLY ARGV IS EVER LOGGED. `log` is called with the shape being attempted, never with a
|
|
17
|
+
* result's stdout or stderr — `auth get`/`auth get-or-create`'s stdout carries the entity's key.
|
|
18
|
+
*/
|
|
19
|
+
import { randomBytes } from 'node:crypto';
|
|
20
|
+
import { quoteArgv } from '../linux/ssh-command.ts';
|
|
21
|
+
import { runRemote } from '../linux/ssh-runner.ts';
|
|
22
|
+
import { SUDO, SudoRefusedError } from '../linux/sudo-allowlist.ts';
|
|
23
|
+
import { cephCommandProblem, quorumStatusArgv } from './ceph-argv.ts';
|
|
24
|
+
import { parseQuorumStatus } from './ceph-auth-parse.ts';
|
|
25
|
+
|
|
26
|
+
export type CephExecResult = {
|
|
27
|
+
readonly exitCode: number;
|
|
28
|
+
readonly stdout: string;
|
|
29
|
+
readonly stderr: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** One attempt against one mon: run `argv` (already sudo-wrapped by the caller) and report. */
|
|
33
|
+
export type CephDial = (node: string, argv: readonly string[]) => Promise<CephExecResult>;
|
|
34
|
+
|
|
35
|
+
const CONNECT_TIMEOUT_SEC = 10;
|
|
36
|
+
const EXEC_TIMEOUT_MS = 30_000;
|
|
37
|
+
|
|
38
|
+
/** ★ A fresh nonce per attempt — this dial never reuses a connection or a marker across calls. */
|
|
39
|
+
export const sshCephDial: CephDial = async (node, argv) => {
|
|
40
|
+
const nonce = randomBytes(8).toString('hex');
|
|
41
|
+
const script = `${SUDO} -n -- ${quoteArgv(argv)}`;
|
|
42
|
+
return runRemote(
|
|
43
|
+
{ connectTimeoutSec: CONNECT_TIMEOUT_SEC, execTimeoutMs: EXEC_TIMEOUT_MS, host: node },
|
|
44
|
+
nonce,
|
|
45
|
+
script,
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type CephTransportOptions = {
|
|
50
|
+
/** TB4 mon ssh destinations, tried in this order. Required; at least one. */
|
|
51
|
+
readonly nodes: readonly string[];
|
|
52
|
+
readonly dial?: CephDial;
|
|
53
|
+
/** One line per attempted argv, before it dials. @default a line on stderr */
|
|
54
|
+
readonly log?: (line: string) => void;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const defaultLog = (line: string) => process.stderr.write(`${line}\n`);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Run one allowed ceph shape against the first mon that answers.
|
|
61
|
+
*
|
|
62
|
+
* ⛔ THE ALLOWLIST CHECK HAPPENS BEFORE THE FIRST NODE IS EVEN TRIED — a refused argv dials no
|
|
63
|
+
* ssh at all, so a bug that builds a bad shape cannot leak so much as a connection attempt.
|
|
64
|
+
*/
|
|
65
|
+
export const runCephCommand = async (
|
|
66
|
+
options: CephTransportOptions,
|
|
67
|
+
argv: readonly string[],
|
|
68
|
+
): Promise<CephExecResult & { readonly node: string }> => {
|
|
69
|
+
const problem = cephCommandProblem(argv);
|
|
70
|
+
if (problem !== undefined) {
|
|
71
|
+
throw new SudoRefusedError(`ceph transport refused ${JSON.stringify(argv)}: ${problem}`);
|
|
72
|
+
}
|
|
73
|
+
if (options.nodes.length === 0) throw new Error('ceph transport: no mon nodes were declared');
|
|
74
|
+
const dial = options.dial ?? sshCephDial;
|
|
75
|
+
const log = options.log ?? defaultLog;
|
|
76
|
+
const attempts: { readonly node: string; readonly message: string }[] = [];
|
|
77
|
+
for (const node of options.nodes) {
|
|
78
|
+
log(`homeflare/ceph sudo -n ${JSON.stringify(argv)} @ ${node}`);
|
|
79
|
+
try {
|
|
80
|
+
const result = await dial(node, argv);
|
|
81
|
+
return { ...result, node };
|
|
82
|
+
} catch (cause) {
|
|
83
|
+
attempts.push({ message: cause instanceof Error ? cause.message : String(cause), node });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// ⛔ NEVER "ABSENT". Every mon this call knew about failed at the transport layer; that is a
|
|
87
|
+
// down anchor (or a network problem), not proof the entity does not exist.
|
|
88
|
+
const detail = attempts.map((a) => `${a.node}: ${a.message}`).join('; ');
|
|
89
|
+
throw new Error(`ceph transport: every mon node failed (never treated as absent) — ${detail}`);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* ★ LOCKOUT SAFETY, THE UFW-ESTABLISHED PATTERN: after every write, re-check quorum on a FRESH
|
|
94
|
+
* connection (a new dial, never the one the write used) and fail the row if it degraded. Never
|
|
95
|
+
* auto-repair, never continue — the caller's write is reported as failed even though it may
|
|
96
|
+
* itself have succeeded, because a write that broke quorum is not a success this transport will
|
|
97
|
+
* call one.
|
|
98
|
+
* ⚠️ THE SHAPE OF `quorum_status -f json` IS REASONED FROM PUBLIC CEPH DOCS, NOT MEASURED against
|
|
99
|
+
* this estate's Ceph release — there is no mon yet. An unparseable or missing `quorum` array
|
|
100
|
+
* fails closed as "degraded", never as "healthy"; see ceph-auth-parse.ts.
|
|
101
|
+
*/
|
|
102
|
+
export const assertFreshQuorum = async (options: CephTransportOptions): Promise<void> => {
|
|
103
|
+
const result = await runCephCommand(options, quorumStatusArgv());
|
|
104
|
+
if (result.exitCode !== 0) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`ceph quorum_status failed after a write (mon ${result.node}): ` +
|
|
107
|
+
`${result.stderr.trim().slice(0, 300)}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const quorum = parseQuorumStatus(result.stdout);
|
|
111
|
+
if (!quorum.healthy) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`ceph quorum degraded after a write (mon ${result.node}): ${quorum.reason} — the write is ` +
|
|
114
|
+
'reported as failed even if it landed; nothing here auto-repairs a quorum problem.',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fake `CephDial` for the tests beside it — no ssh, no spawned process, ever. The same idea as
|
|
3
|
+
* `../linux/fake-sudo.ts` and `../openbao/fake-bao.ts`: production code is handed the real
|
|
4
|
+
* implementation, a test hands this instead, and neither one's internals change to make room for
|
|
5
|
+
* the other.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ TEST-ONLY. Nothing in `src/` imports this outside a `.test.ts` file.
|
|
8
|
+
*/
|
|
9
|
+
import type { CephDial, CephExecResult } from './ceph-transport.ts';
|
|
10
|
+
|
|
11
|
+
/** One call this fake received. */
|
|
12
|
+
export type SeenCephCall = { readonly node: string; readonly argv: readonly string[] };
|
|
13
|
+
|
|
14
|
+
/** What a scripted node answers: a result, or a thrown transport failure. */
|
|
15
|
+
export type CephAnswer =
|
|
16
|
+
| { readonly kind: 'result'; readonly result: CephExecResult }
|
|
17
|
+
| { readonly kind: 'transport-error'; readonly message: string };
|
|
18
|
+
|
|
19
|
+
export type FakeCephDial = {
|
|
20
|
+
readonly dial: CephDial;
|
|
21
|
+
readonly seen: SeenCephCall[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const ok = (stdout: string, exitCode = 0, stderr = ''): CephAnswer => ({
|
|
25
|
+
kind: 'result',
|
|
26
|
+
result: { exitCode, stderr, stdout },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `script` maps a node to the fixed sequence of answers it gives, one per call that reaches it —
|
|
31
|
+
* the last entry repeats once exhausted. A node with no entry always transport-fails, which is
|
|
32
|
+
* exactly "an unreachable mon" without a special case.
|
|
33
|
+
*/
|
|
34
|
+
export const fakeCephDial = (
|
|
35
|
+
script: Readonly<Record<string, readonly CephAnswer[]>>,
|
|
36
|
+
): FakeCephDial => {
|
|
37
|
+
const seen: SeenCephCall[] = [];
|
|
38
|
+
const counts = new Map<string, number>();
|
|
39
|
+
const dial: CephDial = async (node, argv) => {
|
|
40
|
+
seen.push({ argv, node });
|
|
41
|
+
const answers = script[node];
|
|
42
|
+
if (answers === undefined || answers.length === 0) {
|
|
43
|
+
throw new Error(`fakeCephDial: ${node} is not reachable (no script entry)`);
|
|
44
|
+
}
|
|
45
|
+
const at = counts.get(node) ?? 0;
|
|
46
|
+
counts.set(node, at + 1);
|
|
47
|
+
const answer = answers[Math.min(at, answers.length - 1)];
|
|
48
|
+
if (answer === undefined) throw new Error(`fakeCephDial: ${node} has no answer`);
|
|
49
|
+
if (answer.kind === 'transport-error') throw new Error(answer.message);
|
|
50
|
+
return answer.result;
|
|
51
|
+
};
|
|
52
|
+
return { dial, seen };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export { ok as fakeCephOk };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ceph providers for Alchemy — the mon-command transport and `Ceph.AuthEntity` (K-A4).
|
|
3
|
+
*
|
|
4
|
+
* ⛔ THIS BARREL IS THE PUBLIC API, DELIBERATELY SMALLER THAN THE DIRECTORY — see
|
|
5
|
+
* `../openbao/index.ts`'s header for why. The allowlist internals (ceph-argv.ts), the parsing
|
|
6
|
+
* (ceph-auth-parse.ts) and the reconcile loop (ceph-auth-reconcile.ts) are exported by path for
|
|
7
|
+
* a future `CephConfigOption` (K-D) or a test to reach directly, not published here.
|
|
8
|
+
*/
|
|
9
|
+
export type { CephAuthEntityAttributes, CephAuthEntityProps, CephCaps } from './auth-entity.ts';
|
|
10
|
+
export { CephAuthEntity, CephAuthEntityProvider } from './auth-entity.ts';
|
|
11
|
+
export type { CephDial, CephExecResult, CephTransportOptions } from './ceph-transport.ts';
|
|
12
|
+
export { assertFreshQuorum, runCephCommand, sshCephDial } from './ceph-transport.ts';
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* import { BaoMount } from '@homeflare/alchemy/openbao';
|
|
11
11
|
* import { TalosCluster } from '@homeflare/alchemy/talos';
|
|
12
12
|
* import { ProxmoxAcl } from '@homeflare/alchemy/proxmox';
|
|
13
|
+
* import { CephAuthEntity } from '@homeflare/alchemy/ceph';
|
|
13
14
|
* import { LaunchdJob } from '@homeflare/alchemy/launchd';
|
|
14
15
|
* import { ReleaseBinary } from '@homeflare/alchemy/release';
|
|
15
16
|
* import { CaddyConfig } from '@homeflare/alchemy/caddy';
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure unit tests for `judgeDisk` — the red-team C1 fix on PR 297. No network, no fake PVE.
|
|
3
|
-
* ⛔ WHAT THESE PIN: a "fresh" declaration (new-disk GiB,
|
|
4
|
-
* against an existing live volume in the same slot;
|
|
5
|
-
* never written; a size change is refused (no resize
|
|
6
|
-
* written on the LIVE volume id and LIVE size.
|
|
3
|
+
* ⛔ WHAT THESE PIN: a "fresh" declaration (new-disk GiB, `storage:cloudinit`, or
|
|
4
|
+
* `storage:0,import-from=...`) never re-drifts against an existing live volume in the same slot;
|
|
5
|
+
* a genuine volume/storage swap is refused, never written; a size change is refused (no resize
|
|
6
|
+
* call exists here); an option-only change is written on the LIVE volume id and LIVE size.
|
|
7
7
|
*/
|
|
8
8
|
import { describe, expect, test } from 'bun:test';
|
|
9
9
|
import { judgeDisk, parseDisk } from './qemu-volume.ts';
|
|
@@ -20,6 +20,145 @@ describe('a "fresh" declaration matches any existing volume in its slot', () =>
|
|
|
20
20
|
});
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The disk-import convergence bug homeflare-proxmox PR 84's red team found (2026-09-26, real
|
|
25
|
+
* fake-PVE engine): `judgeDisk` never recognized `<storage>:0,import-from=<volid>` as a new-disk
|
|
26
|
+
* spelling, so it compared the literal volname `"0"` against PVE's live read-back of the real
|
|
27
|
+
* `vm-<vmid>-disk-<n>` it allocated on import and refused -- on the very deploy that created the
|
|
28
|
+
* disk, and on every plan after (Talos's `declareTalos` declares the import spelling every time,
|
|
29
|
+
* never switches to the resolved volname). Each `judgeDisk` call below stands in for one PVE read
|
|
30
|
+
* a real Talos deploy makes; none of them are a live PVE call.
|
|
31
|
+
*/
|
|
32
|
+
describe('the import-from create spelling converges (PR 84 red team)', () => {
|
|
33
|
+
const IMPORT_DECLARED = 'cephtb4:0,import-from=cephfs-tb4:import/talos-factory.raw';
|
|
34
|
+
|
|
35
|
+
test('create: post-write verification reads back the real volume PVE allocated', () => {
|
|
36
|
+
// The deploy that creates the VM re-reads the config right after the PUT to verify it landed.
|
|
37
|
+
// Before the fix this was refused (declared volname "0" vs live "vm-10000-disk-0").
|
|
38
|
+
expect(judgeDisk('scsi0', IMPORT_DECLARED, 'cephtb4:vm-10000-disk-0,size=2G')).toEqual({});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('re-plan: the same declared import spelling against the now-adopted live disk', () => {
|
|
42
|
+
// A later `bun run plan` fabricates the same live read as create did; declared is unchanged
|
|
43
|
+
// (declareTalos always emits import-from, never the resolved volname). Must still be a no-op.
|
|
44
|
+
const verdict = judgeDisk('scsi0', IMPORT_DECLARED, 'cephtb4:vm-10000-disk-0,size=2G');
|
|
45
|
+
expect(verdict.refuse).toBeUndefined();
|
|
46
|
+
expect(verdict.put).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('second deploy: PVE has since filled in defaults the fresh check must not treat as drift', () => {
|
|
50
|
+
// pve-qemu-server fills in options nobody declared (ssd/discard/etc.). A "fresh" declaration
|
|
51
|
+
// skips option comparison entirely, so this must be a no-op too -- not a PUT trying to "fix"
|
|
52
|
+
// options that were never wrong, and not a resize of the placeholder "0" against the real size.
|
|
53
|
+
const verdict = judgeDisk(
|
|
54
|
+
'scsi0',
|
|
55
|
+
IMPORT_DECLARED,
|
|
56
|
+
'cephtb4:vm-10000-disk-0,size=2G,ssd=1,discard=on',
|
|
57
|
+
);
|
|
58
|
+
expect(verdict).toEqual({});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('no resize is ever attempted for an import-from disk', () => {
|
|
62
|
+
// The live size (2G, from the imported image) never appears in a `put` -- there is no `put` at
|
|
63
|
+
// all, because `want.kind === 'fresh'` returns before size or options are ever inspected.
|
|
64
|
+
expect(judgeDisk('scsi0', IMPORT_DECLARED, 'cephtb4:vm-10000-disk-0,size=200G')).toEqual({});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('a volid with its own colon (storage:path) still parses as one fresh spelling', () => {
|
|
68
|
+
// import-from points at cephfs-tb4:import/talos-factory.raw -- a second colon inside the value,
|
|
69
|
+
// which a colon-anchored regex could truncate. Confirms the whole declared string is consumed.
|
|
70
|
+
expect(
|
|
71
|
+
judgeDisk(
|
|
72
|
+
'scsi0',
|
|
73
|
+
'cephtb4:0,import-from=cephfs-tb4:import/talos-factory.raw',
|
|
74
|
+
'cephtb4:vm-10042-disk-0,size=2G',
|
|
75
|
+
),
|
|
76
|
+
).toEqual({});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('a genuine size mismatch on an already-adopted volume is still refused', () => {
|
|
80
|
+
// Not an import-from declaration: a plain adopted volume whose declared size disagrees with the
|
|
81
|
+
// live one. Must still hit the existing no-resize refusal, unchanged by the new spelling.
|
|
82
|
+
const verdict = judgeDisk(
|
|
83
|
+
'scsi0',
|
|
84
|
+
'cephtb4:vm-10000-disk-0,size=4G',
|
|
85
|
+
'cephtb4:vm-10000-disk-0,size=2G',
|
|
86
|
+
);
|
|
87
|
+
expect(verdict.refuse).toMatch(/does not resize a disk/);
|
|
88
|
+
expect(verdict.put).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('volname "0" without import-from is not swept into the new "fresh" spelling', () => {
|
|
92
|
+
// Guards NEW_IMPORT's literal ",import-from=" requirement: a coincidental "storage:0,k=v" (no
|
|
93
|
+
// import-from) must still be judged as a real volume named "0", not treated as always-matching.
|
|
94
|
+
const verdict = judgeDisk('scsi0', 'cephtb4:0,ssd=1', 'cephtb4:vm-10000-disk-0,size=2G');
|
|
95
|
+
expect(verdict.refuse).toMatch(/is not the live vm-10000-disk-0/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('a mismatched storage on an import-from declaration is still refused, not silently matched', () => {
|
|
99
|
+
// "fresh" only skips the volname/size/options checks -- the storage check above it still runs.
|
|
100
|
+
const verdict = judgeDisk(
|
|
101
|
+
'scsi0',
|
|
102
|
+
'cephtb4:0,import-from=cephfs-tb4:import/talos-factory.raw',
|
|
103
|
+
'local-zfs:vm-10000-disk-0,size=2G',
|
|
104
|
+
);
|
|
105
|
+
expect(verdict.refuse).toMatch(/the volume is on local-zfs, declared on cephtb4/);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The LAND red team's Important finding on this same PR (2026-09-26): options declared alongside
|
|
111
|
+
* `import-from` were either silently dropped (declared after it -- `.+` swallowed them uncompared)
|
|
112
|
+
* or stranded the disk forever (declared before it -- missed the old regex, fell to the `volume`
|
|
113
|
+
* branch, and got refused against the real live volname just like the bug this file fixes). Fixed by
|
|
114
|
+
* detecting `import-from` after the general comma split instead of a position-anchored regex.
|
|
115
|
+
*/
|
|
116
|
+
describe('options declared alongside import-from are enforced, in either order (LAND red team)', () => {
|
|
117
|
+
const IMPORT = 'cephtb4:0,import-from=cephfs-tb4:import/talos-factory.raw';
|
|
118
|
+
|
|
119
|
+
test('an option after import-from that already matches live is a noop', () => {
|
|
120
|
+
const verdict = judgeDisk(
|
|
121
|
+
'scsi0',
|
|
122
|
+
`${IMPORT},iothread=1`,
|
|
123
|
+
'cephtb4:vm-150-disk-0,iothread=1,size=2G',
|
|
124
|
+
);
|
|
125
|
+
expect(verdict).toEqual({});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('an option after import-from that disagrees with live is written, on the live volume id', () => {
|
|
129
|
+
// Scenario B from the red team: iothread was declared and honored at create; discard/ssd are
|
|
130
|
+
// added later. Before this fix, verify said `noop` and the deploy wrote nothing.
|
|
131
|
+
const verdict = judgeDisk(
|
|
132
|
+
'scsi0',
|
|
133
|
+
`${IMPORT},iothread=1,discard=on,ssd=1`,
|
|
134
|
+
'cephtb4:vm-150-disk-0,iothread=1,size=2G',
|
|
135
|
+
);
|
|
136
|
+
expect(verdict.put).toBe('cephtb4:vm-150-disk-0,iothread=1,discard=on,ssd=1,size=2G');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('an option BEFORE import-from is still recognized as fresh, not a stranded volume', () => {
|
|
140
|
+
// Scenario C from the red team: `cephtb4:0,iothread=1,import-from=...` missed the old
|
|
141
|
+
// position-anchored regex entirely and was refused forever as a volname "0" vs the live one.
|
|
142
|
+
const verdict = judgeDisk(
|
|
143
|
+
'scsi0',
|
|
144
|
+
'cephtb4:0,iothread=1,import-from=cephfs-tb4:import/talos-factory.raw',
|
|
145
|
+
'cephtb4:vm-150-disk-0,iothread=1,size=2G',
|
|
146
|
+
);
|
|
147
|
+
expect(verdict).toEqual({});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('a live-only default the caller never declared is not treated as drift', () => {
|
|
151
|
+
// Only iothread was declared; ssd/discard are PVE's own fill-in. A full-map compare (sameMap)
|
|
152
|
+
// would call this drift and rewrite the disk on every plan -- the subset check must not.
|
|
153
|
+
const verdict = judgeDisk(
|
|
154
|
+
'scsi0',
|
|
155
|
+
`${IMPORT},iothread=1`,
|
|
156
|
+
'cephtb4:vm-150-disk-0,iothread=1,size=2G,ssd=1,discard=on',
|
|
157
|
+
);
|
|
158
|
+
expect(verdict).toEqual({});
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
23
162
|
describe('an existing-volume declaration', () => {
|
|
24
163
|
const live = 'cephtb4:vm-101-disk-0,size=32G,ssd=1';
|
|
25
164
|
|
|
@@ -77,4 +216,14 @@ describe('parseDisk', () => {
|
|
|
77
216
|
expect(disk.size).toBe('32G');
|
|
78
217
|
expect(disk.options).toEqual(new Map([['ssd', '1']]));
|
|
79
218
|
});
|
|
219
|
+
|
|
220
|
+
test('the import-from spelling parses as fresh, same shape as the other two', () => {
|
|
221
|
+
expect(parseDisk('cephtb4:0,import-from=cephfs-tb4:import/talos-factory.raw')).toEqual({
|
|
222
|
+
kind: 'fresh',
|
|
223
|
+
options: new Map(),
|
|
224
|
+
size: undefined,
|
|
225
|
+
source: 'cephtb4',
|
|
226
|
+
volname: '',
|
|
227
|
+
});
|
|
228
|
+
});
|
|
80
229
|
});
|
|
@@ -11,15 +11,48 @@
|
|
|
11
11
|
* creates a fresh cloud-init seed drive; the live slot then reads back
|
|
12
12
|
* `<storage>:vm-<vmid>-cloudinit,media=cdrom`. Both spellings compare equal to an existing volume
|
|
13
13
|
* already in the slot, for the same reason `storage:GiB` does.
|
|
14
|
+
* ⛔ A THIRD NEW-VOLUME SPELLING, FOUND BY homeflare-proxmox PR 84's RED TEAM (2026-09-26, real
|
|
15
|
+
* fake-PVE engine): `<storage>:0,import-from=<volid>` imports a disk from another volume or a
|
|
16
|
+
* downloaded image at create time. Before this fix it fell through to the `volume` branch below
|
|
17
|
+
* (it has a comma, so `NEW_DISK` never matched it), so `want.volname` stayed the literal `"0"`
|
|
18
|
+
* forever while PVE's live read-back reported the real `vm-<vmid>-disk-<n>` it allocated on
|
|
19
|
+
* import -- a volname mismatch, REFUSED. That refusal fired on the very deploy that created the
|
|
20
|
+
* disk (post-write verification reads the config right back) and on every plan after, because
|
|
21
|
+
* the declared value is `import-from=...` again each time (Talos's `declareTalos` recomputes it
|
|
22
|
+
* from the download resource, never switches to the resolved volname). Fixed the same way as the
|
|
23
|
+
* other two: an `import-from` disk also counts as `kind: 'fresh'`, which already matches whatever
|
|
24
|
+
* is live unconditionally when nothing else is declared (see `want.kind === 'fresh'` below) -- no
|
|
25
|
+
* special-casing of the live side needed.
|
|
26
|
+
* ⛔ IMPORT-FROM IMPORTS ONLY AT CREATE. Changing just the `import-from=` *source* (e.g. bumping a
|
|
27
|
+
* factory image path) on an already-created disk still reads as `noop`: PVE does not re-import an
|
|
28
|
+
* existing volume, and this resource has no rebuild-the-disk path. That is correct for Talos, which
|
|
29
|
+
* upgrades in place through `talosctl`, not by recreating its disk -- a caller that DOES want a
|
|
30
|
+
* fresh import on image change must delete and recreate the row itself.
|
|
31
|
+
* ⛔ LAND RED TEAM (2026-09-26): OPTIONS DECLARED ALONGSIDE `import-from` MUST STILL BE ENFORCED, AND
|
|
32
|
+
* MUST BE RECOGNIZED REGARDLESS OF WHERE `import-from` SITS IN THE LIST. The first cut matched
|
|
33
|
+
* `import-from` only when it came immediately after `:0,`, via a regex whose `.+` then swallowed
|
|
34
|
+
* every option after it uncompared (silently never written) while any option declared BEFORE it
|
|
35
|
+
* (`cephtb4:0,iothread=1,import-from=...`) missed the regex entirely and fell to the `volume`
|
|
36
|
+
* branch, comparing literal volname `"0"` against the live real one forever -- REFUSED, same
|
|
37
|
+
* stranded-disk failure this file exists to fix, just for a different option order. Fixed by
|
|
38
|
+
* folding the import check into the general `storage:volname,k=v,...` parse below: ANY declaration
|
|
39
|
+
* whose volname is the literal `"0"` and whose options include an `import-from` key is `fresh`,
|
|
40
|
+
* with `import-from` itself dropped and every other declared option carried through for
|
|
41
|
+
* `judgeDisk` to enforce (see its `want.kind === 'fresh'` branch) -- independent of option order.
|
|
14
42
|
* ⛔ NO RESIZE IS MADE HERE. lxc-volume.ts drives a second endpoint (`PUT .../resize`) this resource
|
|
15
43
|
* does not call — Talos's disks are declared once at their create size. A size difference between
|
|
16
44
|
* two already-adopted volume declarations is REFUSED with the `qm resize` to run by hand, never
|
|
17
45
|
* guessed at or silently written.
|
|
18
|
-
* ⚠️ OPTIONS BEYOND `size` ARE COMPARED AS A WHOLE, NOT KEY BY KEY
|
|
19
|
-
* does not model pve-qemu-server's
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
46
|
+
* ⚠️ OPTIONS BEYOND `size` ARE COMPARED AS A WHOLE, NOT KEY BY KEY, FOR AN ADOPTED `volume` -- BUT AS
|
|
47
|
+
* A SUBSET FOR A `fresh` IMPORT. Unlike lxc-volume.ts, this file does not model pve-qemu-server's
|
|
48
|
+
* per-option defaults (`ssd`, `discard`, `iothread`, …) — that table is unverified here. For an
|
|
49
|
+
* already-adopted volume, any options difference from live is real drift and gets written in full,
|
|
50
|
+
* exactly as the whole-string comparison this replaces already did. An `import-from` disk cannot
|
|
51
|
+
* use that same whole-map comparison: nobody declares PVE's post-import defaults, so comparing the
|
|
52
|
+
* full map would treat every default PVE fills in as drift and rewrite the disk on every plan (the
|
|
53
|
+
* builder's "second deploy" test below pins this). So a `fresh` import instead checks only the
|
|
54
|
+
* options the caller actually declared against live, ignoring any live-only keys, and PUTs the
|
|
55
|
+
* declared options (not merged with what live already has) when one of them disagrees.
|
|
23
56
|
*/
|
|
24
57
|
import { sameMap } from './lxc-wire.ts';
|
|
25
58
|
|
|
@@ -29,7 +62,8 @@ const NEW_DISK = /^([^:\s]+):(\d+(?:\.\d+)?)$/;
|
|
|
29
62
|
const NEW_CLOUDINIT = /^([^:\s]+):cloudinit$/;
|
|
30
63
|
|
|
31
64
|
export type Disk = {
|
|
32
|
-
/** `fresh`:
|
|
65
|
+
/** `fresh`: a new-disk spelling (`storage:GiB`, `storage:cloudinit`, or `storage:0,import-from=...`
|
|
66
|
+
* possibly with more options). `volume`: an existing volume, `storage:volname,...`. */
|
|
33
67
|
readonly kind: 'fresh' | 'volume';
|
|
34
68
|
readonly source: string;
|
|
35
69
|
readonly volname: string;
|
|
@@ -37,15 +71,24 @@ export type Disk = {
|
|
|
37
71
|
readonly options: Map<string, string>;
|
|
38
72
|
};
|
|
39
73
|
|
|
40
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* `storage:volname,k=v,...` (or one of the "fresh" spellings) into its comparable parts.
|
|
76
|
+
* ⚠️ THE IMPORT SPELLING IS DETECTED AFTER THE GENERAL SPLIT, NOT BY ITS OWN REGEX: `import-from`'s
|
|
77
|
+
* value can itself contain a colon (e.g. `cephfs-tb4:import/talos-factory.raw`) and other options
|
|
78
|
+
* can come before or after it, so a regex anchored on `:0,import-from=` right after the colon (as
|
|
79
|
+
* the first cut had) misses that ordering and swallows everything after it into one opaque match.
|
|
80
|
+
* Splitting on commas like any `volume` spelling first, THEN checking for a literal `"0"` volname
|
|
81
|
+
* plus an `import-from` key among the parsed options, catches every ordering and keeps every other
|
|
82
|
+
* declared option (`iothread=1`, `discard=on`, …) visible to `judgeDisk` instead of swallowed.
|
|
83
|
+
*/
|
|
41
84
|
export const parseDisk = (text: string): Disk => {
|
|
42
|
-
const
|
|
43
|
-
if (
|
|
85
|
+
const bare = NEW_DISK.exec(text) ?? NEW_CLOUDINIT.exec(text);
|
|
86
|
+
if (bare !== null) {
|
|
44
87
|
return {
|
|
45
88
|
kind: 'fresh',
|
|
46
89
|
options: new Map(),
|
|
47
90
|
size: undefined,
|
|
48
|
-
source:
|
|
91
|
+
source: bare[1] ?? '',
|
|
49
92
|
volname: '',
|
|
50
93
|
};
|
|
51
94
|
}
|
|
@@ -61,6 +104,10 @@ export const parseDisk = (text: string): Disk => {
|
|
|
61
104
|
);
|
|
62
105
|
const size = options.get('size');
|
|
63
106
|
options.delete('size');
|
|
107
|
+
if (volname === '0' && options.has('import-from')) {
|
|
108
|
+
options.delete('import-from');
|
|
109
|
+
return { kind: 'fresh', options, size: undefined, source, volname: '' };
|
|
110
|
+
}
|
|
64
111
|
return { kind: 'volume', options, size, source, volname };
|
|
65
112
|
};
|
|
66
113
|
|
|
@@ -97,10 +144,20 @@ export const judgeDisk = (key: string, declared: string, live: string): DiskVerd
|
|
|
97
144
|
'between storages is not an update this resource makes -- move it by hand, then declare it.',
|
|
98
145
|
};
|
|
99
146
|
}
|
|
100
|
-
// ⛔ A "fresh" declaration
|
|
101
|
-
//
|
|
102
|
-
// the live one at unusedN (the header's ⛔, ported from
|
|
103
|
-
|
|
147
|
+
// ⛔ A "fresh" declaration never re-triggers create: PVE holds a real volume in this slot already,
|
|
148
|
+
// and re-sending storage:GiB / storage:cloudinit / storage:0,import-from=... as the PUT value
|
|
149
|
+
// would allocate a SECOND volume and park the live one at unusedN (the header's ⛔, ported from
|
|
150
|
+
// lxc-volume.ts's own). So a `fresh` PUT, when one is needed, is always built from the LIVE
|
|
151
|
+
// volume id (`have.volname`/`have.size`), never the declared placeholder -- same as `volume`.
|
|
152
|
+
// ⚠️ ONLY THE OPTIONS THE CALLER ACTUALLY DECLARED ARE CHECKED (a subset, not `sameMap`): nobody
|
|
153
|
+
// declares PVE's post-import defaults, so an empty `want.options` (plain import-from, nothing
|
|
154
|
+
// else declared) is always a noop no matter what live has filled in, and a non-empty one is
|
|
155
|
+
// compared key-by-key against live, ignoring any key live has that the caller never declared.
|
|
156
|
+
if (want.kind === 'fresh') {
|
|
157
|
+
if (want.options.size === 0) return {};
|
|
158
|
+
if ([...want.options].every(([k, v]) => have.options.get(k) === v)) return {};
|
|
159
|
+
return { put: printOptions(have.source, have.volname, want.options, have.size) };
|
|
160
|
+
}
|
|
104
161
|
if (want.volname !== have.volname) {
|
|
105
162
|
return {
|
|
106
163
|
refuse:
|