@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.
Files changed (116) hide show
  1. package/README.md +14 -0
  2. package/dist/argocd/cluster.d.ts +102 -0
  3. package/dist/argocd/cluster.d.ts.map +1 -0
  4. package/dist/argocd/credentials.d.ts +37 -0
  5. package/dist/argocd/credentials.d.ts.map +1 -0
  6. package/dist/argocd/fake-argocd.d.ts +42 -0
  7. package/dist/argocd/fake-argocd.d.ts.map +1 -0
  8. package/dist/argocd/git-credentials.d.ts +29 -0
  9. package/dist/argocd/git-credentials.d.ts.map +1 -0
  10. package/dist/argocd/index.d.ts +19 -0
  11. package/dist/argocd/index.d.ts.map +1 -0
  12. package/dist/argocd/index.js +306 -0
  13. package/dist/argocd/index.js.map +17 -0
  14. package/dist/argocd/providers.d.ts +13 -0
  15. package/dist/argocd/providers.d.ts.map +1 -0
  16. package/dist/argocd/repo-creds.d.ts +67 -0
  17. package/dist/argocd/repo-creds.d.ts.map +1 -0
  18. package/dist/argocd/repository.d.ts +87 -0
  19. package/dist/argocd/repository.d.ts.map +1 -0
  20. package/dist/argocd/resource.d.ts +90 -0
  21. package/dist/argocd/resource.d.ts.map +1 -0
  22. package/dist/cloudflare/index.js +4 -4
  23. package/dist/cloudflare/index.js.map +3 -3
  24. package/dist/cloudflare/r2-bucket-lock.d.ts +14 -5
  25. package/dist/cloudflare/r2-bucket-lock.d.ts.map +1 -1
  26. package/dist/litellm/index.js.map +2 -2
  27. package/dist/litellm/operations.d.ts.map +1 -1
  28. package/dist/proxmox/ceph-daemon-form.d.ts +4 -5
  29. package/dist/proxmox/ceph-daemon-form.d.ts.map +1 -1
  30. package/dist/proxmox/ceph-daemon-wire.d.ts +31 -0
  31. package/dist/proxmox/ceph-daemon-wire.d.ts.map +1 -0
  32. package/dist/proxmox/ceph-daemon.d.ts +40 -6
  33. package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
  34. package/dist/proxmox/ceph-fs-distilled.d.ts +33 -0
  35. package/dist/proxmox/ceph-fs-distilled.d.ts.map +1 -0
  36. package/dist/proxmox/ceph-fs-wire.d.ts +20 -11
  37. package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
  38. package/dist/proxmox/ceph-fs.d.ts +30 -6
  39. package/dist/proxmox/ceph-fs.d.ts.map +1 -1
  40. package/dist/proxmox/ceph-osd-tree.d.ts +12 -3
  41. package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -1
  42. package/dist/proxmox/ceph-osd-write.d.ts +17 -2
  43. package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
  44. package/dist/proxmox/ceph-osd.d.ts +6 -0
  45. package/dist/proxmox/ceph-osd.d.ts.map +1 -1
  46. package/dist/proxmox/ceph-pool-settle.d.ts +19 -12
  47. package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
  48. package/dist/proxmox/ceph-pool-wire.d.ts +37 -0
  49. package/dist/proxmox/ceph-pool-wire.d.ts.map +1 -0
  50. package/dist/proxmox/ceph-pool.d.ts +10 -5
  51. package/dist/proxmox/ceph-pool.d.ts.map +1 -1
  52. package/dist/proxmox/group-wire.d.ts +8 -13
  53. package/dist/proxmox/group-wire.d.ts.map +1 -1
  54. package/dist/proxmox/index.js +605 -483
  55. package/dist/proxmox/index.js.map +20 -17
  56. package/dist/proxmox/storage-wire.d.ts +15 -11
  57. package/dist/proxmox/storage-wire.d.ts.map +1 -1
  58. package/dist/proxmox/unreadable-read.d.ts +7 -7
  59. package/dist/proxmox/user-wire.d.ts +8 -11
  60. package/dist/proxmox/user-wire.d.ts.map +1 -1
  61. package/dist/proxmox/user.d.ts +5 -5
  62. package/docs/argocd-kubernetes.md +100 -0
  63. package/docs/argocd.md +139 -0
  64. package/docs/credentials.md +9 -0
  65. package/docs/distilled-interim.md +15 -3
  66. package/docs/litellm.md +17 -0
  67. package/docs/peers.md +7 -0
  68. package/docs/provider-standard.md +24 -6
  69. package/docs/proxmox.md +20 -0
  70. package/docs/unifi.md +45 -0
  71. package/docs/upstream-conformance-bun.md +67 -0
  72. package/docs/upstream-conformance-conforms.md +55 -0
  73. package/docs/upstream-conformance-families.md +95 -0
  74. package/docs/upstream-conformance-grafana.md +106 -0
  75. package/docs/upstream-conformance.md +20 -298
  76. package/package.json +8 -3
  77. package/src/argocd/cluster.test.ts +120 -0
  78. package/src/argocd/cluster.ts +222 -0
  79. package/src/argocd/credentials.ts +38 -0
  80. package/src/argocd/fake-argocd.ts +60 -0
  81. package/src/argocd/git-credentials.ts +71 -0
  82. package/src/argocd/index.ts +40 -0
  83. package/src/argocd/providers.ts +17 -0
  84. package/src/argocd/repo-creds.test.ts +123 -0
  85. package/src/argocd/repo-creds.ts +115 -0
  86. package/src/argocd/repository.test.ts +167 -0
  87. package/src/argocd/repository.ts +165 -0
  88. package/src/argocd/resource.ts +150 -0
  89. package/src/cloudflare/r2-bucket-lock.test.ts +54 -18
  90. package/src/cloudflare/r2-bucket-lock.ts +25 -18
  91. package/src/litellm/operations.ts +8 -4
  92. package/src/proxmox/ceph-daemon-form.ts +7 -8
  93. package/src/proxmox/ceph-daemon-wire.ts +112 -0
  94. package/src/proxmox/ceph-daemon-write.test.ts +158 -0
  95. package/src/proxmox/ceph-daemon.ts +70 -53
  96. package/src/proxmox/ceph-fs-delete.test.ts +81 -0
  97. package/src/proxmox/ceph-fs-distilled.ts +100 -0
  98. package/src/proxmox/ceph-fs-wire.ts +17 -31
  99. package/src/proxmox/ceph-fs-write.test.ts +67 -0
  100. package/src/proxmox/ceph-fs.ts +68 -73
  101. package/src/proxmox/ceph-osd-tree.ts +14 -5
  102. package/src/proxmox/ceph-osd-write.test.ts +61 -0
  103. package/src/proxmox/ceph-osd-write.ts +33 -6
  104. package/src/proxmox/ceph-osd.ts +6 -0
  105. package/src/proxmox/ceph-pool-settle.ts +24 -17
  106. package/src/proxmox/ceph-pool-wire.ts +101 -0
  107. package/src/proxmox/ceph-pool-write.test.ts +146 -0
  108. package/src/proxmox/ceph-pool.ts +87 -98
  109. package/src/proxmox/group-wire.ts +7 -12
  110. package/src/proxmox/read-failure-propagation.test.ts +5 -5
  111. package/src/proxmox/storage-wire.ts +9 -10
  112. package/src/proxmox/typed-absence.test.ts +146 -0
  113. package/src/proxmox/unreadable-read.ts +7 -7
  114. package/src/proxmox/user-wire.ts +7 -10
  115. package/src/proxmox/user.ts +5 -5
  116. 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` treats it as "nothing to adopt" —
9
- * `reconcileLock`/`deleteLock` let it die like any other tagged error.
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
- const dies = <A, E>(fake: FakeR2Lock, effect: Effect.Effect<A, E, CloudflareOpContext>) =>
32
- Effect.runPromiseExit(effect.pipe(Effect.provide(fakeR2LockLayer(fake))));
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` turns it into a defect.
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 exit = await dies(fake, readLock(props(), false));
89
- expect(Exit.isFailure(exit)).toBe(true);
90
- expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true);
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 dies', async () => {
124
+ test('NoSuchBucket is NOT caught here: a genuinely missing bucket is a typed refusal', async () => {
112
125
  const fake = fakeR2Lock();
113
- const exit = await dies(fake, reconcileLock(props(), undefined));
114
- expect(Exit.isFailure(exit)).toBe(true);
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('NoSuchBucket is NOT caught here either: a genuinely missing bucket dies', async () => {
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
- const exit = await dies(fake, deleteLock(output, FAKE_ACCOUNT));
136
- expect(Exit.isFailure(exit)).toBe(true);
137
- expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true);
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 dies.
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<R2BucketLockAttributes | undefined, never, CloudflareOpContext> =>
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 dies like every other tagged error `putBucketLock` can raise.
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, never, CloudflareOpContext> =>
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
- .putBucketLock({
158
- accountId: news.accountId,
159
- bucketName: news.bucketName,
160
- jurisdiction,
161
- ...toBody(news.rules),
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<void, never, CloudflareOpContext> =>
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.orDie);
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
- * Resolving `Credentials` can itself fail typed (`ConfigError` — S20/S24, credentials.ts), so
62
- * the return type carries that alongside the wrapped operation's own error `E`; every call
63
- * site's declared error type already includes `ConfigError` as a member of the SDK's shared
64
- * `LitellmOpError` (protocol.ts), so this widening is a no-op for callers.
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 { EndpointPair } from './resource-spec.ts';
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
- * That is why `PveSpec['endpoint']` admits a function of props at all.
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` ON ANY OF THEM: none of the three has a PUT, which is why the spec declares no
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, EndpointPair>> = {
51
- mds: { create: 'pve:POST /nodes/{node}/ceph/mds/{name}' },
52
- mgr: { create: 'pve:POST /nodes/{node}/ceph/mgr/{id}' },
53
- mon: { create: 'pve:POST /nodes/{node}/ceph/mon/{monid}' },
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
+ });