@homeflare/alchemy 0.14.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.
- package/dist/openbao/index.d.ts +10 -3
- package/dist/openbao/index.d.ts.map +1 -1
- package/dist/proxmox/acl.d.ts.map +1 -1
- package/dist/proxmox/backup-job.d.ts.map +1 -1
- package/dist/proxmox/constraint-guard.d.ts +28 -0
- package/dist/proxmox/constraint-guard.d.ts.map +1 -0
- package/dist/proxmox/constraints.d.ts +71 -0
- package/dist/proxmox/constraints.d.ts.map +1 -0
- package/dist/proxmox/firewall-alias.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/index.d.ts +19 -0
- package/dist/proxmox/generated/constraints/index.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pbs-config.d.ts +18 -0
- package/dist/proxmox/generated/constraints/pbs-config.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-access.d.ts +18 -0
- package/dist/proxmox/generated/constraints/pve-access.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-cluster.d.ts +18 -0
- package/dist/proxmox/generated/constraints/pve-cluster.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-pools.d.ts +18 -0
- package/dist/proxmox/generated/constraints/pve-pools.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-storage.d.ts +18 -0
- package/dist/proxmox/generated/constraints/pve-storage.d.ts.map +1 -0
- package/dist/proxmox/group.d.ts.map +1 -1
- package/dist/proxmox/ha-resource.d.ts.map +1 -1
- package/dist/proxmox/ha-rule.d.ts.map +1 -1
- package/dist/proxmox/index.d.ts +6 -1
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +816 -257
- package/dist/proxmox/index.js.map +33 -24
- package/dist/proxmox/notification-matcher.d.ts.map +1 -1
- package/dist/proxmox/pbs-datastore-endpoint.d.ts +22 -0
- package/dist/proxmox/pbs-datastore-endpoint.d.ts.map +1 -0
- package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
- package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -1
- package/dist/proxmox/pbs-prune-job.d.ts.map +1 -1
- package/dist/proxmox/pbs-sync-job-form.d.ts.map +1 -1
- package/dist/proxmox/pbs-verify-job.d.ts.map +1 -1
- package/dist/proxmox/pool.d.ts.map +1 -1
- package/dist/proxmox/provision-baseline.d.ts +30 -5
- package/dist/proxmox/provision-baseline.d.ts.map +1 -1
- package/dist/proxmox/replication-job.d.ts.map +1 -1
- package/dist/proxmox/resource-spec.d.ts +120 -0
- package/dist/proxmox/resource-spec.d.ts.map +1 -0
- package/dist/proxmox/resource.d.ts +10 -91
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/role.d.ts.map +1 -1
- package/dist/proxmox/sdn-vnet.d.ts.map +1 -1
- package/dist/proxmox/sdn-zone.d.ts.map +1 -1
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/proxmox/user.d.ts.map +1 -1
- package/docs/provision-baseline.md +34 -7
- package/docs/proxmox-lxc.md +40 -0
- package/package.json +1 -1
- package/src/openbao/index-types.test.ts +87 -0
- package/src/openbao/index.ts +15 -3
- package/src/proxmox/acl.ts +2 -0
- package/src/proxmox/backup-job.ts +2 -0
- package/src/proxmox/constraint-guard.ts +53 -0
- package/src/proxmox/constraints.test.ts +260 -0
- package/src/proxmox/constraints.ts +135 -0
- package/src/proxmox/firewall-alias.ts +5 -0
- package/src/proxmox/generated/constraints/index.ts +31 -0
- package/src/proxmox/generated/constraints/pbs-config.ts +141 -0
- package/src/proxmox/generated/constraints/pve-access.ts +57 -0
- package/src/proxmox/generated/constraints/pve-cluster.ts +174 -0
- package/src/proxmox/generated/constraints/pve-pools.ts +26 -0
- package/src/proxmox/generated/constraints/pve-storage.ts +75 -0
- package/src/proxmox/group.ts +2 -0
- package/src/proxmox/ha-resource.ts +5 -0
- package/src/proxmox/ha-rule.ts +2 -0
- package/src/proxmox/index.ts +12 -0
- package/src/proxmox/lxc-interior.test.ts +61 -0
- package/src/proxmox/notification-matcher.ts +5 -0
- package/src/proxmox/pbs-datastore-endpoint.ts +29 -0
- package/src/proxmox/pbs-datastore.ts +8 -1
- package/src/proxmox/pbs-notification-matcher.ts +5 -0
- package/src/proxmox/pbs-prune-job.ts +2 -0
- package/src/proxmox/pbs-sync-job-form.ts +2 -0
- package/src/proxmox/pbs-verify-job.ts +2 -0
- package/src/proxmox/pool.ts +2 -0
- package/src/proxmox/provision-baseline.test.ts +38 -0
- package/src/proxmox/provision-baseline.ts +73 -14
- package/src/proxmox/provision-bootstrap.test.ts +51 -0
- package/src/proxmox/replication-job.ts +5 -0
- package/src/proxmox/resource-spec.ts +117 -0
- package/src/proxmox/resource.ts +50 -91
- package/src/proxmox/role.ts +2 -0
- package/src/proxmox/sdn-vnet.ts +2 -0
- package/src/proxmox/sdn-zone.ts +2 -0
- package/src/proxmox/storage.ts +2 -0
- package/src/proxmox/user.ts +2 -0
package/src/proxmox/resource.ts
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The four operations every PVE object shares, and the five handlers that wire them to Alchemy.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
23
|
-
import {
|
|
24
|
-
import type {
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
*
|
|
33
|
+
* Both forms against their own endpoint's table.
|
|
93
34
|
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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(
|
package/src/proxmox/role.ts
CHANGED
|
@@ -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}`,
|
package/src/proxmox/sdn-vnet.ts
CHANGED
|
@@ -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 ?? '') &&
|
package/src/proxmox/sdn-zone.ts
CHANGED
|
@@ -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) &&
|
package/src/proxmox/storage.ts
CHANGED
|
@@ -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) &&
|
package/src/proxmox/user.ts
CHANGED
|
@@ -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 ?? '') &&
|