@homeflare/alchemy 0.13.0 → 0.15.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.
Files changed (112) hide show
  1. package/README.md +7 -0
  2. package/dist/github/index.d.ts +18 -0
  3. package/dist/github/index.d.ts.map +1 -0
  4. package/dist/github/index.js +161 -0
  5. package/dist/github/index.js.map +12 -0
  6. package/dist/github/repo-policy-form.d.ts +86 -0
  7. package/dist/github/repo-policy-form.d.ts.map +1 -0
  8. package/dist/github/repo-policy-guards.d.ts +72 -0
  9. package/dist/github/repo-policy-guards.d.ts.map +1 -0
  10. package/dist/github/repo-policy.d.ts +21 -0
  11. package/dist/github/repo-policy.d.ts.map +1 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/openbao/index.d.ts +10 -3
  15. package/dist/openbao/index.d.ts.map +1 -1
  16. package/dist/proxmox/acl.d.ts.map +1 -1
  17. package/dist/proxmox/backup-job.d.ts.map +1 -1
  18. package/dist/proxmox/constraint-guard.d.ts +28 -0
  19. package/dist/proxmox/constraint-guard.d.ts.map +1 -0
  20. package/dist/proxmox/constraints.d.ts +71 -0
  21. package/dist/proxmox/constraints.d.ts.map +1 -0
  22. package/dist/proxmox/firewall-alias.d.ts.map +1 -1
  23. package/dist/proxmox/generated/constraints/index.d.ts +19 -0
  24. package/dist/proxmox/generated/constraints/index.d.ts.map +1 -0
  25. package/dist/proxmox/generated/constraints/pbs-config.d.ts +18 -0
  26. package/dist/proxmox/generated/constraints/pbs-config.d.ts.map +1 -0
  27. package/dist/proxmox/generated/constraints/pve-access.d.ts +18 -0
  28. package/dist/proxmox/generated/constraints/pve-access.d.ts.map +1 -0
  29. package/dist/proxmox/generated/constraints/pve-cluster.d.ts +18 -0
  30. package/dist/proxmox/generated/constraints/pve-cluster.d.ts.map +1 -0
  31. package/dist/proxmox/generated/constraints/pve-pools.d.ts +18 -0
  32. package/dist/proxmox/generated/constraints/pve-pools.d.ts.map +1 -0
  33. package/dist/proxmox/generated/constraints/pve-storage.d.ts +18 -0
  34. package/dist/proxmox/generated/constraints/pve-storage.d.ts.map +1 -0
  35. package/dist/proxmox/group.d.ts.map +1 -1
  36. package/dist/proxmox/ha-resource.d.ts.map +1 -1
  37. package/dist/proxmox/ha-rule.d.ts.map +1 -1
  38. package/dist/proxmox/index.d.ts +6 -1
  39. package/dist/proxmox/index.d.ts.map +1 -1
  40. package/dist/proxmox/index.js +816 -257
  41. package/dist/proxmox/index.js.map +33 -24
  42. package/dist/proxmox/notification-matcher.d.ts.map +1 -1
  43. package/dist/proxmox/pbs-datastore-endpoint.d.ts +22 -0
  44. package/dist/proxmox/pbs-datastore-endpoint.d.ts.map +1 -0
  45. package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
  46. package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -1
  47. package/dist/proxmox/pbs-prune-job.d.ts.map +1 -1
  48. package/dist/proxmox/pbs-sync-job-form.d.ts.map +1 -1
  49. package/dist/proxmox/pbs-verify-job.d.ts.map +1 -1
  50. package/dist/proxmox/pool.d.ts.map +1 -1
  51. package/dist/proxmox/provision-baseline.d.ts +30 -5
  52. package/dist/proxmox/provision-baseline.d.ts.map +1 -1
  53. package/dist/proxmox/replication-job.d.ts.map +1 -1
  54. package/dist/proxmox/resource-spec.d.ts +120 -0
  55. package/dist/proxmox/resource-spec.d.ts.map +1 -0
  56. package/dist/proxmox/resource.d.ts +10 -91
  57. package/dist/proxmox/resource.d.ts.map +1 -1
  58. package/dist/proxmox/role.d.ts.map +1 -1
  59. package/dist/proxmox/sdn-vnet.d.ts.map +1 -1
  60. package/dist/proxmox/sdn-zone.d.ts.map +1 -1
  61. package/dist/proxmox/storage.d.ts.map +1 -1
  62. package/dist/proxmox/user.d.ts.map +1 -1
  63. package/docs/provision-baseline.md +34 -7
  64. package/docs/proxmox-lxc.md +40 -0
  65. package/docs/repo-policy-ruleset-hazards.md +65 -0
  66. package/docs/repo-policy.md +155 -0
  67. package/package.json +5 -1
  68. package/src/github/index.ts +23 -0
  69. package/src/github/repo-policy-form.test.ts +239 -0
  70. package/src/github/repo-policy-form.ts +173 -0
  71. package/src/github/repo-policy-guards.ts +179 -0
  72. package/src/github/repo-policy.test.ts +58 -0
  73. package/src/github/repo-policy.ts +65 -0
  74. package/src/index.ts +1 -0
  75. package/src/openbao/index-types.test.ts +87 -0
  76. package/src/openbao/index.ts +15 -3
  77. package/src/proxmox/acl.ts +2 -0
  78. package/src/proxmox/backup-job.ts +2 -0
  79. package/src/proxmox/constraint-guard.ts +53 -0
  80. package/src/proxmox/constraints.test.ts +260 -0
  81. package/src/proxmox/constraints.ts +135 -0
  82. package/src/proxmox/firewall-alias.ts +5 -0
  83. package/src/proxmox/generated/constraints/index.ts +31 -0
  84. package/src/proxmox/generated/constraints/pbs-config.ts +141 -0
  85. package/src/proxmox/generated/constraints/pve-access.ts +57 -0
  86. package/src/proxmox/generated/constraints/pve-cluster.ts +174 -0
  87. package/src/proxmox/generated/constraints/pve-pools.ts +26 -0
  88. package/src/proxmox/generated/constraints/pve-storage.ts +75 -0
  89. package/src/proxmox/group.ts +2 -0
  90. package/src/proxmox/ha-resource.ts +5 -0
  91. package/src/proxmox/ha-rule.ts +2 -0
  92. package/src/proxmox/index.ts +12 -0
  93. package/src/proxmox/lxc-interior.test.ts +61 -0
  94. package/src/proxmox/notification-matcher.ts +5 -0
  95. package/src/proxmox/pbs-datastore-endpoint.ts +29 -0
  96. package/src/proxmox/pbs-datastore.ts +8 -1
  97. package/src/proxmox/pbs-notification-matcher.ts +5 -0
  98. package/src/proxmox/pbs-prune-job.ts +2 -0
  99. package/src/proxmox/pbs-sync-job-form.ts +2 -0
  100. package/src/proxmox/pbs-verify-job.ts +2 -0
  101. package/src/proxmox/pool.ts +2 -0
  102. package/src/proxmox/provision-baseline.test.ts +38 -0
  103. package/src/proxmox/provision-baseline.ts +73 -14
  104. package/src/proxmox/provision-bootstrap.test.ts +51 -0
  105. package/src/proxmox/replication-job.ts +5 -0
  106. package/src/proxmox/resource-spec.ts +117 -0
  107. package/src/proxmox/resource.ts +50 -91
  108. package/src/proxmox/role.ts +2 -0
  109. package/src/proxmox/sdn-vnet.ts +2 -0
  110. package/src/proxmox/sdn-zone.ts +2 -0
  111. package/src/proxmox/storage.ts +2 -0
  112. package/src/proxmox/user.ts +2 -0
@@ -54,7 +54,7 @@ export const PROVISION_PRIVILEGES: readonly string[] = Object.freeze([
54
54
  ]);
55
55
 
56
56
  /** The names a baseline uses. Every field has a generic default (`PROVISION_DEFAULTS`). */
57
- export interface ProvisionNames {
57
+ export interface CoreProvisionNames {
58
58
  /** The role holding `PROVISION_PRIVILEGES` on `/`. */
59
59
  readonly role?: string | undefined;
60
60
  /** The user OpenBao's `provision` role mints tokens for, realm-qualified. */
@@ -69,11 +69,41 @@ export interface ProvisionNames {
69
69
  readonly comment?: string | undefined;
70
70
  }
71
71
 
72
- /** Every name, resolved: what `PROVISION_DEFAULTS` holds and what a baseline is built from. */
72
+ /** Every core name, resolved: what `PROVISION_DEFAULTS` holds and what a baseline is built from. */
73
73
  export type ResolvedProvisionNames = {
74
- readonly [K in keyof ProvisionNames]-?: Exclude<ProvisionNames[K], undefined>;
74
+ readonly [K in keyof CoreProvisionNames]-?: Exclude<CoreProvisionNames[K], undefined>;
75
75
  };
76
76
 
77
+ /**
78
+ * `CoreProvisionNames` plus a comment per object, each defaulting to `comment`.
79
+ *
80
+ * ⛔ WHY PER OBJECT: AN EXISTING CLUSTER IS THE NORMAL CASE, NOT THE EXCEPTION. One `comment` for
81
+ * three objects can only describe a cluster this baseline made. On one that already has a mint
82
+ * group and a read user — with their own live comments, which a stack elsewhere may already
83
+ * declare — a single comment makes `provisionBootstrap` rewrite objects nobody asked it to
84
+ * touch, and the next deploy of that stack writes them back. Measured on an estate cluster
85
+ * 2026-09-22, where `hf-mint` had NO comment and `hf-read@pve` named its own mount.
86
+ * ★ Overriding one does not change the others: `comment` stays the default for every object that
87
+ * has no override, so the generic case is still one string.
88
+ * ⚠️ Each is checked like `comment` is, and a `null` `readUser` drops `readComment` with its lane.
89
+ */
90
+ export interface ProvisionNames extends CoreProvisionNames {
91
+ /** The mint group's comment. Defaults to `comment`. */
92
+ readonly groupComment?: string | undefined;
93
+ /** The provision user's comment. Defaults to `comment`. */
94
+ readonly provisionComment?: string | undefined;
95
+ /** The read user's comment. Defaults to `comment`. Ignored when `readUser` is `null`. */
96
+ readonly readComment?: string | undefined;
97
+ }
98
+
99
+ /** The resolved comment for each object a baseline holds. */
100
+ export type ProvisionComments = {
101
+ readonly [K in 'group' | ProvisionLane]: string;
102
+ };
103
+
104
+ /** Which credential lane a user or a grant belongs to — the OpenBao role that mints for it. */
105
+ export type ProvisionLane = 'provision' | 'read';
106
+
77
107
  /** ★ Generic, kit-prefixed names: nothing here is any one estate's. */
78
108
  export const PROVISION_DEFAULTS: ResolvedProvisionNames = Object.freeze({
79
109
  comment:
@@ -85,9 +115,6 @@ export const PROVISION_DEFAULTS: ResolvedProvisionNames = Object.freeze({
85
115
  role: 'HfProvisioner',
86
116
  });
87
117
 
88
- /** Which credential lane a user or a grant belongs to — the OpenBao role that mints for it. */
89
- export type ProvisionLane = 'provision' | 'read';
90
-
91
118
  /** A baseline, resolved: exactly what the declaration holds and the bootstrap makes. */
92
119
  export interface ProvisionBaseline {
93
120
  readonly role: { readonly roleid: string; readonly privs: readonly string[] };
@@ -113,9 +140,13 @@ export interface ProvisionBaseline {
113
140
  */
114
141
  const ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
115
142
  const USERID = /^[A-Za-z0-9][A-Za-z0-9._-]*@[A-Za-z][A-Za-z0-9._-]*$/;
116
- const COMMENT = /^[A-Za-z0-9 ._,()@/+-]*$/;
143
+ const COMMENT = /^[A-Za-z0-9 ._,:()@/+-]*$/;
117
144
 
118
- const problems = (names: ResolvedProvisionNames): string[] => {
145
+ const problems = (
146
+ names: ResolvedProvisionNames,
147
+ comments: ProvisionComments,
148
+ given: ProvisionNames,
149
+ ): string[] => {
119
150
  const found: string[] = [];
120
151
  if (!ID.test(names.role)) found.push(`role \`${names.role}\` is not a PVE role id`);
121
152
  if (!ID.test(names.mintGroup)) found.push(`mintGroup \`${names.mintGroup}\` is not a group id`);
@@ -129,20 +160,48 @@ const problems = (names: ResolvedProvisionNames): string[] => {
129
160
  }
130
161
  }
131
162
  if (names.readUser === names.provisionUser) found.push('readUser and provisionUser are one user');
132
- if (!COMMENT.test(names.comment))
133
- found.push('comment holds a character the bootstrap will not quote');
163
+ // ⛔ EVERY comment THE BASELINE WILL HOLD, not just the shared one: each is pasted into `sh` and
164
+ // into a Perl `q{}`. ⚠️ `readComment` only when there is a read lane — a `null` `readUser`
165
+ // drops the user, so nothing would ever be pasted.
166
+ // ★ NAMED BY WHERE THE VALUE CAME FROM, and said once. A bad shared `comment` is one problem
167
+ // called `comment`, not three called after overrides the caller never passed.
168
+ const source = (override: string | undefined, field: string) =>
169
+ override === undefined ? 'comment' : field;
170
+ const checked: (readonly [string, string])[] = [
171
+ [source(given.groupComment, 'groupComment'), comments.group],
172
+ [source(given.provisionComment, 'provisionComment'), comments.provision],
173
+ ];
174
+ if (names.readUser !== null) {
175
+ checked.push([source(given.readComment, 'readComment'), comments.read]);
176
+ }
177
+ const said = new Set<string>();
178
+ for (const [field, comment] of checked) {
179
+ if (COMMENT.test(comment) || said.has(field)) continue;
180
+ said.add(field);
181
+ found.push(`${field} holds a character the bootstrap will not quote`);
182
+ }
134
183
  return found;
135
184
  };
136
185
 
137
186
  /** Resolve `names` over the defaults and check them. Throws with every problem at once. */
138
187
  export const provisionBaseline = (names: ProvisionNames = {}): ProvisionBaseline => {
188
+ // ⚠️ The per-object comments are taken out first: they are not core names, so they must not land
189
+ // in `resolved`, which `PROVISION_DEFAULTS` types and a caller may build a literal of.
190
+ const { groupComment, provisionComment, readComment, ...core } = names;
139
191
  // ⚠️ An explicit `undefined` means "the default", not "blank": spread alone would keep it.
140
- const given = Object.entries(names).filter(([, value]) => value !== undefined);
192
+ const given = Object.entries(core).filter(([, value]) => value !== undefined);
141
193
  const resolved: ResolvedProvisionNames = {
142
194
  ...PROVISION_DEFAULTS,
143
195
  ...Object.fromEntries(given),
144
196
  };
145
- const found = problems(resolved);
197
+ // ★ `?? resolved.comment`, so an override changes ONE object and the generic case stays one
198
+ // string. ⚠️ `??` and not `||`: an empty comment is a real value — a live object with none.
199
+ const comments: ProvisionComments = {
200
+ group: groupComment ?? resolved.comment,
201
+ provision: provisionComment ?? resolved.comment,
202
+ read: readComment ?? resolved.comment,
203
+ };
204
+ const found = problems(resolved, comments, names);
146
205
  if (found.length > 0) throw new Error(`provision baseline: ${found.join('; ')}`);
147
206
  const lanes: [ProvisionLane, string, string][] = [
148
207
  ['provision', resolved.provisionUser, resolved.role],
@@ -150,10 +209,10 @@ export const provisionBaseline = (names: ProvisionNames = {}): ProvisionBaseline
150
209
  if (resolved.readUser !== null) lanes.push(['read', resolved.readUser, resolved.readRole]);
151
210
  return {
152
211
  grants: lanes.map(([lane, userid, roleid]) => ({ lane, roleid, userid })),
153
- group: { comment: resolved.comment, groupid: resolved.mintGroup },
212
+ group: { comment: comments.group, groupid: resolved.mintGroup },
154
213
  role: { privs: PROVISION_PRIVILEGES, roleid: resolved.role },
155
214
  users: lanes.map(([lane, userid]) => ({
156
- comment: resolved.comment,
215
+ comment: comments[lane],
157
216
  groups: [resolved.mintGroup],
158
217
  lane,
159
218
  userid,
@@ -113,6 +113,57 @@ describe('run against a cluster', () => {
113
113
  expect(run.state.acl.map((g) => g.ugid)).toEqual(['hf-provision@pve']);
114
114
  });
115
115
 
116
+ test('an EXISTING cluster: only the new object is written, comments and all', async () => {
117
+ // \u2605 THE CASE ONE SHARED COMMENT COULD NOT DESCRIBE. The cluster already has the mint group
118
+ // (no comment at all) and the read user (its own wording), both of them declared elsewhere
119
+ // at those live values. Only the provision user is new. With per-object comments the script
120
+ // writes exactly that user and its grant -- it does not touch the two that already match,
121
+ // so nothing is left for another stack's next deploy to write back.
122
+ const existing: CliState = {
123
+ acl: [{ path: '/', propagate: 1, roleid: 'PVEAuditor', type: 'user', ugid: 'hf-read@pve' }],
124
+ // \u26a0\ufe0f '' IS "no comment live": the fake's view drops an empty one, as PVE does.
125
+ groups: { 'hf-mint': { comment: '' } },
126
+ roles: { ...freshCluster().roles, Provisioner: ['VM.Audit'] },
127
+ users: { 'hf-read@pve': { ...lane(['hf-mint']), comment: 'mint target: read (ops)' } },
128
+ };
129
+ const run = await runScript(
130
+ provisionBootstrap({
131
+ groupComment: '',
132
+ provisionComment: 'mint target: provision (ops)',
133
+ readComment: 'mint target: read (ops)',
134
+ role: 'Provisioner',
135
+ }),
136
+ existing,
137
+ );
138
+ expect(run.code).toBe(0);
139
+ expect(run.stdout).toContain('group hf-mint: ok');
140
+ expect(run.stdout).toContain('user hf-read@pve: ok');
141
+ // The only writes: the role widened to the baseline, the new user, and its grant.
142
+ expect(run.writes.map((w) => w.split(' ').slice(0, 3).join(' '))).toEqual([
143
+ 'pveum role modify',
144
+ 'pveum user add',
145
+ 'pveum acl modify',
146
+ ]);
147
+ expect(run.state.groups['hf-mint']).toEqual({ comment: '' });
148
+ expect(run.state.users['hf-read@pve']?.comment).toBe('mint target: read (ops)');
149
+ expect(run.state.users['hf-provision@pve']?.comment).toBe('mint target: provision (ops)');
150
+ expect(run.state.roles['Provisioner']).toEqual([...PROVISION_PRIVILEGES]);
151
+
152
+ // \u26d4 NEGATIVE CONTROL, so this test can fail: the SAME cluster with one shared comment
153
+ // rewrites both objects that already matched. That is the write per-object comments exist
154
+ // to avoid, and it is what another stack's next deploy would put back.
155
+ const shared = await runScript(
156
+ provisionBootstrap({ comment: 'mint target: provision (ops)', role: 'Provisioner' }),
157
+ existing,
158
+ );
159
+ expect(shared.code).toBe(0);
160
+ expect(shared.writes.map((w) => w.split(' ').slice(0, 3).join(' '))).toContain(
161
+ 'pveum group modify',
162
+ );
163
+ expect(shared.state.groups['hf-mint']).toEqual({ comment: 'mint target: provision (ops)' });
164
+ expect(shared.state.users['hf-read@pve']?.comment).toBe('mint target: provision (ops)');
165
+ });
166
+
116
167
  test("a site's own names flow through every step", async () => {
117
168
  const names = {
118
169
  mintGroup: 'mint',
@@ -224,6 +224,11 @@ const handlers = pveHandlers<ReplicationJobProps, ReplicationJobAttributes>({
224
224
  * that is busy deleting itself, and matching that would be a noop over a vanishing object. It
225
225
  * settles because `updateBody` actually clears the marker — see there.
226
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
+ },
227
232
  matches: (attributes, props) =>
228
233
  attributes.remove_job === '' &&
229
234
  attributes.schedule === (props.schedule ?? DEFAULT_SCHEDULE) &&
@@ -0,0 +1,117 @@
1
+ /**
2
+ * WHAT A PVE OBJECT DECLARES: one shape for every one of them, so the provider can cover Proxmox
3
+ * rather than a corner of it. The four operations that read this spec are in resource.ts.
4
+ *
5
+ * ⛔ THE ALTERNATIVE IS TWENTY COPIES OF THE SAME 130 LINES. Proxmox exposes LXC, QEMU, storage,
6
+ * pools, users, groups, roles, ACLs, API tokens, SDN zones/vnets/subnets, firewall rules and
7
+ * groups, HA resources, backup and replication jobs, metric servers and notification targets.
8
+ * Each is the same four operations over a different path and a different form body. Written by
9
+ * hand, resource number six is where someone quietly drops the read-back or the isResolved guard
10
+ * and nobody notices until a plan lies.
11
+ *
12
+ * ★ SO THE FOUR OPERATIONS LIVE HERE, ONCE, AND A RESOURCE DECLARES ONLY WHAT IS DIFFERENT:
13
+ * where it lives, how to recognise it, and which fields are mutable. A new PVE object should be
14
+ * thirty lines, not a hundred and thirty.
15
+ *
16
+ * ⚠️ EVERY PVE ANSWER IS WRAPPED IN `{"data": ...}` AND A FAILED CALL CAN STILL BE HTTP 200 with
17
+ * `{"data": null}`. That is why `reconcile` below READS BACK and refuses when the object is still
18
+ * absent, rather than trusting a status code — see the ⛔ in that function.
19
+ */
20
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
21
+ import type { PveForm } from './client.ts';
22
+ import type { EndpointKey } from './constraints.ts';
23
+ import type { ApiTarget, PbsTarget, PveRole, PveTarget } from './credentials.ts';
24
+
25
+ /**
26
+ * ★ WHY SEVERAL FAMILIES HERE DECLARE `defaultRemovalPolicy: 'retain'`, WRITTEN ONCE.
27
+ *
28
+ * Alchemy's own convention: a few resource types default to retain "because their contents are
29
+ * irreplaceable" — `GitHub.Repository` and `Cloudflare.Zone` do — and those opt into deletion
30
+ * with `destroy()`. `retain` means the engine SKIPS `provider.delete` entirely when a resource
31
+ * is orphaned or destroyed: the state row is dropped, the cluster object lives on.
32
+ *
33
+ * So the PVE families whose contents cannot be rebuilt from a line of TypeScript — CephOsd,
34
+ * CephPool, CephFs, CephDaemon, ZfsPool, Storage, NodeNetwork — carry that default. Their
35
+ * `delete` is FULLY IMPLEMENTED and runs the moment a caller opts in with
36
+ * `.pipe(RemovalPolicy.destroy())`. This is deliberately the Terraform `prevent_destroy` shape
37
+ * rather than a stubbed-out operation: a `delete` that silently does nothing lies to whoever is
38
+ * reading the plan, and the lie is discovered at the worst possible time.
39
+ *
40
+ * ⚠️ THE POLICY IS A DECORATION, NOT A PROP, so changing it produces NO DIFF — the resource plans
41
+ * as a noop and the deploy re-commits the row's policy in place. A policy change therefore takes
42
+ * effect from the very next deploy even though the plan shows nothing.
43
+ */
44
+
45
+ /**
46
+ * ⛔ A RESOURCE IS PINNED TO ITS PRODUCT; ONLY THE FACTORY TAKES EITHER. The two hosts are the same
47
+ * two strings, so without a required discriminant TypeScript would accept a PBS host wherever a
48
+ * PVE one belongs — silently, with every call 401ing and `read` folding that into "absent". The
49
+ * whole argument is on `scheme` in credentials.ts.
50
+ */
51
+ /**
52
+ * ★ WHAT EVERY OPERATION IN THIS PACKAGE NEEDS FROM THE RUNTIME, NAMED ONCE.
53
+ *
54
+ * `HttpClient`, for both halves of every call: the credential is minted from OpenBao's HTTP API
55
+ * and the PVE call goes through the same client. That is what Alchemy's own providers do
56
+ * (src/Hetzner/Providers.ts builds on `FetchHttpClient.layer`, and there is no bare `fetch` in
57
+ * its Docker, Kubernetes or GitHub providers).
58
+ * ★ `ChildProcessSpawner` WAS HERE UNTIL 2026-09-14, while the mint shelled out to `bao`. The HTTP
59
+ * client replaced that (credentials.ts), so the requirement went with it.
60
+ *
61
+ * ⚠️ SPELLED OUT AT EACH OF THE FORTY-ODD RESOURCES this would be a union nobody keeps in step —
62
+ * one file left on the old shape is a type error at the stack, far from the cause. Named here,
63
+ * adding a third service later is one edit.
64
+ */
65
+ export type PveRequirements = HttpClient.HttpClient;
66
+
67
+ export type WithTarget = { target: PveTarget };
68
+ export type WithPbsTarget = { target: PbsTarget };
69
+ export type WithApiTarget = { target: ApiTarget };
70
+
71
+ export type PveSpec<Props extends WithApiTarget, Attributes> = {
72
+ /** `pools/lab`, `nodes/node-b/lxc/101` — where ONE object is read, updated and deleted. */
73
+ readonly path: (props: Props) => string;
74
+ /** `pools`, `nodes/node-b/lxc` — where a NEW one is POSTed. */
75
+ readonly collection: (props: Props) => string;
76
+ /** Live JSON to attributes. Returning undefined means "this is not really there". */
77
+ readonly attributes: (live: Record<string, unknown>, props: Props) => Attributes | undefined;
78
+ /** The form PVE wants on create. ⚠️ PVE takes form encoding, not JSON. Arrays: client.ts. */
79
+ readonly createForm: (props: Props) => PveForm;
80
+ /**
81
+ * The form for an update, or undefined when the object has no mutable fields.
82
+ *
83
+ * ⚠️ SOME PVE OBJECTS CANNOT BE UPDATED AT ALL. Returning undefined makes a changed prop a
84
+ * REPLACE rather than a silent no-op, which is the honest answer for an immutable object.
85
+ */
86
+ readonly updateForm?: (props: Props) => PveForm;
87
+ /** True when live already matches props. Decides noop vs update. */
88
+ readonly matches: (attributes: Attributes, props: Props) => boolean;
89
+ /**
90
+ * Which lease reads this family. Defaults to `read`, the 3600s auditor-shaped one.
91
+ *
92
+ * ⛔ THREE FAMILIES SET THIS TO `provision`, AND THE FAILURE IT AVOIDS IS SILENT. PVE gates some
93
+ * SINGLE-OBJECT reads on the allocate privilege rather than the audit one — `/storage/{id}`,
94
+ * `/cluster/sdn/zones/{zone}`, `/cluster/sdn/vnets/{vnet}` — while their COLLECTION reads
95
+ * accept audit, so nothing looks wrong until a resource reads one object. `read` below folds
96
+ * every failure into `undefined`, right for a 404 and wrong for a 403, so the plan says create
97
+ * and PVE answers that the object already exists. Measured both ways on `local`, and written
98
+ * up with the alternative that was rejected, in docs/privileges.md.
99
+ */
100
+ readonly readRole?: PveRole;
101
+ /**
102
+ * The vendor endpoints this family writes, as the generated tables key them —
103
+ * `'pbs:POST /config/verify'`, `'pve:PUT /cluster/sdn/zones/{zone}'`.
104
+ *
105
+ * ⛔ DECLARING IT IS WHAT MAKES THE VENDOR'S OWN LIMITS RUN AT PLAN TIME. Without it the family
106
+ * behaves exactly as it did on 2026-09-22, when `deploy:pbs` adopted ten objects and then
107
+ * failed its one create on a `maxLength` PBS publishes and our generated `comment?: string`
108
+ * does not carry. `codegen/constraints.ts` scans this package for these strings, so a key that
109
+ * names an endpoint the vendor does not have stops the GENERATOR rather than a deploy.
110
+ * ⚠️ The spelling is the vendor's own path TEMPLATE, braces and all — not `spec.path(props)`,
111
+ * which has the ids substituted in.
112
+ */
113
+ readonly endpoint?: {
114
+ readonly create?: EndpointKey;
115
+ readonly update?: EndpointKey;
116
+ };
117
+ };
@@ -1,16 +1,10 @@
1
1
  /**
2
- * One shape for every PVE object, so the provider can cover Proxmox rather than a corner of it.
2
+ * The four operations every PVE object shares, and the five handlers that wire them to Alchemy.
3
3
  *
4
- * ⛔ THE ALTERNATIVE IS TWENTY COPIES OF THE SAME 130 LINES. Proxmox exposes LXC, QEMU, storage,
5
- * pools, users, groups, roles, ACLs, API tokens, SDN zones/vnets/subnets, firewall rules and
6
- * groups, HA resources, backup and replication jobs, metric servers and notification targets.
7
- * Each is the same four operations over a different path and a different form body. Written by
8
- * hand, resource number six is where someone quietly drops the read-back or the isResolved guard
9
- * and nobody notices until a plan lies.
10
- *
11
- * ★ SO THE FOUR OPERATIONS LIVE HERE, ONCE, AND A RESOURCE DECLARES ONLY WHAT IS DIFFERENT:
12
- * where it lives, how to recognise it, and which fields are mutable. A new PVE object should be
13
- * thirty lines, not a hundred and thirty.
4
+ * ★ THE SHAPE THEY READ IS `PveSpec`, IN resource-spec.ts, WITH THE ARGUMENT FOR IT. This file was
5
+ * one file until 2026-09-22, when the vendor-constraint guard took it past the 250-line cap; the
6
+ * split is by what a reader is doing — declaring an object, or changing how every object is
7
+ * written — and the types are re-exported below so no importer moved.
14
8
  *
15
9
  * ⚠️ EVERY PVE ANSWER IS WRAPPED IN `{"data": ...}` AND A FAILED CALL CAN STILL BE HTTP 200 with
16
10
  * `{"data": null}`. That is why `reconcile` below READS BACK and refuses when the object is still
@@ -19,92 +13,45 @@
19
13
  import { isResolved } from 'alchemy/Diff';
20
14
  import type { Input } from 'alchemy/Input';
21
15
  import * as Effect from 'effect/Effect';
22
- import type * as HttpClient from 'effect/unstable/http/HttpClient';
23
- import { type PveForm, pve } from './client.ts';
24
- import type { ApiTarget, PbsTarget, PveRole, PveTarget } from './credentials.ts';
16
+ import { pve } from './client.ts';
17
+ import { guardForm } from './constraint-guard.ts';
18
+ import type { PveSpec, WithApiTarget } from './resource-spec.ts';
25
19
  import { formToSend } from './update-guard.ts';
26
20
 
27
- /**
28
- * ★ WHY SEVERAL FAMILIES HERE DECLARE `defaultRemovalPolicy: 'retain'`, WRITTEN ONCE.
29
- *
30
- * Alchemy's own convention: a few resource types default to retain "because their contents are
31
- * irreplaceable" — `GitHub.Repository` and `Cloudflare.Zone` do — and those opt into deletion
32
- * with `destroy()`. `retain` means the engine SKIPS `provider.delete` entirely when a resource
33
- * is orphaned or destroyed: the state row is dropped, the cluster object lives on.
34
- *
35
- * So the PVE families whose contents cannot be rebuilt from a line of TypeScript — CephOsd,
36
- * CephPool, CephFs, CephDaemon, ZfsPool, Storage, NodeNetwork — carry that default. Their
37
- * `delete` is FULLY IMPLEMENTED and runs the moment a caller opts in with
38
- * `.pipe(RemovalPolicy.destroy())`. This is deliberately the Terraform `prevent_destroy` shape
39
- * rather than a stubbed-out operation: a `delete` that silently does nothing lies to whoever is
40
- * reading the plan, and the lie is discovered at the worst possible time.
41
- *
42
- * ⚠️ THE POLICY IS A DECORATION, NOT A PROP, so changing it produces NO DIFF — the resource plans
43
- * as a noop and the deploy re-commits the row's policy in place. A policy change therefore takes
44
- * effect from the very next deploy even though the plan shows nothing.
45
- */
46
-
47
- /**
48
- * ⛔ A RESOURCE IS PINNED TO ITS PRODUCT; ONLY THE FACTORY TAKES EITHER. The two hosts are the same
49
- * two strings, so without a required discriminant TypeScript would accept a PBS host wherever a
50
- * PVE one belongs — silently, with every call 401ing and `read` folding that into "absent". The
51
- * whole argument is on `scheme` in credentials.ts.
52
- */
53
- /**
54
- * ★ WHAT EVERY OPERATION IN THIS PACKAGE NEEDS FROM THE RUNTIME, NAMED ONCE.
55
- *
56
- * `HttpClient`, for both halves of every call: the credential is minted from OpenBao's HTTP API
57
- * and the PVE call goes through the same client. That is what Alchemy's own providers do
58
- * (src/Hetzner/Providers.ts builds on `FetchHttpClient.layer`, and there is no bare `fetch` in
59
- * its Docker, Kubernetes or GitHub providers).
60
- * ★ `ChildProcessSpawner` WAS HERE UNTIL 2026-09-14, while the mint shelled out to `bao`. The HTTP
61
- * client replaced that (credentials.ts), so the requirement went with it.
62
- *
63
- * ⚠️ SPELLED OUT AT EACH OF THE FORTY-ODD RESOURCES this would be a union nobody keeps in step —
64
- * one file left on the old shape is a type error at the stack, far from the cause. Named here,
65
- * adding a third service later is one edit.
66
- */
67
- export type PveRequirements = HttpClient.HttpClient;
68
-
69
- export type WithTarget = { target: PveTarget };
70
- export type WithPbsTarget = { target: PbsTarget };
71
- export type WithApiTarget = { target: ApiTarget };
21
+ export type {
22
+ PveRequirements,
23
+ PveSpec,
24
+ WithApiTarget,
25
+ WithPbsTarget,
26
+ WithTarget,
27
+ } from './resource-spec.ts';
72
28
 
73
- export type PveSpec<Props extends WithApiTarget, Attributes> = {
74
- /** `pools/lab`, `nodes/node-b/lxc/101` — where ONE object is read, updated and deleted. */
75
- readonly path: (props: Props) => string;
76
- /** `pools`, `nodes/node-b/lxc` — where a NEW one is POSTed. */
77
- readonly collection: (props: Props) => string;
78
- /** Live JSON to attributes. Returning undefined means "this is not really there". */
79
- readonly attributes: (live: Record<string, unknown>, props: Props) => Attributes | undefined;
80
- /** The form PVE wants on create. ⚠️ PVE takes form encoding, not JSON. Arrays: client.ts. */
81
- readonly createForm: (props: Props) => PveForm;
82
- /**
83
- * The form for an update, or undefined when the object has no mutable fields.
84
- *
85
- * ⚠️ SOME PVE OBJECTS CANNOT BE UPDATED AT ALL. Returning undefined makes a changed prop a
86
- * REPLACE rather than a silent no-op, which is the honest answer for an immutable object.
87
- */
88
- readonly updateForm?: (props: Props) => PveForm;
89
- /** True when live already matches props. Decides noop vs update. */
90
- readonly matches: (attributes: Attributes, props: Props) => boolean;
29
+ export const pveOperations = <Props extends WithApiTarget, Attributes>(
30
+ spec: PveSpec<Props, Attributes>,
31
+ ) => {
91
32
  /**
92
- * Which lease reads this family. Defaults to `read`, the 3600s auditor-shaped one.
33
+ * Both forms against their own endpoint's table.
93
34
  *
94
- * ⛔ THREE FAMILIES SET THIS TO `provision`, AND THE FAILURE IT AVOIDS IS SILENT. PVE gates some
95
- * SINGLE-OBJECT reads on the allocate privilege rather than the audit one — `/storage/{id}`,
96
- * `/cluster/sdn/zones/{zone}`, `/cluster/sdn/vnets/{vnet}` — while their COLLECTION reads
97
- * accept audit, so nothing looks wrong until a resource reads one object. `read` below folds
98
- * every failure into `undefined`, right for a 404 and wrong for a 403, so the plan says create
99
- * and PVE answers that the object already exists. Measured both ways on `local`, and written
100
- * up with the alternative that was rejected, in docs/privileges.md.
35
+ * ⚠️ PRESENCE IS CHECKED ON CREATE ONLY. An update form is deliberately partial — `formToSend`
36
+ * sends what changed — so requiring the vendor's required parameters there would refuse every
37
+ * ordinary edit. Value rules (length, range, enum, pattern) apply to both.
101
38
  */
102
- readonly readRole?: PveRole;
103
- };
39
+ const guardForms = (props: Props) =>
40
+ Effect.gen(function* () {
41
+ /**
42
+ * ⛔ THE FORM IS BUILT ONLY WHEN THERE IS A TABLE TO CHECK IT AGAINST. Calling
43
+ * `spec.createForm` unconditionally would run every family's form builder on every diff,
44
+ * including the fifteen that declare no `endpoint` — work nobody asked for, and a new way
45
+ * for a builder that throws on an update-only path to break a plan it never touched.
46
+ */
47
+ const create = spec.endpoint?.create;
48
+ if (create !== undefined) yield* guardForm(create, spec.createForm(props), true);
49
+ const update = spec.endpoint?.update;
50
+ if (update === undefined) return;
51
+ const form = spec.updateForm?.(props);
52
+ if (form !== undefined) yield* guardForm(update, form, false);
53
+ });
104
54
 
105
- export const pveOperations = <Props extends WithApiTarget, Attributes>(
106
- spec: PveSpec<Props, Attributes>,
107
- ) => {
108
55
  /** The live object, or undefined. ⚠️ A 404 and `{"data": null}` are ANSWERS, not failures. */
109
56
  const read = (props: Props) =>
110
57
  pve<Record<string, unknown>>(
@@ -128,7 +75,15 @@ export const pveOperations = <Props extends WithApiTarget, Attributes>(
128
75
  // ⚠️ A prop can still be an unresolved Output at plan time. Alchemy's own providers guard
129
76
  // with isResolved and skip rather than guess; comparing a placeholder to a live value
130
77
  // reports an update nobody asked for.
131
- if (output === undefined || !isResolved(news)) return undefined;
78
+ if (!isResolved(news)) return undefined;
79
+ /**
80
+ * ⛔ THE VENDOR CHECK RUNS BEFORE THE `output === undefined` RETURN, AND THE ORDER IS THE
81
+ * WHOLE FIX. `output === undefined` IS the resource Alchemy has no state for — the
82
+ * create. Checking after it would leave exactly the case that failed on 2026-09-22
83
+ * unchecked, and the refusal would arrive from the server, half a deploy in.
84
+ */
85
+ yield* guardForms(news);
86
+ if (output === undefined) return undefined;
132
87
  const live = yield* read(news);
133
88
  // ⚠️ `update`, NOT `create` — Alchemy's Diff admits only noop/update/replace. An object
134
89
  // Alchemy has state for but the cluster does not is drift, and reconcile repairs it.
@@ -142,6 +97,10 @@ export const pveOperations = <Props extends WithApiTarget, Attributes>(
142
97
 
143
98
  reconcile: (news: Props) =>
144
99
  Effect.gen(function* () {
100
+ // ⚠️ CHECKED AGAIN HERE, AND NOT BECAUSE `diff` IS UNTRUSTED. `reconcile` also runs for an
101
+ // ADOPTED row, whose diff answer Alchemy discards (verify/fake-engine.ts records it), and
102
+ // a family that writes its own reconcile bypasses the one above entirely.
103
+ yield* guardForms(news);
145
104
  const live = yield* read(news);
146
105
  if (live === undefined) {
147
106
  yield* pve(
@@ -131,6 +131,8 @@ const handlers = pveHandlers<RoleProps, RoleAttributes>({
131
131
  * a plan that reports an update every single time and a deploy that writes the same role back
132
132
  * forever. Cheap insurance against the exact trap named in the header.
133
133
  */
134
+ /** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
135
+ endpoint: { create: 'pve:POST /access/roles', update: 'pve:PUT /access/roles/{roleid}' },
134
136
  matches: (attributes, props) =>
135
137
  canonical(attributes.privs).join(',') === canonical(props.privs).join(','),
136
138
  path: (props) => `access/roles/${props.roleid}`,
@@ -193,6 +193,8 @@ const handlers = pveHandlers<SdnVnetProps, SdnVnetAttributes>({
193
193
  * would report an update on every plan until somebody ran the apply, and then report another
194
194
  * one the moment anybody else staged anything at all.
195
195
  */
196
+ /** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
197
+ endpoint: { create: 'pve:POST /cluster/sdn/vnets', update: 'pve:PUT /cluster/sdn/vnets/{vnet}' },
196
198
  matches: (attributes, props) =>
197
199
  attributes.zone === props.zone &&
198
200
  attributes.alias === (props.alias ?? '') &&
@@ -210,6 +210,8 @@ const handlers = pveHandlers<SdnZoneProps, SdnZoneAttributes>({
210
210
  * handled above by refusing to recognise a zone of the wrong one. Comparing either here would
211
211
  * produce a diff whose update can never satisfy it.
212
212
  */
213
+ /** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
214
+ endpoint: { create: 'pve:POST /cluster/sdn/zones', update: 'pve:PUT /cluster/sdn/zones/{zone}' },
213
215
  matches: (attributes, props) =>
214
216
  attributes.mtu === (props.mtu ?? 0) &&
215
217
  attributes.nodes === csv(props.nodes) &&
@@ -217,6 +217,8 @@ const handlers = pveHandlers<StorageProps, StorageAttributes>({
217
217
  * PVE returns the create-only fields on read but refuses them on PUT, so diffing one could only
218
218
  * plan an update that no write can apply: a plan that reports work on every run, forever.
219
219
  */
220
+ /** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
221
+ endpoint: { create: 'pve:POST /storage', update: 'pve:PUT /storage/{storage}' },
220
222
  matches: (attributes, props) =>
221
223
  sameList(props.content, attributes.content) &&
222
224
  sameList(props.nodes, attributes.nodes) &&
@@ -186,6 +186,8 @@ const handlers = pveHandlers<UserProps, UserAttributes>({
186
186
  * what a PUT can put back; a field that PVE reports but will not accept is drift nobody can
187
187
  * fix, and diffing it would report an update on every plan forever.
188
188
  */
189
+ /** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
190
+ endpoint: { create: 'pve:POST /access/users', update: 'pve:PUT /access/users/{userid}' },
189
191
  matches: (attributes, props) =>
190
192
  attributes.comment === (props.comment ?? '') &&
191
193
  attributes.email === (props.email ?? '') &&