@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
|
@@ -12,6 +12,7 @@ import { spec } from './group.ts';
|
|
|
12
12
|
import { fakeFailure, fakeOpnsense, fakeOpnsenseLayer, getOnlyOrFail } from './fake-opnsense.ts';
|
|
13
13
|
import type { OpnsenseWriteRefused } from './policy.ts';
|
|
14
14
|
import { opnsenseOperations } from './resource.ts';
|
|
15
|
+
import { optionMap } from './wire.ts';
|
|
15
16
|
|
|
16
17
|
const asRefusal = (value: unknown) => value as OpnsenseWriteRefused;
|
|
17
18
|
|
|
@@ -24,14 +25,18 @@ const PROPS: GroupProps = {
|
|
|
24
25
|
uuid: UUID,
|
|
25
26
|
};
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
// `liveItem` builds a `ModelIfgroupentryReadItem` — the WHOLE-MODEL `get()`'s read-shaped item,
|
|
29
|
+
// whose `members` decodes as an option map, not a comma string (OPNSENSE-2 — see group-form.ts).
|
|
30
|
+
const liveItem = (
|
|
31
|
+
overrides: Partial<group.ModelIfgroupentryReadItem> = {},
|
|
32
|
+
): group.ModelIfgroupentryReadItem => ({
|
|
28
33
|
ifname: 'IOT_DEVICES',
|
|
29
|
-
members: 'opt1,opt2',
|
|
34
|
+
members: optionMap('opt1', 'opt2'),
|
|
30
35
|
sequence: '1',
|
|
31
36
|
...overrides,
|
|
32
37
|
});
|
|
33
38
|
|
|
34
|
-
const getResponse = (items: Record<string, group.
|
|
39
|
+
const getResponse = (items: Record<string, group.ModelIfgroupentryReadItem>) =>
|
|
35
40
|
Response.json({ group: { ifgroupentry: items } });
|
|
36
41
|
|
|
37
42
|
describe('Opnsense.Firewall.Group spec.fetchLive', () => {
|
|
@@ -86,17 +91,21 @@ describe('opnsenseOperations(spec).read — S7/S8, marker-less API', () => {
|
|
|
86
91
|
|
|
87
92
|
describe('opnsenseOperations(spec).diff', () => {
|
|
88
93
|
test('noop when the declared member set matches live, regardless of order', async () => {
|
|
89
|
-
const fake = getOnlyOrFail(() =>
|
|
94
|
+
const fake = getOnlyOrFail(() =>
|
|
95
|
+
getResponse({ [UUID]: liveItem({ members: optionMap('opt2', 'opt1') }) }),
|
|
96
|
+
);
|
|
90
97
|
const result = await Effect.runPromise(
|
|
91
98
|
opnsenseOperations(spec)
|
|
92
|
-
.diff(PROPS, attributesOf(UUID, liveItem({ members: 'opt2,opt1' })))
|
|
99
|
+
.diff(PROPS, attributesOf(UUID, liveItem({ members: optionMap('opt2', 'opt1') })))
|
|
93
100
|
.pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
|
|
94
101
|
);
|
|
95
102
|
expect(result).toEqual({ action: 'noop' });
|
|
96
103
|
});
|
|
97
104
|
|
|
98
105
|
test('update when a member was removed live', async () => {
|
|
99
|
-
const fake = getOnlyOrFail(() =>
|
|
106
|
+
const fake = getOnlyOrFail(() =>
|
|
107
|
+
getResponse({ [UUID]: liveItem({ members: optionMap('opt1') }) }),
|
|
108
|
+
);
|
|
100
109
|
const result = await Effect.runPromise(
|
|
101
110
|
opnsenseOperations(spec)
|
|
102
111
|
.diff(PROPS, attributesOf(UUID, liveItem()))
|
|
@@ -108,7 +117,9 @@ describe('opnsenseOperations(spec).diff', () => {
|
|
|
108
117
|
|
|
109
118
|
describe('opnsenseOperations(spec).reconcile and destroy — the read-only policy', () => {
|
|
110
119
|
test('reconcile observes with one GET, then refuses — never a write', async () => {
|
|
111
|
-
const fake = getOnlyOrFail(() =>
|
|
120
|
+
const fake = getOnlyOrFail(() =>
|
|
121
|
+
getResponse({ [UUID]: liveItem({ members: optionMap('opt1') }) }),
|
|
122
|
+
);
|
|
112
123
|
const failure = asRefusal(
|
|
113
124
|
await Effect.runPromise(
|
|
114
125
|
Effect.flip(
|
package/src/opnsense/group.ts
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* `Opnsense.Firewall.Group` — a read-only declaration of one `firewall/group` item (an
|
|
3
3
|
* "Interface Group", not a permission group) on the edge. READ-ONLY BY DESIGN — see policy.ts's
|
|
4
4
|
* header. `reconcile`/`delete` always refuse. Mirrors alias.ts exactly; see that file's header
|
|
5
|
-
* for why `get()` (whole model) is the read source
|
|
6
|
-
*
|
|
5
|
+
* for why `get()` (whole model) is the read source uniformly across this family rather than the
|
|
6
|
+
* now-correct `getGroup` per-item operation (OPNSENSE-1 fixed `GetGroupRequest`'s missing `uuid`,
|
|
7
|
+
* but switching to it is a separate, later PR). `members` decodes as an option map, not a comma
|
|
8
|
+
* string — see `group-form.ts`'s `attributesOf` (OPNSENSE-2).
|
|
7
9
|
*/
|
|
8
10
|
import { adopt } from 'alchemy/AdoptPolicy';
|
|
9
11
|
import { Resource } from 'alchemy';
|
|
@@ -54,7 +56,12 @@ export const isOpnsenseFirewallGroup = (value: unknown): value is OpnsenseFirewa
|
|
|
54
56
|
value !== null &&
|
|
55
57
|
(value as { Type?: unknown }).Type === 'Opnsense.Firewall.Group';
|
|
56
58
|
|
|
57
|
-
export const spec: OpnsenseSpec<
|
|
59
|
+
export const spec: OpnsenseSpec<
|
|
60
|
+
GroupProps,
|
|
61
|
+
Group.ModelIfgroupentryReadItem,
|
|
62
|
+
GroupAttributes,
|
|
63
|
+
OpnsenseOpError
|
|
64
|
+
> = {
|
|
58
65
|
attributes: attributesOf,
|
|
59
66
|
fetchLive: (props) =>
|
|
60
67
|
Group.get({}).pipe(Effect.map((res) => res.group?.ifgroupentry?.[props.uuid])),
|
package/src/opnsense/wire.ts
CHANGED
|
@@ -2,20 +2,27 @@
|
|
|
2
2
|
* How OPNsense's MVC model spells a value on the wire, and how to get it back to something
|
|
3
3
|
* comparable — the `../proxmox/values.ts` idea, scoped to this vendor's own spellings.
|
|
4
4
|
*
|
|
5
|
-
* ★ OPNSENSE HAS EXACTLY TWO WIRE SHAPES THIS FAMILY NEEDS
|
|
6
|
-
*
|
|
7
|
-
* string
|
|
8
|
-
* `ModelRelationField` is a SINGLE comma-joined string, not a JSON array
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* ★ OPNSENSE HAS EXACTLY TWO WIRE SHAPES THIS FAMILY NEEDS ON WRITE (props stay plain values;
|
|
6
|
+
* this family never writes, but `matches` compares against them). Every `BooleanField` is the
|
|
7
|
+
* string `"1"` or `"0"` (never a JSON boolean). Every multi-select `OptionField`/
|
|
8
|
+
* `ModelRelationField`'s WRITE-side spelling is a SINGLE comma-joined string, not a JSON array.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ READ IS A THIRD SHAPE (OPNSENSE-2, distilled `homeflare/opnsense`'s `read-shape.ts`): the
|
|
11
|
+
* SAME list-shaped fields decode on GET as an option map `{key: {value, selected}}`, not a
|
|
12
|
+
* string — `AliasItem`/`GroupItem` (this family's OLD, pre-fix type references) were the
|
|
13
|
+
* WRITE-shaped structs; `fetchLive` actually decodes `ModelAliasReadItem`/
|
|
14
|
+
* `ModelIfgroupentryReadItem` (the WHOLE-MODEL GET's read-shaped item), whose `type`/
|
|
15
|
+
* `interface`/`proto`/`categories`/`members` are option maps. `selectedOf`/`selectedOneOf`
|
|
16
|
+
* below extract the selected key(s) back to the SAME plain-string/string[] shape `matches`
|
|
17
|
+
* already compares, so the rest of this family's logic (and `AliasAttributes`/
|
|
18
|
+
* `GroupAttributes`'s own field types) is unchanged.
|
|
11
19
|
*
|
|
12
20
|
* ⚠️ COMMA-LIST ORDER ROUND-TRIP IS NOT ASSUMED STABLE. OPNsense builds these from a PHP
|
|
13
|
-
* associative array
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* `groups` field.
|
|
21
|
+
* associative array, and nothing in the model XML promises the join order survives a
|
|
22
|
+
* save-then-read unchanged. Sorting and deduplicating both sides before comparing is what makes
|
|
23
|
+
* `matches` (alias-form.ts, group-form.ts) actually settle instead of reporting drift on a
|
|
24
|
+
* re-ordered list that changed nothing — the same reasoning `../proxmox/user-wire.ts`'s
|
|
25
|
+
* `groupSet` documents for PVE's `groups` field.
|
|
19
26
|
*/
|
|
20
27
|
|
|
21
28
|
/** OPNsense's own boolean spelling. Absent takes the caller's stated default — OPNsense's own
|
|
@@ -54,3 +61,41 @@ export const numField = (value: string | undefined, fallback: number): number =>
|
|
|
54
61
|
const parsed = Number(value);
|
|
55
62
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
56
63
|
};
|
|
64
|
+
|
|
65
|
+
/** One entry of a GET-decoded option map — `{value: <label>, selected: 0|1}`, see this file's
|
|
66
|
+
* module doc. Structural, not imported from the SDK: every generated `*Map` field shares this
|
|
67
|
+
* exact shape (`OptionEntry` in each service module), and staying structural here avoids a
|
|
68
|
+
* per-resource-file type import just for this. */
|
|
69
|
+
export interface OpnsenseOptionEntry {
|
|
70
|
+
readonly value: string;
|
|
71
|
+
readonly selected: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The selected key(s) of a GET-decoded option map, normalised the same way `csvSet` normalises
|
|
76
|
+
* the write-side comma string — sorted, deduplicated, empty-key filtered. Use for a
|
|
77
|
+
* `Multiple="Y"` field (`AliasItem.proto`, `AliasItem.categories`, `GroupItem.members`); for a
|
|
78
|
+
* single-select field use `selectedOneOf`.
|
|
79
|
+
*/
|
|
80
|
+
export const selectedOf = (
|
|
81
|
+
map: Readonly<Record<string, OpnsenseOptionEntry | undefined>> | undefined,
|
|
82
|
+
): readonly string[] => {
|
|
83
|
+
if (map === undefined) return [];
|
|
84
|
+
const keys = Object.entries(map)
|
|
85
|
+
.filter(([key, entry]) => key !== '' && entry?.selected === 1)
|
|
86
|
+
.map(([key]) => key);
|
|
87
|
+
return [...new Set(keys)].sort();
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Single-select convenience over `selectedOf` — the one selected key, or `''` if none (the same
|
|
91
|
+
* "absent" spelling a plain string field uses, e.g. `AliasItem.type`/`GroupItem`'s own
|
|
92
|
+
* `.\InterfaceField` members when not `Multiple="Y"`). */
|
|
93
|
+
export const selectedOneOf = (
|
|
94
|
+
map: Readonly<Record<string, OpnsenseOptionEntry | undefined>> | undefined,
|
|
95
|
+
): string => selectedOf(map)[0] ?? '';
|
|
96
|
+
|
|
97
|
+
/** The encode direction, for test fixtures: every listed key marked `selected: 1`, mirroring a
|
|
98
|
+
* real OPNsense GET response (`{[key]: {value, selected: 1}}`) — so a fixture exercises the exact
|
|
99
|
+
* decode path `selectedOf`/`selectedOneOf` handle, not the pre-fix plain-string shape. */
|
|
100
|
+
export const optionMap = (...keys: readonly string[]): Record<string, OpnsenseOptionEntry> =>
|
|
101
|
+
Object.fromEntries(keys.map((key) => [key, { value: key, selected: 1 }]));
|
|
@@ -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:
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
* both files under the 250-line cap — the api-token.ts/api-token-form.ts seam, same reasoning.
|
|
5
5
|
*/
|
|
6
6
|
import type * as storage from '@distilled.cloud/proxmox/storage';
|
|
7
|
+
import { sharedAccepted } from './storage-plugin-options.ts';
|
|
7
8
|
import type { StorageProps } from './storage.ts';
|
|
9
|
+
import { changedProps } from './storage-wire.ts';
|
|
10
|
+
import type { StorageAttributes } from './storage-wire.ts';
|
|
8
11
|
import { flag } from './values.ts';
|
|
9
12
|
|
|
10
13
|
export const STORAGE_CREATE = 'pve:POST /storage';
|
|
@@ -75,6 +78,16 @@ const locatorForm = (locator: StorageLocator | undefined) =>
|
|
|
75
78
|
const underscored = (form: Record<string, string>): Record<string, string> =>
|
|
76
79
|
Object.fromEntries(Object.entries(form).map(([key, value]) => [key.replaceAll('-', '_'), value]));
|
|
77
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The 7 fields `mutable()` reads, narrowed out of `StorageProps` — not `StorageProps` itself, so
|
|
83
|
+
* a partial diff (storage-wire.ts's `changedProps`, which carries none of `storage`/`locator`/
|
|
84
|
+
* `target`) can satisfy this type directly.
|
|
85
|
+
*/
|
|
86
|
+
export type MutableFields = Pick<
|
|
87
|
+
StorageProps,
|
|
88
|
+
'comment' | 'content' | 'disable' | 'nodes' | 'preallocation' | 'prune-backups' | 'shared'
|
|
89
|
+
>;
|
|
90
|
+
|
|
78
91
|
/**
|
|
79
92
|
* The mutable half, for create and update alike.
|
|
80
93
|
*
|
|
@@ -93,15 +106,21 @@ const underscored = (form: Record<string, string>): Record<string, string> =>
|
|
|
93
106
|
* vendor-constraint table is keyed by PVE's own names (`underscored`'s own header has the
|
|
94
107
|
* measured evidence). `toDistilledCreate`/`toDistilledUpdate` translate a COPY for the actual
|
|
95
108
|
* SDK call; this function and its callers never see the underscored form.
|
|
109
|
+
*
|
|
110
|
+
* ⛔ `shared` IS GATED BY TYPE — `sharedAccepted` (storage-plugin-options.ts, vendor-cited): most
|
|
111
|
+
* plugins refuse it outright ("unexpected property 'shared'", the measured bug this gate
|
|
112
|
+
* fixes), so it is dropped here regardless of what the caller passed, for both create and
|
|
113
|
+
* update alike — `type` is always required precisely so this gate can run on the FULL declared
|
|
114
|
+
* set at create time too, not only on a partial update diff.
|
|
96
115
|
*/
|
|
97
|
-
const mutable = (props:
|
|
116
|
+
const mutable = (type: string, props: MutableFields) => ({
|
|
98
117
|
...field('comment', props.comment),
|
|
99
118
|
...field('content', props.content),
|
|
100
119
|
...field('disable', flag(props.disable)),
|
|
101
120
|
...field('nodes', props.nodes),
|
|
102
121
|
...field('preallocation', props.preallocation),
|
|
103
122
|
...field('prune-backups', props['prune-backups']),
|
|
104
|
-
...field('shared', flag(props.shared)),
|
|
123
|
+
...(sharedAccepted(type) ? field('shared', flag(props.shared)) : {}),
|
|
105
124
|
});
|
|
106
125
|
|
|
107
126
|
/**
|
|
@@ -113,7 +132,7 @@ const mutable = (props: StorageProps) => ({
|
|
|
113
132
|
*/
|
|
114
133
|
export const createForm = (props: StorageProps): Record<string, string> => ({
|
|
115
134
|
...locatorForm(props.locator),
|
|
116
|
-
...mutable(props),
|
|
135
|
+
...mutable(props.type, props),
|
|
117
136
|
storage: props.storage,
|
|
118
137
|
type: props.type,
|
|
119
138
|
});
|
|
@@ -123,9 +142,23 @@ export const createForm = (props: StorageProps): Record<string, string> => ({
|
|
|
123
142
|
* (`PutStorageRequest` has no `type`, and the plugin locator fields it does carry are for a
|
|
124
143
|
* different purpose: moving an existing volume, not re-pointing the definition), and comparing
|
|
125
144
|
* either could only plan an update that no write can apply: a plan that reports work forever.
|
|
145
|
+
*
|
|
146
|
+
* ★ PARTIAL ON A KNOWN LIVE OBJECT — THE FIX FOR THE MEASURED BUG. `live === undefined` means
|
|
147
|
+
* there is no live row to diff against yet (`diff()`'s own early sanity check, run before any
|
|
148
|
+
* read; and the "about to create" branch in `reconcile()`) and this returns the FULL declared
|
|
149
|
+
* set, same as before this change. Once `live` is a real read, only `changedProps`
|
|
150
|
+
* (storage-wire.ts) — the declared fields that actually differ from what PVE just reported —
|
|
151
|
+
* reach the wire. Before this, `reconcile` PUT the full declared set whenever ANYTHING
|
|
152
|
+
* differed, so a declared `shared` that already MATCHED live still rode along on every update;
|
|
153
|
+
* for `storage-cephfs-tb4` (`content` was the only real drift, measured 2026-09-27) that meant
|
|
154
|
+
* sending `shared` to a plugin whose own `options()` never accepts it (storage-plugin-options.ts
|
|
155
|
+
* has the vendor citation), and PVE answered `unexpected property 'shared'`.
|
|
126
156
|
*/
|
|
127
|
-
export const updateForm = (
|
|
128
|
-
|
|
157
|
+
export const updateForm = (
|
|
158
|
+
props: StorageProps,
|
|
159
|
+
live: StorageAttributes | undefined,
|
|
160
|
+
): Record<string, string> => ({
|
|
161
|
+
...mutable(props.type, live === undefined ? props : changedProps(live, props)),
|
|
129
162
|
storage: props.storage,
|
|
130
163
|
});
|
|
131
164
|
|
|
@@ -139,6 +172,13 @@ export const updateForm = (props: StorageProps): Record<string, string> => ({
|
|
|
139
172
|
export const toDistilledCreate = (props: StorageProps): storage.CreateStorageRequest =>
|
|
140
173
|
underscored(createForm(props)) as unknown as storage.CreateStorageRequest;
|
|
141
174
|
|
|
142
|
-
/**
|
|
143
|
-
|
|
144
|
-
|
|
175
|
+
/**
|
|
176
|
+
* The actual `putStorage` call body — translated once. See `toDistilledCreate`.
|
|
177
|
+
* ⚠️ TAKES THE ALREADY-BUILT FORM, NOT `props`+`live` — storage.ts's `reconcile` computes
|
|
178
|
+
* `updateForm(news, before)` exactly ONCE and passes that SAME object here and to `guardWrite`,
|
|
179
|
+
* so the vendor-constraint check and the wire body can never diverge. Two independent calls to
|
|
180
|
+
* `updateForm`, each running its own `changedProps`, would still agree today, but "the guard
|
|
181
|
+
* checks what is actually sent" (the house rule this fix follows) should not depend on that.
|
|
182
|
+
*/
|
|
183
|
+
export const toDistilledUpdate = (form: Record<string, string>): storage.PutStorageRequest =>
|
|
184
|
+
underscored(form) as unknown as storage.PutStorageRequest;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which PVE storage plugin TYPES accept the `shared` property on write (create or update) — from
|
|
3
|
+
* each plugin's own `options()` in vendor source, NOT the generic combined property list
|
|
4
|
+
* `pve-apidoc` documentation carries (storage.ts's own header has that gap: apidoc.js merges
|
|
5
|
+
* every plugin's fields into one flat list for display, so `shared` LOOKS like a plain valid
|
|
6
|
+
* property of PUT /storage/{storage} there — `generated/pve/storage.ts`'s own
|
|
7
|
+
* `StorageStoragePutParams.shared` came from exactly that merged list, and so did the constraint
|
|
8
|
+
* codegen's own inclusion of `shared` as a recognized wire field). The check PVE actually runs is
|
|
9
|
+
* PER STORAGE TYPE, against that concrete plugin's own `options()` accepted-key set
|
|
10
|
+
* (PVE::SectionConfig) — a key the plugin never names is refused outright, which is why a cephfs
|
|
11
|
+
* PUT carrying `shared` answers `unexpected property 'shared'` even though the apidoc schema and
|
|
12
|
+
* the generated wire types both carry the field.
|
|
13
|
+
*
|
|
14
|
+
* MEASURED against github.com/proxmox/pve-storage (the official read-only GitHub mirror of
|
|
15
|
+
* git.proxmox.com/pve-storage.git), branch `master`, read 2026-09-27 — each plugin's own
|
|
16
|
+
* `sub options` block. Extended 2026-09-27 (PR 314's own red team) to cover the five types the
|
|
17
|
+
* first pass left unverified rather than guessed at; only these two lists moved:
|
|
18
|
+
*
|
|
19
|
+
* accepts `shared` (`shared => { optional => 1 }`):
|
|
20
|
+
* DirPlugin.pm:85, LVMPlugin.pm:453, BTRFSPlugin.pm:69, ESXiPlugin.pm:52
|
|
21
|
+
*
|
|
22
|
+
* `shared` is ABSENT from `options()` entirely — PVE's per-type accepted-key check refuses it
|
|
23
|
+
* on both create and update, whatever the combined apidoc schema shows:
|
|
24
|
+
* CephFSPlugin.pm, RBDPlugin.pm, ZFSPoolPlugin.pm, LvmThinPlugin.pm, NFSPlugin.pm,
|
|
25
|
+
* CIFSPlugin.pm, PBSPlugin.pm, ISCSIPlugin.pm, ISCSIDirectPlugin.pm, ZFSPlugin.pm (the remote
|
|
26
|
+
* ZFS-over-iSCSI plugin, distinct from `zfspool`)
|
|
27
|
+
*
|
|
28
|
+
* `storage-cephfs-tb4`'s PUT failure (measured 2026-09-27, `bun run deploy`,
|
|
29
|
+
* `InternalServerError: update storage failed: unexpected property 'shared'`) is exactly this:
|
|
30
|
+
* `cephfs` has no `shared` in its own `options()`, so the base class's generic `shared` property
|
|
31
|
+
* (`PVE::Storage::Plugin`'s `$defaultData->{propertyList}`: `type => 'boolean', optional => 1`,
|
|
32
|
+
* no `fixed`) is never in the accepted set for that plugin, whether or not the declared value
|
|
33
|
+
* would have matched what PVE already has.
|
|
34
|
+
*
|
|
35
|
+
* ⚠️ STILL NOT EXHAUSTIVE. Every PVE plugin type is now checked against vendor source EXCEPT
|
|
36
|
+
* whatever ships after this file was last read — `sharedAccepted` treats an unrecognized type
|
|
37
|
+
* as NOT accepting `shared`, the safe default: a wrongly-excluded type just never sends a
|
|
38
|
+
* declared `shared` (storage-form.ts's `mutable`) and, if the declared value actually disagrees
|
|
39
|
+
* with what PVE reports, the plan dies loud rather than lying `noop` (storage-wire.ts's
|
|
40
|
+
* `matches`) — it does not risk a second `unexpected property` 500. Read a new plugin's own
|
|
41
|
+
* `options()` on github.com/proxmox/pve-storage before adding it to either list.
|
|
42
|
+
*/
|
|
43
|
+
const SHARED_ACCEPTED_TYPES: ReadonlySet<string> = new Set(['dir', 'lvm', 'btrfs', 'esxi']);
|
|
44
|
+
|
|
45
|
+
export const sharedAccepted = (type: string): boolean => SHARED_ACCEPTED_TYPES.has(type);
|