@homeflare/alchemy 0.40.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/dist/caddy/index.js.map +2 -2
- package/dist/ceph/index.js +2 -1
- package/dist/ceph/index.js.map +3 -3
- 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 +48 -34
- package/dist/proxmox/index.js.map +7 -6
- 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/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/package.json +2 -2
- 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/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
|
@@ -16,6 +16,7 @@ import { spec } from './alias.ts';
|
|
|
16
16
|
import { fakeFailure, fakeOpnsense, fakeOpnsenseLayer, getOnlyOrFail } from './fake-opnsense.ts';
|
|
17
17
|
import type { OpnsenseWriteRefused } from './policy.ts';
|
|
18
18
|
import { opnsenseOperations } from './resource.ts';
|
|
19
|
+
import { optionMap } from './wire.ts';
|
|
19
20
|
|
|
20
21
|
/** `Effect.flip`'s promise resolves to the union of every error this spec can raise; every
|
|
21
22
|
* refusal test below is asserting specifically on the `OpnsenseWriteRefused` member. */
|
|
@@ -30,15 +31,17 @@ const PROPS: AliasProps = {
|
|
|
30
31
|
uuid: UUID,
|
|
31
32
|
};
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
// `liveItem` builds a `ModelAliasReadItem` — the WHOLE-MODEL `get()`'s read-shaped item, whose
|
|
35
|
+
// `content`/`type` decode as option maps, not strings (OPNSENSE-2 — see alias-form.ts).
|
|
36
|
+
const liveItem = (overrides: Partial<alias.ModelAliasReadItem> = {}): alias.ModelAliasReadItem => ({
|
|
37
|
+
content: optionMap('10.20.10.1'),
|
|
35
38
|
enabled: '1',
|
|
36
39
|
name: 'homeflare_edge',
|
|
37
|
-
type: 'host',
|
|
40
|
+
type: optionMap('host'),
|
|
38
41
|
...overrides,
|
|
39
42
|
});
|
|
40
43
|
|
|
41
|
-
const getResponse = (items: Record<string, alias.
|
|
44
|
+
const getResponse = (items: Record<string, alias.ModelAliasReadItem>) =>
|
|
42
45
|
Response.json({ alias: { aliases: { alias: items } } });
|
|
43
46
|
|
|
44
47
|
describe('Opnsense.Firewall.Alias spec.fetchLive', () => {
|
|
@@ -126,7 +129,9 @@ describe('opnsenseOperations(spec).diff', () => {
|
|
|
126
129
|
});
|
|
127
130
|
|
|
128
131
|
test('update when live content drifted from the declaration — never from a read error', async () => {
|
|
129
|
-
const fake = getOnlyOrFail(() =>
|
|
132
|
+
const fake = getOnlyOrFail(() =>
|
|
133
|
+
getResponse({ [UUID]: liveItem({ content: optionMap('10.20.10.9') }) }),
|
|
134
|
+
);
|
|
130
135
|
const result = await Effect.runPromise(
|
|
131
136
|
opnsenseOperations(spec)
|
|
132
137
|
.diff(PROPS, attributesOf(UUID, liveItem()))
|
|
@@ -138,7 +143,9 @@ describe('opnsenseOperations(spec).diff', () => {
|
|
|
138
143
|
|
|
139
144
|
describe('opnsenseOperations(spec).reconcile and destroy — the read-only policy', () => {
|
|
140
145
|
test('reconcile observes with one GET, then refuses — never a write', async () => {
|
|
141
|
-
const fake = getOnlyOrFail(() =>
|
|
146
|
+
const fake = getOnlyOrFail(() =>
|
|
147
|
+
getResponse({ [UUID]: liveItem({ content: optionMap('drifted') }) }),
|
|
148
|
+
);
|
|
142
149
|
const failure = asRefusal(
|
|
143
150
|
await Effect.runPromise(
|
|
144
151
|
Effect.flip(
|
package/src/opnsense/alias.ts
CHANGED
|
@@ -3,15 +3,16 @@
|
|
|
3
3
|
* READ-ONLY BY DESIGN — see policy.ts's header. `reconcile`/`delete` always refuse.
|
|
4
4
|
*
|
|
5
5
|
* ★ READ GOES THROUGH `get()`, NEVER `getItem`/`searchItem`. `get()` (`firewall_alias.ts#get`)
|
|
6
|
-
* returns the WHOLE model tree — `{ alias: { aliases: { alias: { <uuid>:
|
|
7
|
-
* — so finding one item by uuid is an object-key lookup, not a request the vendor
|
|
8
|
-
* ambiguously.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
6
|
+
* returns the WHOLE model tree — `{ alias: { aliases: { alias: { <uuid>: ModelAliasReadItem,
|
|
7
|
+
* ... } } } }` — so finding one item by uuid is an object-key lookup, not a request the vendor
|
|
8
|
+
* could answer ambiguously. `Alias` has no per-item `getAlias` operation at all (`getItemAction`
|
|
9
|
+
* decorates its result — see the distilled clone's package README) — only `get` (whole model)
|
|
10
|
+
* and `searchAlias` (paginated, see below) exist, so this family's whole-model read is the only
|
|
11
|
+
* option here (unlike Category/Group, whose `getCategory`/`getGroup` per-item operations now
|
|
12
|
+
* exist correctly — OPNSENSE-1 — but this file still uses `get()` uniformly across the three
|
|
13
|
+
* resources; switching Category/Group to their per-item operation is a separate, later PR).
|
|
14
|
+
* `ModelAliasReadItem`'s list-shaped fields (`type`/`interface`/`proto`/`categories`) decode as
|
|
15
|
+
* option maps, not strings (OPNSENSE-2) — see `alias-form.ts`'s `attributesOf`.
|
|
15
16
|
*
|
|
16
17
|
* ⛔ NOT `searchAlias`. It is POST-shaped (a read, per OPNsense's own MVC convention — see the
|
|
17
18
|
* family's docs page) and paginated with no typed limit/offset the generated request schema
|
|
@@ -87,7 +88,12 @@ export const isOpnsenseFirewallAlias = (value: unknown): value is OpnsenseFirewa
|
|
|
87
88
|
(value as { Type?: unknown }).Type === 'Opnsense.Firewall.Alias';
|
|
88
89
|
|
|
89
90
|
/** Exported for direct testing against `fake-opnsense.ts` — the same seam `../discord/*.ts` uses. */
|
|
90
|
-
export const spec: OpnsenseSpec<
|
|
91
|
+
export const spec: OpnsenseSpec<
|
|
92
|
+
AliasProps,
|
|
93
|
+
Alias.ModelAliasReadItem,
|
|
94
|
+
AliasAttributes,
|
|
95
|
+
OpnsenseOpError
|
|
96
|
+
> = {
|
|
91
97
|
attributes: attributesOf,
|
|
92
98
|
fetchLive: (props) =>
|
|
93
99
|
Alias.get({}).pipe(Effect.map((res) => res.alias?.aliases?.alias?.[props.uuid])),
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `group-form.ts`'s pure functions against literal `
|
|
2
|
+
* `group-form.ts`'s pure functions against literal `ModelIfgroupentryReadItem` fixtures (the
|
|
3
|
+
* WHOLE-MODEL `get()`'s read-shaped item — `members` is an option map, OPNSENSE-2) — no network.
|
|
3
4
|
*/
|
|
4
5
|
import { describe, expect, test } from 'bun:test';
|
|
5
6
|
import type * as group from '@distilled.cloud/opnsense/firewall_group';
|
|
6
7
|
import { attributesOf, matches } from './group-form.ts';
|
|
7
8
|
import type { GroupProps } from './group.ts';
|
|
9
|
+
import { optionMap } from './wire.ts';
|
|
8
10
|
|
|
9
11
|
const UUID = '33333333-3333-3333-3333-333333333333';
|
|
10
12
|
|
|
11
|
-
const liveGroup = (
|
|
13
|
+
const liveGroup = (
|
|
14
|
+
overrides: Partial<group.ModelIfgroupentryReadItem> = {},
|
|
15
|
+
): group.ModelIfgroupentryReadItem => ({
|
|
12
16
|
ifname: 'IOT_DEVICES',
|
|
13
|
-
members: 'opt1,opt2',
|
|
17
|
+
members: optionMap('opt1', 'opt2'),
|
|
14
18
|
sequence: '1',
|
|
15
19
|
...overrides,
|
|
16
20
|
});
|
|
@@ -27,8 +31,8 @@ describe('group-form attributesOf', () => {
|
|
|
27
31
|
});
|
|
28
32
|
});
|
|
29
33
|
|
|
30
|
-
test('members are
|
|
31
|
-
const live = liveGroup({ members: 'opt2,opt1
|
|
34
|
+
test('members are the selected keys, sorted and deduplicated', () => {
|
|
35
|
+
const live = liveGroup({ members: optionMap('opt2', 'opt1') });
|
|
32
36
|
expect(attributesOf(UUID, live).members).toEqual(['opt1', 'opt2']);
|
|
33
37
|
});
|
|
34
38
|
|
|
@@ -50,13 +54,13 @@ describe('group-form matches', () => {
|
|
|
50
54
|
});
|
|
51
55
|
|
|
52
56
|
test('member-list order alone is not drift', () => {
|
|
53
|
-
const live = attributesOf(UUID, liveGroup({ members: 'opt2,opt1' }));
|
|
57
|
+
const live = attributesOf(UUID, liveGroup({ members: optionMap('opt2', 'opt1') }));
|
|
54
58
|
const declared: GroupProps = { ...props, members: ['opt1', 'opt2'] };
|
|
55
59
|
expect(matches(live, declared)).toBe(true);
|
|
56
60
|
});
|
|
57
61
|
|
|
58
62
|
test('a removed member is drift', () => {
|
|
59
|
-
const live = attributesOf(UUID, liveGroup({ members: 'opt1' }));
|
|
63
|
+
const live = attributesOf(UUID, liveGroup({ members: optionMap('opt1') }));
|
|
60
64
|
expect(matches(live, props)).toBe(false);
|
|
61
65
|
});
|
|
62
66
|
});
|
|
@@ -65,7 +69,7 @@ describe('propsFromLive round-trip', () => {
|
|
|
65
69
|
test('the props it returns plan noop against the exact live object they came from', () => {
|
|
66
70
|
const live = liveGroup({
|
|
67
71
|
descr: 'IoT VLANs',
|
|
68
|
-
members: 'opt3,opt1,opt2',
|
|
72
|
+
members: optionMap('opt3', 'opt1', 'opt2'),
|
|
69
73
|
nogroup: '1',
|
|
70
74
|
sequence: '5',
|
|
71
75
|
});
|
|
@@ -5,13 +5,22 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { GroupAttributes, GroupProps } from './group.ts';
|
|
7
7
|
import type * as group from '@distilled.cloud/opnsense/firewall_group';
|
|
8
|
-
import { bool01,
|
|
8
|
+
import { bool01, csvSetOf, numField, selectedOf } from './wire.ts';
|
|
9
9
|
|
|
10
|
-
/**
|
|
11
|
-
|
|
10
|
+
/**
|
|
11
|
+
* ★ Attributes and the declaration renderer are one function — see alias-form.ts's header note.
|
|
12
|
+
*
|
|
13
|
+
* `live` is `ModelIfgroupentryReadItem` (the WHOLE-MODEL `get()`'s read-shaped item), not
|
|
14
|
+
* `GroupItem` — `members` (`.\InterfaceField`, `Multiple="Y"`) decodes as an option map, not a
|
|
15
|
+
* comma string (OPNSENSE-2; see wire.ts's module doc).
|
|
16
|
+
*/
|
|
17
|
+
export const attributesOf = (
|
|
18
|
+
uuid: string,
|
|
19
|
+
live: group.ModelIfgroupentryReadItem,
|
|
20
|
+
): GroupAttributes => ({
|
|
12
21
|
description: live.descr ?? '',
|
|
13
22
|
ifname: live.ifname,
|
|
14
|
-
members:
|
|
23
|
+
members: selectedOf(live.members),
|
|
15
24
|
nogroup: bool01(live.nogroup, false),
|
|
16
25
|
sequence: numField(live.sequence, 0),
|
|
17
26
|
uuid,
|
|
@@ -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 }]));
|
|
@@ -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);
|