@homeflare/alchemy 0.31.2 → 0.33.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 +39 -3
- package/dist/grafana/dashboard-model.d.ts +77 -0
- package/dist/grafana/dashboard-model.d.ts.map +1 -0
- package/dist/grafana/dashboard.d.ts +98 -0
- package/dist/grafana/dashboard.d.ts.map +1 -0
- package/dist/grafana/folder.d.ts +100 -0
- package/dist/grafana/folder.d.ts.map +1 -0
- package/dist/grafana/index.d.ts +9 -8
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +158 -6
- package/dist/grafana/index.js.map +8 -4
- package/dist/grafana/providers.d.ts +1 -1
- package/dist/grafana/providers.d.ts.map +1 -1
- package/dist/grafana/provisioned.d.ts +11 -0
- package/dist/grafana/provisioned.d.ts.map +1 -0
- package/dist/linux/container-form.d.ts +129 -0
- package/dist/linux/container-form.d.ts.map +1 -0
- package/dist/linux/container-generator.d.ts +74 -0
- package/dist/linux/container-generator.d.ts.map +1 -0
- package/dist/linux/container-handlers.d.ts +15 -0
- package/dist/linux/container-handlers.d.ts.map +1 -0
- package/dist/linux/container-lifecycle.d.ts +37 -0
- package/dist/linux/container-lifecycle.d.ts.map +1 -0
- package/dist/linux/container-preflight.d.ts +34 -0
- package/dist/linux/container-preflight.d.ts.map +1 -0
- package/dist/linux/container-secrets.d.ts +37 -0
- package/dist/linux/container-secrets.d.ts.map +1 -0
- package/dist/linux/container-settle.d.ts +34 -0
- package/dist/linux/container-settle.d.ts.map +1 -0
- package/dist/linux/container.d.ts +30 -0
- package/dist/linux/container.d.ts.map +1 -0
- package/dist/linux/fake-quadlet.d.ts +25 -0
- package/dist/linux/fake-quadlet.d.ts.map +1 -0
- package/dist/linux/index.d.ts +6 -3
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +641 -268
- package/dist/linux/index.js.map +15 -7
- package/dist/linux/providers.d.ts +1 -1
- package/dist/linux/providers.d.ts.map +1 -1
- package/dist/linux/systemctl.d.ts +7 -1
- package/dist/linux/systemctl.d.ts.map +1 -1
- package/dist/opnsense/alias-form.d.ts +29 -0
- package/dist/opnsense/alias-form.d.ts.map +1 -0
- package/dist/opnsense/alias.d.ts +81 -0
- package/dist/opnsense/alias.d.ts.map +1 -0
- package/dist/opnsense/category-form.d.ts +10 -0
- package/dist/opnsense/category-form.d.ts.map +1 -0
- package/dist/opnsense/category.d.ts +58 -0
- package/dist/opnsense/category.d.ts.map +1 -0
- package/dist/opnsense/credentials.d.ts +36 -0
- package/dist/opnsense/credentials.d.ts.map +1 -0
- package/dist/opnsense/fake-opnsense.d.ts +51 -0
- package/dist/opnsense/fake-opnsense.d.ts.map +1 -0
- package/dist/opnsense/group-form.d.ts +11 -0
- package/dist/opnsense/group-form.d.ts.map +1 -0
- package/dist/opnsense/group.d.ts +62 -0
- package/dist/opnsense/group.d.ts.map +1 -0
- package/dist/opnsense/index.d.ts +16 -0
- package/dist/opnsense/index.d.ts.map +1 -0
- package/dist/opnsense/index.js +211 -0
- package/dist/opnsense/index.js.map +20 -0
- package/dist/opnsense/policy.d.ts +20 -0
- package/dist/opnsense/policy.d.ts.map +1 -0
- package/dist/opnsense/providers.d.ts +13 -0
- package/dist/opnsense/providers.d.ts.map +1 -0
- package/dist/opnsense/resource.d.ts +74 -0
- package/dist/opnsense/resource.d.ts.map +1 -0
- package/dist/opnsense/wire.d.ts +39 -0
- package/dist/opnsense/wire.d.ts.map +1 -0
- package/dist/proxmox/index.js +170 -80
- package/dist/proxmox/index.js.map +9 -7
- package/dist/proxmox/storage-form.d.ts +54 -0
- package/dist/proxmox/storage-form.d.ts.map +1 -0
- package/dist/proxmox/storage-wire.d.ts +56 -0
- package/dist/proxmox/storage-wire.d.ts.map +1 -0
- package/dist/proxmox/storage.d.ts +55 -60
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/proxmox/zfs-pool-form.d.ts +124 -0
- package/dist/proxmox/zfs-pool-form.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool-wire.d.ts +78 -0
- package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool.d.ts +27 -52
- package/dist/proxmox/zfs-pool.d.ts.map +1 -1
- package/dist/unifi/fake-unifi.d.ts +48 -0
- package/dist/unifi/fake-unifi.d.ts.map +1 -0
- package/dist/unifi/firewall-zone-form.d.ts +40 -0
- package/dist/unifi/firewall-zone-form.d.ts.map +1 -0
- package/dist/unifi/firewall-zone.d.ts +50 -0
- package/dist/unifi/firewall-zone.d.ts.map +1 -0
- package/dist/unifi/index.d.ts +13 -0
- package/dist/unifi/index.d.ts.map +1 -0
- package/dist/unifi/index.js +190 -0
- package/dist/unifi/index.js.map +16 -0
- package/dist/unifi/network-form.d.ts +67 -0
- package/dist/unifi/network-form.d.ts.map +1 -0
- package/dist/unifi/network.d.ts +67 -0
- package/dist/unifi/network.d.ts.map +1 -0
- package/dist/unifi/policy.d.ts +20 -0
- package/dist/unifi/policy.d.ts.map +1 -0
- package/dist/unifi/providers.d.ts +13 -0
- package/dist/unifi/providers.d.ts.map +1 -0
- package/dist/unifi/resource.d.ts +64 -0
- package/dist/unifi/resource.d.ts.map +1 -0
- package/docs/grafana-folder-dashboard.md +145 -0
- package/docs/grafana.md +11 -10
- package/docs/opnsense.md +146 -0
- package/docs/provider-standard.md +1 -1
- package/docs/quadlet-container.md +132 -0
- package/docs/unifi.md +93 -0
- package/docs/upstream-conformance.md +9 -4
- package/package.json +12 -2
- package/src/grafana/dashboard-model.test.ts +111 -0
- package/src/grafana/dashboard-model.ts +111 -0
- package/src/grafana/dashboard.test.ts +232 -0
- package/src/grafana/dashboard.ts +161 -0
- package/src/grafana/folder.test.ts +212 -0
- package/src/grafana/folder.ts +161 -0
- package/src/grafana/index.ts +21 -8
- package/src/grafana/providers.ts +7 -1
- package/src/grafana/provisioned.ts +49 -0
- package/src/linux/container-fixture.test.ts +92 -0
- package/src/linux/container-form.ts +243 -0
- package/src/linux/container-generator.test.ts +195 -0
- package/src/linux/container-generator.ts +113 -0
- package/src/linux/container-handlers.ts +73 -0
- package/src/linux/container-lifecycle.test.ts +209 -0
- package/src/linux/container-lifecycle.ts +217 -0
- package/src/linux/container-preflight.ts +97 -0
- package/src/linux/container-secrets.test.ts +171 -0
- package/src/linux/container-secrets.ts +112 -0
- package/src/linux/container-settle.ts +97 -0
- package/src/linux/container.ts +60 -0
- package/src/linux/fake-quadlet.ts +127 -0
- package/src/linux/index.ts +11 -3
- package/src/linux/providers.ts +2 -0
- package/src/linux/systemctl.ts +9 -0
- package/src/opnsense/alias-form.test.ts +89 -0
- package/src/opnsense/alias-form.ts +53 -0
- package/src/opnsense/alias.test.ts +203 -0
- package/src/opnsense/alias.ts +117 -0
- package/src/opnsense/category-form.test.ts +54 -0
- package/src/opnsense/category-form.ts +20 -0
- package/src/opnsense/category.test.ts +138 -0
- package/src/opnsense/category.ts +82 -0
- package/src/opnsense/credentials.ts +41 -0
- package/src/opnsense/fake-opnsense.ts +79 -0
- package/src/opnsense/group-form.test.ts +75 -0
- package/src/opnsense/group-form.ts +25 -0
- package/src/opnsense/group.test.ts +141 -0
- package/src/opnsense/group.ts +78 -0
- package/src/opnsense/index.ts +45 -0
- package/src/opnsense/policy.ts +49 -0
- package/src/opnsense/providers.ts +21 -0
- package/src/opnsense/resource.ts +146 -0
- package/src/opnsense/wire.ts +56 -0
- package/src/opnsense/write-refusal.test.ts +148 -0
- package/src/proxmox/constraints-host-forms.test.ts +1 -1
- package/src/proxmox/storage-form.ts +144 -0
- package/src/proxmox/storage-read-failure.test.ts +214 -0
- package/src/proxmox/storage-wire.ts +124 -0
- package/src/proxmox/storage.ts +165 -175
- package/src/proxmox/zfs-pool-adopt.test.ts +26 -0
- package/src/proxmox/zfs-pool-form.ts +236 -0
- package/src/proxmox/zfs-pool-read-failure.test.ts +118 -0
- package/src/proxmox/zfs-pool-wire.ts +132 -0
- package/src/proxmox/zfs-pool.ts +106 -76
- package/src/unifi/fake-unifi.ts +60 -0
- package/src/unifi/firewall-zone-form.ts +65 -0
- package/src/unifi/firewall-zone.test.ts +88 -0
- package/src/unifi/firewall-zone.ts +66 -0
- package/src/unifi/index.ts +26 -0
- package/src/unifi/network-form.ts +207 -0
- package/src/unifi/network.test.ts +209 -0
- package/src/unifi/network.ts +83 -0
- package/src/unifi/policy.ts +37 -0
- package/src/unifi/providers.ts +15 -0
- package/src/unifi/resource.test.ts +133 -0
- package/src/unifi/resource.ts +131 -0
- package/dist/proxmox/zfs-pool-spec.d.ts +0 -27
- package/dist/proxmox/zfs-pool-spec.d.ts.map +0 -1
- package/dist/proxmox/zfs-pool-write.d.ts +0 -128
- package/dist/proxmox/zfs-pool-write.d.ts.map +0 -1
- package/src/proxmox/zfs-pool-spec.ts +0 -76
- package/src/proxmox/zfs-pool-write.ts +0 -196
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One shape for every OPNsense object this family declares — `read`/`diff` mirroring
|
|
3
|
+
* `../discord/resource.ts`'s marker-less-API posture exactly (S7–S9, H1), and `reconcile`/
|
|
4
|
+
* `delete` replaced end to end with the read-only refusal `policy.ts` states.
|
|
5
|
+
*
|
|
6
|
+
* ★ WHY ONE ENGINE FOR THREE RESOURCES. `alias.ts`, `category.ts` and `group.ts` differ only in
|
|
7
|
+
* which `firewall_*` module they call and how a live item maps to attributes — the read/diff/
|
|
8
|
+
* reconcile/delete flow is identical, so it lives here once, the way `netbox/resource.ts` and
|
|
9
|
+
* `discord/resource.ts` already do for their own families.
|
|
10
|
+
*
|
|
11
|
+
* ★ `Unowned(attrs)` ON A COLD READ, PLAIN ATTRIBUTES ON A WARM ONE (S7, S8). None of Alias,
|
|
12
|
+
* Category or Group carries an ownership marker OPNsense itself understands — no tag, no
|
|
13
|
+
* metadata field a stack could stamp — so this is the same "marker-less API" S8 describes, and
|
|
14
|
+
* the same posture `../discord/resource.ts` documents against `Snippet.ts`'s reference read.
|
|
15
|
+
* `adopt(true)` (H5), piped on by each resource file's convenience constructor, is what turns
|
|
16
|
+
* an `Unowned` first read into a silent one-time takeover instead of an `OwnedBySomeoneElse`
|
|
17
|
+
* refusal — exactly what an import script generating `alchemy.run.ts` rows from live reads
|
|
18
|
+
* needs: declare what is live, adopt it, change nothing.
|
|
19
|
+
*
|
|
20
|
+
* ⛔ `reconcile` AND `delete` NEVER CALL AN SDK OPERATION. `reconcile` still OBSERVES first (one
|
|
21
|
+
* GET, via `read`) so the refusal it raises can say whether it would have created or updated —
|
|
22
|
+
* S9's "observe before you write" doctrine, kept for the error message even though there is no
|
|
23
|
+
* write to guard. `delete` observes nothing: it refuses unconditionally, because under this
|
|
24
|
+
* policy a delete never succeeds regardless of whether the object is still live. See policy.ts.
|
|
25
|
+
*/
|
|
26
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
27
|
+
import { isResolved } from 'alchemy/Diff';
|
|
28
|
+
import type { Input } from 'alchemy/Input';
|
|
29
|
+
import type { OpnsenseOpContext } from '@distilled.cloud/opnsense/Protocol';
|
|
30
|
+
import * as Effect from 'effect/Effect';
|
|
31
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
32
|
+
import { CredentialsFromEnv } from './credentials.ts';
|
|
33
|
+
import { refuseWrite } from './policy.ts';
|
|
34
|
+
|
|
35
|
+
/** What every stack must still provide after `opnsenseHandlers` bakes in `CredentialsFromEnv`. */
|
|
36
|
+
export type OpnsenseRequirements = HttpClient.HttpClient;
|
|
37
|
+
|
|
38
|
+
/** Every resource in this family identifies its live object by OPNsense's own assigned uuid. */
|
|
39
|
+
export interface OpnsenseIdentity {
|
|
40
|
+
readonly uuid: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One OPNsense object's read path. `Live` is whatever the generated `get()` response decodes to
|
|
45
|
+
* for one item (an `AliasItem`, `CategoryItem`, `GroupItem`); `E` is left to each resource file
|
|
46
|
+
* to declare — in practice always `OpnsenseOpError`, since every generated operation shares one
|
|
47
|
+
* error channel (protocol.ts).
|
|
48
|
+
*/
|
|
49
|
+
export type OpnsenseSpec<Props extends OpnsenseIdentity, Live, Attributes extends object, E> = {
|
|
50
|
+
readonly resourceType: string;
|
|
51
|
+
/** Absent when `props.uuid` is not a key in the live model's map — see each resource file. */
|
|
52
|
+
readonly fetchLive: (props: Props) => Effect.Effect<Live | undefined, E, OpnsenseOpContext>;
|
|
53
|
+
/** Also the declaration renderer (S.T. propsFromLive re-export) — see each `*-form.ts`. */
|
|
54
|
+
readonly attributes: (uuid: string, live: Live) => Attributes;
|
|
55
|
+
readonly matches: (attributes: Attributes, props: Props) => boolean;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const opnsenseOperations = <
|
|
59
|
+
Props extends OpnsenseIdentity,
|
|
60
|
+
Live,
|
|
61
|
+
Attributes extends object,
|
|
62
|
+
E,
|
|
63
|
+
>(
|
|
64
|
+
spec: OpnsenseSpec<Props, Live, Attributes, E>,
|
|
65
|
+
) => {
|
|
66
|
+
const read = (props: Props) =>
|
|
67
|
+
spec
|
|
68
|
+
.fetchLive(props)
|
|
69
|
+
.pipe(
|
|
70
|
+
Effect.map((live) => (live === undefined ? undefined : spec.attributes(props.uuid, live))),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
read: ({ olds, output }: { olds: Props; output: Attributes | undefined }) =>
|
|
75
|
+
Effect.gen(function* () {
|
|
76
|
+
const attrs = yield* read(olds);
|
|
77
|
+
if (attrs === undefined) return undefined;
|
|
78
|
+
// Warm read: a prior apply already adopted this exact uuid — ours.
|
|
79
|
+
if (output !== undefined) return attrs;
|
|
80
|
+
// Cold read (fresh store, or state lost): no ownership marker exists on this vendor.
|
|
81
|
+
return Unowned(attrs);
|
|
82
|
+
}),
|
|
83
|
+
|
|
84
|
+
diff: (news: Input<Props>, output: Attributes | undefined) =>
|
|
85
|
+
Effect.gen(function* () {
|
|
86
|
+
if (output === undefined || !isResolved(news)) return undefined;
|
|
87
|
+
const live = yield* read(news);
|
|
88
|
+
if (live === undefined) return { action: 'update' } as const;
|
|
89
|
+
return spec.matches(live, news)
|
|
90
|
+
? ({ action: 'noop' } as const)
|
|
91
|
+
: ({ action: 'update' } as const);
|
|
92
|
+
}),
|
|
93
|
+
|
|
94
|
+
reconcile: (news: Props) =>
|
|
95
|
+
Effect.gen(function* () {
|
|
96
|
+
const live = yield* read(news);
|
|
97
|
+
return yield* refuseWrite(
|
|
98
|
+
spec.resourceType,
|
|
99
|
+
news.uuid,
|
|
100
|
+
live === undefined ? 'create' : 'update',
|
|
101
|
+
);
|
|
102
|
+
}),
|
|
103
|
+
|
|
104
|
+
destroy: (olds: Props) => refuseWrite(spec.resourceType, olds.uuid, 'delete'),
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The provider handlers for a spec'd OPNsense object, wired once. `list` answers empty — every
|
|
110
|
+
* `get()` in this family returns the WHOLE model tree with no per-account/per-scope narrowing,
|
|
111
|
+
* so a `list` that enumerated it would sweep the estate's entire alias/category/group set into
|
|
112
|
+
* one plan; adoption stays explicit, one uuid at a time, the same reasoning netbox's and
|
|
113
|
+
* discord's own `list` give for a shared or marker-less object space (S12).
|
|
114
|
+
*
|
|
115
|
+
* `nuke: { skip: true }` — `delete` refuses unconditionally (policy.ts), so `alchemy unsafe
|
|
116
|
+
* nuke` could never do anything here but fail loudly on every object it found; skipping keeps
|
|
117
|
+
* that refusal out of a nuke census entirely, matching `postgres/database.ts` and
|
|
118
|
+
* `github/repository-ruleset.ts`'s own `nuke: { skip: true }` for the same reason.
|
|
119
|
+
*/
|
|
120
|
+
export const opnsenseHandlers = <
|
|
121
|
+
Props extends OpnsenseIdentity,
|
|
122
|
+
Live,
|
|
123
|
+
Attributes extends object,
|
|
124
|
+
E,
|
|
125
|
+
>(
|
|
126
|
+
spec: OpnsenseSpec<Props, Live, Attributes, E>,
|
|
127
|
+
) => {
|
|
128
|
+
const ops = opnsenseOperations(spec);
|
|
129
|
+
// ⚠️ GENERIC OVER ITS OWN ERROR AND REQUIREMENT CHANNELS, NOT PINNED TO `E`/`OpnsenseOpContext`.
|
|
130
|
+
// `read`/`diff` fail with `E` and need `OpnsenseOpContext`; `reconcile` fails with
|
|
131
|
+
// `E | OpnsenseWriteRefused` (it observes, then refuses) and still needs `OpnsenseOpContext`
|
|
132
|
+
// for that observe; `delete` fails with `OpnsenseWriteRefused` alone and needs NOTHING (it
|
|
133
|
+
// never observes) — three different signatures sharing one credentials-providing wrapper.
|
|
134
|
+
const withCredentials = <A, Err, R>(effect: Effect.Effect<A, Err, R>) =>
|
|
135
|
+
Effect.provide(effect, CredentialsFromEnv);
|
|
136
|
+
return {
|
|
137
|
+
list: () => Effect.succeed([]),
|
|
138
|
+
nuke: { skip: true },
|
|
139
|
+
read: ({ olds, output }: { olds: Props; output: Attributes | undefined }) =>
|
|
140
|
+
withCredentials(ops.read({ olds, output })),
|
|
141
|
+
diff: ({ news, output }: { news: Input<Props>; output: Attributes | undefined }) =>
|
|
142
|
+
withCredentials(ops.diff(news, output)),
|
|
143
|
+
reconcile: ({ news }: { news: Props }) => withCredentials(ops.reconcile(news)),
|
|
144
|
+
delete: ({ olds }: { olds: Props }) => withCredentials(ops.destroy(olds)),
|
|
145
|
+
};
|
|
146
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How OPNsense's MVC model spells a value on the wire, and how to get it back to something
|
|
3
|
+
* comparable — the `../proxmox/values.ts` idea, scoped to this vendor's own spellings.
|
|
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.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ 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.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** OPNsense's own boolean spelling. Absent takes the caller's stated default — OPNsense's own
|
|
22
|
+
* default for a `BooleanField` is form-specific, so there is no house-wide fallback here. */
|
|
23
|
+
export const bool01 = (value: string | undefined, fallback: boolean): boolean =>
|
|
24
|
+
value === undefined ? fallback : value === '1';
|
|
25
|
+
|
|
26
|
+
/** The write direction, for symmetry — unused today (this family never writes), kept so a form
|
|
27
|
+
* helper never has to guess the spelling if lifting the read-only policy adds one later. */
|
|
28
|
+
export const toBool01 = (value: boolean): string => (value ? '1' : '0');
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A comma-joined multi-select column, normalised to a sorted, deduplicated set — see the module
|
|
32
|
+
* header's ⚠️ for why normalising (not just splitting) is what makes a diff settle.
|
|
33
|
+
*/
|
|
34
|
+
export const csvSet = (value: string | undefined): readonly string[] => {
|
|
35
|
+
if (value === undefined || value.trim() === '') return [];
|
|
36
|
+
const items = value
|
|
37
|
+
.split(',')
|
|
38
|
+
.map((item) => item.trim())
|
|
39
|
+
.filter((item) => item !== '');
|
|
40
|
+
return [...new Set(items)].sort();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** A declared array, normalised the SAME way `csvSet` normalises the wire, so `matches` compares
|
|
44
|
+
* two sets built by one function rather than trusting the caller's array to already be sorted. */
|
|
45
|
+
export const csvSetOf = (value: readonly string[] | undefined): readonly string[] =>
|
|
46
|
+
csvSet(value === undefined ? undefined : value.join(','));
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* OPNsense's numeric-as-string fields (`"60"`, `"0"`), tolerant of an already-numeric input the
|
|
50
|
+
* same way `../proxmox/user-wire.ts`'s `int` is tolerant of PVE's mixed number/string releases.
|
|
51
|
+
*/
|
|
52
|
+
export const numField = (value: string | undefined, fallback: number): number => {
|
|
53
|
+
if (value === undefined) return fallback;
|
|
54
|
+
const parsed = Number(value);
|
|
55
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
56
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PROOF: no SDK write operation — `addItem`, `setItem`, `delItem`, `toggleItem`, `set`, or
|
|
3
|
+
* OPNsense's own POST-shaped search reads — is reachable from ANY handler of ANY resource in this
|
|
4
|
+
* family, for ANY input. `getOnlyOrFail` (fake-opnsense.ts) fails the test itself the instant a
|
|
5
|
+
* non-GET request is made, so this does not rely on inspecting what each handler happens to call
|
|
6
|
+
* — it fails loudly the moment anything but a `get()` reaches the wire.
|
|
7
|
+
*
|
|
8
|
+
* `alias.test.ts`, `category.test.ts` and `group.test.ts` already run every scenario through this
|
|
9
|
+
* same fake and would catch a write there too; this file exists so ONE place in this family
|
|
10
|
+
* states the guarantee plainly, exercising the public `handlers` object each resource file wires
|
|
11
|
+
* into its `Provider` (not just the lower-level `opnsenseOperations`), across every lifecycle
|
|
12
|
+
* method `Provider.of` calls and against both an absent and a drifted live object.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ `handlers.*` BAKES IN `CredentialsFromEnv` (resource.ts's `withCredentials`), unlike
|
|
15
|
+
* `opnsenseOperations(spec)` — see fake-opnsense.ts's warning on `fakeOpnsenseLayer`. Providing
|
|
16
|
+
* `fakeOpnsenseLayer`'s own `Credentials` layer from here does nothing: the inner `Effect.provide`
|
|
17
|
+
* already closed over that tag, so it wins. Left unaddressed, `CredentialsFromEnv` reads real
|
|
18
|
+
* `OPNSENSE_*` env vars via `EffectConfig`, finds none in CI, and `Effect.orDie`s before any
|
|
19
|
+
* request is built — `fake.seen` stays empty and `.every(...)` on an empty array is vacuously
|
|
20
|
+
* `true`. `fakeCredentialsConfigProvider` below fixes this WITHOUT touching real `process.env`
|
|
21
|
+
* (which a parallel test file could race on): it overrides the `effect/ConfigProvider` the
|
|
22
|
+
* `EffectConfig.String("OPNSENSE_*")` reads inside `CredentialsFromEnv` resolve against, which is
|
|
23
|
+
* a different tag CredentialsFromEnv never shadows — proved against this exact fake/real pattern
|
|
24
|
+
* before landing here. Every "only GETs" test below also asserts `fake.seen` has exactly the
|
|
25
|
+
* one GET `fetchLive` issues, so a run that silently makes zero requests (or a future
|
|
26
|
+
* double-GET regression) fails loudly instead of passing on an under-checked `.every()`.
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ THIS FIX HAS ONE INVARIANT: `CredentialsFromEnv` (distilled-opnsense's `credentials.ts`) must
|
|
29
|
+
* keep resolving `OPNSENSE_*` through the AMBIENT `ConfigProvider` rather than one it pins
|
|
30
|
+
* itself. If it ever starts providing its own `ConfigProvider` — the way it already closes over
|
|
31
|
+
* the `Credentials` tag via `withCredentials` — this override would be shadowed the same way
|
|
32
|
+
* `fakeOpnsenseLayer`'s `Credentials` layer is today, and the vacuous-pass bug this file exists
|
|
33
|
+
* to prevent would return silently.
|
|
34
|
+
*/
|
|
35
|
+
import { describe, expect, test } from 'bun:test';
|
|
36
|
+
import * as Effect from 'effect/Effect';
|
|
37
|
+
import * as Layer from 'effect/Layer';
|
|
38
|
+
import * as ConfigProvider from 'effect/ConfigProvider';
|
|
39
|
+
import { handlers as aliasHandlers, spec as aliasSpec } from './alias.ts';
|
|
40
|
+
import { handlers as categoryHandlers, spec as categorySpec } from './category.ts';
|
|
41
|
+
import {
|
|
42
|
+
FAKE_BASE,
|
|
43
|
+
FAKE_KEY,
|
|
44
|
+
FAKE_SECRET,
|
|
45
|
+
fakeOpnsenseLayer,
|
|
46
|
+
getOnlyOrFail,
|
|
47
|
+
} from './fake-opnsense.ts';
|
|
48
|
+
import { handlers as groupHandlers, spec as groupSpec } from './group.ts';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Overrides the `ConfigProvider` `CredentialsFromEnv`'s `EffectConfig.String("OPNSENSE_*")`
|
|
52
|
+
* resolves against, so `handlers.*` (which bakes in real env-var credentials resolution) gets
|
|
53
|
+
* the same placeholder values `fakeOpnsenseLayer`'s default `Credentials` layer uses — without
|
|
54
|
+
* ever reading or writing real `process.env`.
|
|
55
|
+
*/
|
|
56
|
+
const fakeCredentialsConfigProvider = ConfigProvider.layer(
|
|
57
|
+
ConfigProvider.fromUnknown({
|
|
58
|
+
OPNSENSE_API_KEY: FAKE_KEY,
|
|
59
|
+
OPNSENSE_API_SECRET: FAKE_SECRET,
|
|
60
|
+
OPNSENSE_URL: FAKE_BASE,
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
/** `fakeOpnsenseLayer` plus the env override `handlers.*` needs — see the file header. */
|
|
65
|
+
const fakeHandlersLayer = (fetchFn: typeof globalThis.fetch) =>
|
|
66
|
+
Layer.mergeAll(fakeOpnsenseLayer(fetchFn), fakeCredentialsConfigProvider);
|
|
67
|
+
|
|
68
|
+
const families = [
|
|
69
|
+
{
|
|
70
|
+
empty: Response.json({ alias: {} }),
|
|
71
|
+
handlers: aliasHandlers,
|
|
72
|
+
live: Response.json({
|
|
73
|
+
alias: { aliases: { alias: { u: { content: 'x', enabled: '1', name: 'n', type: 'host' } } } },
|
|
74
|
+
}),
|
|
75
|
+
props: { content: 'x', name: 'n', type: 'host', uuid: 'u' },
|
|
76
|
+
resourceType: aliasSpec.resourceType,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
empty: Response.json({ category: {} }),
|
|
80
|
+
handlers: categoryHandlers,
|
|
81
|
+
live: Response.json({ category: { categories: { category: { u: { name: 'n' } } } } }),
|
|
82
|
+
props: { name: 'n', uuid: 'u' },
|
|
83
|
+
resourceType: categorySpec.resourceType,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
empty: Response.json({ group: {} }),
|
|
87
|
+
handlers: groupHandlers,
|
|
88
|
+
live: Response.json({
|
|
89
|
+
group: { ifgroupentry: { u: { ifname: 'n', members: '', sequence: '0' } } },
|
|
90
|
+
}),
|
|
91
|
+
props: { ifname: 'n', members: [], uuid: 'u' },
|
|
92
|
+
resourceType: groupSpec.resourceType,
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
describe.each(families)(
|
|
97
|
+
'$resourceType — write-refusal proof',
|
|
98
|
+
({ empty, handlers, live, props }) => {
|
|
99
|
+
test('list() is empty and issues no request', async () => {
|
|
100
|
+
const fake = getOnlyOrFail(() => {
|
|
101
|
+
throw new Error('list() must never call the wire at all');
|
|
102
|
+
});
|
|
103
|
+
const result = await Effect.runPromise(
|
|
104
|
+
Effect.provide(handlers.list(), fakeOpnsenseLayer(fake.fetch)),
|
|
105
|
+
);
|
|
106
|
+
expect(result).toEqual([]);
|
|
107
|
+
expect(fake.seen).toHaveLength(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('reconcile against a LIVE object still only GETs, then refuses', async () => {
|
|
111
|
+
const fake = getOnlyOrFail(() => live);
|
|
112
|
+
const outcome = await Effect.runPromiseExit(
|
|
113
|
+
Effect.provide(handlers.reconcile({ news: props as never }), fakeHandlersLayer(fake.fetch)),
|
|
114
|
+
);
|
|
115
|
+
expect(outcome._tag).toBe('Failure');
|
|
116
|
+
// ⚠️ Not sufficient alone — `.every()` on an empty array is vacuously true. See the file
|
|
117
|
+
// header: without `fakeHandlersLayer`'s ConfigProvider override this assertion never ran
|
|
118
|
+
// a single request, because `CredentialsFromEnv` died first. Exactly 1, not just > 0:
|
|
119
|
+
// `fetchLive` (alias.ts/category.ts/group.ts) issues one `get()` — matches
|
|
120
|
+
// `alias.test.ts`'s own `toHaveLength(1)` and also catches a future double-GET regression.
|
|
121
|
+
expect(fake.seen).toHaveLength(1);
|
|
122
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('reconcile against an ABSENT object still only GETs, then refuses', async () => {
|
|
126
|
+
const fake = getOnlyOrFail(() => empty);
|
|
127
|
+
const outcome = await Effect.runPromiseExit(
|
|
128
|
+
Effect.provide(handlers.reconcile({ news: props as never }), fakeHandlersLayer(fake.fetch)),
|
|
129
|
+
);
|
|
130
|
+
expect(outcome._tag).toBe('Failure');
|
|
131
|
+
expect(fake.seen).toHaveLength(1);
|
|
132
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('delete makes ZERO requests and refuses', async () => {
|
|
136
|
+
const fake = getOnlyOrFail(() => live);
|
|
137
|
+
const outcome = await Effect.runPromiseExit(
|
|
138
|
+
Effect.provide(handlers.delete({ olds: props as never }), fakeOpnsenseLayer(fake.fetch)),
|
|
139
|
+
);
|
|
140
|
+
expect(outcome._tag).toBe('Failure');
|
|
141
|
+
expect(fake.seen).toHaveLength(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('nuke is skipped, so account-wide teardown never even lists this type', () => {
|
|
145
|
+
expect(handlers.nuke).toEqual({ skip: true });
|
|
146
|
+
});
|
|
147
|
+
},
|
|
148
|
+
);
|
|
@@ -13,7 +13,7 @@ import { constraintsFor, formViolations } from './constraint-guard.ts';
|
|
|
13
13
|
import type { PbsTarget, PveTarget } from './credentials.ts';
|
|
14
14
|
import { createBody as ifaceCreateForm } from './node-network-form.ts';
|
|
15
15
|
import { resolveGroups, targetForm } from './pbs-notification-target-form.ts';
|
|
16
|
-
import { createForm as zfsCreateForm } from './zfs-pool-
|
|
16
|
+
import { createForm as zfsCreateForm } from './zfs-pool-form.ts';
|
|
17
17
|
|
|
18
18
|
const TARGET: PveTarget = { members: ['pve.test:8006'], mount: 'pve-test', scheme: 'pve' };
|
|
19
19
|
const PBS: PbsTarget = { api: 'https://pbs.test:8007/api2/json', mount: 'pbs-test', scheme: 'pbs' };
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.Storage`'s WRITE side: the locator bag, the create/update forms, and the translation
|
|
3
|
+
* into distilled's own typed request shape. Split out of storage-wire.ts (2026-09-24) to keep
|
|
4
|
+
* both files under the 250-line cap — the api-token.ts/api-token-form.ts seam, same reasoning.
|
|
5
|
+
*/
|
|
6
|
+
import type * as storage from '@distilled.cloud/proxmox/storage';
|
|
7
|
+
import type { StorageProps } from './storage.ts';
|
|
8
|
+
import { flag } from './values.ts';
|
|
9
|
+
|
|
10
|
+
export const STORAGE_CREATE = 'pve:POST /storage';
|
|
11
|
+
export const STORAGE_UPDATE = 'pve:PUT /storage/{storage}';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The create-time, plugin-specific half of a storage: `dir` wants `path`, `nfs` `server`+`export`,
|
|
15
|
+
* `cifs` `server`+`share`, `pbs` `server`+`datastore`, `rbd` `pool`+`monhost`, `lvmthin`
|
|
16
|
+
* `vgname`+`thinpool`, `zfspool` `pool`. A bag rather than two dozen named fields, because PVE
|
|
17
|
+
* ships about that many plugins and each brings its own locator. ⚠️ Never diffed — see `matches`
|
|
18
|
+
* (storage-wire.ts).
|
|
19
|
+
*
|
|
20
|
+
* ⛔ `password`, `keyring` AND `encryption-key` ARE `never` ON PURPOSE — see the ⛔ in storage.ts's
|
|
21
|
+
* header. POSITIVE-CONTROLLED rather than assumed: all three are a compile error, `TS2322: Type
|
|
22
|
+
* 'string' is not assignable to type 'undefined'`.
|
|
23
|
+
* ⚠️ EVERY KEY IN THIS BAG GOES THROUGH `underscored` (below) BEFORE IT REACHES DISTILLED — see
|
|
24
|
+
* that function's own header. A caller writes PVE's own field names here (`fs-name`, matching
|
|
25
|
+
* `pvesm`/pve-docs), never distilled's renamed ones.
|
|
26
|
+
*/
|
|
27
|
+
export type StorageLocator = Record<string, string> & {
|
|
28
|
+
password?: never;
|
|
29
|
+
keyring?: never;
|
|
30
|
+
'encryption-key'?: never;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const field = (name: string, value: string | undefined): Record<string, string> =>
|
|
34
|
+
value === undefined ? {} : { [name]: value };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* ⚠️ THE CAST DROPS THE THREE `never` GUARDS AND NOTHING ELSE, and it is load-bearing rather than
|
|
38
|
+
* lazy: an intersection carrying OPTIONAL properties is not assignable to `Record<string,string>`
|
|
39
|
+
* at all, so the guards and the form body cannot both exist without one conversion. At runtime a
|
|
40
|
+
* locator is a plain string map; the guards only ever existed to fail a declaration.
|
|
41
|
+
*/
|
|
42
|
+
const locatorForm = (locator: StorageLocator | undefined) =>
|
|
43
|
+
({ ...locator }) as Record<string, string>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* ★ ROUTES EVERY OUTGOING FIELD THROUGH DISTILLED'S OWN TYPED PROPERTY, RATHER THAN THROUGH ITS
|
|
47
|
+
* "UNKNOWN KEY" FALLBACK — NOT BECAUSE THE WIRE NEEDS THE UNDERSCORED SPELLING. MEASURED both
|
|
48
|
+
* ways (`storage-read-failure.test.ts`'s create test, run once with this function and once
|
|
49
|
+
* without it): PVE's own hyphenated name (`content-dirs=…`) reaches the wire either way —
|
|
50
|
+
* `@distilled.cloud/proxmox`'s generated `T.Body("content-dirs")` annotation re-hyphenates its
|
|
51
|
+
* underscored `content_dirs` property on the way out, AND an untranslated hyphenated key also
|
|
52
|
+
* passes through unchanged as an "unknown key", form-encoded alongside the recognized ones (the
|
|
53
|
+
* GET-request version of that same passthrough is what caused kit 0.31.1's regression — see
|
|
54
|
+
* user.ts/group.ts — but a POST/PUT is already body-bearing, so the failure mode there doesn't
|
|
55
|
+
* apply). So EITHER path is wire-correct for a plain rename. What `underscored` buys instead:
|
|
56
|
+
* distilled's own schema only runs its declared per-field logic (a `T.Body()` rename, but also
|
|
57
|
+
* whatever type coercion or list-joining a FUTURE plugin field might carry) for a property it
|
|
58
|
+
* recognizes — an unrecognized key gets none of that, silently. `StorageLocator` is free-form
|
|
59
|
+
* on purpose (the header's own reasoning), so this file cannot enumerate every field a plugin
|
|
60
|
+
* might need; routing through the recognized property is the one way to get distilled's own
|
|
61
|
+
* per-field handling for a field this package's author never tried, rather than a bare rename.
|
|
62
|
+
* Renamed against every hyphenated field on the generated `CreateStorageRequest`/
|
|
63
|
+
* `PutStorageRequest` today (`prune-backups` -> `prune_backups`, `fs-name` -> `fs_name`,
|
|
64
|
+
* `max-protected-backups` -> `max_protected_backups`, `zfs-base-path` -> `zfs_base_path`,
|
|
65
|
+
* `content-dirs` -> `content_dirs`, `saferemove-stepsize` -> `saferemove_stepsize`, and so on) —
|
|
66
|
+
* a generic transform, not a lookup table, for the same free-form reason. No other migrated
|
|
67
|
+
* family (acl/group/role/user/api-token) hit any of this: none of their fields are hyphenated.
|
|
68
|
+
* ⛔ NEVER APPLIED TO THE FORM `guardWrite` CHECKS, REGARDLESS. The generated vendor-constraint
|
|
69
|
+
* tables (`generated/constraints/pve-storage.ts`) are keyed by PVE's OWN hyphenated names —
|
|
70
|
+
* running this first would make every constraint on `prune-backups`, `fs-name`, etc. silently
|
|
71
|
+
* never match, the vendor check passing over a value it never actually inspected. `createForm`/
|
|
72
|
+
* `updateForm` stay hyphenated for exactly that reason; only `toDistilledCreate`/
|
|
73
|
+
* `toDistilledUpdate` (the actual SDK call, storage.ts's `reconcile`) apply this.
|
|
74
|
+
*/
|
|
75
|
+
const underscored = (form: Record<string, string>): Record<string, string> =>
|
|
76
|
+
Object.fromEntries(Object.entries(form).map(([key, value]) => [key.replaceAll('-', '_'), value]));
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The mutable half, for create and update alike.
|
|
80
|
+
*
|
|
81
|
+
* ⚠️ AN UNDECLARED FIELD IS NEITHER SENT NOR COMPARED: undeclared means UNMANAGED here, unlike
|
|
82
|
+
* `Proxmox.Pool` where an absent comment means `''`. A storage has no single default to fall back
|
|
83
|
+
* on — `content` defaults per plugin, and clearing a field needs an explicit `delete=` parameter
|
|
84
|
+
* rather than an empty value — so a guessed default would rewrite a storage somebody tuned by
|
|
85
|
+
* hand, and comparing one would report drift nobody declared.
|
|
86
|
+
*
|
|
87
|
+
* ⚠️ RECONCILE PUTs UNCONDITIONALLY whenever the object exists (group.ts's own reconcile has the
|
|
88
|
+
* same note), so this form must be safe to re-apply to a storage that already matches. It is:
|
|
89
|
+
* the declared set and nothing else, and PVE leaves absent parameters alone.
|
|
90
|
+
*
|
|
91
|
+
* ★ KEPT HYPHENATED (`'prune-backups'`, PVE's own wire name), NOT DISTILLED'S UNDERSCORED
|
|
92
|
+
* `prune_backups` — this form is what `guardWrite` checks (storage.ts), and the generated
|
|
93
|
+
* vendor-constraint table is keyed by PVE's own names (`underscored`'s own header has the
|
|
94
|
+
* measured evidence). `toDistilledCreate`/`toDistilledUpdate` translate a COPY for the actual
|
|
95
|
+
* SDK call; this function and its callers never see the underscored form.
|
|
96
|
+
*/
|
|
97
|
+
const mutable = (props: StorageProps) => ({
|
|
98
|
+
...field('comment', props.comment),
|
|
99
|
+
...field('content', props.content),
|
|
100
|
+
...field('disable', flag(props.disable)),
|
|
101
|
+
...field('nodes', props.nodes),
|
|
102
|
+
...field('preallocation', props.preallocation),
|
|
103
|
+
...field('prune-backups', props['prune-backups']),
|
|
104
|
+
...field('shared', flag(props.shared)),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* ⚠️ THE BAG IS SPREAD FIRST SO NOTHING IN IT CAN SHADOW A FIELD THIS RESOURCE MANAGES. A stray
|
|
109
|
+
* `storage` or `type` in a locator would otherwise rename the object being created, and PVE
|
|
110
|
+
* would build the wrong thing under a name Alchemy then records as the declared one.
|
|
111
|
+
* ★ HYPHENATED, THE WIRE SHAPE — `guardWrite` (storage.ts) checks this form directly; see
|
|
112
|
+
* `underscored`'s header for why translating it here would blind that check.
|
|
113
|
+
*/
|
|
114
|
+
export const createForm = (props: StorageProps): Record<string, string> => ({
|
|
115
|
+
...locatorForm(props.locator),
|
|
116
|
+
...mutable(props),
|
|
117
|
+
storage: props.storage,
|
|
118
|
+
type: props.type,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* ⚠️ `type` AND THE LOCATOR ARE NEVER SENT ON UPDATE — PVE's PUT refuses the create-only fields
|
|
123
|
+
* (`PutStorageRequest` has no `type`, and the plugin locator fields it does carry are for a
|
|
124
|
+
* different purpose: moving an existing volume, not re-pointing the definition), and comparing
|
|
125
|
+
* either could only plan an update that no write can apply: a plan that reports work forever.
|
|
126
|
+
*/
|
|
127
|
+
export const updateForm = (props: StorageProps): Record<string, string> => ({
|
|
128
|
+
...mutable(props),
|
|
129
|
+
storage: props.storage,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The actual `createStorage` call body — `createForm`, translated once. See `underscored`.
|
|
134
|
+
* ⚠️ `as unknown as` — same load-bearing cast as `locatorForm`'s own ⚠️: a plain
|
|
135
|
+
* `Record<string,string>` does not structurally overlap a named-field interface enough for
|
|
136
|
+
* TypeScript to accept a direct cast, even though every field it carries fits one of the
|
|
137
|
+
* interface's own (all-optional-but-two) properties.
|
|
138
|
+
*/
|
|
139
|
+
export const toDistilledCreate = (props: StorageProps): storage.CreateStorageRequest =>
|
|
140
|
+
underscored(createForm(props)) as unknown as storage.CreateStorageRequest;
|
|
141
|
+
|
|
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;
|