@homeflare/alchemy 0.40.0 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/caddy/index.js.map +2 -2
- package/dist/ceph/index.js +2 -1
- package/dist/ceph/index.js.map +3 -3
- package/dist/launchd/fake-runner.d.ts.map +1 -1
- package/dist/launchd/index.d.ts +1 -1
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +7 -1
- package/dist/launchd/index.js.map +5 -5
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +21 -0
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-runner.d.ts.map +1 -1
- package/dist/linux/directory-lifecycle.d.ts.map +1 -1
- package/dist/linux/fake-linux-host.d.ts.map +1 -1
- package/dist/linux/index.d.ts +1 -1
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +16 -4
- package/dist/linux/index.js.map +7 -7
- package/dist/linux/ssh-runner.d.ts.map +1 -1
- package/dist/linux/sudo-runner.d.ts.map +1 -1
- package/dist/opnsense/alias-form.d.ts +14 -1
- package/dist/opnsense/alias-form.d.ts.map +1 -1
- package/dist/opnsense/alias.d.ts +1 -1
- package/dist/opnsense/alias.d.ts.map +1 -1
- package/dist/opnsense/group-form.d.ts +8 -2
- package/dist/opnsense/group-form.d.ts.map +1 -1
- package/dist/opnsense/group.d.ts +1 -1
- package/dist/opnsense/group.d.ts.map +1 -1
- package/dist/opnsense/index.js +15 -7
- package/dist/opnsense/index.js.map +7 -7
- package/dist/opnsense/wire.d.ts +42 -12
- package/dist/opnsense/wire.d.ts.map +1 -1
- package/dist/proxmox/index.js +48 -34
- package/dist/proxmox/index.js.map +7 -6
- package/dist/proxmox/storage-form.d.ts +28 -3
- package/dist/proxmox/storage-form.d.ts.map +1 -1
- package/dist/proxmox/storage-plugin-options.d.ts +2 -0
- package/dist/proxmox/storage-plugin-options.d.ts.map +1 -0
- package/dist/proxmox/storage-wire.d.ts +28 -1
- package/dist/proxmox/storage-wire.d.ts.map +1 -1
- package/dist/proxmox/storage.d.ts +6 -1
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/release/index.d.ts +1 -1
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js.map +2 -2
- package/docs/distilled-interim.md +7 -0
- package/docs/launchd.md +5 -3
- package/docs/provider-standard.md +5 -1
- package/docs/talos-argocd-dogfood.md +160 -0
- package/package.json +2 -2
- package/src/launchd/fake-runner.ts +2 -0
- package/src/launchd/index.ts +8 -1
- package/src/launchd/local-runner.ts +16 -1
- package/src/launchd/runner.ts +22 -0
- package/src/launchd/sudo-runner.ts +3 -0
- package/src/linux/directory-lifecycle.ts +18 -3
- package/src/linux/directory-sudo.test.ts +59 -0
- package/src/linux/directory.test.ts +34 -8
- package/src/linux/fake-linux-host.ts +2 -0
- package/src/linux/index.ts +1 -0
- package/src/linux/ssh-runner.ts +4 -0
- package/src/linux/sudo-runner.ts +3 -0
- package/src/opnsense/alias-form.test.ts +24 -12
- package/src/opnsense/alias-form.ts +20 -7
- package/src/opnsense/alias.test.ts +13 -6
- package/src/opnsense/alias.ts +16 -10
- package/src/opnsense/group-form.test.ts +12 -8
- package/src/opnsense/group-form.ts +13 -4
- package/src/opnsense/group.test.ts +18 -7
- package/src/opnsense/group.ts +10 -3
- package/src/opnsense/wire.ts +57 -12
- package/src/proxmox/storage-form.ts +48 -8
- package/src/proxmox/storage-plugin-options.ts +45 -0
- package/src/proxmox/storage-update.test.ts +183 -0
- package/src/proxmox/storage-wire.ts +54 -7
- package/src/proxmox/storage.ts +18 -6
- package/src/release/index.ts +1 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.Storage`'s partial-update fix. Measured 2026-09-27, `bun run deploy`: PVE's PUT
|
|
3
|
+
* /storage/{storage} refused `storage-cephfs-tb4`'s update with `unexpected property 'shared'`,
|
|
4
|
+
* because `reconcile` used to PUT the FULL declared set whenever anything drifted — including a
|
|
5
|
+
* `shared` that already matched live, which cephfs's own plugin `options()` never accepts at all
|
|
6
|
+
* (storage-plugin-options.ts has the vendor citation). storage-form.ts's `updateForm` now sends
|
|
7
|
+
* only the fields that actually differ from a live read; these tests prove the PUT body shape
|
|
8
|
+
* directly, never touching a real PVE cluster.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import * as Layer from 'effect/Layer';
|
|
12
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
13
|
+
import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
|
|
14
|
+
import { createForm } from './storage-form.ts';
|
|
15
|
+
import { ProxmoxStorage, ProxmoxStorageProvider } from './storage.ts';
|
|
16
|
+
|
|
17
|
+
type Live = Record<string, string>;
|
|
18
|
+
|
|
19
|
+
/** `storage` (POST) and `storage/{id}` (GET/PUT), keyed by storage id — group.test.ts's shape. */
|
|
20
|
+
const cluster = (stores: Map<string, Live>) =>
|
|
21
|
+
fakePve((call: PveCall) => {
|
|
22
|
+
const match = /^storage(?:\/(.+))?$/.exec(call.path);
|
|
23
|
+
if (match === null) return undefined;
|
|
24
|
+
const id = match[1];
|
|
25
|
+
if (call.method === 'GET' && id !== undefined) return stores.get(id);
|
|
26
|
+
if (call.method === 'PUT' && id !== undefined) {
|
|
27
|
+
const live = stores.get(id);
|
|
28
|
+
if (live !== undefined) stores.set(id, { ...live, ...call.form });
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const engineFor = (fake: ReturnType<typeof cluster>) =>
|
|
34
|
+
engineOver(ProxmoxStorageProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
35
|
+
|
|
36
|
+
describe('Proxmox.Storage update: a partial PUT, never a property the type refuses', () => {
|
|
37
|
+
test('a cephfs storage whose only drift is content: PUT carries content and the id, never shared', async () => {
|
|
38
|
+
// MEASURED SHAPE: live already reports `shared: '1'` (cephfs is implicitly shared) and the
|
|
39
|
+
// declaration agrees — only `content` (Talos's `import`, the change that triggered the bug)
|
|
40
|
+
// actually differs.
|
|
41
|
+
const stores = new Map<string, Live>([
|
|
42
|
+
['cephfs-tb4', { content: 'backup', shared: '1', storage: 'cephfs-tb4', type: 'cephfs' }],
|
|
43
|
+
]);
|
|
44
|
+
const fake = cluster(stores);
|
|
45
|
+
const declare = () =>
|
|
46
|
+
ProxmoxStorage('cephfs', {
|
|
47
|
+
content: 'backup,import',
|
|
48
|
+
shared: true,
|
|
49
|
+
storage: 'cephfs-tb4',
|
|
50
|
+
target: FAKE_TARGET,
|
|
51
|
+
type: 'cephfs',
|
|
52
|
+
});
|
|
53
|
+
await withoutBao(async () => {
|
|
54
|
+
const engine = engineFor(fake);
|
|
55
|
+
const report = await engine.verify(declare());
|
|
56
|
+
expect(report.rows[0]).toMatchObject({ diff: 'update' });
|
|
57
|
+
expect(await engine.deploy(declare())).toEqual({ cephfs: 'adopted' });
|
|
58
|
+
});
|
|
59
|
+
// ⚠️ `storage` NAMES THE OBJECT VIA THE PUT'S OWN PATH (`storage/cephfs-tb4` here), NOT A
|
|
60
|
+
// BODY FIELD — distilled routes it as a path label (distilled-doctrine: Label -> path), so
|
|
61
|
+
// it never reaches `call.form`. `fake.writes()` is what proves the id.
|
|
62
|
+
expect(fake.writes()).toEqual(['PUT storage/cephfs-tb4']);
|
|
63
|
+
// ⛔ THE BUG THIS PROVES FIXED: before this fix the body was the full declared set, `shared`
|
|
64
|
+
// included — exactly what PVE refused with "unexpected property 'shared'".
|
|
65
|
+
expect(fake.calls.find((call) => call.method === 'PUT')?.form).toEqual({
|
|
66
|
+
content: 'backup,import',
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('no drift at all: no PUT is sent', async () => {
|
|
71
|
+
const stores = new Map<string, Live>([
|
|
72
|
+
['cephfs-tb4', { content: 'backup,import', storage: 'cephfs-tb4', type: 'cephfs' }],
|
|
73
|
+
]);
|
|
74
|
+
const fake = cluster(stores);
|
|
75
|
+
const declare = () =>
|
|
76
|
+
ProxmoxStorage('cephfs', {
|
|
77
|
+
content: 'backup,import',
|
|
78
|
+
storage: 'cephfs-tb4',
|
|
79
|
+
target: FAKE_TARGET,
|
|
80
|
+
type: 'cephfs',
|
|
81
|
+
});
|
|
82
|
+
await withoutBao(async () => {
|
|
83
|
+
const engine = engineFor(fake);
|
|
84
|
+
const report = await engine.verify(declare());
|
|
85
|
+
expect(report.rows[0]).toMatchObject({ diff: 'noop' });
|
|
86
|
+
// ⚠️ `'adopted'`, NOT `'noop'` — a first deploy of a matching object is always reported
|
|
87
|
+
// `adopted` (adopt-verify.md's own note on Plan.ts/Apply.ts: "the node is adopted either
|
|
88
|
+
// way"). The provider's own `diff` (asserted above) is what proves nothing changed;
|
|
89
|
+
// `fake.writes()` below is what proves `reconcile` actually wrote nothing.
|
|
90
|
+
expect(await engine.deploy(declare())).toEqual({ cephfs: 'adopted' });
|
|
91
|
+
});
|
|
92
|
+
expect(fake.writes()).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('a dir storage DOES accept `shared` on update: the PUT carries it', async () => {
|
|
96
|
+
// VENDOR EVIDENCE (storage-plugin-options.ts): `DirPlugin.pm`'s own `options()` names
|
|
97
|
+
// `shared => { optional => 1 }`, unlike cephfs — this is the "one more type" contrast case.
|
|
98
|
+
const stores = new Map<string, Live>([
|
|
99
|
+
['local-extra', { content: 'iso', shared: '0', storage: 'local-extra', type: 'dir' }],
|
|
100
|
+
]);
|
|
101
|
+
const fake = cluster(stores);
|
|
102
|
+
const declare = () =>
|
|
103
|
+
ProxmoxStorage('dir', {
|
|
104
|
+
content: 'iso',
|
|
105
|
+
shared: true,
|
|
106
|
+
storage: 'local-extra',
|
|
107
|
+
target: FAKE_TARGET,
|
|
108
|
+
type: 'dir',
|
|
109
|
+
});
|
|
110
|
+
await withoutBao(async () => {
|
|
111
|
+
const engine = engineFor(fake);
|
|
112
|
+
expect(await engine.deploy(declare())).toEqual({ dir: 'adopted' });
|
|
113
|
+
});
|
|
114
|
+
expect(fake.writes()).toEqual(['PUT storage/local-extra']);
|
|
115
|
+
expect(fake.calls.find((call) => call.method === 'PUT')?.form).toEqual({ shared: '1' });
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* PR 314's own red team (2026-09-27, Important finding): `changedProps` above leaves `shared`
|
|
121
|
+
* out of its result for any type outside `sharedAccepted`, on purpose — but that means a
|
|
122
|
+
* genuinely disagreeing declared value would otherwise vanish into an empty diff and report
|
|
123
|
+
* `noop` forever, with the recorded attribute silently stuck on whatever PVE already has. These
|
|
124
|
+
* two tests prove `matches` (storage-wire.ts) catches exactly that one case and no other.
|
|
125
|
+
*/
|
|
126
|
+
describe('a declared `shared` a type can never apply dies the plan, never noops forever', () => {
|
|
127
|
+
test('zfspool (never accepts shared): a genuine disagreement dies rather than reporting noop', async () => {
|
|
128
|
+
const stores = new Map<string, Live>([
|
|
129
|
+
['speed', { content: 'images', shared: '0', storage: 'speed', type: 'zfspool' }],
|
|
130
|
+
]);
|
|
131
|
+
const fake = cluster(stores);
|
|
132
|
+
const declare = () =>
|
|
133
|
+
ProxmoxStorage('speed', {
|
|
134
|
+
content: 'images',
|
|
135
|
+
shared: true,
|
|
136
|
+
storage: 'speed',
|
|
137
|
+
target: FAKE_TARGET,
|
|
138
|
+
type: 'zfspool',
|
|
139
|
+
});
|
|
140
|
+
await withoutBao(async () => {
|
|
141
|
+
const engine = engineFor(fake);
|
|
142
|
+
await expect(engine.verify(declare())).rejects.toBeDefined();
|
|
143
|
+
});
|
|
144
|
+
expect(fake.writes()).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('zfspool: a declared shared that already agrees with live still plans noop, not a die', async () => {
|
|
148
|
+
const stores = new Map<string, Live>([
|
|
149
|
+
['speed', { content: 'images', shared: '0', storage: 'speed', type: 'zfspool' }],
|
|
150
|
+
]);
|
|
151
|
+
const fake = cluster(stores);
|
|
152
|
+
const declare = () =>
|
|
153
|
+
ProxmoxStorage('speed', {
|
|
154
|
+
content: 'images',
|
|
155
|
+
shared: false,
|
|
156
|
+
storage: 'speed',
|
|
157
|
+
target: FAKE_TARGET,
|
|
158
|
+
type: 'zfspool',
|
|
159
|
+
});
|
|
160
|
+
await withoutBao(async () => {
|
|
161
|
+
const engine = engineFor(fake);
|
|
162
|
+
const report = await engine.verify(declare());
|
|
163
|
+
expect(report.rows[0]).toMatchObject({ diff: 'noop' });
|
|
164
|
+
});
|
|
165
|
+
expect(fake.writes()).toEqual([]);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
/** PR 314's own red team (Minor finding): the create path's own `shared` gate had no test. */
|
|
170
|
+
describe('createForm: `shared` is gated by type on create too, not only on update', () => {
|
|
171
|
+
test('a declared shared is dropped for cephfs, rbd and pbs, kept for dir, lvm, btrfs and esxi', () => {
|
|
172
|
+
for (const type of ['dir', 'lvm', 'btrfs', 'esxi']) {
|
|
173
|
+
expect(createForm({ shared: true, storage: 's', target: FAKE_TARGET, type })).toMatchObject({
|
|
174
|
+
shared: '1',
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
for (const type of ['cephfs', 'rbd', 'pbs']) {
|
|
178
|
+
expect(
|
|
179
|
+
createForm({ shared: true, storage: 's', target: FAKE_TARGET, type }),
|
|
180
|
+
).not.toHaveProperty('shared');
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import * as storage from '@distilled.cloud/proxmox/storage';
|
|
8
8
|
import * as Effect from 'effect/Effect';
|
|
9
9
|
import { runPve } from './distilled-pve.ts';
|
|
10
|
+
import { sharedAccepted } from './storage-plugin-options.ts';
|
|
11
|
+
import type { MutableFields } from './storage-form.ts';
|
|
10
12
|
import type { StorageProps } from './storage.ts';
|
|
11
13
|
import { UNREADABLE, type Unreadable, readOrUnreadable } from './unreadable-read.ts';
|
|
12
14
|
import { bool } from './values.ts';
|
|
@@ -109,15 +111,60 @@ export const readStorage = (props: StorageProps) =>
|
|
|
109
111
|
export const dropUnreadable = (live: StorageAttributes | Unreadable | undefined) =>
|
|
110
112
|
live === UNREADABLE ? undefined : live;
|
|
111
113
|
|
|
114
|
+
/**
|
|
115
|
+
* The declared fields (of the 7 `mutable()` — storage-form.ts — ever writes) whose value
|
|
116
|
+
* genuinely differs from what PVE just reported. `matches` below and storage-form.ts's
|
|
117
|
+
* `updateForm` share this one comparison, so the plan's noop/update verdict and the partial PUT
|
|
118
|
+
* body it sends can never disagree about what "changed" means for a list, a boolean or `shared`.
|
|
119
|
+
*
|
|
120
|
+
* ⛔ `shared` IS SKIPPED ENTIRELY FOR A TYPE THAT DOESN'T ACCEPT IT (storage-plugin-options.ts's
|
|
121
|
+
* `sharedAccepted`, vendor-cited there) — the same treatment `type` already gets below
|
|
122
|
+
* (create-only, never compared) and for the identical reason: PVE will never apply a `shared`
|
|
123
|
+
* PUT for cephfs/rbd/zfspool/… (it 500s, `unexpected property 'shared'` — the bug this family
|
|
124
|
+
* shipped with, measured 2026-09-27), so comparing it against a live value that can never move
|
|
125
|
+
* to match could only ever report `update` forever. Without this skip, `mutable()`'s own gate
|
|
126
|
+
* (storage-form.ts) would still keep `shared` out of the wire body, but `matches` would keep
|
|
127
|
+
* reporting drift for a field nothing can ever apply — a plan that never reaches noop.
|
|
128
|
+
*/
|
|
129
|
+
export const changedProps = (
|
|
130
|
+
live: StorageAttributes,
|
|
131
|
+
props: StorageProps,
|
|
132
|
+
): Partial<MutableFields> => ({
|
|
133
|
+
...(sameList(props.content, live.content) ? {} : { content: props.content }),
|
|
134
|
+
...(sameList(props.nodes, live.nodes) ? {} : { nodes: props.nodes }),
|
|
135
|
+
...(sameList(props['prune-backups'], live['prune-backups'])
|
|
136
|
+
? {}
|
|
137
|
+
: { 'prune-backups': props['prune-backups'] }),
|
|
138
|
+
...(same(props.disable, live.disable) ? {} : { disable: props.disable }),
|
|
139
|
+
...(sharedAccepted(props.type) && !same(props.shared, live.shared)
|
|
140
|
+
? { shared: props.shared }
|
|
141
|
+
: {}),
|
|
142
|
+
...(same(props.preallocation, live.preallocation) ? {} : { preallocation: props.preallocation }),
|
|
143
|
+
...(same(props.comment, live.comment) ? {} : { comment: props.comment }),
|
|
144
|
+
});
|
|
145
|
+
|
|
112
146
|
/**
|
|
113
147
|
* ⚠️ `type` IS NEVER COMPARED — CREATE-ONLY (`storage.ts`'s own `StorageProps.type` doc), so a
|
|
114
148
|
* changed declaration plans `noop` on it, same as before the migration.
|
|
149
|
+
*
|
|
150
|
+
* ⛔ A DECLARED `shared` A TYPE CAN NEVER APPLY MUST FAIL LOUD, NOT NOOP FOREVER — PR 314's own
|
|
151
|
+
* red team, 2026-09-27. `changedProps` above leaves `shared` out of its result whenever the
|
|
152
|
+
* type is outside `sharedAccepted`, on purpose (PVE 500s on the PUT otherwise) — but that
|
|
153
|
+
* means a genuinely disagreeing declared value would otherwise vanish into an empty diff and
|
|
154
|
+
* report `noop` forever, with the recorded attribute silently stuck on whatever PVE already
|
|
155
|
+
* has. `maxfiles` (storage.ts's header) refuses that same class of bug at compile time; this is
|
|
156
|
+
* the update-time equivalent for a field that can't be typed away. So this checks the ONE case
|
|
157
|
+
* `changedProps` deliberately can't see — declared and live actually differ, on a type that can
|
|
158
|
+
* never carry the write — and dies with a message naming the fix, before `changedProps` runs.
|
|
115
159
|
*/
|
|
116
160
|
export const matches = (attributes: StorageAttributes, props: StorageProps) =>
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
161
|
+
!sharedAccepted(props.type) && !same(props.shared, attributes.shared)
|
|
162
|
+
? Effect.die(
|
|
163
|
+
new Error(
|
|
164
|
+
`storage/${props.storage}: declared shared: ${String(props.shared)}, but PVE reports ` +
|
|
165
|
+
`${String(attributes.shared)} and '${props.type}' storages never accept a 'shared' ` +
|
|
166
|
+
'write (storage-plugin-options.ts) -- remove the declared shared or set it to match ' +
|
|
167
|
+
'what PVE already reports; this value can never be applied.',
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
: Effect.succeed(Object.keys(changedProps(attributes, props)).length === 0);
|
package/src/proxmox/storage.ts
CHANGED
|
@@ -112,7 +112,12 @@ export interface StorageProps extends WithTarget {
|
|
|
112
112
|
nodes?: string;
|
|
113
113
|
/** Keeps the definition but stops PVE using it. */
|
|
114
114
|
disable?: boolean;
|
|
115
|
-
/**
|
|
115
|
+
/**
|
|
116
|
+
* Tells PVE the same volumes are visible from every node — a claim, not a mechanism.
|
|
117
|
+
* ⚠️ ONLY SENT WHEN THE TYPE'S OWN PLUGIN ACCEPTS IT (storage-plugin-options.ts, vendor-cited)
|
|
118
|
+
* — elsewhere it is dropped from the wire, and a disagreeing declared value dies the plan
|
|
119
|
+
* loud (storage-wire.ts's `matches`) rather than PUTting a field PVE would refuse outright.
|
|
120
|
+
*/
|
|
116
121
|
shared?: boolean;
|
|
117
122
|
/** `off` | `metadata` | `falloc` | `full`. File-based plugins only. */
|
|
118
123
|
preallocation?: string;
|
|
@@ -166,7 +171,9 @@ export const ProxmoxStorageProvider = () =>
|
|
|
166
171
|
diff: Effect.fn(function* ({ news, output }) {
|
|
167
172
|
if (!isResolved(news)) return undefined;
|
|
168
173
|
yield* guardWrite(STORAGE_CREATE, createForm(news), output === undefined);
|
|
169
|
-
|
|
174
|
+
// ⚠️ `live: undefined`: no read yet, so this only sanity-checks the FULL declared set
|
|
175
|
+
// (storage-form.ts's `updateForm` doc) — `reconcile` builds the actual partial body.
|
|
176
|
+
yield* guardWrite(STORAGE_UPDATE, updateForm(news, undefined), false);
|
|
170
177
|
if (output === undefined) return undefined;
|
|
171
178
|
// ⚠️ `readStorageOrFail`, NOT `readStorage` — see that function's own ⛔ in
|
|
172
179
|
// storage-wire.ts. A genuine TRANSIENT failure here propagates and fails the whole
|
|
@@ -182,7 +189,7 @@ export const ProxmoxStorageProvider = () =>
|
|
|
182
189
|
yield* guardWrite(STORAGE_CREATE, createForm(news), true);
|
|
183
190
|
return { action: 'update' } as const;
|
|
184
191
|
}
|
|
185
|
-
return matches(live, news)
|
|
192
|
+
return (yield* matches(live, news))
|
|
186
193
|
? ({ action: 'noop' } as const)
|
|
187
194
|
: ({ action: 'update' } as const);
|
|
188
195
|
}),
|
|
@@ -192,7 +199,12 @@ export const ProxmoxStorageProvider = () =>
|
|
|
192
199
|
// handles — group.ts's/user.ts's reconcile have the same note.
|
|
193
200
|
const before = dropUnreadable(yield* readStorage(news));
|
|
194
201
|
yield* guardWrite(STORAGE_CREATE, createForm(news), before === undefined);
|
|
195
|
-
|
|
202
|
+
// ★ COMPUTED ONCE, THEN REUSED FOR BOTH THE GUARD AND THE WRITE — storage-form.ts's own
|
|
203
|
+
// ⚠️ on `toDistilledUpdate`: `update` is the partial body (only fields that differ
|
|
204
|
+
// from `before`, and never `shared` for a type that doesn't accept it), so the
|
|
205
|
+
// constraint check below inspects exactly what the PUT, if any, will carry.
|
|
206
|
+
const update = updateForm(news, before);
|
|
207
|
+
yield* guardWrite(STORAGE_UPDATE, update, false);
|
|
196
208
|
if (before === undefined) {
|
|
197
209
|
yield* runPve(
|
|
198
210
|
news.target,
|
|
@@ -200,12 +212,12 @@ export const ProxmoxStorageProvider = () =>
|
|
|
200
212
|
true,
|
|
201
213
|
storage.createStorage(toDistilledCreate(news)),
|
|
202
214
|
);
|
|
203
|
-
} else if (!matches(before, news)) {
|
|
215
|
+
} else if (!(yield* matches(before, news))) {
|
|
204
216
|
yield* runPve(
|
|
205
217
|
news.target,
|
|
206
218
|
'provision',
|
|
207
219
|
true,
|
|
208
|
-
storage.putStorage(toDistilledUpdate(
|
|
220
|
+
storage.putStorage(toDistilledUpdate(update)),
|
|
209
221
|
);
|
|
210
222
|
}
|
|
211
223
|
const after = dropUnreadable(yield* readStorage(news));
|