@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.
Files changed (77) hide show
  1. package/dist/caddy/index.js.map +2 -2
  2. package/dist/ceph/index.js +2 -1
  3. package/dist/ceph/index.js.map +3 -3
  4. package/dist/launchd/fake-runner.d.ts.map +1 -1
  5. package/dist/launchd/index.d.ts +1 -1
  6. package/dist/launchd/index.d.ts.map +1 -1
  7. package/dist/launchd/index.js +7 -1
  8. package/dist/launchd/index.js.map +5 -5
  9. package/dist/launchd/local-runner.d.ts.map +1 -1
  10. package/dist/launchd/runner.d.ts +21 -0
  11. package/dist/launchd/runner.d.ts.map +1 -1
  12. package/dist/launchd/sudo-runner.d.ts.map +1 -1
  13. package/dist/linux/directory-lifecycle.d.ts.map +1 -1
  14. package/dist/linux/fake-linux-host.d.ts.map +1 -1
  15. package/dist/linux/index.d.ts +1 -1
  16. package/dist/linux/index.d.ts.map +1 -1
  17. package/dist/linux/index.js +16 -4
  18. package/dist/linux/index.js.map +7 -7
  19. package/dist/linux/ssh-runner.d.ts.map +1 -1
  20. package/dist/linux/sudo-runner.d.ts.map +1 -1
  21. package/dist/opnsense/alias-form.d.ts +14 -1
  22. package/dist/opnsense/alias-form.d.ts.map +1 -1
  23. package/dist/opnsense/alias.d.ts +1 -1
  24. package/dist/opnsense/alias.d.ts.map +1 -1
  25. package/dist/opnsense/group-form.d.ts +8 -2
  26. package/dist/opnsense/group-form.d.ts.map +1 -1
  27. package/dist/opnsense/group.d.ts +1 -1
  28. package/dist/opnsense/group.d.ts.map +1 -1
  29. package/dist/opnsense/index.js +15 -7
  30. package/dist/opnsense/index.js.map +7 -7
  31. package/dist/opnsense/wire.d.ts +42 -12
  32. package/dist/opnsense/wire.d.ts.map +1 -1
  33. package/dist/proxmox/index.js +48 -34
  34. package/dist/proxmox/index.js.map +7 -6
  35. package/dist/proxmox/storage-form.d.ts +28 -3
  36. package/dist/proxmox/storage-form.d.ts.map +1 -1
  37. package/dist/proxmox/storage-plugin-options.d.ts +2 -0
  38. package/dist/proxmox/storage-plugin-options.d.ts.map +1 -0
  39. package/dist/proxmox/storage-wire.d.ts +28 -1
  40. package/dist/proxmox/storage-wire.d.ts.map +1 -1
  41. package/dist/proxmox/storage.d.ts +6 -1
  42. package/dist/proxmox/storage.d.ts.map +1 -1
  43. package/dist/release/index.d.ts +1 -1
  44. package/dist/release/index.d.ts.map +1 -1
  45. package/dist/release/index.js.map +2 -2
  46. package/docs/distilled-interim.md +7 -0
  47. package/docs/launchd.md +5 -3
  48. package/docs/provider-standard.md +5 -1
  49. package/docs/talos-argocd-dogfood.md +160 -0
  50. package/package.json +2 -2
  51. package/src/launchd/fake-runner.ts +2 -0
  52. package/src/launchd/index.ts +8 -1
  53. package/src/launchd/local-runner.ts +16 -1
  54. package/src/launchd/runner.ts +22 -0
  55. package/src/launchd/sudo-runner.ts +3 -0
  56. package/src/linux/directory-lifecycle.ts +18 -3
  57. package/src/linux/directory-sudo.test.ts +59 -0
  58. package/src/linux/directory.test.ts +34 -8
  59. package/src/linux/fake-linux-host.ts +2 -0
  60. package/src/linux/index.ts +1 -0
  61. package/src/linux/ssh-runner.ts +4 -0
  62. package/src/linux/sudo-runner.ts +3 -0
  63. package/src/opnsense/alias-form.test.ts +24 -12
  64. package/src/opnsense/alias-form.ts +20 -7
  65. package/src/opnsense/alias.test.ts +13 -6
  66. package/src/opnsense/alias.ts +16 -10
  67. package/src/opnsense/group-form.test.ts +12 -8
  68. package/src/opnsense/group-form.ts +13 -4
  69. package/src/opnsense/group.test.ts +18 -7
  70. package/src/opnsense/group.ts +10 -3
  71. package/src/opnsense/wire.ts +57 -12
  72. package/src/proxmox/storage-form.ts +48 -8
  73. package/src/proxmox/storage-plugin-options.ts +45 -0
  74. package/src/proxmox/storage-update.test.ts +183 -0
  75. package/src/proxmox/storage-wire.ts +54 -7
  76. package/src/proxmox/storage.ts +18 -6
  77. 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
- const liveItem = (overrides: Partial<alias.AliasItem> = {}): alias.AliasItem => ({
34
- content: '10.20.10.1',
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.AliasItem>) =>
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(() => getResponse({ [UUID]: liveItem({ content: '10.20.10.9' }) }));
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(() => getResponse({ [UUID]: liveItem({ content: 'drifted' }) }));
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(
@@ -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>: AliasItem, ... } } } }`
7
- * — so finding one item by uuid is an object-key lookup, not a request the vendor could answer
8
- * ambiguously. That sidesteps two real gaps in this early SDK, both measured against the
9
- * generated `firewall_alias.ts`: (1) there is no per-item `getAlias` operation at all — only
10
- * `get` (whole model) and `searchAlias` (paginated, see below) exist; (2) `firewall_category.ts`
11
- * and `firewall_group.ts` DO export a `getCategory`/`getGroup`, but their generated request
12
- * schema carries no `uuid` path parameter (`GetCategoryRequest`/`GetGroupRequest` are both
13
- * `S.Struct({})` against a `uri` with no `{uuid}` placeholder) — a generation gap, not a design
14
- * choice, so this family does not rely on them for any of the three resources, uniformly.
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<AliasProps, Alias.AliasItem, AliasAttributes, OpnsenseOpError> = {
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 `GroupItem` fixtures — no network.
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 = (overrides: Partial<group.GroupItem> = {}): group.GroupItem => ({
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 split, sorted and deduplicated', () => {
31
- const live = liveGroup({ members: 'opt2,opt1,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, csvSet, csvSetOf, numField } from './wire.ts';
8
+ import { bool01, csvSetOf, numField, selectedOf } from './wire.ts';
9
9
 
10
- /** ★ Attributes and the declaration renderer are one function — see alias-form.ts's header note. */
11
- export const attributesOf = (uuid: string, live: group.GroupItem): GroupAttributes => ({
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: csvSet(live.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
- const liveItem = (overrides: Partial<group.GroupItem> = {}): group.GroupItem => ({
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.GroupItem>) =>
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(() => getResponse({ [UUID]: liveItem({ members: 'opt2,opt1' }) }));
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(() => getResponse({ [UUID]: liveItem({ members: 'opt1' }) }));
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(() => getResponse({ [UUID]: liveItem({ members: 'opt1' }) }));
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(
@@ -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 rather than `getGroup` — the same generation
6
- * gap (no `uuid` in `GetGroupRequest`'s schema) applies here too.
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<GroupProps, Group.GroupItem, GroupAttributes, OpnsenseOpError> = {
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])),
@@ -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. Every `BooleanField` is the string
6
- * `"1"` or `"0"` (never a JSON boolean — `AliasItem.enabled: string`, `GroupItem.nogroup?:
7
- * string`, see `firewall_alias.ts`/`firewall_group.ts`). Every multi-select `OptionField`/
8
- * `ModelRelationField` is a SINGLE comma-joined string, not a JSON array (`AliasItem.categories`,
9
- * `GroupItem.members` — both carry the doc comment "wire value is a single comma-joined string,
10
- * not a JSON array" in the generated SDK). Nothing here is specific to one resource.
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 (`ModelRelationField`/`OptionField::getNodeData()`), and nothing in the
14
- * model XML promises the join order survives a save-then-read unchanged. Sorting and
15
- * deduplicating both sides before comparing is what makes `matches` (alias-form.ts,
16
- * group-form.ts) actually settle instead of reporting drift on a re-ordered list that changed
17
- * nothing — the same reasoning `../proxmox/user-wire.ts`'s `groupSet` documents for PVE's
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: StorageProps) => ({
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 = (props: StorageProps): Record<string, string> => ({
128
- ...mutable(props),
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
- /** The actual `putStorage` call body — `updateForm`, translated once. See `toDistilledCreate`. */
143
- export const toDistilledUpdate = (props: StorageProps): storage.PutStorageRequest =>
144
- underscored(updateForm(props)) as unknown as storage.PutStorageRequest;
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);