@homeflare/alchemy 0.31.1 → 0.32.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 +44 -7
- package/dist/grafana/index.d.ts +6 -5
- package/dist/grafana/index.d.ts.map +1 -1
- 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/acl.d.ts.map +1 -1
- package/dist/proxmox/api-token.d.ts.map +1 -1
- package/dist/proxmox/group-wire.d.ts +44 -5
- package/dist/proxmox/group-wire.d.ts.map +1 -1
- package/dist/proxmox/group.d.ts.map +1 -1
- package/dist/proxmox/index.js +326 -264
- package/dist/proxmox/index.js.map +13 -11
- package/dist/proxmox/role.d.ts.map +1 -1
- 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/user-wire.d.ts +34 -5
- package/dist/proxmox/user-wire.d.ts.map +1 -1
- package/dist/proxmox/user.d.ts +6 -6
- package/dist/proxmox/user.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/dist/verify/fake-engine.d.ts +14 -0
- package/dist/verify/fake-engine.d.ts.map +1 -1
- package/docs/grafana.md +47 -17
- 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 +14 -4
- package/package.json +13 -3
- package/src/grafana/index.ts +6 -5
- 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/acl.ts +10 -4
- package/src/proxmox/api-token.ts +20 -12
- package/src/proxmox/group-wire.ts +62 -8
- package/src/proxmox/group.ts +23 -35
- package/src/proxmox/read-failure-propagation.test.ts +183 -0
- package/src/proxmox/role.ts +9 -2
- 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/user-wire.ts +44 -8
- package/src/proxmox/user.ts +29 -27
- 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/src/verify/fake-engine.ts +23 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The 0.31.1 regression (measured live 2026-09-24, homeflare-proxmox `bun run plan`): every
|
|
3
|
+
* `Proxmox.User`/`Proxmox.Group` row planned `update` with no warning, because their reads were
|
|
4
|
+
* silently folding EVERY failure — not only a refused credential — into "absent". Two fixes,
|
|
5
|
+
* both pinned here with REAL-SHAPED fixtures (live response bodies, nothing secret):
|
|
6
|
+
*
|
|
7
|
+
* 1. `access.getAccessUser`/`getAccessGroup` must be called with ONLY the schema's own label
|
|
8
|
+
* field, never the whole declared props object — the root cause: distilled's `buildRequest`
|
|
9
|
+
* treats every OTHER key as an "unknown key" and attaches it as a JSON body to what must stay
|
|
10
|
+
* a bodyless GET, which a stricter fetch client refuses outright.
|
|
11
|
+
* 2. `diff` must never fold a genuine (non-credential-denial) read failure into "absent" — only
|
|
12
|
+
* `read`/`reconcile` may, where a wrongful fold costs at most a redundant, loudly-refused
|
|
13
|
+
* create, never a silent wrong write.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
import * as Layer from 'effect/Layer';
|
|
17
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
18
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
19
|
+
import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
|
|
20
|
+
import { ProxmoxGroup, ProxmoxGroupProvider } from './group.ts';
|
|
21
|
+
import { ProxmoxUser, ProxmoxUserProvider } from './user.ts';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A fake cluster whose GET on `path` answers 200 with `live`, but REFUSES (500, mimicking PVE's
|
|
25
|
+
* real "InternalServerError" shape) once `transientFrom` is `true` — and asserts no request ever
|
|
26
|
+
* carries a body on a GET, the request-shape regression's own signature.
|
|
27
|
+
*/
|
|
28
|
+
const clusterWithTransientFailure = (path: string, live: unknown) => {
|
|
29
|
+
let transient = false;
|
|
30
|
+
const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
31
|
+
const request =
|
|
32
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
33
|
+
const url = new URL(request.url);
|
|
34
|
+
if (url.pathname.includes('/creds/')) {
|
|
35
|
+
const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
|
|
36
|
+
return Response.json({ data, lease_duration: 0 });
|
|
37
|
+
}
|
|
38
|
+
if (request.method === 'GET') {
|
|
39
|
+
const body = await request.text();
|
|
40
|
+
// ⛔ THE REQUEST-SHAPE REGRESSION'S OWN SIGNATURE: a real fetch client refuses this outright.
|
|
41
|
+
expect(body).toBe('');
|
|
42
|
+
}
|
|
43
|
+
// ⚠️ `decodeURIComponent` — user.ts's own ⚠️: distilled percent-encodes a `{userid}`/
|
|
44
|
+
// `{groupid}` label substitution (`tim@pve` -> `tim%40pve`), which a literal `path` compare
|
|
45
|
+
// would silently never match, turning every case below into a no-op stub.
|
|
46
|
+
const wire = decodeURIComponent(url.pathname.replace(/^\/api2\/json\//, ''));
|
|
47
|
+
if (wire !== path) return Response.json({ data: null });
|
|
48
|
+
if (transient) {
|
|
49
|
+
// MEASURED 2026-09-24, live TB4: a missing user/group answers exactly this shape.
|
|
50
|
+
return Response.json({ data: null, message: `no such object (${path})` }, { status: 500 });
|
|
51
|
+
}
|
|
52
|
+
return Response.json({ data: live });
|
|
53
|
+
};
|
|
54
|
+
const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
|
|
55
|
+
return {
|
|
56
|
+
breakReads: () => {
|
|
57
|
+
transient = true;
|
|
58
|
+
},
|
|
59
|
+
layer: FetchHttpClient.layer.pipe(
|
|
60
|
+
Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchStub as typeof fetch)),
|
|
61
|
+
),
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
describe('a transient read failure fails the plan loudly, never a silent false update', () => {
|
|
66
|
+
test('Proxmox.User: diff() propagates a transient read failure once a state row exists', async () => {
|
|
67
|
+
// MEASURED 2026-09-24, live TB4 `GET /access/users/tim@pve`.
|
|
68
|
+
const live = {
|
|
69
|
+
comment: 'Primary admin — PVE fallback',
|
|
70
|
+
email: 'tim@schenanigans.com',
|
|
71
|
+
enable: 1,
|
|
72
|
+
expire: 0,
|
|
73
|
+
firstname: 'Timothy',
|
|
74
|
+
groups: ['Schenanigans'],
|
|
75
|
+
lastname: 'Schneider',
|
|
76
|
+
tokens: null,
|
|
77
|
+
};
|
|
78
|
+
const declare = () =>
|
|
79
|
+
ProxmoxUser('tim', {
|
|
80
|
+
comment: 'Primary admin — PVE fallback',
|
|
81
|
+
email: 'tim@schenanigans.com',
|
|
82
|
+
firstname: 'Timothy',
|
|
83
|
+
groups: ['Schenanigans'],
|
|
84
|
+
lastname: 'Schneider',
|
|
85
|
+
target: FAKE_TARGET,
|
|
86
|
+
userid: 'tim@pve',
|
|
87
|
+
});
|
|
88
|
+
const fake = clusterWithTransientFailure('access/users/tim@pve', live);
|
|
89
|
+
await withoutBao(async () => {
|
|
90
|
+
const engine = engineOver(ProxmoxUserProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
91
|
+
// First deploy: adopts the matching account, records a state row.
|
|
92
|
+
expect(await engine.deploy(declare())).toEqual({ tim: 'adopted' });
|
|
93
|
+
// Now every read of this account fails the way the live regression measured.
|
|
94
|
+
fake.breakReads();
|
|
95
|
+
// ⛔ THE FIX: this must REJECT (fail the plan), never resolve with a false 'update'.
|
|
96
|
+
await expect(engine.verify(declare(), { all: true })).rejects.toBeDefined();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('Proxmox.Group: diff() propagates a transient read failure once a state row exists', async () => {
|
|
101
|
+
// MEASURED 2026-09-24, live TB4 `GET /access/groups/HomeAssistant`.
|
|
102
|
+
const live = { comment: 'Home Assistant service group', members: [] };
|
|
103
|
+
const declare = () =>
|
|
104
|
+
ProxmoxGroup('ha', {
|
|
105
|
+
comment: 'Home Assistant service group',
|
|
106
|
+
groupid: 'HomeAssistant',
|
|
107
|
+
target: FAKE_TARGET,
|
|
108
|
+
});
|
|
109
|
+
const fake = clusterWithTransientFailure('access/groups/HomeAssistant', live);
|
|
110
|
+
await withoutBao(async () => {
|
|
111
|
+
const engine = engineOver(ProxmoxGroupProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
112
|
+
expect(await engine.deploy(declare())).toEqual({ ha: 'adopted' });
|
|
113
|
+
fake.breakReads();
|
|
114
|
+
await expect(engine.verify(declare(), { all: true })).rejects.toBeDefined();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ★ A brand-new declaration's own create-from-a-500 path is already covered end to end by
|
|
119
|
+
// user.test.ts's `missingAs500` fixture — `readUser`/`readGroup` (used by `reconcile`) kept
|
|
120
|
+
// their fold on purpose, exactly so that path stays unaffected by this fix.
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('the same fix, one command over: alchemy drift never reports a transient failure as missing', () => {
|
|
124
|
+
// ⛔ FOUND BY ADVERSARIAL REVIEW OF THIS VERY FIX, 2026-09-24 — NOT part of the live-measured
|
|
125
|
+
// regression above, but the SAME bug class one layer deeper. `alchemy/src/Drift.ts` (`alchemy
|
|
126
|
+
// drift`/`sync`/`deploy --detect-drift`) calls a provider's `read` hook DIRECTLY on an
|
|
127
|
+
// already-confirmed row (`output: old.attr`, defined — Drift.ts:239-292), the same situation
|
|
128
|
+
// `diff` handles above. Before `user.ts`/`group.ts`'s `read` hook learned to branch on
|
|
129
|
+
// `output` (this fix's second half), it ALWAYS called the folding `readUser`/`readGroup` —
|
|
130
|
+
// so a transient failure here folded to `undefined`, and Drift.ts's own dry-run branch
|
|
131
|
+
// reports that as `{action: 'missing'}` with NO error surfaced at all: a silent false "this
|
|
132
|
+
// account is gone" report, worse than the false `update` `diff` used to produce because
|
|
133
|
+
// nothing here even logs a warning. `engineOver`'s new `drift` capability
|
|
134
|
+
// (verify/fake-engine.ts) drives the real `Alchemy.Drift.detect` over the same fake cluster.
|
|
135
|
+
test('Proxmox.User: a confirmed row propagates instead of reporting missing', async () => {
|
|
136
|
+
const live = {
|
|
137
|
+
comment: 'Primary admin — PVE fallback',
|
|
138
|
+
email: 'tim@schenanigans.com',
|
|
139
|
+
enable: 1,
|
|
140
|
+
expire: 0,
|
|
141
|
+
firstname: 'Timothy',
|
|
142
|
+
groups: ['Schenanigans'],
|
|
143
|
+
lastname: 'Schneider',
|
|
144
|
+
tokens: null,
|
|
145
|
+
};
|
|
146
|
+
const declare = () =>
|
|
147
|
+
ProxmoxUser('tim', {
|
|
148
|
+
comment: 'Primary admin — PVE fallback',
|
|
149
|
+
email: 'tim@schenanigans.com',
|
|
150
|
+
firstname: 'Timothy',
|
|
151
|
+
groups: ['Schenanigans'],
|
|
152
|
+
lastname: 'Schneider',
|
|
153
|
+
target: FAKE_TARGET,
|
|
154
|
+
userid: 'tim@pve',
|
|
155
|
+
});
|
|
156
|
+
const fake = clusterWithTransientFailure('access/users/tim@pve', live);
|
|
157
|
+
await withoutBao(async () => {
|
|
158
|
+
const engine = engineOver(ProxmoxUserProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
159
|
+
expect(await engine.deploy(declare())).toEqual({ tim: 'adopted' });
|
|
160
|
+
fake.breakReads();
|
|
161
|
+
// ⛔ THE FIX: this must REJECT. Before `read`'s `output` branch, it resolved with
|
|
162
|
+
// `{ tim: { action: 'missing' } }` and no error anywhere.
|
|
163
|
+
await expect(engine.drift(declare())).rejects.toBeDefined();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('Proxmox.Group: a confirmed row propagates instead of reporting missing', async () => {
|
|
168
|
+
const live = { comment: 'Home Assistant service group', members: [] };
|
|
169
|
+
const declare = () =>
|
|
170
|
+
ProxmoxGroup('ha', {
|
|
171
|
+
comment: 'Home Assistant service group',
|
|
172
|
+
groupid: 'HomeAssistant',
|
|
173
|
+
target: FAKE_TARGET,
|
|
174
|
+
});
|
|
175
|
+
const fake = clusterWithTransientFailure('access/groups/HomeAssistant', live);
|
|
176
|
+
await withoutBao(async () => {
|
|
177
|
+
const engine = engineOver(ProxmoxGroupProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
178
|
+
expect(await engine.deploy(declare())).toEqual({ ha: 'adopted' });
|
|
179
|
+
fake.breakReads();
|
|
180
|
+
await expect(engine.drift(declare())).rejects.toBeDefined();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
});
|
package/src/proxmox/role.ts
CHANGED
|
@@ -107,7 +107,15 @@ export interface ProxmoxRole extends Resource<
|
|
|
107
107
|
|
|
108
108
|
export const ProxmoxRole = Resource<ProxmoxRole>('Proxmox.Role');
|
|
109
109
|
|
|
110
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* ★ Default `read` role — see the header's ⚠️ on the list read's privilege requirement.
|
|
112
|
+
* ⛔ NO `orElseSucceed` — found 2026-09-24, the same bug class as the credential denial fix. A
|
|
113
|
+
* role's absence is ALWAYS a SUCCESSFUL read (the list comes back and the role is not in it —
|
|
114
|
+
* `access.listAccessRoles` never 404s/500s per-role the way `getAccessUser`/`getAccessGroup`
|
|
115
|
+
* do, MEASURED), so this family never needed a folding/non-folding split the way user.ts and
|
|
116
|
+
* group.ts do: any THROWN failure here is genuinely unexpected and must propagate and fail the
|
|
117
|
+
* whole plan loudly, in `diff`, `read` and `reconcile` alike — never fold to "absent → update".
|
|
118
|
+
*/
|
|
111
119
|
const readRole = (props: RoleProps) =>
|
|
112
120
|
readOrUnreadable(runPve(props.target, 'read', false, access.listAccessRoles({}))).pipe(
|
|
113
121
|
Effect.map((rows) => {
|
|
@@ -115,7 +123,6 @@ const readRole = (props: RoleProps) =>
|
|
|
115
123
|
const row = find(rows, props.roleid);
|
|
116
124
|
return row === undefined ? undefined : attributesOf(row, props);
|
|
117
125
|
}),
|
|
118
|
-
Effect.orElseSucceed(() => undefined),
|
|
119
126
|
);
|
|
120
127
|
|
|
121
128
|
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
|
@@ -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;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.Storage`'s own cries-wolf fix, PLUS the request-shape and transient-failure fixes
|
|
3
|
+
* kit 0.31.2 (PR 239) found for User/Group — all three apply here for the first time on a
|
|
4
|
+
* `provision`-role read, not a `read`-role one. Real-shaped fixtures (live TB4 response bodies,
|
|
5
|
+
* measured 2026-09-24, nothing secret): `GET /storage/local` -> `{"data":{"storage":"local",
|
|
6
|
+
* "type":"dir","content":"backup,vztmpl,iso","path":"/var/lib/vz","digest":"..."}}`, and a
|
|
7
|
+
* missing storage -> `{"message":"storage '...' does not exist\n"}` at HTTP 500, the same shape
|
|
8
|
+
* user.ts/group.ts measured for a missing user/group.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import * as Layer from 'effect/Layer';
|
|
12
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
13
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
14
|
+
import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
|
|
15
|
+
import { ProxmoxStorage, ProxmoxStorageProvider } from './storage.ts';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One storage row, an OpenBao mint the test can deny for `provision` mid-run (the cries-wolf
|
|
19
|
+
* case), and a cluster answer the test can break into a transient 500 mid-run (the request-shape
|
|
20
|
+
* regression's own case) — never both at once, so one fixture serves every test below.
|
|
21
|
+
*/
|
|
22
|
+
const clusterOverStorage = (id: string, live: Record<string, unknown>) => {
|
|
23
|
+
let denyProvision = false;
|
|
24
|
+
let transient = false;
|
|
25
|
+
const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
26
|
+
const request =
|
|
27
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
28
|
+
const url = new URL(request.url);
|
|
29
|
+
if (url.pathname.includes('/creds/')) {
|
|
30
|
+
if (denyProvision && url.pathname.endsWith('/creds/provision')) {
|
|
31
|
+
return Response.json({ errors: ['permission denied'] }, { status: 403 });
|
|
32
|
+
}
|
|
33
|
+
const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
|
|
34
|
+
return Response.json({ data, lease_duration: 0 });
|
|
35
|
+
}
|
|
36
|
+
if (request.method === 'GET') {
|
|
37
|
+
// ⛔ THE REQUEST-SHAPE REGRESSION'S OWN SIGNATURE (user.ts/group.ts): a real fetch client
|
|
38
|
+
// refuses a GET carrying a body outright.
|
|
39
|
+
expect(await request.text()).toBe('');
|
|
40
|
+
}
|
|
41
|
+
const wire = decodeURIComponent(url.pathname.replace(/^\/api2\/json\//, ''));
|
|
42
|
+
if (wire !== `storage/${id}`) return Response.json({ data: null });
|
|
43
|
+
if (transient) {
|
|
44
|
+
// MEASURED 2026-09-24, live TB4: a missing storage answers exactly this shape.
|
|
45
|
+
return Response.json(
|
|
46
|
+
{ message: `storage '${id}' does not exist\n`, data: null },
|
|
47
|
+
{ status: 500 },
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return Response.json({ data: live });
|
|
51
|
+
};
|
|
52
|
+
const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
|
|
53
|
+
return {
|
|
54
|
+
breakReads: () => {
|
|
55
|
+
transient = true;
|
|
56
|
+
},
|
|
57
|
+
denyProvision: () => {
|
|
58
|
+
denyProvision = true;
|
|
59
|
+
},
|
|
60
|
+
layer: FetchHttpClient.layer.pipe(
|
|
61
|
+
Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchStub as typeof fetch)),
|
|
62
|
+
),
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// MEASURED 2026-09-24, live TB4 `GET /storage/local`.
|
|
67
|
+
const live = { content: 'backup,vztmpl,iso', storage: 'local', type: 'dir' };
|
|
68
|
+
const declare = () =>
|
|
69
|
+
ProxmoxStorage('local', {
|
|
70
|
+
content: 'backup,vztmpl,iso',
|
|
71
|
+
storage: 'local',
|
|
72
|
+
target: FAKE_TARGET,
|
|
73
|
+
type: 'dir',
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("Proxmox.Storage's own cries-wolf fix: a refused provision mint reports noop, never update", () => {
|
|
77
|
+
test('an already-correct storage plans noop once the credential is denied, and nothing is written', async () => {
|
|
78
|
+
const fake = clusterOverStorage('local', live);
|
|
79
|
+
await withoutBao(async () => {
|
|
80
|
+
const engine = engineOver(ProxmoxStorageProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
81
|
+
expect(await engine.deploy(declare())).toEqual({ local: 'adopted' });
|
|
82
|
+
fake.denyProvision();
|
|
83
|
+
const report = await engine.verify(declare(), { all: true });
|
|
84
|
+
// ⛔ THE BUG THIS PROVES FIXED — MEASURED live 2026-09-24: before this migration this row
|
|
85
|
+
// read `update` with nothing compared, the same as every other family before its fix.
|
|
86
|
+
expect(report.rows[0]).toMatchObject({ diff: 'noop' });
|
|
87
|
+
expect(await engine.deploy(declare())).toEqual({ local: 'noop' });
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('the same request-shape and transient-failure fixes, once for Proxmox.Storage', () => {
|
|
93
|
+
test('diff() propagates a transient read failure once a state row exists', async () => {
|
|
94
|
+
const fake = clusterOverStorage('local', live);
|
|
95
|
+
await withoutBao(async () => {
|
|
96
|
+
const engine = engineOver(ProxmoxStorageProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
97
|
+
expect(await engine.deploy(declare())).toEqual({ local: 'adopted' });
|
|
98
|
+
fake.breakReads();
|
|
99
|
+
// ⛔ THE FIX: this must REJECT, never resolve with a false 'update'.
|
|
100
|
+
await expect(engine.verify(declare(), { all: true })).rejects.toBeDefined();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('alchemy drift never reports a transient failure as missing', async () => {
|
|
105
|
+
const fake = clusterOverStorage('local', live);
|
|
106
|
+
await withoutBao(async () => {
|
|
107
|
+
const engine = engineOver(ProxmoxStorageProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
108
|
+
expect(await engine.deploy(declare())).toEqual({ local: 'adopted' });
|
|
109
|
+
fake.breakReads();
|
|
110
|
+
// ⛔ THE FIX: this must REJECT. Without `read`'s `output` branch it resolved with
|
|
111
|
+
// `{ local: { action: 'missing' } }` and no error anywhere — user.ts's/group.ts's own gap.
|
|
112
|
+
await expect(engine.drift(declare())).rejects.toBeDefined();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A fake cluster for a storage that does NOT exist yet: every GET is a 500 ("does not exist",
|
|
119
|
+
* the measured shape) until `create()` fires, which records the POST body and starts answering
|
|
120
|
+
* `live` afterward — so `reconcile`'s `before === undefined` branch, untouched by every test
|
|
121
|
+
* above, actually runs.
|
|
122
|
+
*/
|
|
123
|
+
const clusterCreatingStorage = (id: string, live: Record<string, unknown>) => {
|
|
124
|
+
let created = false;
|
|
125
|
+
const posted: string[] = [];
|
|
126
|
+
const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
127
|
+
const request =
|
|
128
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
129
|
+
const url = new URL(request.url);
|
|
130
|
+
if (url.pathname.includes('/creds/')) {
|
|
131
|
+
const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
|
|
132
|
+
return Response.json({ data, lease_duration: 0 });
|
|
133
|
+
}
|
|
134
|
+
const wire = decodeURIComponent(url.pathname.replace(/^\/api2\/json\//, ''));
|
|
135
|
+
if (request.method === 'POST' && wire === 'storage') {
|
|
136
|
+
posted.push(await request.text());
|
|
137
|
+
created = true;
|
|
138
|
+
return Response.json({ data: null });
|
|
139
|
+
}
|
|
140
|
+
if (wire !== `storage/${id}`) return Response.json({ data: null });
|
|
141
|
+
if (!created) {
|
|
142
|
+
return Response.json(
|
|
143
|
+
{ message: `storage '${id}' does not exist\n`, data: null },
|
|
144
|
+
{ status: 500 },
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
return Response.json({ data: live });
|
|
148
|
+
};
|
|
149
|
+
const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
|
|
150
|
+
return {
|
|
151
|
+
posted,
|
|
152
|
+
layer: FetchHttpClient.layer.pipe(
|
|
153
|
+
Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchStub as typeof fetch)),
|
|
154
|
+
),
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
describe('reconcile creating a brand-new storage — the before === undefined branch', () => {
|
|
159
|
+
// ⛔ FOUND BY ADVERSARIAL REVIEW OF THIS PR: every test above adopts an EXISTING storage, so
|
|
160
|
+
// `reconcile`'s create branch (`toDistilledCreate`, the hyphen -> underscore translation) was
|
|
161
|
+
// never actually exercised — "correct by reading the generated SDK source", not by a test.
|
|
162
|
+
test('translates a hyphenated locator field for the actual SDK call, not the vendor-check form', async () => {
|
|
163
|
+
const newLive = {
|
|
164
|
+
content: 'iso',
|
|
165
|
+
'content-dirs': 'iso=/mnt/hf-test-new/iso',
|
|
166
|
+
storage: 'hf-test-new',
|
|
167
|
+
type: 'dir',
|
|
168
|
+
};
|
|
169
|
+
const fake = clusterCreatingStorage('hf-test-new', newLive);
|
|
170
|
+
await withoutBao(async () => {
|
|
171
|
+
const engine = engineOver(ProxmoxStorageProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
172
|
+
const declareNew = () =>
|
|
173
|
+
ProxmoxStorage('newone', {
|
|
174
|
+
content: 'iso',
|
|
175
|
+
locator: { 'content-dirs': 'iso=/mnt/hf-test-new/iso', path: '/mnt/hf-test-new' },
|
|
176
|
+
storage: 'hf-test-new',
|
|
177
|
+
target: FAKE_TARGET,
|
|
178
|
+
type: 'dir',
|
|
179
|
+
});
|
|
180
|
+
expect(await engine.deploy(declareNew())).toEqual({ newone: 'create' });
|
|
181
|
+
expect(fake.posted).toHaveLength(1);
|
|
182
|
+
// ★ MEASURED, NOT ASSUMED: the wire itself carries PVE's own hyphenated `content-dirs=`
|
|
183
|
+
// either way — distilled's generated `T.Body("content-dirs")` annotation re-hyphenates
|
|
184
|
+
// its underscored `content_dirs` property on the way out, and (confirmed by temporarily
|
|
185
|
+
// removing `underscored()` and re-running this exact test) an untranslated hyphenated key
|
|
186
|
+
// also reaches the wire unchanged as a passthrough "unknown key". `underscored()` is not
|
|
187
|
+
// about THIS field's own wire spelling, then — it is about routing every locator field
|
|
188
|
+
// through distilled's typed, declared property (comment further up `toDistilledCreate`),
|
|
189
|
+
// rather than the weaker unknown-key fallback, for whatever OTHER per-field wire logic a
|
|
190
|
+
// future plugin field might carry beyond a plain rename (a type coercion, a list join) —
|
|
191
|
+
// which this test cannot rule out for every field distilled might ever add.
|
|
192
|
+
expect(fake.posted[0]).toContain('content-dirs=iso');
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('a vendor-constraint violation on a hyphenated field is refused before any write', async () => {
|
|
197
|
+
const fake = clusterCreatingStorage('hf-test-bad', {});
|
|
198
|
+
await withoutBao(async () => {
|
|
199
|
+
const engine = engineOver(ProxmoxStorageProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
200
|
+
const declareBad = () =>
|
|
201
|
+
ProxmoxStorage('bad', {
|
|
202
|
+
// ⛔ `saferemove-stepsize` only allows 1/2/4/8/16/32 (generated/constraints/pve-storage.ts)
|
|
203
|
+
// — '99' must be refused by `guardWrite`, which proves it inspected the HYPHENATED
|
|
204
|
+
// key `createForm` builds, not the underscored one only the SDK call ever sees.
|
|
205
|
+
locator: { path: '/mnt/hf-test-bad', 'saferemove-stepsize': '99' },
|
|
206
|
+
storage: 'hf-test-bad',
|
|
207
|
+
target: FAKE_TARGET,
|
|
208
|
+
type: 'dir',
|
|
209
|
+
});
|
|
210
|
+
await expect(engine.deploy(declareBad())).rejects.toBeDefined();
|
|
211
|
+
expect(fake.posted).toHaveLength(0);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|