@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
|
@@ -14,13 +14,13 @@ import * as Cache from 'effect/Cache';
|
|
|
14
14
|
import * as Duration from 'effect/Duration';
|
|
15
15
|
import * as Effect from 'effect/Effect';
|
|
16
16
|
import * as Exit from 'effect/Exit';
|
|
17
|
-
import type
|
|
18
|
-
import { type LeaseKey, makeLeases, timeToLive } from './lease-cache.ts';
|
|
17
|
+
import { type PveCredential, type PveTarget, mintTier } from './credentials.ts';
|
|
18
|
+
import { type LeaseKey, keyOf, makeLeases, targetForKey, timeToLive } from './lease-cache.ts';
|
|
19
19
|
|
|
20
20
|
const C1: LeaseKey = {
|
|
21
21
|
mount: 'proxmox-c1',
|
|
22
|
-
role: 'read',
|
|
23
22
|
scheme: 'pve',
|
|
23
|
+
tier: 'read',
|
|
24
24
|
};
|
|
25
25
|
/** ⚠️ Same role as C1, DIFFERENT mount — the collision the key has to survive. */
|
|
26
26
|
const C2: LeaseKey = { ...C1, mount: 'proxmox-c2' };
|
|
@@ -79,6 +79,61 @@ describe('how long a lease is kept', () => {
|
|
|
79
79
|
});
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
describe('K-T1: keyOf resolves an overridden tier into the cache key (I1)', () => {
|
|
83
|
+
// ⛔ The one line the PR exists for (`tier: mintTier(target, role)` in `keyOf`) had no direct
|
|
84
|
+
// test: revert it to `tier: role` and the rest of the suite still passes, because
|
|
85
|
+
// `targetForKey`'s tests prove only the reconstruction half and `mintTier`'s tests prove only
|
|
86
|
+
// the resolution function, never the composition a real `leased()` call depends on.
|
|
87
|
+
it('a plain PveTarget with no override keys on the bare role', () => {
|
|
88
|
+
const target: PveTarget = { members: [], mount: 'proxmox-tb4', scheme: 'pve' };
|
|
89
|
+
assert.equal(keyOf(target, 'read').tier, 'read');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('a PveTarget.roles override reaches the cache key, not just a direct mint', () => {
|
|
93
|
+
const target: PveTarget = {
|
|
94
|
+
members: [],
|
|
95
|
+
mount: 'proxmox-tb4',
|
|
96
|
+
roles: { read: 'talos-provision' },
|
|
97
|
+
scheme: 'pve',
|
|
98
|
+
};
|
|
99
|
+
assert.equal(keyOf(target, 'read').tier, 'talos-provision');
|
|
100
|
+
// The unmentioned role still falls back to itself on the same target.
|
|
101
|
+
assert.equal(keyOf(target, 'provision').tier, 'provision');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('a PbsTarget key never carries an override — the type has no roles field', () => {
|
|
105
|
+
const pbs = { api: 'https://pbs.invalid/api2/json', mount: 'pbs-c1', scheme: 'pbs' as const };
|
|
106
|
+
assert.equal(keyOf(pbs, 'read').tier, 'read');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('K-T1/M3: targetForKey round-trips the resolved tier back through mintTier', () => {
|
|
111
|
+
// ⛔ Since M3 dropped `role` from `LeaseKey`, `targetForKey` always mints a pve reconstruction
|
|
112
|
+
// with the fixed role `'read'` — the caller's ORIGINAL role is irrelevant here, only `tier`
|
|
113
|
+
// (already resolved by `keyOf`) has to round-trip.
|
|
114
|
+
it('a plain tier reconstructs to itself', () => {
|
|
115
|
+
const key: LeaseKey = { mount: 'proxmox-tb4', scheme: 'pve', tier: 'read' };
|
|
116
|
+
assert.equal(mintTier(targetForKey(key), 'read'), 'read');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('an overridden tier reconstructs to the SAME tier, not the bare role', () => {
|
|
120
|
+
// ⛔ This is the exact reconstruction the cache-miss path depends on: `keyOf` resolved
|
|
121
|
+
// `talos-provision` from the caller's real target once; `targetForKey` never sees that
|
|
122
|
+
// target again, only this key, so it has to be able to rebuild the same answer from `tier`
|
|
123
|
+
// alone.
|
|
124
|
+
const key: LeaseKey = { mount: 'proxmox-tb4', scheme: 'pve', tier: 'talos-provision' };
|
|
125
|
+
assert.equal(mintTier(targetForKey(key), 'read'), 'talos-provision');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('a pbs key never carries an override through', () => {
|
|
129
|
+
const key: LeaseKey = { mount: 'pbs-c1', scheme: 'pbs', tier: 'provision' };
|
|
130
|
+
assert.equal(mintTier(targetForKey(key), 'provision'), 'provision');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ⚠️ `mintForKey`'s own request-path behavior is exercised in lease-cache-mint.test.ts, against a
|
|
135
|
+
// fake OpenBao server — not hand-reproduced here.
|
|
136
|
+
|
|
82
137
|
describe('lease cache under concurrency', () => {
|
|
83
138
|
it('mints once for many concurrent callers, and serves the next from the cache', async () => {
|
|
84
139
|
const mint = countingMint(() => cred('hf-read@pve!a', 3600));
|
|
@@ -123,7 +178,7 @@ describe('lease cache under concurrency', () => {
|
|
|
123
178
|
assert.equal(mint.calls(), 2);
|
|
124
179
|
});
|
|
125
180
|
|
|
126
|
-
it('does not serve one cluster the credential of another with the same
|
|
181
|
+
it('does not serve one cluster the credential of another with the same tier', async () => {
|
|
127
182
|
const mint = countingMint((call) => cred(`hf-read@pve!${String(call)}`, 3600));
|
|
128
183
|
const [c1, c2] = await Effect.runPromise(
|
|
129
184
|
Effect.gen(function* () {
|
|
@@ -137,8 +192,8 @@ describe('lease cache under concurrency', () => {
|
|
|
137
192
|
|
|
138
193
|
it('shares one lease across two members of the same cluster mount', async () => {
|
|
139
194
|
const mint = countingMint((call) => cred(`hf-read@pve!${String(call)}`, 3600));
|
|
140
|
-
const NODE_B: LeaseKey = { mount: 'proxmox-c1',
|
|
141
|
-
const NODE_C: LeaseKey = { mount: 'proxmox-c1',
|
|
195
|
+
const NODE_B: LeaseKey = { mount: 'proxmox-c1', scheme: 'pve', tier: 'read' };
|
|
196
|
+
const NODE_C: LeaseKey = { mount: 'proxmox-c1', scheme: 'pve', tier: 'read' };
|
|
142
197
|
await Effect.runPromise(
|
|
143
198
|
Effect.gen(function* () {
|
|
144
199
|
const leases = yield* makeLeases(mint.mintFor);
|
|
@@ -151,7 +206,7 @@ describe('lease cache under concurrency', () => {
|
|
|
151
206
|
|
|
152
207
|
it('keeps read and provision apart', async () => {
|
|
153
208
|
const mint = countingMint((call) => cred(`hf@pve!${String(call)}`, 3600));
|
|
154
|
-
const PROVISION: LeaseKey = { ...C1,
|
|
209
|
+
const PROVISION: LeaseKey = { ...C1, tier: 'provision' };
|
|
155
210
|
const [read, provision] = await Effect.runPromise(
|
|
156
211
|
Effect.gen(function* () {
|
|
157
212
|
const leases = yield* makeLeases(mint.mintFor);
|
|
@@ -160,4 +215,25 @@ describe('lease cache under concurrency', () => {
|
|
|
160
215
|
);
|
|
161
216
|
assert.notEqual(read.tokenId, provision.tokenId);
|
|
162
217
|
});
|
|
218
|
+
|
|
219
|
+
// ⛔ K-T1's own reason `tier` joined the key: two consumers on the SAME mount/scheme but
|
|
220
|
+
// DIFFERENT `PveTarget.roles` overrides would otherwise collide into one cache entry and hand
|
|
221
|
+
// each other's credential to the wrong caller. M3 dropped `role` from the key entirely (it
|
|
222
|
+
// was always redundant with `tier`), which this test still exercises unchanged.
|
|
223
|
+
it('keeps two different tier overrides of the same mount/scheme apart', async () => {
|
|
224
|
+
const mint = countingMint((call) => cred(`hf@pve!${String(call)}`, 3600));
|
|
225
|
+
const DEFAULT_READ: LeaseKey = { mount: 'proxmox-tb4', scheme: 'pve', tier: 'read' };
|
|
226
|
+
const OVERRIDDEN_READ: LeaseKey = { ...DEFAULT_READ, tier: 'talos-provision' };
|
|
227
|
+
const [plain, overridden] = await Effect.runPromise(
|
|
228
|
+
Effect.gen(function* () {
|
|
229
|
+
const leases = yield* makeLeases(mint.mintFor);
|
|
230
|
+
return [
|
|
231
|
+
yield* Cache.get(leases, DEFAULT_READ),
|
|
232
|
+
yield* Cache.get(leases, OVERRIDDEN_READ),
|
|
233
|
+
] as const;
|
|
234
|
+
}),
|
|
235
|
+
);
|
|
236
|
+
assert.notEqual(plain.tokenId, overridden.tokenId);
|
|
237
|
+
assert.equal(mint.calls(), 2);
|
|
238
|
+
});
|
|
163
239
|
});
|
|
@@ -47,7 +47,13 @@ import * as Cache from 'effect/Cache';
|
|
|
47
47
|
import * as Duration from 'effect/Duration';
|
|
48
48
|
import * as Effect from 'effect/Effect';
|
|
49
49
|
import * as Exit from 'effect/Exit';
|
|
50
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
type ApiTarget,
|
|
52
|
+
type BaoEnvironment,
|
|
53
|
+
type PveCredential,
|
|
54
|
+
type PveRole,
|
|
55
|
+
mintTier,
|
|
56
|
+
} from './credentials.ts';
|
|
51
57
|
import { mint } from './mint.ts';
|
|
52
58
|
|
|
53
59
|
/**
|
|
@@ -66,24 +72,49 @@ const REMINT_MARGIN_SECONDS = 60;
|
|
|
66
72
|
/**
|
|
67
73
|
* What a credential is cached under.
|
|
68
74
|
*
|
|
69
|
-
* ⛔ KEYED ON THE OPENBAO MOUNT AND
|
|
70
|
-
* cluster-wide — `user.cfg` is replicated — so node-b, node-c and
|
|
71
|
-
* `api` would mint three identical tokens when failover
|
|
75
|
+
* ⛔ KEYED ON THE OPENBAO MOUNT AND TIER, NOT ON A MEMBER HOSTNAME OR ON `role` (M3, red team on
|
|
76
|
+
* K-T1). A PVE API token is cluster-wide — `user.cfg` is replicated — so node-b, node-c and
|
|
77
|
+
* node-d share one credential. Keying on `api` would mint three identical tokens when failover
|
|
78
|
+
* rotates members. `role` USED to be part of this key too, but the minted credential depends
|
|
79
|
+
* only on `(mount, scheme, tier)` — `role` only ever fed `mintTier` to re-derive the tier the
|
|
80
|
+
* key already carries — so `roles: { read: 'x', provision: 'x' }` (one tier for both roles, the
|
|
81
|
+
* shape `docs/credentials.md` itself recommends) used to open TWO cache entries and mint TWO
|
|
82
|
+
* byte-for-byte interchangeable tokens for what is provably one credential. Dropping `role`
|
|
83
|
+
* collapses that back to one entry without changing the default case, where `tier === role`
|
|
84
|
+
* already.
|
|
72
85
|
* ★ THE MOUNT CANNOT COLLIDE ACROSS CLUSTERS: `proxmox-c1`, `proxmox-c2` and `pbs-c1` are
|
|
73
86
|
* distinct OpenBao mounts with distinct policies; two estates never share a mount name.
|
|
74
87
|
* ★ A PLAIN OBJECT IS A SAFE KEY BECAUSE EFFECT COMPARES KEYS STRUCTURALLY — two separately built
|
|
75
88
|
* literals with the same fields are one entry, which the probe above measured.
|
|
89
|
+
*
|
|
90
|
+
* ⛔ `tier` IS THE KEY, NOT `role` (K-T1). `targetForKey` below has to RECONSTRUCT a target from
|
|
91
|
+
* the key alone (it does not keep the caller's original `ApiTarget` around), and a
|
|
92
|
+
* `PveTarget.roles` override lives on THAT original target, not on `role` — so without `tier`
|
|
93
|
+
* in the key, every `leased()` call for an overridden role would resolve its override once at
|
|
94
|
+
* the first `keyOf`, then reuse whatever `targetForKey` happens to reconstruct on a cache miss,
|
|
95
|
+
* and TWO DIFFERENT overrides sharing one `{mount, scheme}` (e.g. one consumer's default `read`
|
|
96
|
+
* tier and another's `roles: {read: 'x'}` override, same mount) would collide into ONE cache
|
|
97
|
+
* entry and hand each other's credential to the wrong caller. Resolving the tier at `keyOf` —
|
|
98
|
+
* where the real target is still in hand — and keying on it closes that: two different tiers
|
|
99
|
+
* are two different entries, always.
|
|
76
100
|
*/
|
|
77
101
|
export type LeaseKey = {
|
|
78
102
|
readonly mount: string;
|
|
79
|
-
readonly role: PveRole;
|
|
80
103
|
readonly scheme: ApiTarget['scheme'];
|
|
104
|
+
readonly tier: string;
|
|
81
105
|
};
|
|
82
106
|
|
|
83
|
-
|
|
107
|
+
/**
|
|
108
|
+
* ⚠️ EXPORTED FOR THE TEST (I1, red team on K-T1). This is the one line the whole PR exists
|
|
109
|
+
* for — `tier: mintTier(target, role)` is what makes an overridden `PveTarget.roles` reach the
|
|
110
|
+
* cached path at all. Nothing else in this file's test suite calls `keyOf` or `leased`
|
|
111
|
+
* directly, so without this export a regression here (e.g. reverting to `tier: role`) passed
|
|
112
|
+
* the whole suite silently.
|
|
113
|
+
*/
|
|
114
|
+
export const keyOf = (target: ApiTarget, role: PveRole): LeaseKey => ({
|
|
84
115
|
mount: target.mount,
|
|
85
|
-
role,
|
|
86
116
|
scheme: target.scheme,
|
|
117
|
+
tier: mintTier(target, role),
|
|
87
118
|
});
|
|
88
119
|
|
|
89
120
|
/**
|
|
@@ -115,12 +146,45 @@ export const timeToLive = (exit: Exit.Exit<PveCredential, unknown>): Duration.Du
|
|
|
115
146
|
export const makeLeases = <E, R>(mintFor: (key: LeaseKey) => Effect.Effect<PveCredential, E, R>) =>
|
|
116
147
|
Cache.makeWith(mintFor, { capacity: 64, requireServicesAt: 'lookup', timeToLive });
|
|
117
148
|
|
|
118
|
-
|
|
149
|
+
/**
|
|
150
|
+
* ⛔ CARRIES `key.tier` BACK IN AS A `roles` OVERRIDE (K-T1), SO `mint`'S OWN `mintTier` CALL
|
|
151
|
+
* REPRODUCES THE SAME TIER `keyOf` ALREADY RESOLVED. This target is synthetic — it is never the
|
|
152
|
+
* caller's real `PveTarget` (`members`/`api` are throwaway placeholders `mint` never reads) —
|
|
153
|
+
* and, since M3 dropped `role` from `LeaseKey`, it no longer even knows which semantic role
|
|
154
|
+
* produced `key.tier`. It doesn't need to: `roles: { read: key.tier }` plus always minting with
|
|
155
|
+
* the fixed role `'read'` (below) makes `mintTier(targetForKey(key), 'read') === key.tier`
|
|
156
|
+
* unconditionally, because `mintTier` only ever consults the ONE role it's called with. Which
|
|
157
|
+
* role the ORIGINAL caller asked for is irrelevant here — `keyOf` already folded that into
|
|
158
|
+
* `key.tier` before this function ever sees the key.
|
|
159
|
+
* ⚠️ THE PBS BRANCH MINTS WITH `key.tier` ITSELF AS THE ROLE, NOT `'read'`. A `PbsTarget` has no
|
|
160
|
+
* `roles` override (see `credentials.ts`'s ⛔ on `PbsTarget`), so `mintTier` for `scheme:'pbs'`
|
|
161
|
+
* always returns its `role` argument unchanged — meaning `key.tier` for a PBS key IS the literal
|
|
162
|
+
* role name (`'read'`/`'provision'`) `keyOf` recorded. Passing anything else here would ask
|
|
163
|
+
* OpenBao for the wrong tier.
|
|
164
|
+
* ⚠️ EXPORTED FOR THE TEST, which checks that round trip directly rather than only through the
|
|
165
|
+
* cache's externally visible behaviour.
|
|
166
|
+
*/
|
|
167
|
+
export const targetForKey = (key: LeaseKey): ApiTarget =>
|
|
119
168
|
key.scheme === 'pbs'
|
|
120
169
|
? { api: 'https://pbs.invalid/api2/json', mount: key.mount, scheme: 'pbs' }
|
|
121
|
-
: { members: [], mount: key.mount, scheme: 'pve' };
|
|
170
|
+
: { members: [], mount: key.mount, roles: { read: key.tier }, scheme: 'pve' };
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* ⚠️ EXPORTED FOR THE TEST (second red-team pass on K-T1). `targetForKey`'s own tests call
|
|
174
|
+
* `mintTier(targetForKey(key), 'read')` to check the round trip, which hand-reproduces this
|
|
175
|
+
* function's fixed choice of `'read'` as the pve mint role rather than exercising it — change
|
|
176
|
+
* that choice here and those tests keep passing while a live cache miss mints the wrong tier.
|
|
177
|
+
* Testing this function directly (against a fake OpenBao) pins the choice itself.
|
|
178
|
+
*
|
|
179
|
+
* ⚠️ `env` DEFAULTS TO `process.env`, SAME AS `mint` ITSELF (credentials.ts) — the cache's real
|
|
180
|
+
* caller below (`Effect.runSync(makeLeases(mintForKey))`) always calls this with one argument,
|
|
181
|
+
* so the default is what production runs on. The parameter exists only so the test can pass a
|
|
182
|
+
* fake OpenBao's address without touching the real environment.
|
|
183
|
+
*/
|
|
184
|
+
export const mintForKey = (key: LeaseKey, env: BaoEnvironment = process.env) =>
|
|
185
|
+
mint(targetForKey(key), key.scheme === 'pbs' ? (key.tier as PveRole) : 'read', env);
|
|
122
186
|
|
|
123
|
-
const leases = Effect.runSync(makeLeases(
|
|
187
|
+
const leases = Effect.runSync(makeLeases(mintForKey));
|
|
124
188
|
|
|
125
189
|
/**
|
|
126
190
|
* A credential for `role` on `target`: a still-valid lease when one is kept, one shared mint when
|
package/src/proxmox/mint.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Minting one PVE/PBS credential from OpenBao: `GET /v1/<mount>/creds/<
|
|
2
|
+
* Minting one PVE/PBS credential from OpenBao: `GET /v1/<mount>/creds/<tier>`, alive for five
|
|
3
3
|
* minutes. Split out of `credentials.ts` (2026-09-24) to keep that file's TYPES under the
|
|
4
4
|
* 250-line cap once `PveCredentialDenied`'s catchers needed a real header there; the reasoning
|
|
5
5
|
* for WHY the estate mints rather than stores a token is still credentials.ts's own header.
|
|
@@ -14,6 +14,7 @@ import * as Headers from 'effect/unstable/http/Headers';
|
|
|
14
14
|
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
15
15
|
import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest';
|
|
16
16
|
import { PveCredentialDenied } from './credential-errors.ts';
|
|
17
|
+
import { mintTier } from './credentials.ts';
|
|
17
18
|
import type { ApiTarget, BaoEnvironment, PveCredential, PveRole } from './credentials.ts';
|
|
18
19
|
|
|
19
20
|
/** openbao v2.6.2 api/env.go:22-34 — a present BAO_* wins, even empty; else its VAULT_* twin. */
|
|
@@ -21,7 +22,9 @@ const baoVariable = (env: BaoEnvironment, name: string) =>
|
|
|
21
22
|
env[`BAO_${name}`] ?? env[`VAULT_${name}`] ?? '';
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
|
-
* Mint one credential for `role`: `GET /v1/<mount>/creds/<
|
|
25
|
+
* Mint one credential for `role`: `GET /v1/<mount>/creds/<tier>` on OpenBao's HTTP API, where
|
|
26
|
+
* `tier` is `mintTier(target, role)` (credentials.ts) — `role` itself unless `target.roles`
|
|
27
|
+
* overrides it (K-T1).
|
|
25
28
|
*
|
|
26
29
|
* 🔴 IT SHELLED OUT TO `bao read -format=json` UNTIL 2026-09-14. The case for the CLI was that one
|
|
27
30
|
* client could not disagree with `bao kv get` about what this machine may do. <estate>/openbao then
|
|
@@ -46,7 +49,11 @@ const baoVariable = (env: BaoEnvironment, name: string) =>
|
|
|
46
49
|
*/
|
|
47
50
|
export const mint = (target: ApiTarget, role: PveRole, env: BaoEnvironment = process.env) =>
|
|
48
51
|
Effect.gen(function* () {
|
|
49
|
-
|
|
52
|
+
// ⛔ THE ONE PLACE A `PveRole` BECOMES A TIER NAME. `mintTier` (credentials.ts) reads
|
|
53
|
+
// `target.roles` when the target overrides it; every other line in this function uses
|
|
54
|
+
// `tier`, never `role`, for anything that reaches OpenBao or an error message.
|
|
55
|
+
const tier = mintTier(target, role);
|
|
56
|
+
const path = `${target.mount}/creds/${tier}`;
|
|
50
57
|
// ⛔ METHOD, PATH AND OpenBao's `errors` ONLY — never a header (the token), never a success
|
|
51
58
|
// body (the credential is IN it, so echoing it would print the secret into a log).
|
|
52
59
|
const refuse = (status: number, detail: string) =>
|
|
@@ -125,7 +132,7 @@ export const mint = (target: ApiTarget, role: PveRole, env: BaoEnvironment = pro
|
|
|
125
132
|
* so it is the one signal trusted here.
|
|
126
133
|
*/
|
|
127
134
|
if (status === 403 && isOpenBaoBody) {
|
|
128
|
-
return yield* Effect.fail(new PveCredentialDenied({ detail, mount: target.mount,
|
|
135
|
+
return yield* Effect.fail(new PveCredentialDenied({ detail, mount: target.mount, tier }));
|
|
129
136
|
}
|
|
130
137
|
return yield* Effect.fail(refuse(status, detail || '(no errors given)'));
|
|
131
138
|
}
|
|
@@ -135,7 +142,7 @@ export const mint = (target: ApiTarget, role: PveRole, env: BaoEnvironment = pro
|
|
|
135
142
|
// ⛔ REFUSE RATHER THAN RETURN A HALF CREDENTIAL, and again WITHOUT quoting the body.
|
|
136
143
|
return yield* Effect.fail(
|
|
137
144
|
new Error(
|
|
138
|
-
`${target.mount}/creds/${
|
|
145
|
+
`${target.mount}/creds/${tier} returned no token_id/secret. Either this approle lacks ` +
|
|
139
146
|
`the grant, or the mount is not ${target.mount}. Say which mount and role you needed ` +
|
|
140
147
|
`than falling back to a stored credential -- every stored PVE token is read-only.`,
|
|
141
148
|
),
|
|
@@ -94,6 +94,7 @@ import {
|
|
|
94
94
|
} from './node-network-wire.ts';
|
|
95
95
|
import type { NodeNetworkAttributes } from './node-network-wire.ts';
|
|
96
96
|
import { runPve } from './distilled-pve.ts';
|
|
97
|
+
import { mintTier } from './credentials.ts';
|
|
97
98
|
import { type PveRequirements } from './resource-spec.ts';
|
|
98
99
|
import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
|
|
99
100
|
|
|
@@ -173,7 +174,12 @@ export const ProxmoxNodeNetworkProvider = () =>
|
|
|
173
174
|
// ⛔ THE CRIES-WOLF FIX: a refused mint used to fall into `undefined` and force `update`
|
|
174
175
|
// on an interface that was plainly there — see unreadable-read.ts.
|
|
175
176
|
if (live === UNREADABLE) {
|
|
176
|
-
yield* unreadableWarning(
|
|
177
|
+
yield* unreadableWarning(
|
|
178
|
+
'Proxmox.NodeNetwork',
|
|
179
|
+
`${news.node}/${news.iface}`,
|
|
180
|
+
news.target.mount,
|
|
181
|
+
mintTier(news.target, 'read'),
|
|
182
|
+
);
|
|
177
183
|
return { action: 'noop' } as const;
|
|
178
184
|
}
|
|
179
185
|
if (live === undefined) {
|
package/src/proxmox/role.ts
CHANGED
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
} from './role-wire.ts';
|
|
65
65
|
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
66
66
|
import { runPve } from './distilled-pve.ts';
|
|
67
|
+
import { mintTier } from './credentials.ts';
|
|
67
68
|
import {
|
|
68
69
|
UNREADABLE,
|
|
69
70
|
type Unreadable,
|
|
@@ -151,7 +152,12 @@ export const ProxmoxRoleProvider = () =>
|
|
|
151
152
|
// ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
|
|
152
153
|
// `update` on a role that was plainly there — see unreadable-read.ts.
|
|
153
154
|
if (live === UNREADABLE) {
|
|
154
|
-
yield* unreadableWarning(
|
|
155
|
+
yield* unreadableWarning(
|
|
156
|
+
'Proxmox.Role',
|
|
157
|
+
news.roleid,
|
|
158
|
+
news.target.mount,
|
|
159
|
+
mintTier(news.target, 'read'),
|
|
160
|
+
);
|
|
155
161
|
return { action: 'noop' } as const;
|
|
156
162
|
}
|
|
157
163
|
if (live === undefined) {
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
* both files under the 250-line cap — the api-token.ts/api-token-form.ts seam, same reasoning.
|
|
5
5
|
*/
|
|
6
6
|
import type * as storage from '@distilled.cloud/proxmox/storage';
|
|
7
|
+
import { sharedAccepted } from './storage-plugin-options.ts';
|
|
7
8
|
import type { StorageProps } from './storage.ts';
|
|
9
|
+
import { changedProps } from './storage-wire.ts';
|
|
10
|
+
import type { StorageAttributes } from './storage-wire.ts';
|
|
8
11
|
import { flag } from './values.ts';
|
|
9
12
|
|
|
10
13
|
export const STORAGE_CREATE = 'pve:POST /storage';
|
|
@@ -75,6 +78,16 @@ const locatorForm = (locator: StorageLocator | undefined) =>
|
|
|
75
78
|
const underscored = (form: Record<string, string>): Record<string, string> =>
|
|
76
79
|
Object.fromEntries(Object.entries(form).map(([key, value]) => [key.replaceAll('-', '_'), value]));
|
|
77
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The 7 fields `mutable()` reads, narrowed out of `StorageProps` — not `StorageProps` itself, so
|
|
83
|
+
* a partial diff (storage-wire.ts's `changedProps`, which carries none of `storage`/`locator`/
|
|
84
|
+
* `target`) can satisfy this type directly.
|
|
85
|
+
*/
|
|
86
|
+
export type MutableFields = Pick<
|
|
87
|
+
StorageProps,
|
|
88
|
+
'comment' | 'content' | 'disable' | 'nodes' | 'preallocation' | 'prune-backups' | 'shared'
|
|
89
|
+
>;
|
|
90
|
+
|
|
78
91
|
/**
|
|
79
92
|
* The mutable half, for create and update alike.
|
|
80
93
|
*
|
|
@@ -93,15 +106,21 @@ const underscored = (form: Record<string, string>): Record<string, string> =>
|
|
|
93
106
|
* vendor-constraint table is keyed by PVE's own names (`underscored`'s own header has the
|
|
94
107
|
* measured evidence). `toDistilledCreate`/`toDistilledUpdate` translate a COPY for the actual
|
|
95
108
|
* SDK call; this function and its callers never see the underscored form.
|
|
109
|
+
*
|
|
110
|
+
* ⛔ `shared` IS GATED BY TYPE — `sharedAccepted` (storage-plugin-options.ts, vendor-cited): most
|
|
111
|
+
* plugins refuse it outright ("unexpected property 'shared'", the measured bug this gate
|
|
112
|
+
* fixes), so it is dropped here regardless of what the caller passed, for both create and
|
|
113
|
+
* update alike — `type` is always required precisely so this gate can run on the FULL declared
|
|
114
|
+
* set at create time too, not only on a partial update diff.
|
|
96
115
|
*/
|
|
97
|
-
const mutable = (props:
|
|
116
|
+
const mutable = (type: string, props: MutableFields) => ({
|
|
98
117
|
...field('comment', props.comment),
|
|
99
118
|
...field('content', props.content),
|
|
100
119
|
...field('disable', flag(props.disable)),
|
|
101
120
|
...field('nodes', props.nodes),
|
|
102
121
|
...field('preallocation', props.preallocation),
|
|
103
122
|
...field('prune-backups', props['prune-backups']),
|
|
104
|
-
...field('shared', flag(props.shared)),
|
|
123
|
+
...(sharedAccepted(type) ? field('shared', flag(props.shared)) : {}),
|
|
105
124
|
});
|
|
106
125
|
|
|
107
126
|
/**
|
|
@@ -113,7 +132,7 @@ const mutable = (props: StorageProps) => ({
|
|
|
113
132
|
*/
|
|
114
133
|
export const createForm = (props: StorageProps): Record<string, string> => ({
|
|
115
134
|
...locatorForm(props.locator),
|
|
116
|
-
...mutable(props),
|
|
135
|
+
...mutable(props.type, props),
|
|
117
136
|
storage: props.storage,
|
|
118
137
|
type: props.type,
|
|
119
138
|
});
|
|
@@ -123,9 +142,23 @@ export const createForm = (props: StorageProps): Record<string, string> => ({
|
|
|
123
142
|
* (`PutStorageRequest` has no `type`, and the plugin locator fields it does carry are for a
|
|
124
143
|
* different purpose: moving an existing volume, not re-pointing the definition), and comparing
|
|
125
144
|
* either could only plan an update that no write can apply: a plan that reports work forever.
|
|
145
|
+
*
|
|
146
|
+
* ★ PARTIAL ON A KNOWN LIVE OBJECT — THE FIX FOR THE MEASURED BUG. `live === undefined` means
|
|
147
|
+
* there is no live row to diff against yet (`diff()`'s own early sanity check, run before any
|
|
148
|
+
* read; and the "about to create" branch in `reconcile()`) and this returns the FULL declared
|
|
149
|
+
* set, same as before this change. Once `live` is a real read, only `changedProps`
|
|
150
|
+
* (storage-wire.ts) — the declared fields that actually differ from what PVE just reported —
|
|
151
|
+
* reach the wire. Before this, `reconcile` PUT the full declared set whenever ANYTHING
|
|
152
|
+
* differed, so a declared `shared` that already MATCHED live still rode along on every update;
|
|
153
|
+
* for `storage-cephfs-tb4` (`content` was the only real drift, measured 2026-09-27) that meant
|
|
154
|
+
* sending `shared` to a plugin whose own `options()` never accepts it (storage-plugin-options.ts
|
|
155
|
+
* has the vendor citation), and PVE answered `unexpected property 'shared'`.
|
|
126
156
|
*/
|
|
127
|
-
export const updateForm = (
|
|
128
|
-
|
|
157
|
+
export const updateForm = (
|
|
158
|
+
props: StorageProps,
|
|
159
|
+
live: StorageAttributes | undefined,
|
|
160
|
+
): Record<string, string> => ({
|
|
161
|
+
...mutable(props.type, live === undefined ? props : changedProps(live, props)),
|
|
129
162
|
storage: props.storage,
|
|
130
163
|
});
|
|
131
164
|
|
|
@@ -139,6 +172,13 @@ export const updateForm = (props: StorageProps): Record<string, string> => ({
|
|
|
139
172
|
export const toDistilledCreate = (props: StorageProps): storage.CreateStorageRequest =>
|
|
140
173
|
underscored(createForm(props)) as unknown as storage.CreateStorageRequest;
|
|
141
174
|
|
|
142
|
-
/**
|
|
143
|
-
|
|
144
|
-
|
|
175
|
+
/**
|
|
176
|
+
* The actual `putStorage` call body — translated once. See `toDistilledCreate`.
|
|
177
|
+
* ⚠️ TAKES THE ALREADY-BUILT FORM, NOT `props`+`live` — storage.ts's `reconcile` computes
|
|
178
|
+
* `updateForm(news, before)` exactly ONCE and passes that SAME object here and to `guardWrite`,
|
|
179
|
+
* so the vendor-constraint check and the wire body can never diverge. Two independent calls to
|
|
180
|
+
* `updateForm`, each running its own `changedProps`, would still agree today, but "the guard
|
|
181
|
+
* checks what is actually sent" (the house rule this fix follows) should not depend on that.
|
|
182
|
+
*/
|
|
183
|
+
export const toDistilledUpdate = (form: Record<string, string>): storage.PutStorageRequest =>
|
|
184
|
+
underscored(form) as unknown as storage.PutStorageRequest;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which PVE storage plugin TYPES accept the `shared` property on write (create or update) — from
|
|
3
|
+
* each plugin's own `options()` in vendor source, NOT the generic combined property list
|
|
4
|
+
* `pve-apidoc` documentation carries (storage.ts's own header has that gap: apidoc.js merges
|
|
5
|
+
* every plugin's fields into one flat list for display, so `shared` LOOKS like a plain valid
|
|
6
|
+
* property of PUT /storage/{storage} there — `generated/pve/storage.ts`'s own
|
|
7
|
+
* `StorageStoragePutParams.shared` came from exactly that merged list, and so did the constraint
|
|
8
|
+
* codegen's own inclusion of `shared` as a recognized wire field). The check PVE actually runs is
|
|
9
|
+
* PER STORAGE TYPE, against that concrete plugin's own `options()` accepted-key set
|
|
10
|
+
* (PVE::SectionConfig) — a key the plugin never names is refused outright, which is why a cephfs
|
|
11
|
+
* PUT carrying `shared` answers `unexpected property 'shared'` even though the apidoc schema and
|
|
12
|
+
* the generated wire types both carry the field.
|
|
13
|
+
*
|
|
14
|
+
* MEASURED against github.com/proxmox/pve-storage (the official read-only GitHub mirror of
|
|
15
|
+
* git.proxmox.com/pve-storage.git), branch `master`, read 2026-09-27 — each plugin's own
|
|
16
|
+
* `sub options` block. Extended 2026-09-27 (PR 314's own red team) to cover the five types the
|
|
17
|
+
* first pass left unverified rather than guessed at; only these two lists moved:
|
|
18
|
+
*
|
|
19
|
+
* accepts `shared` (`shared => { optional => 1 }`):
|
|
20
|
+
* DirPlugin.pm:85, LVMPlugin.pm:453, BTRFSPlugin.pm:69, ESXiPlugin.pm:52
|
|
21
|
+
*
|
|
22
|
+
* `shared` is ABSENT from `options()` entirely — PVE's per-type accepted-key check refuses it
|
|
23
|
+
* on both create and update, whatever the combined apidoc schema shows:
|
|
24
|
+
* CephFSPlugin.pm, RBDPlugin.pm, ZFSPoolPlugin.pm, LvmThinPlugin.pm, NFSPlugin.pm,
|
|
25
|
+
* CIFSPlugin.pm, PBSPlugin.pm, ISCSIPlugin.pm, ISCSIDirectPlugin.pm, ZFSPlugin.pm (the remote
|
|
26
|
+
* ZFS-over-iSCSI plugin, distinct from `zfspool`)
|
|
27
|
+
*
|
|
28
|
+
* `storage-cephfs-tb4`'s PUT failure (measured 2026-09-27, `bun run deploy`,
|
|
29
|
+
* `InternalServerError: update storage failed: unexpected property 'shared'`) is exactly this:
|
|
30
|
+
* `cephfs` has no `shared` in its own `options()`, so the base class's generic `shared` property
|
|
31
|
+
* (`PVE::Storage::Plugin`'s `$defaultData->{propertyList}`: `type => 'boolean', optional => 1`,
|
|
32
|
+
* no `fixed`) is never in the accepted set for that plugin, whether or not the declared value
|
|
33
|
+
* would have matched what PVE already has.
|
|
34
|
+
*
|
|
35
|
+
* ⚠️ STILL NOT EXHAUSTIVE. Every PVE plugin type is now checked against vendor source EXCEPT
|
|
36
|
+
* whatever ships after this file was last read — `sharedAccepted` treats an unrecognized type
|
|
37
|
+
* as NOT accepting `shared`, the safe default: a wrongly-excluded type just never sends a
|
|
38
|
+
* declared `shared` (storage-form.ts's `mutable`) and, if the declared value actually disagrees
|
|
39
|
+
* with what PVE reports, the plan dies loud rather than lying `noop` (storage-wire.ts's
|
|
40
|
+
* `matches`) — it does not risk a second `unexpected property` 500. Read a new plugin's own
|
|
41
|
+
* `options()` on github.com/proxmox/pve-storage before adding it to either list.
|
|
42
|
+
*/
|
|
43
|
+
const SHARED_ACCEPTED_TYPES: ReadonlySet<string> = new Set(['dir', 'lvm', 'btrfs', 'esxi']);
|
|
44
|
+
|
|
45
|
+
export const sharedAccepted = (type: string): boolean => SHARED_ACCEPTED_TYPES.has(type);
|