@homeflare/alchemy 0.40.0 → 0.42.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/acl.d.ts.map +1 -1
- package/dist/proxmox/api-token.d.ts.map +1 -1
- package/dist/proxmox/credential-errors.d.ts +18 -4
- package/dist/proxmox/credential-errors.d.ts.map +1 -1
- package/dist/proxmox/credentials.d.ts +35 -1
- package/dist/proxmox/credentials.d.ts.map +1 -1
- package/dist/proxmox/group.d.ts.map +1 -1
- package/dist/proxmox/index.js +81 -51
- package/dist/proxmox/index.js.map +19 -17
- package/dist/proxmox/lease-cache.d.ts +67 -5
- package/dist/proxmox/lease-cache.d.ts.map +1 -1
- package/dist/proxmox/mint.d.ts +4 -2
- package/dist/proxmox/mint.d.ts.map +1 -1
- package/dist/proxmox/node-network.d.ts.map +1 -1
- package/dist/proxmox/role.d.ts.map +1 -1
- 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/proxmox/unreadable-read.d.ts +8 -1
- package/dist/proxmox/unreadable-read.d.ts.map +1 -1
- package/dist/proxmox/user.d.ts.map +1 -1
- package/dist/proxmox/zfs-pool.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/credentials.md +14 -1
- 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/acl.ts +7 -1
- package/src/proxmox/api-token.ts +7 -1
- package/src/proxmox/credential-errors.ts +19 -5
- package/src/proxmox/credentials.test.ts +73 -1
- package/src/proxmox/credentials.ts +72 -1
- package/src/proxmox/group.ts +7 -1
- package/src/proxmox/lease-cache-mint.test.ts +70 -0
- package/src/proxmox/lease-cache.test.ts +83 -7
- package/src/proxmox/lease-cache.ts +74 -10
- package/src/proxmox/mint.ts +12 -5
- package/src/proxmox/node-network.ts +7 -1
- package/src/proxmox/role.ts +7 -1
- 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 +25 -7
- package/src/proxmox/unreadable-read.ts +16 -4
- package/src/proxmox/user.ts +7 -1
- package/src/proxmox/zfs-pool.ts +7 -1
- 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
|
@@ -86,6 +86,7 @@ import {
|
|
|
86
86
|
updateForm,
|
|
87
87
|
} from './storage-form.ts';
|
|
88
88
|
import { dropUnreadable, matches, readStorage, readStorageOrFail } from './storage-wire.ts';
|
|
89
|
+
import { mintTier } from './credentials.ts';
|
|
89
90
|
import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
|
|
90
91
|
import type { StorageAttributes } from './storage-wire.ts';
|
|
91
92
|
import type { StorageLocator } from './storage-form.ts';
|
|
@@ -112,7 +113,12 @@ export interface StorageProps extends WithTarget {
|
|
|
112
113
|
nodes?: string;
|
|
113
114
|
/** Keeps the definition but stops PVE using it. */
|
|
114
115
|
disable?: boolean;
|
|
115
|
-
/**
|
|
116
|
+
/**
|
|
117
|
+
* Tells PVE the same volumes are visible from every node — a claim, not a mechanism.
|
|
118
|
+
* ⚠️ ONLY SENT WHEN THE TYPE'S OWN PLUGIN ACCEPTS IT (storage-plugin-options.ts, vendor-cited)
|
|
119
|
+
* — elsewhere it is dropped from the wire, and a disagreeing declared value dies the plan
|
|
120
|
+
* loud (storage-wire.ts's `matches`) rather than PUTting a field PVE would refuse outright.
|
|
121
|
+
*/
|
|
116
122
|
shared?: boolean;
|
|
117
123
|
/** `off` | `metadata` | `falloc` | `full`. File-based plugins only. */
|
|
118
124
|
preallocation?: string;
|
|
@@ -166,7 +172,9 @@ export const ProxmoxStorageProvider = () =>
|
|
|
166
172
|
diff: Effect.fn(function* ({ news, output }) {
|
|
167
173
|
if (!isResolved(news)) return undefined;
|
|
168
174
|
yield* guardWrite(STORAGE_CREATE, createForm(news), output === undefined);
|
|
169
|
-
|
|
175
|
+
// ⚠️ `live: undefined`: no read yet, so this only sanity-checks the FULL declared set
|
|
176
|
+
// (storage-form.ts's `updateForm` doc) — `reconcile` builds the actual partial body.
|
|
177
|
+
yield* guardWrite(STORAGE_UPDATE, updateForm(news, undefined), false);
|
|
170
178
|
if (output === undefined) return undefined;
|
|
171
179
|
// ⚠️ `readStorageOrFail`, NOT `readStorage` — see that function's own ⛔ in
|
|
172
180
|
// storage-wire.ts. A genuine TRANSIENT failure here propagates and fails the whole
|
|
@@ -175,14 +183,19 @@ export const ProxmoxStorageProvider = () =>
|
|
|
175
183
|
// ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
|
|
176
184
|
// `update` on a storage that was plainly there — see unreadable-read.ts.
|
|
177
185
|
if (live === UNREADABLE) {
|
|
178
|
-
yield* unreadableWarning(
|
|
186
|
+
yield* unreadableWarning(
|
|
187
|
+
'Proxmox.Storage',
|
|
188
|
+
news.storage,
|
|
189
|
+
news.target.mount,
|
|
190
|
+
mintTier(news.target, 'provision'),
|
|
191
|
+
);
|
|
179
192
|
return { action: 'noop' } as const;
|
|
180
193
|
}
|
|
181
194
|
if (live === undefined) {
|
|
182
195
|
yield* guardWrite(STORAGE_CREATE, createForm(news), true);
|
|
183
196
|
return { action: 'update' } as const;
|
|
184
197
|
}
|
|
185
|
-
return matches(live, news)
|
|
198
|
+
return (yield* matches(live, news))
|
|
186
199
|
? ({ action: 'noop' } as const)
|
|
187
200
|
: ({ action: 'update' } as const);
|
|
188
201
|
}),
|
|
@@ -192,7 +205,12 @@ export const ProxmoxStorageProvider = () =>
|
|
|
192
205
|
// handles — group.ts's/user.ts's reconcile have the same note.
|
|
193
206
|
const before = dropUnreadable(yield* readStorage(news));
|
|
194
207
|
yield* guardWrite(STORAGE_CREATE, createForm(news), before === undefined);
|
|
195
|
-
|
|
208
|
+
// ★ COMPUTED ONCE, THEN REUSED FOR BOTH THE GUARD AND THE WRITE — storage-form.ts's own
|
|
209
|
+
// ⚠️ on `toDistilledUpdate`: `update` is the partial body (only fields that differ
|
|
210
|
+
// from `before`, and never `shared` for a type that doesn't accept it), so the
|
|
211
|
+
// constraint check below inspects exactly what the PUT, if any, will carry.
|
|
212
|
+
const update = updateForm(news, before);
|
|
213
|
+
yield* guardWrite(STORAGE_UPDATE, update, false);
|
|
196
214
|
if (before === undefined) {
|
|
197
215
|
yield* runPve(
|
|
198
216
|
news.target,
|
|
@@ -200,12 +218,12 @@ export const ProxmoxStorageProvider = () =>
|
|
|
200
218
|
true,
|
|
201
219
|
storage.createStorage(toDistilledCreate(news)),
|
|
202
220
|
);
|
|
203
|
-
} else if (!matches(before, news)) {
|
|
221
|
+
} else if (!(yield* matches(before, news))) {
|
|
204
222
|
yield* runPve(
|
|
205
223
|
news.target,
|
|
206
224
|
'provision',
|
|
207
225
|
true,
|
|
208
|
-
storage.putStorage(toDistilledUpdate(
|
|
226
|
+
storage.putStorage(toDistilledUpdate(update)),
|
|
209
227
|
);
|
|
210
228
|
}
|
|
211
229
|
const after = dropUnreadable(yield* readStorage(news));
|
|
@@ -51,10 +51,22 @@ export const readOrUnreadable = <A, E, R>(
|
|
|
51
51
|
* in `bun run plan`'s own output (Effect's default logger), even though the Diff type has
|
|
52
52
|
* nowhere to carry the distinction through to the printed action. See the header for why a log
|
|
53
53
|
* line, not a failed diff or a fourth Diff action, is what "the plan reports" means here.
|
|
54
|
+
*
|
|
55
|
+
* ⛔ M6 (Opus red team, K-T1): NAMES `mount` AND `tier`, NOT JUST THE RESOURCE. Before this, the
|
|
56
|
+
* line read "no grant for this credential's role" with nothing to act on — an operator debugging
|
|
57
|
+
* a scoped-down `PveTarget.roles` override (the whole point of K-T1) had to go re-derive which
|
|
58
|
+
* mount and tier the read actually asked OpenBao for before they could even open that mount's
|
|
59
|
+
* policy. `PveCredentialDenied`'s own message already made this exact call — reading `tier`, not
|
|
60
|
+
* `role`, per that class's own ⛔ — so this warning takes the same two fields for the same reason.
|
|
54
61
|
*/
|
|
55
|
-
export const unreadableWarning = (
|
|
62
|
+
export const unreadableWarning = (
|
|
63
|
+
type: string,
|
|
64
|
+
identity: string,
|
|
65
|
+
mount: string,
|
|
66
|
+
tier: string,
|
|
67
|
+
): Effect.Effect<void> =>
|
|
56
68
|
Effect.logWarning(
|
|
57
|
-
`${type} ${identity}: the read was refused (no grant for this
|
|
58
|
-
'reporting noop rather than forcing an update with nothing compared.
|
|
59
|
-
'that can read this object before trusting this row.',
|
|
69
|
+
`${type} ${identity}: the read of ${mount}/creds/${tier} was refused (no grant for this ` +
|
|
70
|
+
"credential's role) -- reporting noop rather than forcing an update with nothing compared. " +
|
|
71
|
+
'Re-plan on a lane that can read this object before trusting this row.',
|
|
60
72
|
);
|
package/src/proxmox/user.ts
CHANGED
|
@@ -60,6 +60,7 @@ import * as Effect from 'effect/Effect';
|
|
|
60
60
|
import { guardWrite } from './distilled-guard.ts';
|
|
61
61
|
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
62
62
|
import { runPve } from './distilled-pve.ts';
|
|
63
|
+
import { mintTier } from './credentials.ts';
|
|
63
64
|
import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
|
|
64
65
|
import {
|
|
65
66
|
USER_CREATE,
|
|
@@ -162,7 +163,12 @@ export const ProxmoxUserProvider = () =>
|
|
|
162
163
|
// ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
|
|
163
164
|
// `update` on an account that was plainly there — see unreadable-read.ts.
|
|
164
165
|
if (live === UNREADABLE) {
|
|
165
|
-
yield* unreadableWarning(
|
|
166
|
+
yield* unreadableWarning(
|
|
167
|
+
'Proxmox.User',
|
|
168
|
+
news.userid,
|
|
169
|
+
news.target.mount,
|
|
170
|
+
mintTier(news.target, 'read'),
|
|
171
|
+
);
|
|
166
172
|
return { action: 'noop' } as const;
|
|
167
173
|
}
|
|
168
174
|
if (live === undefined) {
|
package/src/proxmox/zfs-pool.ts
CHANGED
|
@@ -78,6 +78,7 @@ import * as Effect from 'effect/Effect';
|
|
|
78
78
|
import { asForm } from './distilled-guard.ts';
|
|
79
79
|
import { guardForm } from './constraint-guard.ts';
|
|
80
80
|
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
81
|
+
import { mintTier } from './credentials.ts';
|
|
81
82
|
import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
|
|
82
83
|
import { dropUnreadable, readPool, readPoolOrFail } from './zfs-pool-wire.ts';
|
|
83
84
|
import type { ZfsPoolAttributes } from './zfs-pool-wire.ts';
|
|
@@ -202,7 +203,12 @@ export const ProxmoxZfsPoolProvider = () =>
|
|
|
202
203
|
// update, the cries-wolf class of bug.
|
|
203
204
|
const live = yield* readPoolOrFail(news);
|
|
204
205
|
if (live === UNREADABLE) {
|
|
205
|
-
yield* unreadableWarning(
|
|
206
|
+
yield* unreadableWarning(
|
|
207
|
+
'Proxmox.ZfsPool',
|
|
208
|
+
`${news.node}/${news.name}`,
|
|
209
|
+
news.target.mount,
|
|
210
|
+
mintTier(news.target, 'read'),
|
|
211
|
+
);
|
|
206
212
|
return { action: 'noop' } as const;
|
|
207
213
|
}
|
|
208
214
|
// ⛔ FOUND WRITING THIS FILE, 2026-09-24 — an earlier edit that removed the dead
|