@homeflare/alchemy 0.37.5 → 0.37.7

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 (126) hide show
  1. package/dist/openbao/auth-role-form.d.ts +0 -6
  2. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  3. package/dist/openbao/auth-role-identity.d.ts +11 -0
  4. package/dist/openbao/auth-role-identity.d.ts.map +1 -0
  5. package/dist/openbao/auth-role-wire.d.ts +23 -0
  6. package/dist/openbao/auth-role-wire.d.ts.map +1 -0
  7. package/dist/openbao/auth-role.d.ts.map +1 -1
  8. package/dist/openbao/bao-http.d.ts +34 -13
  9. package/dist/openbao/bao-http.d.ts.map +1 -1
  10. package/dist/openbao/distilled.d.ts +25 -0
  11. package/dist/openbao/distilled.d.ts.map +1 -0
  12. package/dist/openbao/fake-engines-roles.d.ts +5 -1
  13. package/dist/openbao/fake-engines-roles.d.ts.map +1 -1
  14. package/dist/openbao/index.js +358 -305
  15. package/dist/openbao/index.js.map +13 -9
  16. package/dist/openbao/policy-identity.d.ts +12 -0
  17. package/dist/openbao/policy-identity.d.ts.map +1 -0
  18. package/dist/openbao/policy-wire.d.ts +9 -8
  19. package/dist/openbao/policy-wire.d.ts.map +1 -1
  20. package/dist/openbao/policy.d.ts.map +1 -1
  21. package/dist/openbao/rename.d.ts +5 -0
  22. package/dist/openbao/rename.d.ts.map +1 -1
  23. package/dist/proxmox/firewall-alias-config.d.ts +36 -0
  24. package/dist/proxmox/firewall-alias-config.d.ts.map +1 -0
  25. package/dist/proxmox/firewall-alias-form.d.ts +3 -1
  26. package/dist/proxmox/firewall-alias-form.d.ts.map +1 -1
  27. package/dist/proxmox/firewall-alias-lifecycle.d.ts +43 -0
  28. package/dist/proxmox/firewall-alias-lifecycle.d.ts.map +1 -0
  29. package/dist/proxmox/firewall-alias.d.ts +2 -8
  30. package/dist/proxmox/firewall-alias.d.ts.map +1 -1
  31. package/dist/proxmox/index.js +795 -526
  32. package/dist/proxmox/index.js.map +24 -11
  33. package/dist/proxmox/lxc-errors.d.ts +12 -0
  34. package/dist/proxmox/lxc-errors.d.ts.map +1 -0
  35. package/dist/proxmox/lxc-lifecycle.d.ts +9 -31
  36. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -1
  37. package/dist/proxmox/lxc-read.d.ts +4 -0
  38. package/dist/proxmox/lxc-read.d.ts.map +1 -0
  39. package/dist/proxmox/lxc-task.d.ts +15 -0
  40. package/dist/proxmox/lxc-task.d.ts.map +1 -0
  41. package/dist/proxmox/network-apply-read.d.ts +7 -14
  42. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  43. package/dist/proxmox/network-apply-run.d.ts +7 -0
  44. package/dist/proxmox/network-apply-run.d.ts.map +1 -0
  45. package/dist/proxmox/network-apply.d.ts +2 -3
  46. package/dist/proxmox/network-apply.d.ts.map +1 -1
  47. package/dist/proxmox/node-network-wire.d.ts +14 -10
  48. package/dist/proxmox/node-network-wire.d.ts.map +1 -1
  49. package/dist/proxmox/qemu-errors.d.ts +5 -0
  50. package/dist/proxmox/qemu-errors.d.ts.map +1 -0
  51. package/dist/proxmox/qemu-form.d.ts +22 -0
  52. package/dist/proxmox/qemu-form.d.ts.map +1 -0
  53. package/dist/proxmox/qemu-lifecycle.d.ts +26 -0
  54. package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -0
  55. package/dist/proxmox/qemu-read.d.ts +5 -0
  56. package/dist/proxmox/qemu-read.d.ts.map +1 -0
  57. package/dist/proxmox/qemu-task.d.ts +9 -0
  58. package/dist/proxmox/qemu-task.d.ts.map +1 -0
  59. package/dist/proxmox/qemu.d.ts +9 -14
  60. package/dist/proxmox/qemu.d.ts.map +1 -1
  61. package/dist/proxmox/replication-firewall-fixtures.d.ts +30 -0
  62. package/dist/proxmox/replication-firewall-fixtures.d.ts.map +1 -0
  63. package/dist/proxmox/replication-job-config.d.ts +56 -0
  64. package/dist/proxmox/replication-job-config.d.ts.map +1 -0
  65. package/dist/proxmox/replication-job-lifecycle.d.ts +62 -0
  66. package/dist/proxmox/replication-job-lifecycle.d.ts.map +1 -0
  67. package/dist/proxmox/replication-job.d.ts +5 -6
  68. package/dist/proxmox/replication-job.d.ts.map +1 -1
  69. package/dist/release/archive.d.ts.map +1 -1
  70. package/dist/release/index.js +3 -2
  71. package/dist/release/index.js.map +3 -3
  72. package/docs/proxmox-lxc-distilled.md +56 -0
  73. package/docs/proxmox-replication-firewall-distilled.md +53 -0
  74. package/docs/proxmox-sdk-migration.md +23 -18
  75. package/docs/upstream-conformance.md +5 -3
  76. package/package.json +3 -2
  77. package/src/openbao/auth-role-form.test.ts +11 -9
  78. package/src/openbao/auth-role-form.ts +0 -19
  79. package/src/openbao/auth-role-identity.ts +24 -0
  80. package/src/openbao/auth-role-sdk.test.ts +155 -0
  81. package/src/openbao/auth-role-wire.test.ts +128 -0
  82. package/src/openbao/auth-role-wire.ts +89 -0
  83. package/src/openbao/auth-role.ts +9 -9
  84. package/src/openbao/bao-http.ts +9 -7
  85. package/src/openbao/distilled-retry.test.ts +164 -0
  86. package/src/openbao/distilled.test.ts +146 -0
  87. package/src/openbao/distilled.ts +88 -0
  88. package/src/openbao/fake-engines-roles.ts +9 -2
  89. package/src/openbao/policy-distilled.test.ts +98 -0
  90. package/src/openbao/policy-identity.ts +24 -0
  91. package/src/openbao/policy-rename.test.ts +2 -2
  92. package/src/openbao/policy-wire.test.ts +5 -5
  93. package/src/openbao/policy-wire.ts +31 -11
  94. package/src/openbao/policy.ts +10 -13
  95. package/src/openbao/rename.ts +17 -1
  96. package/src/proxmox/firewall-alias-config.ts +39 -0
  97. package/src/proxmox/firewall-alias-form.ts +1 -1
  98. package/src/proxmox/firewall-alias-lifecycle.ts +100 -0
  99. package/src/proxmox/firewall-alias.ts +4 -67
  100. package/src/proxmox/lxc-create.test.ts +4 -1
  101. package/src/proxmox/lxc-distilled.test.ts +239 -0
  102. package/src/proxmox/lxc-errors.ts +9 -0
  103. package/src/proxmox/lxc-lifecycle.ts +31 -113
  104. package/src/proxmox/lxc-read.test.ts +29 -6
  105. package/src/proxmox/lxc-read.ts +88 -0
  106. package/src/proxmox/lxc-task.ts +71 -0
  107. package/src/proxmox/network-apply-distilled.test.ts +76 -0
  108. package/src/proxmox/network-apply-read.ts +18 -39
  109. package/src/proxmox/network-apply-run.ts +97 -0
  110. package/src/proxmox/network-apply.ts +5 -94
  111. package/src/proxmox/node-network-read-failure.test.ts +24 -0
  112. package/src/proxmox/node-network-wire.ts +15 -17
  113. package/src/proxmox/qemu-errors.ts +7 -0
  114. package/src/proxmox/qemu-form.ts +15 -0
  115. package/src/proxmox/qemu-lifecycle.test.ts +171 -0
  116. package/src/proxmox/qemu-lifecycle.ts +134 -0
  117. package/src/proxmox/qemu-read.ts +73 -0
  118. package/src/proxmox/qemu-task.ts +64 -0
  119. package/src/proxmox/qemu.ts +11 -44
  120. package/src/proxmox/replication-firewall-fixtures.ts +71 -0
  121. package/src/proxmox/replication-firewall-lifecycle.test.ts +109 -0
  122. package/src/proxmox/replication-firewall-wire.test.ts +173 -0
  123. package/src/proxmox/replication-job-config.ts +79 -0
  124. package/src/proxmox/replication-job-lifecycle.ts +113 -0
  125. package/src/proxmox/replication-job.ts +7 -84
  126. package/src/release/archive.ts +4 -1
@@ -0,0 +1,113 @@
1
+ /** Named SDK transport; pve-manager 9.2.11 ReplicationConfig.pm defines absence and removal. */
2
+ import { isResolved } from 'alchemy/Diff';
3
+ import type { Input } from 'alchemy/Input';
4
+ import * as cluster from '@distilled.cloud/proxmox/cluster';
5
+ import { ProxmoxParseError } from '@distilled.cloud/proxmox/Errors';
6
+ import * as Effect from 'effect/Effect';
7
+ import * as Schema from 'effect/Schema';
8
+ import { runPve } from './distilled-pve.ts';
9
+ import type { ReplicationJobAttributes, ReplicationJobProps } from './replication-job.ts';
10
+ import { replicationJobSpec as spec } from './replication-job-config.ts';
11
+ import { jobId } from './replication-job-form.ts';
12
+ import { specGuards } from './resource-guard.ts';
13
+ import { formToSend } from './update-guard.ts';
14
+ import { int } from './values.ts';
15
+
16
+ const { guardCreate, guardUpdate } = specGuards(spec);
17
+ const malformed = () =>
18
+ new ProxmoxParseError({
19
+ body: undefined,
20
+ cause: 'Replication job response does not match the requested vendor identity',
21
+ });
22
+
23
+ export const readReplicationJob = (props: ReplicationJobProps) =>
24
+ runPve(props.target, 'read', false, cluster.getClusterReplication({ id: jobId(props) })).pipe(
25
+ Effect.flatMap((live) =>
26
+ // ⚠️ SectionConfig releases can echo `guest`/`jobnum` (and `rate`) as text rather than a JSON
27
+ // number — measured on replication-job-config.ts's own `int()`/`rateOf()` fields, which is
28
+ // why every other numeric read in this family goes through `int()` rather than a strict
29
+ // type. `GetClusterReplicationResponse` types `guest`/`jobnum` as `S.Number` with no
30
+ // coercion, so a string echo fails decode before the identity check below ever runs;
31
+ // normalize both through `int()` first, same as `rate` is normalized to skip its own
32
+ // release-dependent shape.
33
+ Schema.decodeUnknownEffect(Schema.toType(cluster.GetClusterReplicationResponse))({
34
+ ...live,
35
+ guest: int(live.guest, Number.NaN),
36
+ jobnum: int(live.jobnum, Number.NaN),
37
+ rate: undefined,
38
+ }).pipe(Effect.mapError(malformed), Effect.as(live)),
39
+ ),
40
+ Effect.flatMap((live) =>
41
+ // ⛔ `id` alone (a string PVE always returns, e.g. "900-0") already proves identity; the
42
+ // guest/jobnum recheck is defense in depth, so it must tolerate the same string-or-number
43
+ // wire shape as the decode above rather than `===` against the raw, possibly-text value.
44
+ live.id === jobId(props) &&
45
+ int(live.guest, Number.NaN) === props.guest &&
46
+ int(live.jobnum, Number.NaN) === props.jobnum
47
+ ? Effect.succeed(spec.attributes({ ...live }, props))
48
+ : Effect.fail(malformed()),
49
+ ),
50
+ Effect.catchTag('ReplicationJobNotFound', () => Effect.succeed(undefined)),
51
+ );
52
+
53
+ /** DELETE without force/keep marks full background removal; it must never force immediate removal. */
54
+ export const deleteReplicationJob = (props: ReplicationJobProps) =>
55
+ runPve(
56
+ props.target,
57
+ 'provision',
58
+ true,
59
+ cluster.deleteClusterReplication({ id: jobId(props) }),
60
+ ).pipe(Effect.catchTag('ReplicationJobNotFound', () => Effect.void));
61
+
62
+ export const replicationJobHandlers = {
63
+ // Explicit declaration is the only way to adopt a job and gain deletion authority over replicas.
64
+ list: () => Effect.succeed([]),
65
+ read: ({ olds }: { olds: ReplicationJobProps }) => readReplicationJob(olds),
66
+ diff: Effect.fn(function* ({
67
+ news,
68
+ output,
69
+ }: {
70
+ news: Input<ReplicationJobProps>;
71
+ output: ReplicationJobAttributes | undefined;
72
+ }) {
73
+ if (!isResolved(news)) return undefined;
74
+ yield* guardCreate(news, output === undefined);
75
+ yield* guardUpdate(news);
76
+ if (output === undefined) return undefined;
77
+ const live = yield* readReplicationJob(news);
78
+ if (live === undefined) {
79
+ yield* guardCreate(news, true);
80
+ return { action: 'update' } as const;
81
+ }
82
+ return { action: spec.matches(live, news) ? 'noop' : 'update' } as const;
83
+ }),
84
+ reconcile: Effect.fn(function* ({ news }: { news: ReplicationJobProps }) {
85
+ const live = yield* readReplicationJob(news);
86
+ yield* guardCreate(news, live === undefined);
87
+ yield* guardUpdate(news);
88
+ if (live === undefined) {
89
+ yield* runPve(
90
+ news.target,
91
+ 'provision',
92
+ true,
93
+ cluster.createClusterReplication(spec.createForm(news)),
94
+ );
95
+ } else {
96
+ const form = formToSend(spec.matches, live, news, spec.updateForm(news));
97
+ if (form !== undefined)
98
+ yield* runPve(
99
+ news.target,
100
+ 'provision',
101
+ true,
102
+ cluster.putClusterReplication({ ...form, id: jobId(news) }),
103
+ );
104
+ }
105
+ const after = yield* readReplicationJob(news);
106
+ if (after === undefined)
107
+ return yield* Effect.fail(
108
+ new Error(`${spec.path(news)}: write returned success but the resource is still absent`),
109
+ );
110
+ return after;
111
+ }),
112
+ delete: ({ olds }: { olds: ReplicationJobProps }) => deleteReplicationJob(olds),
113
+ };
@@ -56,13 +56,12 @@
56
56
  * checks `VM.Audit` on `/vms/<guest>`, `hf-read@pve` holds the built-in `PVEAuditor` at `/`
57
57
  * with propagate — both MEASURED — and `PVEAuditor` carries `VM.Audit`. This family is not a
58
58
  * fourth one that must borrow the provision lease.
59
- * ⚠️ A MISSING JOB IS A 500, NOT A 404 — "no such replication job '100-0'" — and the factory
60
- * folds any failed read into "absent", so a 403 reads as absent too. A plan insisting on
61
- * creating a job that plainly exists is a credential problem; the honest error arrives one step
62
- * later, from the create.
59
+ * ⚠️ A MISSING JOB IS A 500, NOT A 404 — "no such replication job '100-0'" — and distilled now
60
+ * exposes a precise missing-job tag. Permission failures and unrelated server errors propagate
61
+ * before any write; only the vendor missing-job signal means absent.
63
62
  *
64
63
  * ⛔ DELETE DOES NOT DELETE, AND A DESTROY IS NOT FREE. MEASURED: with neither `force` nor `keep` —
65
- * and the factory's `destroy` sends no form at all — the handler only sets `remove_job = 'full'`
64
+ * and the named SDK delete sends neither option — the handler only sets `remove_job = 'full'`
66
65
  * on the job and writes the config back. The job stays in replication.cfg until the SOURCE node's
67
66
  * `pvesr` timer next runs it, at which point it removes the local replication snapshots, removes
68
67
  * the replicated volumes on the target, and finally removes itself. So a destroy costs the
@@ -86,9 +85,8 @@
86
85
  import { Resource } from 'alchemy';
87
86
  import * as Provider from 'alchemy/Provider';
88
87
  import * as Effect from 'effect/Effect';
89
- import { DEFAULT_SCHEDULE, createBody, jobId, updateBody } from './replication-job-form.ts';
90
- import { type PveRequirements, type WithTarget, pveHandlers } from './resource.ts';
91
- import { bool, int, text } from './values.ts';
88
+ import type { PveRequirements, WithTarget } from './resource-spec.ts';
89
+ import { replicationJobHandlers } from './replication-job-lifecycle.ts';
92
90
 
93
91
  export interface ReplicationJobProps extends WithTarget {
94
92
  /** ⛔ The guest being replicated. 100 or above — PVE reserves everything below. Identity. */
@@ -164,81 +162,6 @@ export interface ProxmoxReplicationJob extends Resource<
164
162
 
165
163
  export const ProxmoxReplicationJob = Resource<ProxmoxReplicationJob>('Proxmox.ReplicationJob');
166
164
 
167
- /**
168
- * ⚠️ `rate` IS THE ONLY FLOAT IN THIS PACKAGE, so it gets a coercion of its own rather than a sixth
169
- * entry in `values.ts` — the rule that module states for itself: a coercion only one PVE object
170
- * needs belongs in that object's file, next to the field it serves. `int` cannot serve it, since
171
- * `Number.parseInt('10.5')` is 10 and a rate silently rounded down is both a limit nobody chose
172
- * and a comparison that never settles. It accepts a string for the reason `int` does: these are
173
- * SectionConfig sections, and whether `10` arrives as a number is a property of the release.
174
- * ⚠️ 0 MEANS UNSET, and is safe as the marker because PVE's schema gives `rate` a minimum of 1 —
175
- * the same trick as `metric-server.ts`'s `UNSET = -1`, for the same reason.
176
- */
177
- const rateOf = (value: unknown): number => {
178
- if (typeof value === 'number') return value;
179
- const parsed = Number.parseFloat(text(value));
180
- return Number.isFinite(parsed) ? parsed : 0;
181
- };
182
-
183
- const handlers = pveHandlers<ReplicationJobProps, ReplicationJobAttributes>({
184
- /**
185
- * ⚠️ EVERY FALLBACK IS PVE'S DOCUMENTED DEFAULT, NOT `false`/`''` PICKED FOR CONVENIENCE, so the
186
- * answer does not depend on whether a given release echoes back a key it never wrote.
187
- * SectionConfig stores what it was handed; a job created in the UI without a schedule has no
188
- * schedule line, and reading that absence as anything but `DEFAULT_SCHEDULE` would report
189
- * drift on a job nobody touched and then WRITE the reading back.
190
- * ⛔ NO "IS IT REALLY THERE" GUARD, for `backup-job.ts`'s reason: absence is the API declining to
191
- * answer, which `pveOperations.read` already handles, not a key missing from an answer that did
192
- * arrive. `digest` is deliberately absent too — it changes when ANY job in the file changes,
193
- * including one somebody else declared (`ha-resource.ts` hit exactly that).
194
- */
195
- attributes: (live, props) => ({
196
- comment: text(live['comment'], ''),
197
- disable: bool(live['disable'], false),
198
- guest: int(live['guest'], props.guest),
199
- id: text(live['id'], jobId(props)),
200
- jobnum: int(live['jobnum'], props.jobnum),
201
- rate: rateOf(live['rate']),
202
- remove_job: text(live['remove_job'], ''),
203
- schedule: text(live['schedule'], DEFAULT_SCHEDULE),
204
- source: text(live['source'], ''),
205
- targetNode: text(live['target'], props.targetNode),
206
- type: text(live['type'], 'local'),
207
- }),
208
- collection: () => 'cluster/replication',
209
- createForm: createBody,
210
- /**
211
- * ⚠️ `targetNode` AND `source` ARE OUT OF THIS COMPARISON FOR TWO REASONS EACH, AND EITHER ALONE
212
- * WOULD BE ENOUGH. They are create-only — `target` is a `fixed` option, `source` has no honest
213
- * declared value — AND PVE REWRITES BOTH BEHIND YOUR BACK. MEASURED in
214
- * `switch_replication_job_target_nolock`: when a guest migrates, PVE sets
215
- * `$jobcfg->{target} = $new_target` and `$jobcfg->{source} = $old_target`, so the job follows
216
- * the guest. Compared, a single migration would make every later plan report an update that the
217
- * PUT cannot perform, forever. The cost of leaving them out is stated on the `targetNode` prop:
218
- * a changed target plans as noop, and a migrated job is not dragged back.
219
- * ⚠️ `guest`, `jobnum`, `type` AND `id` ARE NOT COMPARED EITHER: the first two are the id in
220
- * integer form, `type` has one legal value, and `id` is the path — a change there is a
221
- * different object, which reads as absent and is created.
222
- * ⛔ `remove_job` IS COMPARED, AND IT IS THE ONE FIELD HERE THAT LOOKS LIKE A FOREVER-UPDATE AND
223
- * IS NOT. A DELETE marks the job rather than removing it, so a re-declared id can name a job
224
- * that is busy deleting itself, and matching that would be a noop over a vanishing object. It
225
- * settles because `updateBody` actually clears the marker — see there.
226
- */
227
- /** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
228
- endpoint: {
229
- create: 'pve:POST /cluster/replication',
230
- update: 'pve:PUT /cluster/replication/{id}',
231
- },
232
- matches: (attributes, props) =>
233
- attributes.remove_job === '' &&
234
- attributes.schedule === (props.schedule ?? DEFAULT_SCHEDULE) &&
235
- attributes.disable === (props.disable === true) &&
236
- attributes.comment === (props.comment ?? '') &&
237
- (props.rate === undefined || attributes.rate === props.rate),
238
- path: (props) => `cluster/replication/${jobId(props)}`,
239
- updateForm: updateBody,
240
- });
241
-
242
165
  /**
243
166
  * ⛔ Empty `list` like every other resource here: `GET /cluster/replication` hands back every job an
244
167
  * operator ever made, and adopting one is how a later `alchemy destroy` takes away a standby copy
@@ -250,5 +173,5 @@ const handlers = pveHandlers<ReplicationJobProps, ReplicationJobAttributes>({
250
173
  export const ProxmoxReplicationJobProvider = () =>
251
174
  Provider.effect(
252
175
  ProxmoxReplicationJob,
253
- Effect.succeed(ProxmoxReplicationJob.Provider.of(handlers)),
176
+ Effect.succeed(ProxmoxReplicationJob.Provider.of(replicationJobHandlers)),
254
177
  );
@@ -54,7 +54,10 @@ export const extractMembers = async (
54
54
  offset += SLICE;
55
55
  },
56
56
  });
57
- const unzipped = source.pipeThrough(new DecompressionStream('gzip')).getReader();
57
+ // ⚠️ TS 7's DOM lib types this writable chunk as BufferSource, which pipeThrough
58
+ // will not accept as Uint8Array. The slices enqueued above are Uint8Array.
59
+ const gunzip = new DecompressionStream('gzip') as ReadableWritablePair<Uint8Array, Uint8Array>;
60
+ const unzipped = source.pipeThrough(gunzip).getReader();
58
61
  try {
59
62
  for (;;) {
60
63
  const { done, value } = await unzipped.read();