@homeflare/alchemy 0.36.0 → 0.37.1
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 +14 -0
- package/dist/argocd/cluster.d.ts +102 -0
- package/dist/argocd/cluster.d.ts.map +1 -0
- package/dist/argocd/credentials.d.ts +37 -0
- package/dist/argocd/credentials.d.ts.map +1 -0
- package/dist/argocd/fake-argocd.d.ts +42 -0
- package/dist/argocd/fake-argocd.d.ts.map +1 -0
- package/dist/argocd/git-credentials.d.ts +29 -0
- package/dist/argocd/git-credentials.d.ts.map +1 -0
- package/dist/argocd/index.d.ts +19 -0
- package/dist/argocd/index.d.ts.map +1 -0
- package/dist/argocd/index.js +306 -0
- package/dist/argocd/index.js.map +17 -0
- package/dist/argocd/providers.d.ts +13 -0
- package/dist/argocd/providers.d.ts.map +1 -0
- package/dist/argocd/repo-creds.d.ts +67 -0
- package/dist/argocd/repo-creds.d.ts.map +1 -0
- package/dist/argocd/repository.d.ts +87 -0
- package/dist/argocd/repository.d.ts.map +1 -0
- package/dist/argocd/resource.d.ts +90 -0
- package/dist/argocd/resource.d.ts.map +1 -0
- package/dist/cloudflare/index.js +4 -4
- package/dist/cloudflare/index.js.map +3 -3
- package/dist/cloudflare/r2-bucket-lock.d.ts +14 -5
- package/dist/cloudflare/r2-bucket-lock.d.ts.map +1 -1
- package/dist/litellm/index.js.map +2 -2
- package/dist/litellm/operations.d.ts.map +1 -1
- package/dist/proxmox/ceph-daemon-form.d.ts +4 -5
- package/dist/proxmox/ceph-daemon-form.d.ts.map +1 -1
- package/dist/proxmox/ceph-daemon-wire.d.ts +31 -0
- package/dist/proxmox/ceph-daemon-wire.d.ts.map +1 -0
- package/dist/proxmox/ceph-daemon.d.ts +40 -6
- package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs-distilled.d.ts +33 -0
- package/dist/proxmox/ceph-fs-distilled.d.ts.map +1 -0
- package/dist/proxmox/ceph-fs-wire.d.ts +20 -11
- package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs.d.ts +30 -6
- package/dist/proxmox/ceph-fs.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-tree.d.ts +12 -3
- package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-write.d.ts +17 -2
- package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd.d.ts +6 -0
- package/dist/proxmox/ceph-osd.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool-settle.d.ts +19 -12
- package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool-wire.d.ts +37 -0
- package/dist/proxmox/ceph-pool-wire.d.ts.map +1 -0
- package/dist/proxmox/ceph-pool.d.ts +10 -5
- package/dist/proxmox/ceph-pool.d.ts.map +1 -1
- package/dist/proxmox/group-wire.d.ts +8 -13
- package/dist/proxmox/group-wire.d.ts.map +1 -1
- package/dist/proxmox/index.js +605 -483
- package/dist/proxmox/index.js.map +20 -17
- package/dist/proxmox/storage-wire.d.ts +15 -11
- package/dist/proxmox/storage-wire.d.ts.map +1 -1
- package/dist/proxmox/unreadable-read.d.ts +7 -7
- package/dist/proxmox/user-wire.d.ts +8 -11
- package/dist/proxmox/user-wire.d.ts.map +1 -1
- package/dist/proxmox/user.d.ts +5 -5
- package/docs/argocd-kubernetes.md +100 -0
- package/docs/argocd.md +139 -0
- package/docs/credentials.md +9 -0
- package/docs/distilled-interim.md +15 -3
- package/docs/litellm.md +17 -0
- package/docs/peers.md +7 -0
- package/docs/provider-standard.md +24 -6
- package/docs/proxmox.md +20 -0
- package/docs/unifi.md +45 -0
- package/docs/upstream-conformance-bun.md +67 -0
- package/docs/upstream-conformance-conforms.md +55 -0
- package/docs/upstream-conformance-families.md +95 -0
- package/docs/upstream-conformance-grafana.md +106 -0
- package/docs/upstream-conformance.md +20 -298
- package/package.json +8 -3
- package/src/argocd/cluster.test.ts +120 -0
- package/src/argocd/cluster.ts +222 -0
- package/src/argocd/credentials.ts +38 -0
- package/src/argocd/fake-argocd.ts +60 -0
- package/src/argocd/git-credentials.ts +71 -0
- package/src/argocd/index.ts +40 -0
- package/src/argocd/providers.ts +17 -0
- package/src/argocd/repo-creds.test.ts +123 -0
- package/src/argocd/repo-creds.ts +115 -0
- package/src/argocd/repository.test.ts +167 -0
- package/src/argocd/repository.ts +165 -0
- package/src/argocd/resource.ts +150 -0
- package/src/cloudflare/r2-bucket-lock.test.ts +54 -18
- package/src/cloudflare/r2-bucket-lock.ts +25 -18
- package/src/litellm/operations.ts +8 -4
- package/src/proxmox/ceph-daemon-form.ts +7 -8
- package/src/proxmox/ceph-daemon-wire.ts +112 -0
- package/src/proxmox/ceph-daemon-write.test.ts +158 -0
- package/src/proxmox/ceph-daemon.ts +70 -53
- package/src/proxmox/ceph-fs-delete.test.ts +81 -0
- package/src/proxmox/ceph-fs-distilled.ts +100 -0
- package/src/proxmox/ceph-fs-wire.ts +17 -31
- package/src/proxmox/ceph-fs-write.test.ts +67 -0
- package/src/proxmox/ceph-fs.ts +68 -73
- package/src/proxmox/ceph-osd-tree.ts +14 -5
- package/src/proxmox/ceph-osd-write.test.ts +61 -0
- package/src/proxmox/ceph-osd-write.ts +33 -6
- package/src/proxmox/ceph-osd.ts +6 -0
- package/src/proxmox/ceph-pool-settle.ts +24 -17
- package/src/proxmox/ceph-pool-wire.ts +101 -0
- package/src/proxmox/ceph-pool-write.test.ts +146 -0
- package/src/proxmox/ceph-pool.ts +87 -98
- package/src/proxmox/group-wire.ts +7 -12
- package/src/proxmox/read-failure-propagation.test.ts +5 -5
- package/src/proxmox/storage-wire.ts +9 -10
- package/src/proxmox/typed-absence.test.ts +146 -0
- package/src/proxmox/unreadable-read.ts +7 -7
- package/src/proxmox/user-wire.ts +7 -10
- package/src/proxmox/user.ts +5 -5
- package/src/proxmox/distilled-task-await.test.ts +0 -124
|
@@ -5,15 +5,15 @@
|
|
|
5
5
|
*
|
|
6
6
|
* ★ THE POINT OF THIS FILE IS THE TAGGED-ERROR PATHS, per the transport swap onto
|
|
7
7
|
* `@distilled.cloud/cloudflare/r2`: `NoSuchBucket` is matched by Cloudflare's own error code
|
|
8
|
-
* (10006), never by HTTP status, and only `readLock`
|
|
9
|
-
* `reconcileLock
|
|
8
|
+
* (10006), never by HTTP status, and only `readLock`/`deleteLock` treat it as "nothing to do" —
|
|
9
|
+
* `reconcileLock` still fails on it, but as a typed SDK error, not the `Effect.orDie`
|
|
10
|
+
* defect this family used to raise (WI-7, decision 49 "upstream wins", 2026-09-24).
|
|
10
11
|
*/
|
|
12
|
+
import * as Retry from '@distilled.cloud/cloudflare/Retry';
|
|
11
13
|
import type { CloudflareOpContext } from '@distilled.cloud/cloudflare/r2';
|
|
12
14
|
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
13
15
|
import { describe, expect, test } from 'bun:test';
|
|
14
|
-
import * as Cause from 'effect/Cause';
|
|
15
16
|
import * as Effect from 'effect/Effect';
|
|
16
|
-
import * as Exit from 'effect/Exit';
|
|
17
17
|
import {
|
|
18
18
|
FAKE_ACCOUNT,
|
|
19
19
|
type FakeR2Lock,
|
|
@@ -28,8 +28,14 @@ import { type R2BucketLockProps, deleteLock, readLock, reconcileLock } from './r
|
|
|
28
28
|
const run = <A, E>(fake: FakeR2Lock, effect: Effect.Effect<A, E, CloudflareOpContext>) =>
|
|
29
29
|
Effect.runPromise(effect.pipe(Effect.provide(fakeR2LockLayer(fake))));
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The operation's typed refusal, never a defect — asserts S20/S21 (WI-7). `Retry.none` keeps a
|
|
33
|
+
* simulated 429 from actually waiting out distilled's real, unbounded-under-test throttling
|
|
34
|
+
* backoff (`applyRetry`'s `makeDefault` in `@distilled.cloud/core/api.ts`) — the house's own
|
|
35
|
+
* speed doctrine (S26, `testing-speed-doctrine`) never lets a test wait on a real retry schedule.
|
|
36
|
+
*/
|
|
37
|
+
const refused = <A, E>(fake: FakeR2Lock, effect: Effect.Effect<A, E, CloudflareOpContext>) =>
|
|
38
|
+
Effect.runPromise(Effect.flip(effect).pipe(Retry.none, Effect.provide(fakeR2LockLayer(fake))));
|
|
33
39
|
|
|
34
40
|
const rule: R2LockRule = {
|
|
35
41
|
id: 'keep-30d',
|
|
@@ -83,11 +89,18 @@ describe('readLock', () => {
|
|
|
83
89
|
test('a different tagged error at 404 is NOT swallowed as NoSuchBucket', async () => {
|
|
84
90
|
// ⛔ code 7003 is `InvalidRoute`, not `NoSuchBucket` — even at the same 404 status a plain
|
|
85
91
|
// status check would have treated as "no bucket". `catchTag('NoSuchBucket', …)` leaves it
|
|
86
|
-
// alone, and `Effect.orDie`
|
|
92
|
+
// alone, and it now surfaces as a typed SDK error, never an `Effect.orDie` defect.
|
|
87
93
|
const fake = fakeR2Lock({ onRoute: () => fakeFailure(404, 7003, 'Invalid route') });
|
|
88
|
-
const
|
|
89
|
-
expect(
|
|
90
|
-
|
|
94
|
+
const refusal = await refused(fake, readLock(props(), false));
|
|
95
|
+
expect(refusal._tag).toBe('InvalidRoute');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('a rate-limited read surfaces as a typed refusal, not a crash', async () => {
|
|
99
|
+
// ⛔ 429 WITH NO MATCHED CODE maps to distilled's `TooManyRequests` (protocol.ts step 3,
|
|
100
|
+
// "Throttling"), one of the `CloudflareOpError` members this resource does not special-case.
|
|
101
|
+
const fake = fakeR2Lock({ onRoute: () => fakeFailure(429, 0, 'rate limited') });
|
|
102
|
+
const refusal = await refused(fake, readLock(props(), false));
|
|
103
|
+
expect(refusal._tag).toBe('TooManyRequests');
|
|
91
104
|
});
|
|
92
105
|
});
|
|
93
106
|
|
|
@@ -108,11 +121,10 @@ describe('reconcileLock', () => {
|
|
|
108
121
|
expect(fake.locks.get('my-backups')).toEqual([rule]);
|
|
109
122
|
});
|
|
110
123
|
|
|
111
|
-
test('NoSuchBucket is NOT caught here: a genuinely missing bucket
|
|
124
|
+
test('NoSuchBucket is NOT caught here: a genuinely missing bucket is a typed refusal', async () => {
|
|
112
125
|
const fake = fakeR2Lock();
|
|
113
|
-
const
|
|
114
|
-
expect(
|
|
115
|
-
expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true);
|
|
126
|
+
const refusal = await refused(fake, reconcileLock(props(), undefined));
|
|
127
|
+
expect(refusal._tag).toBe('NoSuchBucket');
|
|
116
128
|
});
|
|
117
129
|
});
|
|
118
130
|
|
|
@@ -126,14 +138,38 @@ describe('deleteLock', () => {
|
|
|
126
138
|
expect(fake.locks.has('my-backups')).toBe(false);
|
|
127
139
|
});
|
|
128
140
|
|
|
129
|
-
test('
|
|
141
|
+
test('deleting when the bucket is already gone succeeds (S11, P10: delete is idempotent)', async () => {
|
|
142
|
+
// ⛔ THE ONE BEHAVIOUR CHANGE WI-7 MAKES: unlike `reconcileLock`, `deleteLock` now catches
|
|
143
|
+
// `NoSuchBucket` into success — a bucket that no longer exists has nothing left to unlock.
|
|
130
144
|
const fake = fakeR2Lock();
|
|
131
145
|
const output = await run(
|
|
132
146
|
fakeR2Lock({ existingBuckets: ['my-backups'] }),
|
|
133
147
|
reconcileLock(props(), undefined),
|
|
134
148
|
);
|
|
135
|
-
|
|
136
|
-
expect(
|
|
137
|
-
|
|
149
|
+
await run(fake, deleteLock(output, FAKE_ACCOUNT));
|
|
150
|
+
expect(writes(fake)).toEqual(['PUT']);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('delete does not swallow InvalidRoute at 404', async () => {
|
|
154
|
+
const fake = fakeR2Lock({ onRoute: () => fakeFailure(404, 7003, 'Invalid route') });
|
|
155
|
+
const refusal = await refused(
|
|
156
|
+
fake,
|
|
157
|
+
deleteLock(
|
|
158
|
+
{ bucketName: 'my-backups', jurisdiction: 'default', rules: [rule] },
|
|
159
|
+
FAKE_ACCOUNT,
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
expect(refusal._tag).toBe('InvalidRoute');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('a rate-limited delete surfaces as a typed refusal, not a crash', async () => {
|
|
166
|
+
const setup = fakeR2Lock({ existingBuckets: ['my-backups'] });
|
|
167
|
+
const output = await run(setup, reconcileLock(props(), undefined));
|
|
168
|
+
const limited = fakeR2Lock({
|
|
169
|
+
existingBuckets: ['my-backups'],
|
|
170
|
+
onRoute: () => fakeFailure(429, 0, 'rate limited'),
|
|
171
|
+
});
|
|
172
|
+
const refusal = await refused(limited, deleteLock(output, FAKE_ACCOUNT));
|
|
173
|
+
expect(refusal._tag).toBe('TooManyRequests');
|
|
138
174
|
});
|
|
139
175
|
});
|
|
@@ -103,7 +103,9 @@ export const R2BucketLock: ResourceClass<R2BucketLock> = Resource<R2BucketLock>(
|
|
|
103
103
|
* same case the status check used to cover — but by the vendor's own error identity, not by
|
|
104
104
|
* reading a number off the response. Letting it propagate would make the resource impossible to
|
|
105
105
|
* plan; swallowing every failure would also hide a 403 from a mis-scoped token, which is the
|
|
106
|
-
* failure this estate spends the most time on. So: `NoSuchBucket` → absent, everything else
|
|
106
|
+
* failure this estate spends the most time on. So: `NoSuchBucket` → absent, everything else
|
|
107
|
+
* propagates with its SDK tag. Upstream `Cloudflare/R2/BucketSippy.ts` at beta.79 uses this
|
|
108
|
+
* same catch-and-propagate shape; a blanket remap would hide those typed identities (S20/S21).
|
|
107
109
|
*
|
|
108
110
|
* ⛔ AN EMPTY LIVE RULE SET IS "NO LOCK", NOT "A LOCK WITH NOTHING IN IT", so it reads as absent
|
|
109
111
|
* and the resource CREATES. The API answers `{}` for a bucket that has never been locked and
|
|
@@ -117,15 +119,16 @@ export const R2BucketLock: ResourceClass<R2BucketLock> = Resource<R2BucketLock>(
|
|
|
117
119
|
export const readLock = (
|
|
118
120
|
props: { accountId: string; bucketName: string; jurisdiction?: Jurisdiction },
|
|
119
121
|
written: boolean,
|
|
120
|
-
): Effect.Effect<
|
|
122
|
+
): Effect.Effect<
|
|
123
|
+
R2BucketLockAttributes | undefined,
|
|
124
|
+
Exclude<r2.GetBucketLockError, { _tag: 'NoSuchBucket' }>,
|
|
125
|
+
CloudflareOpContext
|
|
126
|
+
> =>
|
|
121
127
|
Effect.gen(function* () {
|
|
122
128
|
const jurisdiction = jurisdictionOf(props);
|
|
123
129
|
const live = yield* r2
|
|
124
130
|
.getBucketLock({ accountId: props.accountId, bucketName: props.bucketName, jurisdiction })
|
|
125
|
-
.pipe(
|
|
126
|
-
Effect.catchTag('NoSuchBucket', () => Effect.succeed(undefined)),
|
|
127
|
-
Effect.orDie,
|
|
128
|
-
);
|
|
131
|
+
.pipe(Effect.catchTag('NoSuchBucket', () => Effect.succeed(undefined)));
|
|
129
132
|
if (live === undefined) return undefined;
|
|
130
133
|
const rules = (live.rules ?? []) as readonly R2LockRule[];
|
|
131
134
|
if (rules.length === 0) return undefined;
|
|
@@ -144,23 +147,21 @@ export const readLock = (
|
|
|
144
147
|
* persisted (or what `read` just adopted), so the comparison costs nothing either.
|
|
145
148
|
* ⛔ `NoSuchBucket` IS NOT CAUGHT HERE. Unlike `read`, a `reconcile` that hits it means the bucket
|
|
146
149
|
* this declaration names is genuinely gone — that is a real failure to surface, not a case to
|
|
147
|
-
* paper over, so it
|
|
150
|
+
* paper over, so it propagates like every other typed SDK error `putBucketLock` can raise.
|
|
148
151
|
*/
|
|
149
152
|
export const reconcileLock = (
|
|
150
153
|
news: R2BucketLockProps,
|
|
151
154
|
output: R2BucketLockAttributes | undefined,
|
|
152
|
-
): Effect.Effect<R2BucketLockAttributes,
|
|
155
|
+
): Effect.Effect<R2BucketLockAttributes, r2.PutBucketLockError, CloudflareOpContext> =>
|
|
153
156
|
Effect.gen(function* () {
|
|
154
157
|
const jurisdiction = jurisdictionOf(news);
|
|
155
158
|
if (output !== undefined && rulesEqual(output.rules, news.rules)) return output;
|
|
156
|
-
yield* r2
|
|
157
|
-
.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
})
|
|
163
|
-
.pipe(Effect.orDie);
|
|
159
|
+
yield* r2.putBucketLock({
|
|
160
|
+
accountId: news.accountId,
|
|
161
|
+
bucketName: news.bucketName,
|
|
162
|
+
jurisdiction,
|
|
163
|
+
...toBody(news.rules),
|
|
164
|
+
});
|
|
164
165
|
return { bucketName: news.bucketName, jurisdiction, rules: news.rules };
|
|
165
166
|
});
|
|
166
167
|
|
|
@@ -170,11 +171,17 @@ export const reconcileLock = (
|
|
|
170
171
|
* rule is configured, so retiring the repository has to start here — and it is behind
|
|
171
172
|
* `defaultRemovalPolicy: 'retain'` and the estate CLI's refusal of `destroy` precisely because
|
|
172
173
|
* no ordinary deploy should ever reach it.
|
|
174
|
+
* ⛔ IDEMPOTENT (S11): a missing bucket has nothing left to unlock. Catch only `NoSuchBucket`;
|
|
175
|
+
* every other SDK error propagates, like upstream `R2/BucketSippy.ts` at beta.79.
|
|
173
176
|
*/
|
|
174
177
|
export const deleteLock = (
|
|
175
178
|
output: R2BucketLockAttributes,
|
|
176
179
|
accountId: string,
|
|
177
|
-
): Effect.Effect<
|
|
180
|
+
): Effect.Effect<
|
|
181
|
+
void,
|
|
182
|
+
Exclude<r2.PutBucketLockError, { _tag: 'NoSuchBucket' }>,
|
|
183
|
+
CloudflareOpContext
|
|
184
|
+
> =>
|
|
178
185
|
Effect.gen(function* () {
|
|
179
186
|
yield* r2
|
|
180
187
|
.putBucketLock({
|
|
@@ -183,7 +190,7 @@ export const deleteLock = (
|
|
|
183
190
|
jurisdiction: output.jurisdiction,
|
|
184
191
|
rules: [],
|
|
185
192
|
})
|
|
186
|
-
.pipe(Effect.
|
|
193
|
+
.pipe(Effect.catchTag('NoSuchBucket', () => Effect.void));
|
|
187
194
|
});
|
|
188
195
|
|
|
189
196
|
export const R2BucketLockProvider = () =>
|
|
@@ -58,10 +58,14 @@ const semaphoreFor = (baseUrl: string): Effect.Effect<Semaphore.Semaphore> =>
|
|
|
58
58
|
/**
|
|
59
59
|
* Serialises a write against this call's own base URL — see the file header.
|
|
60
60
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
61
|
+
* ⚠️ `Credentials` itself ends in `Effect.orDie` on a missing/misspelled env var (Q6, decision
|
|
62
|
+
* 49 "upstream wins", 2026-09-24, matching distilled's own convention — 73 of 80 packages'
|
|
63
|
+
* `credentials.ts` do the same at `homeflare/base`), so resolving it here can no longer fail
|
|
64
|
+
* typed. The `| ConfigError` this function still adds to its return type stays only because
|
|
65
|
+
* `@distilled.cloud/litellm`'s own `LitellmOpError` (protocol.ts) unconditionally declares
|
|
66
|
+
* `ConfigError` as a member of every generated operation's error union regardless of
|
|
67
|
+
* `Credentials`'s own type — so `E` (the wrapped operation's error) already contains it, and
|
|
68
|
+
* this widening is a genuine no-op for callers, not dead type-state left over from the revert.
|
|
65
69
|
*/
|
|
66
70
|
const mutate = <A, E>(
|
|
67
71
|
io: Effect.Effect<A, E, LitellmOpContext>,
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
*
|
|
30
30
|
*/
|
|
31
31
|
import type { CephDaemonAttributes, CephDaemonKind, CephDaemonProps } from './ceph-daemon.ts';
|
|
32
|
-
import type {
|
|
32
|
+
import type { EndpointKey } from './constraints.ts';
|
|
33
33
|
import { bool, flag, num, text } from './values.ts';
|
|
34
34
|
|
|
35
35
|
/**
|
|
@@ -38,19 +38,18 @@ import { bool, flag, num, text } from './values.ts';
|
|
|
38
38
|
* ⛔ ONE PER KIND, NOT A `{type}` PATH. PVE registers `mds`, `mgr` and `mon` as three separate
|
|
39
39
|
* nodes with three separate parameter schemas — `hotstandby` belongs to the first and
|
|
40
40
|
* `mon-address` to the third — so a single key would enforce one kind's rules on all three.
|
|
41
|
-
*
|
|
41
|
+
* `ceph-daemon.ts` indexes this by `props.kind` for exactly that reason, on every `guardWrite`.
|
|
42
42
|
* ⛔ THE STRINGS MUST STAY LITERALS. `codegen/constraints.ts` finds the endpoints to table by
|
|
43
43
|
* SCANNING THIS PACKAGE'S TEXT, so a key assembled from `props.kind` would be tabled by
|
|
44
44
|
* nothing and `constraintsFor` would throw on the first deploy that reached it.
|
|
45
45
|
* ⚠️ THE PARAMETER NAMES DIFFER AND ARE THE VENDOR'S OWN — `{name}`, `{id}`, `{monid}`. The
|
|
46
46
|
* generator resolves each against the schema, so a tidied-up spelling stops the build.
|
|
47
|
-
* ⚠️ NO `update`
|
|
48
|
-
* `updateForm` either.
|
|
47
|
+
* ⚠️ NO `update` HERE: none of the three kinds has a PUT — ceph-daemon-wire.ts's own header.
|
|
49
48
|
*/
|
|
50
|
-
export const DAEMON_ENDPOINTS: Readonly<Record<CephDaemonKind,
|
|
51
|
-
mds:
|
|
52
|
-
mgr:
|
|
53
|
-
mon:
|
|
49
|
+
export const DAEMON_ENDPOINTS: Readonly<Record<CephDaemonKind, EndpointKey>> = {
|
|
50
|
+
mds: 'pve:POST /nodes/{node}/ceph/mds/{name}',
|
|
51
|
+
mgr: 'pve:POST /nodes/{node}/ceph/mgr/{id}',
|
|
52
|
+
mon: 'pve:POST /nodes/{node}/ceph/mon/{monid}',
|
|
54
53
|
};
|
|
55
54
|
|
|
56
55
|
/**
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.CephDaemon`'s distilled wire lane: one dispatch per kind, because mon/mgr/mds are three
|
|
3
|
+
* separate distilled operations with three separate request shapes (`{node,monid}` /
|
|
4
|
+
* `{node,id}` / `{node,name}`) — the SAME reason `DAEMON_ENDPOINTS` (ceph-daemon-form.ts) is a
|
|
5
|
+
* per-kind table rather than one templated string.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ ABSENCE IS A SUCCESSFUL INDEX WITH NO MATCHING ROW. The original transport swap
|
|
8
|
+
* preserved `pveOperations.read`'s catch-all fold; the 2026-09-24 follow-up removes it. A failed
|
|
9
|
+
* mon/mgr/mds list proves nothing about whether a daemon exists, so its SDK error propagates.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ `mon_address` IS THE ONE RENAME THIS FAMILY NEEDS — distilled's generator maps the wire name
|
|
12
|
+
* `mon-address` to `mon_address` on `UpdateNodeCephMonRequest` (`T.Body("mon-address")`,
|
|
13
|
+
* confirmed against its schema), the same one-key pattern node-network-form.ts's `RENAMED`
|
|
14
|
+
* table carries for three fields. `hotstandby` needs no rename.
|
|
15
|
+
*/
|
|
16
|
+
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
17
|
+
import * as Effect from 'effect/Effect';
|
|
18
|
+
import type { CephDaemonKind, CephDaemonProps } from './ceph-daemon.ts';
|
|
19
|
+
import { daemonAttributes, daemonId, daemonPath } from './ceph-daemon-form.ts';
|
|
20
|
+
import { runPve } from './distilled-pve.ts';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The index, per kind — the same array `daemonAttributes`/`findRow` already narrow.
|
|
24
|
+
*
|
|
25
|
+
* ⚠️ CAST TO `unknown[]` PER BRANCH, NOT LEFT TO INFERENCE. mon/mgr/mds answer three DIFFERENT
|
|
26
|
+
* row shapes (`ListNodeCephMonResponseBodyItem` and its two siblings), and a switch returning
|
|
27
|
+
* each as-is infers their INTERSECTION rather than their union — TypeScript refuses that under
|
|
28
|
+
* `exactOptionalPropertyTypes`. `daemonAttributes` already treats the row generically (it reads
|
|
29
|
+
* by string key, the same shape acl.ts narrows), so the cast costs nothing real.
|
|
30
|
+
*/
|
|
31
|
+
const listRows = (props: CephDaemonProps) => {
|
|
32
|
+
const node = props.node;
|
|
33
|
+
switch (props.kind) {
|
|
34
|
+
case 'mon':
|
|
35
|
+
return runPve(props.target, 'read', false, nodes.listNodeCephMon({ node })).pipe(
|
|
36
|
+
Effect.map((rows): unknown[] => rows),
|
|
37
|
+
);
|
|
38
|
+
case 'mgr':
|
|
39
|
+
return runPve(props.target, 'read', false, nodes.listNodeCephMgr({ node })).pipe(
|
|
40
|
+
Effect.map((rows): unknown[] => rows),
|
|
41
|
+
);
|
|
42
|
+
case 'mds':
|
|
43
|
+
return runPve(props.target, 'read', false, nodes.listNodeCephMds({ node })).pipe(
|
|
44
|
+
Effect.map((rows): unknown[] => rows),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** A missing row is absent; a failed index read keeps its typed SDK error. */
|
|
50
|
+
export const readDaemon = (props: CephDaemonProps) =>
|
|
51
|
+
listRows(props).pipe(
|
|
52
|
+
Effect.map((rows) => daemonAttributes(rows as unknown as Record<string, unknown>, props)),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
/** POST — distilled's generator misnames it "update" (checked against `T.Http`); it is a create. */
|
|
56
|
+
export const createDaemon = (props: CephDaemonProps) => {
|
|
57
|
+
const node = props.node;
|
|
58
|
+
const id = daemonId(props);
|
|
59
|
+
switch (props.kind) {
|
|
60
|
+
case 'mon':
|
|
61
|
+
return runPve(
|
|
62
|
+
props.target,
|
|
63
|
+
'provision',
|
|
64
|
+
true,
|
|
65
|
+
nodes.updateNodeCephMon({
|
|
66
|
+
...(props['mon-address'] === undefined ? {} : { mon_address: props['mon-address'] }),
|
|
67
|
+
monid: id,
|
|
68
|
+
node,
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
case 'mgr':
|
|
72
|
+
return runPve(props.target, 'provision', true, nodes.updateNodeCephMgr({ id, node }));
|
|
73
|
+
case 'mds':
|
|
74
|
+
return runPve(
|
|
75
|
+
props.target,
|
|
76
|
+
'provision',
|
|
77
|
+
true,
|
|
78
|
+
nodes.updateNodeCephMds({
|
|
79
|
+
...(props.hotstandby === undefined ? {} : { hotstandby: props.hotstandby ? '1' : '0' }),
|
|
80
|
+
name: id,
|
|
81
|
+
node,
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** ⚠️ NO FLAGS ON ANY OF THE THREE — unlike CephFs/CephOsd, nothing here is at risk of a
|
|
88
|
+
* DELETE-body PVE would ignore, because nothing here is ever sent. */
|
|
89
|
+
export const deleteDaemon = (props: CephDaemonProps) => {
|
|
90
|
+
const node = props.node;
|
|
91
|
+
const id = daemonId(props);
|
|
92
|
+
switch (props.kind) {
|
|
93
|
+
case 'mon':
|
|
94
|
+
return runPve(props.target, 'provision', true, nodes.deleteNodeCephMon({ monid: id, node }));
|
|
95
|
+
case 'mgr':
|
|
96
|
+
return runPve(props.target, 'provision', true, nodes.deleteNodeCephMgr({ id, node }));
|
|
97
|
+
case 'mds':
|
|
98
|
+
return runPve(props.target, 'provision', true, nodes.deleteNodeCephMds({ name: id, node }));
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Re-exported so ceph-daemon.ts can narrow a switch exhaustively without importing the union twice. */
|
|
103
|
+
export type { CephDaemonKind };
|
|
104
|
+
|
|
105
|
+
/** ★ Moved here from ceph-daemon.ts's `reconcile` for the 250-line cap — matches ceph-fs.ts's own
|
|
106
|
+
* `notCreated`/`notDestroyed` seam: the message, not the decision to die, lives with the calls. */
|
|
107
|
+
export const notCreated = (props: CephDaemonProps) =>
|
|
108
|
+
new Error(
|
|
109
|
+
`${daemonPath(props)}: no ${props.kind} named ${daemonId(props)} in GET nodes/` +
|
|
110
|
+
`${props.node}/ceph/${props.kind} after the create task finished. POST answers a UPID, ` +
|
|
111
|
+
'not a result -- watch the task in the PVE UI (ceph-daemon-form.ts).',
|
|
112
|
+
);
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.CephDaemon`'s write paths, through Alchemy's own Plan and Apply over a fake cluster —
|
|
3
|
+
* the paths ceph-adopt.test.ts does not reach: it is adopt-only by design (every assertion there
|
|
4
|
+
* ends on `fake.writes()` empty). Proves the distilled-based create (ceph-daemon-wire.ts) POSTs
|
|
5
|
+
* with the right per-kind body — `mon-address` renamed to `mon_address` for mon, `hotstandby` for
|
|
6
|
+
* mds — reads back, and that delete sends exactly one DELETE.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
import * as Layer from 'effect/Layer';
|
|
12
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
13
|
+
import { ProxmoxCephDaemon, ProxmoxCephDaemonProvider, reconcileDaemon } from './ceph-daemon.ts';
|
|
14
|
+
import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
|
|
15
|
+
|
|
16
|
+
const NODE = 'node-b';
|
|
17
|
+
|
|
18
|
+
describe('a genuinely new mon', () => {
|
|
19
|
+
test('POSTs mon-address as mon_address, and reads the collection back', async () => {
|
|
20
|
+
const NAME = 'node-b';
|
|
21
|
+
const COLLECTION = `nodes/${NODE}/ceph/mon`;
|
|
22
|
+
const OBJECT = `${COLLECTION}/${NAME}`;
|
|
23
|
+
let created = false;
|
|
24
|
+
const fake = fakePve((call: PveCall) => {
|
|
25
|
+
if (call.method === 'GET' && call.path === COLLECTION) {
|
|
26
|
+
return created ? [{ addr: '198.51.100.12:6789/0', name: NAME, state: 'running' }] : [];
|
|
27
|
+
}
|
|
28
|
+
// ⚠️ POST lands on the daemon's OWN path (`{monid}`), not the collection — see
|
|
29
|
+
// ceph-daemon-form.ts's own ⛔ on `collectionPath`/`daemonPath`.
|
|
30
|
+
if (call.method === 'POST' && call.path === OBJECT) {
|
|
31
|
+
created = true;
|
|
32
|
+
return `UPID:${NODE}:fake:createmon`;
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
});
|
|
36
|
+
await withoutBao(async () => {
|
|
37
|
+
const engine = engineOver(ProxmoxCephDaemonProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
38
|
+
const declared = () =>
|
|
39
|
+
ProxmoxCephDaemon('node-b-mon', {
|
|
40
|
+
kind: 'mon',
|
|
41
|
+
'mon-address': '198.51.100.12',
|
|
42
|
+
name: NAME,
|
|
43
|
+
node: NODE,
|
|
44
|
+
target: FAKE_TARGET,
|
|
45
|
+
});
|
|
46
|
+
expect(await engine.deploy(declared())).toEqual({ 'node-b-mon': 'create' });
|
|
47
|
+
});
|
|
48
|
+
expect(fake.calls.find((c) => c.method === 'POST')?.form['mon-address']).toBe('198.51.100.12');
|
|
49
|
+
expect(fake.writes()).toEqual([`POST ${OBJECT}`]);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('a genuinely new mds', () => {
|
|
54
|
+
test('POSTs hotstandby, and reads the collection back', async () => {
|
|
55
|
+
const NAME = 'node-b';
|
|
56
|
+
const COLLECTION = `nodes/${NODE}/ceph/mds`;
|
|
57
|
+
const OBJECT = `${COLLECTION}/${NAME}`;
|
|
58
|
+
let created = false;
|
|
59
|
+
const fake = fakePve((call: PveCall) => {
|
|
60
|
+
if (call.method === 'GET' && call.path === COLLECTION) {
|
|
61
|
+
return created ? [{ name: NAME, state: 'up:standby' }] : [];
|
|
62
|
+
}
|
|
63
|
+
if (call.method === 'POST' && call.path === OBJECT) {
|
|
64
|
+
created = true;
|
|
65
|
+
return `UPID:${NODE}:fake:createmds`;
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
});
|
|
69
|
+
await withoutBao(async () => {
|
|
70
|
+
const engine = engineOver(ProxmoxCephDaemonProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
71
|
+
const declared = () =>
|
|
72
|
+
ProxmoxCephDaemon('node-b-mds', {
|
|
73
|
+
hotstandby: true,
|
|
74
|
+
kind: 'mds',
|
|
75
|
+
name: NAME,
|
|
76
|
+
node: NODE,
|
|
77
|
+
target: FAKE_TARGET,
|
|
78
|
+
});
|
|
79
|
+
expect(await engine.deploy(declared())).toEqual({ 'node-b-mds': 'create' });
|
|
80
|
+
});
|
|
81
|
+
expect(fake.calls.find((c) => c.method === 'POST')?.form['hotstandby']).toBe('1');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('destroying a mgr', () => {
|
|
86
|
+
test('RemovalPolicy.destroy() then undeclaring sends exactly one DELETE', async () => {
|
|
87
|
+
const NAME = 'node-b';
|
|
88
|
+
const COLLECTION = `nodes/${NODE}/ceph/mgr`;
|
|
89
|
+
const OBJECT = `${COLLECTION}/${NAME}`;
|
|
90
|
+
let deleted = false;
|
|
91
|
+
const fake = fakePve((call: PveCall) => {
|
|
92
|
+
if (call.method === 'GET' && call.path === COLLECTION) {
|
|
93
|
+
return deleted ? [] : [{ name: NAME, state: 'active' }];
|
|
94
|
+
}
|
|
95
|
+
if (call.method === 'DELETE' && call.path === OBJECT) {
|
|
96
|
+
deleted = true;
|
|
97
|
+
return `UPID:${NODE}:fake:destroymgr`;
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
});
|
|
101
|
+
const declared = () =>
|
|
102
|
+
ProxmoxCephDaemon('node-b-mgr', { kind: 'mgr', name: NAME, node: NODE, target: FAKE_TARGET });
|
|
103
|
+
await withoutBao(async () => {
|
|
104
|
+
const engine = engineOver(ProxmoxCephDaemonProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
105
|
+
expect(await engine.deploy(declared())).toEqual({ 'node-b-mgr': 'adopted' });
|
|
106
|
+
await engine.deploy(declared().pipe(RemovalPolicy.destroy()));
|
|
107
|
+
await engine.deploy(Effect.void);
|
|
108
|
+
});
|
|
109
|
+
expect(fake.writes()).toEqual([`DELETE ${OBJECT}`]);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("reconcile called directly, no diff first — the adversarial review's own proof", () => {
|
|
114
|
+
/**
|
|
115
|
+
* ⚠️ STRUCTURAL, NOT A REFUSAL — checked directly before writing this test:
|
|
116
|
+
* `constraints.ts`'s `violations()` never checks a bare `format` rule (`mon-address`'s own
|
|
117
|
+
* `{"format":"ip-list","type":"string"}` carries no `pattern`), and `mds`/`mgr`'s create
|
|
118
|
+
* endpoints have EMPTY constraint tables — so nothing in this family's vendor table can
|
|
119
|
+
* actually refuse a bad value today (a separate, recorded gap; not this PR's to fix). What IS
|
|
120
|
+
* provable, and what the review's finding was actually about, is that `guardWrite` — and so
|
|
121
|
+
* `createForm(news)`, which it must evaluate to build the value it checks — runs on the
|
|
122
|
+
* ADOPTED-ROW PATH at all, before the `live !== undefined` early return. A getter on
|
|
123
|
+
* `mon-address` proves it: `createForm` is the ONLY thing that reads this field on that path
|
|
124
|
+
* (`createDaemon`, the other reader, is never reached when `live` is already defined), so it
|
|
125
|
+
* firing at all means the guard ran; it firing zero times is exactly the bug the review found.
|
|
126
|
+
*/
|
|
127
|
+
test('guardWrite (and the createForm it checks) still runs on an adopted row, before the early return', async () => {
|
|
128
|
+
const NAME = 'node-b';
|
|
129
|
+
const COLLECTION = `nodes/${NODE}/ceph/mon`;
|
|
130
|
+
const fake = fakePve((call: PveCall) => {
|
|
131
|
+
if (call.method === 'GET' && call.path === COLLECTION) {
|
|
132
|
+
return [{ addr: '198.51.100.12:6789/0', name: NAME, state: 'running' }];
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
});
|
|
136
|
+
let reads = 0;
|
|
137
|
+
const news = {
|
|
138
|
+
get 'mon-address'() {
|
|
139
|
+
reads += 1;
|
|
140
|
+
return '198.51.100.99';
|
|
141
|
+
},
|
|
142
|
+
kind: 'mon' as const,
|
|
143
|
+
name: NAME,
|
|
144
|
+
node: NODE,
|
|
145
|
+
target: FAKE_TARGET,
|
|
146
|
+
};
|
|
147
|
+
await withoutBao(async () => {
|
|
148
|
+
// ⛔ NOT engine.deploy() — this calls the exported `reconcileDaemon` straight, the way a
|
|
149
|
+
// resumed apply or a verify harness would, with no `diff` run first.
|
|
150
|
+
const after = await Effect.runPromise(
|
|
151
|
+
reconcileDaemon({ news }).pipe(Effect.provide(fake.layer)),
|
|
152
|
+
);
|
|
153
|
+
expect(after.state).toBe('running');
|
|
154
|
+
});
|
|
155
|
+
expect(reads).toBeGreaterThan(0);
|
|
156
|
+
expect(fake.writes()).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
});
|