@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.
- package/dist/openbao/auth-role-form.d.ts +0 -6
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role-identity.d.ts +11 -0
- package/dist/openbao/auth-role-identity.d.ts.map +1 -0
- package/dist/openbao/auth-role-wire.d.ts +23 -0
- package/dist/openbao/auth-role-wire.d.ts.map +1 -0
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/bao-http.d.ts +34 -13
- package/dist/openbao/bao-http.d.ts.map +1 -1
- package/dist/openbao/distilled.d.ts +25 -0
- package/dist/openbao/distilled.d.ts.map +1 -0
- package/dist/openbao/fake-engines-roles.d.ts +5 -1
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -1
- package/dist/openbao/index.js +358 -305
- package/dist/openbao/index.js.map +13 -9
- package/dist/openbao/policy-identity.d.ts +12 -0
- package/dist/openbao/policy-identity.d.ts.map +1 -0
- package/dist/openbao/policy-wire.d.ts +9 -8
- package/dist/openbao/policy-wire.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/rename.d.ts +5 -0
- package/dist/openbao/rename.d.ts.map +1 -1
- package/dist/proxmox/firewall-alias-config.d.ts +36 -0
- package/dist/proxmox/firewall-alias-config.d.ts.map +1 -0
- package/dist/proxmox/firewall-alias-form.d.ts +3 -1
- package/dist/proxmox/firewall-alias-form.d.ts.map +1 -1
- package/dist/proxmox/firewall-alias-lifecycle.d.ts +43 -0
- package/dist/proxmox/firewall-alias-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/firewall-alias.d.ts +2 -8
- package/dist/proxmox/firewall-alias.d.ts.map +1 -1
- package/dist/proxmox/index.js +795 -526
- package/dist/proxmox/index.js.map +24 -11
- package/dist/proxmox/lxc-errors.d.ts +12 -0
- package/dist/proxmox/lxc-errors.d.ts.map +1 -0
- package/dist/proxmox/lxc-lifecycle.d.ts +9 -31
- package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -1
- package/dist/proxmox/lxc-read.d.ts +4 -0
- package/dist/proxmox/lxc-read.d.ts.map +1 -0
- package/dist/proxmox/lxc-task.d.ts +15 -0
- package/dist/proxmox/lxc-task.d.ts.map +1 -0
- package/dist/proxmox/network-apply-read.d.ts +7 -14
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/network-apply-run.d.ts +7 -0
- package/dist/proxmox/network-apply-run.d.ts.map +1 -0
- package/dist/proxmox/network-apply.d.ts +2 -3
- package/dist/proxmox/network-apply.d.ts.map +1 -1
- package/dist/proxmox/node-network-wire.d.ts +14 -10
- package/dist/proxmox/node-network-wire.d.ts.map +1 -1
- package/dist/proxmox/qemu-errors.d.ts +5 -0
- package/dist/proxmox/qemu-errors.d.ts.map +1 -0
- package/dist/proxmox/qemu-form.d.ts +22 -0
- package/dist/proxmox/qemu-form.d.ts.map +1 -0
- package/dist/proxmox/qemu-lifecycle.d.ts +26 -0
- package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/qemu-read.d.ts +5 -0
- package/dist/proxmox/qemu-read.d.ts.map +1 -0
- package/dist/proxmox/qemu-task.d.ts +9 -0
- package/dist/proxmox/qemu-task.d.ts.map +1 -0
- package/dist/proxmox/qemu.d.ts +9 -14
- package/dist/proxmox/qemu.d.ts.map +1 -1
- package/dist/proxmox/replication-firewall-fixtures.d.ts +30 -0
- package/dist/proxmox/replication-firewall-fixtures.d.ts.map +1 -0
- package/dist/proxmox/replication-job-config.d.ts +56 -0
- package/dist/proxmox/replication-job-config.d.ts.map +1 -0
- package/dist/proxmox/replication-job-lifecycle.d.ts +62 -0
- package/dist/proxmox/replication-job-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/replication-job.d.ts +5 -6
- package/dist/proxmox/replication-job.d.ts.map +1 -1
- package/dist/release/archive.d.ts.map +1 -1
- package/dist/release/index.js +3 -2
- package/dist/release/index.js.map +3 -3
- package/docs/proxmox-lxc-distilled.md +56 -0
- package/docs/proxmox-replication-firewall-distilled.md +53 -0
- package/docs/proxmox-sdk-migration.md +23 -18
- package/docs/upstream-conformance.md +5 -3
- package/package.json +3 -2
- package/src/openbao/auth-role-form.test.ts +11 -9
- package/src/openbao/auth-role-form.ts +0 -19
- package/src/openbao/auth-role-identity.ts +24 -0
- package/src/openbao/auth-role-sdk.test.ts +155 -0
- package/src/openbao/auth-role-wire.test.ts +128 -0
- package/src/openbao/auth-role-wire.ts +89 -0
- package/src/openbao/auth-role.ts +9 -9
- package/src/openbao/bao-http.ts +9 -7
- package/src/openbao/distilled-retry.test.ts +164 -0
- package/src/openbao/distilled.test.ts +146 -0
- package/src/openbao/distilled.ts +88 -0
- package/src/openbao/fake-engines-roles.ts +9 -2
- package/src/openbao/policy-distilled.test.ts +98 -0
- package/src/openbao/policy-identity.ts +24 -0
- package/src/openbao/policy-rename.test.ts +2 -2
- package/src/openbao/policy-wire.test.ts +5 -5
- package/src/openbao/policy-wire.ts +31 -11
- package/src/openbao/policy.ts +10 -13
- package/src/openbao/rename.ts +17 -1
- package/src/proxmox/firewall-alias-config.ts +39 -0
- package/src/proxmox/firewall-alias-form.ts +1 -1
- package/src/proxmox/firewall-alias-lifecycle.ts +100 -0
- package/src/proxmox/firewall-alias.ts +4 -67
- package/src/proxmox/lxc-create.test.ts +4 -1
- package/src/proxmox/lxc-distilled.test.ts +239 -0
- package/src/proxmox/lxc-errors.ts +9 -0
- package/src/proxmox/lxc-lifecycle.ts +31 -113
- package/src/proxmox/lxc-read.test.ts +29 -6
- package/src/proxmox/lxc-read.ts +88 -0
- package/src/proxmox/lxc-task.ts +71 -0
- package/src/proxmox/network-apply-distilled.test.ts +76 -0
- package/src/proxmox/network-apply-read.ts +18 -39
- package/src/proxmox/network-apply-run.ts +97 -0
- package/src/proxmox/network-apply.ts +5 -94
- package/src/proxmox/node-network-read-failure.test.ts +24 -0
- package/src/proxmox/node-network-wire.ts +15 -17
- package/src/proxmox/qemu-errors.ts +7 -0
- package/src/proxmox/qemu-form.ts +15 -0
- package/src/proxmox/qemu-lifecycle.test.ts +171 -0
- package/src/proxmox/qemu-lifecycle.ts +134 -0
- package/src/proxmox/qemu-read.ts +73 -0
- package/src/proxmox/qemu-task.ts +64 -0
- package/src/proxmox/qemu.ts +11 -44
- package/src/proxmox/replication-firewall-fixtures.ts +71 -0
- package/src/proxmox/replication-firewall-lifecycle.test.ts +109 -0
- package/src/proxmox/replication-firewall-wire.test.ts +173 -0
- package/src/proxmox/replication-job-config.ts +79 -0
- package/src/proxmox/replication-job-lifecycle.ts +113 -0
- package/src/proxmox/replication-job.ts +7 -84
- package/src/release/archive.ts +4 -1
package/src/openbao/policy.ts
CHANGED
|
@@ -10,9 +10,9 @@ import { ownedRead } from '../ownership/probe.ts';
|
|
|
10
10
|
import { provingResumes } from '../ownership/resume.ts';
|
|
11
11
|
import { sha256 } from './digest.ts';
|
|
12
12
|
import { isEmptyAssembly } from './policy-assembly.ts';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import { guardRename
|
|
13
|
+
import { judgePolicyRename, policyIdentity } from './policy-identity.ts';
|
|
14
|
+
import { deletePolicy, readPolicy, writePolicy } from './policy-wire.ts';
|
|
15
|
+
import { guardRename } from './rename-identity.ts';
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* An OpenBao ACL policy, assembled from the HCL fragments that declare it.
|
|
@@ -103,9 +103,6 @@ export const BaoPolicy = Resource<BaoPolicy>('Bao.Policy', {
|
|
|
103
103
|
defaultRemovalPolicy: 'retain',
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
-
/** A policy is its name, keyed as OpenBao keys it (`policyKey`). */
|
|
107
|
-
const IDENTITY = nameIdentity<BaoPolicyAttributes>('Bao.Policy', policyPath, policyKey);
|
|
108
|
-
|
|
109
106
|
/** `path "..."` grant count — the same number the apply script prints. */
|
|
110
107
|
const grantsOf = (hcl: string) => (hcl.match(/^path /gm) ?? []).length;
|
|
111
108
|
|
|
@@ -140,12 +137,12 @@ export const BaoPolicyProvider = () =>
|
|
|
140
137
|
*/
|
|
141
138
|
list: () => Effect.succeed([]),
|
|
142
139
|
|
|
143
|
-
// ★
|
|
140
|
+
// ★ Only undefined means absent (a typed 404); even a live empty policy occupies its name.
|
|
144
141
|
// ⛔ With no attributes in state, a live policy is `Unowned` unless it is the one our own
|
|
145
142
|
// interrupted create wrote: these fragments, byte for byte (ownership/probe.ts).
|
|
146
143
|
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
147
144
|
const live = yield* readPolicy(olds.name);
|
|
148
|
-
const found = live
|
|
145
|
+
const found = live === undefined ? undefined : attributesOf(olds.name, live, 0);
|
|
149
146
|
const ours = Effect.map(
|
|
150
147
|
assemble(olds.fragments),
|
|
151
148
|
(a) => sha256(a.joined) === found?.digest,
|
|
@@ -170,7 +167,7 @@ export const BaoPolicyProvider = () =>
|
|
|
170
167
|
* delete, so a role outside this graph that names the old policy must move in the same
|
|
171
168
|
* PR (REPLACE.md).
|
|
172
169
|
*/
|
|
173
|
-
const move = yield*
|
|
170
|
+
const move = yield* judgePolicyRename(olds, news, output);
|
|
174
171
|
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
175
172
|
if (output === undefined) return undefined;
|
|
176
173
|
if (move !== undefined) return { action: 'replace' } as const;
|
|
@@ -180,7 +177,7 @@ export const BaoPolicyProvider = () =>
|
|
|
180
177
|
if (!isResolved(news)) return undefined;
|
|
181
178
|
const { joined } = yield* assemble(news.fragments);
|
|
182
179
|
const live = yield* readPolicy(news.name);
|
|
183
|
-
return live
|
|
180
|
+
return live !== undefined && sha256(live) === sha256(joined)
|
|
184
181
|
? ({ action: 'noop' } as const)
|
|
185
182
|
: ({ action: 'update' } as const);
|
|
186
183
|
}),
|
|
@@ -188,7 +185,7 @@ export const BaoPolicyProvider = () =>
|
|
|
188
185
|
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
189
186
|
const name = news.name;
|
|
190
187
|
// ⛔ An `update` across a rename the diff could not see — refused before any write.
|
|
191
|
-
yield* guardRename(
|
|
188
|
+
yield* guardRename(policyIdentity, news, output);
|
|
192
189
|
const { joined, parts } = yield* assemble(news.fragments);
|
|
193
190
|
/**
|
|
194
191
|
* ⛔ AN EMPTY ASSEMBLY IS A REFUSAL, NOT AN EMPTY POLICY. `bao policy write` with
|
|
@@ -218,9 +215,9 @@ export const BaoPolicyProvider = () =>
|
|
|
218
215
|
* sibling PVE provider does exactly this in pveOperations.reconcile.
|
|
219
216
|
*/
|
|
220
217
|
const current = yield* readPolicy(name);
|
|
221
|
-
if (current
|
|
218
|
+
if (current !== undefined)
|
|
222
219
|
yield* refuseTakeover({ fqn, instanceId, output }, `Bao.Policy ${name}`);
|
|
223
|
-
if (current
|
|
220
|
+
if (current !== undefined && sha256(current) === sha256(joined)) {
|
|
224
221
|
return attributesOf(name, joined, parts);
|
|
225
222
|
}
|
|
226
223
|
// ⚠️ In the request body, never on argv — see `writePolicy` for why the temp file went.
|
package/src/openbao/rename.ts
CHANGED
|
@@ -127,12 +127,28 @@ export const judgeMove = (
|
|
|
127
127
|
declared: string | undefined,
|
|
128
128
|
pathOf: (identity: string) => string,
|
|
129
129
|
key: (identity: string) => string = exact,
|
|
130
|
+
) =>
|
|
131
|
+
judgeMoveWith(
|
|
132
|
+
family,
|
|
133
|
+
tried,
|
|
134
|
+
declared,
|
|
135
|
+
(identity) => Effect.map(baoRead(pathOf(identity)), (value) => value !== undefined),
|
|
136
|
+
key,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
/** SDK families supply their own typed read so a rename cannot fall back to raw HTTP. */
|
|
140
|
+
export const judgeMoveWith = <E, R>(
|
|
141
|
+
family: string,
|
|
142
|
+
tried: string | undefined,
|
|
143
|
+
declared: string | undefined,
|
|
144
|
+
occupied: (identity: string) => Effect.Effect<boolean, E, R>,
|
|
145
|
+
key: (identity: string) => string = exact,
|
|
130
146
|
) =>
|
|
131
147
|
Effect.gen(function* () {
|
|
132
148
|
if (tried === undefined || declared === undefined || key(tried) === key(declared)) {
|
|
133
149
|
return undefined;
|
|
134
150
|
}
|
|
135
|
-
if ((yield*
|
|
151
|
+
if (!(yield* occupied(declared))) return { from: tried, to: declared };
|
|
136
152
|
return yield* Effect.die(
|
|
137
153
|
new Error(
|
|
138
154
|
`${family}: ${tried} → ${declared} would land on an object that already exists at ` +
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Existing alias normalization and forms, independent of the named SDK transport. */
|
|
2
|
+
import type { FirewallAliasAttributes, FirewallAliasProps } from './firewall-alias.ts';
|
|
3
|
+
import type { PveSpec } from './resource-spec.ts';
|
|
4
|
+
import { body, cidr, comment } from './firewall-alias-form.ts';
|
|
5
|
+
import { int, text } from './values.ts';
|
|
6
|
+
|
|
7
|
+
export const firewallAliasSpec = {
|
|
8
|
+
/** The SDK read refuses malformed successful payloads before applying these normalizers. */
|
|
9
|
+
attributes: (live, props) => {
|
|
10
|
+
const address = cidr(live['cidr']);
|
|
11
|
+
return {
|
|
12
|
+
cidr: address,
|
|
13
|
+
comment: comment(live['comment']),
|
|
14
|
+
ipversion: int(live['ipversion'], 0),
|
|
15
|
+
/** The read checks identity; the stored spelling is preserved for display. */
|
|
16
|
+
name: text(live['name'], props.name),
|
|
17
|
+
};
|
|
18
|
+
},
|
|
19
|
+
collection: () => 'cluster/firewall/aliases',
|
|
20
|
+
createForm: (props) => ({ ...body(props), name: props.name }),
|
|
21
|
+
/**
|
|
22
|
+
* ⚠️ EXACTLY THE TWO FIELDS A PUT CAN PUT BACK, both sides through the same normalisers. `name`
|
|
23
|
+
* is identity and case-only drift (see the header); `ipversion` is derived and unwritable.
|
|
24
|
+
* Comparing either would report an update that no update can settle.
|
|
25
|
+
*/
|
|
26
|
+
/** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
|
|
27
|
+
endpoint: {
|
|
28
|
+
create: 'pve:POST /cluster/firewall/aliases',
|
|
29
|
+
update: 'pve:PUT /cluster/firewall/aliases/{name}',
|
|
30
|
+
},
|
|
31
|
+
matches: (attributes, props) =>
|
|
32
|
+
attributes.cidr === cidr(props.cidr) && attributes.comment === comment(props.comment),
|
|
33
|
+
/**
|
|
34
|
+
* ⚠️ UNESCAPED ON PURPOSE: `pve-fw-alias` admits only `[A-Za-z][A-Za-z0-9\-\_]+`, so there is no
|
|
35
|
+
* character here that a URL would need to encode, and PVE lowercases the segment on arrival.
|
|
36
|
+
*/
|
|
37
|
+
path: (props) => `cluster/firewall/aliases/${props.name}`,
|
|
38
|
+
updateForm: body,
|
|
39
|
+
} satisfies PveSpec<FirewallAliasProps, FirewallAliasAttributes>;
|
|
@@ -106,7 +106,7 @@ export const comment = (value: unknown) => {
|
|
|
106
106
|
* this form would be a second, invisible identity for the object, set from a field the resource
|
|
107
107
|
* does not have.
|
|
108
108
|
*/
|
|
109
|
-
export const body = (props: FirewallAliasProps): Record<string, string> => {
|
|
109
|
+
export const body = (props: FirewallAliasProps): Record<string, string> & { cidr: string } => {
|
|
110
110
|
const note = comment(props.comment);
|
|
111
111
|
return { cidr: cidr(props.cidr), ...(note === '' ? {} : { comment: note }) };
|
|
112
112
|
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** Named SDK operations; pve-firewall 6.0.5 Aliases.pm supplies exact absence and idempotent delete. */
|
|
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 type { FirewallAliasAttributes, FirewallAliasProps } from './firewall-alias.ts';
|
|
8
|
+
import { firewallAliasSpec as spec } from './firewall-alias-config.ts';
|
|
9
|
+
import { cidr, fold } from './firewall-alias-form.ts';
|
|
10
|
+
import { runPve } from './distilled-pve.ts';
|
|
11
|
+
import { specGuards } from './resource-guard.ts';
|
|
12
|
+
import { formToSend } from './update-guard.ts';
|
|
13
|
+
|
|
14
|
+
const { guardCreate, guardUpdate } = specGuards(spec);
|
|
15
|
+
|
|
16
|
+
export const readFirewallAlias = (props: FirewallAliasProps) =>
|
|
17
|
+
runPve(props.target, 'read', false, cluster.getClusterFirewallAlias({ name: props.name })).pipe(
|
|
18
|
+
Effect.flatMap((live) => {
|
|
19
|
+
// Vendor's GET schema is bare object; read_alias returns the stored name/cidr entry.
|
|
20
|
+
// ⛔ A malformed successful read is not absence and must never authorize a POST.
|
|
21
|
+
if (
|
|
22
|
+
live === null ||
|
|
23
|
+
typeof live !== 'object' ||
|
|
24
|
+
Array.isArray(live) ||
|
|
25
|
+
!('name' in live) ||
|
|
26
|
+
typeof live.name !== 'string' ||
|
|
27
|
+
fold(live.name) !== fold(props.name) ||
|
|
28
|
+
!('cidr' in live) ||
|
|
29
|
+
typeof live.cidr !== 'string' ||
|
|
30
|
+
cidr(live.cidr) === ''
|
|
31
|
+
) {
|
|
32
|
+
return Effect.fail(
|
|
33
|
+
new ProxmoxParseError({
|
|
34
|
+
body: undefined,
|
|
35
|
+
cause: 'Firewall alias response does not match the requested vendor identity',
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return Effect.succeed(spec.attributes({ ...live }, props));
|
|
40
|
+
}),
|
|
41
|
+
Effect.catchTag('FirewallAliasNotFound', () => Effect.succeed(undefined)),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
/** Vendor deletion silently removes a missing key. All actual SDK failures still propagate. */
|
|
45
|
+
export const deleteFirewallAlias = (props: FirewallAliasProps) =>
|
|
46
|
+
runPve(props.target, 'provision', true, cluster.deleteClusterFirewallAlias({ name: props.name }));
|
|
47
|
+
|
|
48
|
+
export const firewallAliasHandlers = {
|
|
49
|
+
list: () => Effect.succeed([]),
|
|
50
|
+
read: ({ olds }: { olds: FirewallAliasProps }) => readFirewallAlias(olds),
|
|
51
|
+
diff: Effect.fn(function* ({
|
|
52
|
+
news,
|
|
53
|
+
output,
|
|
54
|
+
}: {
|
|
55
|
+
news: Input<FirewallAliasProps>;
|
|
56
|
+
output: FirewallAliasAttributes | undefined;
|
|
57
|
+
}) {
|
|
58
|
+
if (!isResolved(news)) return undefined;
|
|
59
|
+
yield* guardCreate(news, output === undefined);
|
|
60
|
+
yield* guardUpdate(news);
|
|
61
|
+
if (output === undefined) return undefined;
|
|
62
|
+
// A case-only spelling is the same vendor key; a real rename remains create-first replacement.
|
|
63
|
+
if (fold(news.name) !== fold(output.name)) return { action: 'replace' } as const;
|
|
64
|
+
const live = yield* readFirewallAlias(news);
|
|
65
|
+
if (live === undefined) {
|
|
66
|
+
yield* guardCreate(news, true);
|
|
67
|
+
return { action: 'update' } as const;
|
|
68
|
+
}
|
|
69
|
+
return { action: spec.matches(live, news) ? 'noop' : 'update' } as const;
|
|
70
|
+
}),
|
|
71
|
+
reconcile: Effect.fn(function* ({ news }: { news: FirewallAliasProps }) {
|
|
72
|
+
const live = yield* readFirewallAlias(news);
|
|
73
|
+
yield* guardCreate(news, live === undefined);
|
|
74
|
+
yield* guardUpdate(news);
|
|
75
|
+
if (live === undefined) {
|
|
76
|
+
yield* runPve(
|
|
77
|
+
news.target,
|
|
78
|
+
'provision',
|
|
79
|
+
true,
|
|
80
|
+
cluster.createClusterFirewallAlias(spec.createForm(news)),
|
|
81
|
+
);
|
|
82
|
+
} else {
|
|
83
|
+
const form = formToSend(spec.matches, live, news, spec.updateForm(news));
|
|
84
|
+
if (form !== undefined)
|
|
85
|
+
yield* runPve(
|
|
86
|
+
news.target,
|
|
87
|
+
'provision',
|
|
88
|
+
true,
|
|
89
|
+
cluster.putClusterFirewallAlias({ ...form, name: news.name }),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
const after = yield* readFirewallAlias(news);
|
|
93
|
+
if (after === undefined)
|
|
94
|
+
return yield* Effect.fail(
|
|
95
|
+
new Error(`${spec.path(news)}: write returned success but the resource is still absent`),
|
|
96
|
+
);
|
|
97
|
+
return after;
|
|
98
|
+
}),
|
|
99
|
+
delete: ({ olds }: { olds: FirewallAliasProps }) => deleteFirewallAlias(olds),
|
|
100
|
+
};
|
|
@@ -93,12 +93,10 @@
|
|
|
93
93
|
* confirm the SECOND plan says noop before trusting this file.
|
|
94
94
|
*/
|
|
95
95
|
import { Resource } from 'alchemy';
|
|
96
|
-
import { isResolved } from 'alchemy/Diff';
|
|
97
96
|
import * as Provider from 'alchemy/Provider';
|
|
98
97
|
import * as Effect from 'effect/Effect';
|
|
99
|
-
import {
|
|
100
|
-
import {
|
|
101
|
-
import { int, text } from './values.ts';
|
|
98
|
+
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
99
|
+
import { firewallAliasHandlers } from './firewall-alias-lifecycle.ts';
|
|
102
100
|
|
|
103
101
|
export interface FirewallAliasProps extends WithTarget {
|
|
104
102
|
/**
|
|
@@ -144,70 +142,9 @@ export interface ProxmoxFirewallAlias extends Resource<
|
|
|
144
142
|
|
|
145
143
|
export const ProxmoxFirewallAlias = Resource<ProxmoxFirewallAlias>('Proxmox.FirewallAlias');
|
|
146
144
|
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* ⚠️ AN ENTRY WITH NO CIDR IS NOT AN ALIAS. `read_alias` raises for a name it does not hold, so
|
|
150
|
-
* absence normally arrives as a failed call the factory folds into `undefined`; this guard
|
|
151
|
-
* covers the other shape — a 200 carrying something that is not an alias — rather than
|
|
152
|
-
* recording state for an object with an empty address. The next reconcile then POSTs and PVE
|
|
153
|
-
* answers "alias already exists", which is loud and points at the read.
|
|
154
|
-
*/
|
|
155
|
-
attributes: (live, props) => {
|
|
156
|
-
const address = cidr(live['cidr']);
|
|
157
|
-
return address === ''
|
|
158
|
-
? undefined
|
|
159
|
-
: {
|
|
160
|
-
cidr: address,
|
|
161
|
-
comment: comment(live['comment']),
|
|
162
|
-
ipversion: int(live['ipversion'], 0),
|
|
163
|
-
/** ⚠️ FROM THE CLUSTER, falling back to props: the stored spelling is the interesting one. */
|
|
164
|
-
name: text(live['name'], props.name),
|
|
165
|
-
};
|
|
166
|
-
},
|
|
167
|
-
collection: () => 'cluster/firewall/aliases',
|
|
168
|
-
createForm: (props) => ({ ...body(props), name: props.name }),
|
|
169
|
-
/**
|
|
170
|
-
* ⚠️ EXACTLY THE TWO FIELDS A PUT CAN PUT BACK, both sides through the same normalisers. `name`
|
|
171
|
-
* is identity and case-only drift (see the header); `ipversion` is derived and unwritable.
|
|
172
|
-
* Comparing either would report an update that no update can settle.
|
|
173
|
-
*/
|
|
174
|
-
/** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
|
|
175
|
-
endpoint: {
|
|
176
|
-
create: 'pve:POST /cluster/firewall/aliases',
|
|
177
|
-
update: 'pve:PUT /cluster/firewall/aliases/{name}',
|
|
178
|
-
},
|
|
179
|
-
matches: (attributes, props) =>
|
|
180
|
-
attributes.cidr === cidr(props.cidr) && attributes.comment === comment(props.comment),
|
|
181
|
-
/**
|
|
182
|
-
* ⚠️ UNESCAPED ON PURPOSE: `pve-fw-alias` admits only `[A-Za-z][A-Za-z0-9\-\_]+`, so there is no
|
|
183
|
-
* character here that a URL would need to encode, and PVE lowercases the segment on arrival.
|
|
184
|
-
*/
|
|
185
|
-
path: (props) => `cluster/firewall/aliases/${props.name}`,
|
|
186
|
-
updateForm: body,
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* ⛔ ONE HANDLER IS OVERRIDDEN AND THE REST COME FROM THE FACTORY, for the reason `acl.ts` spells
|
|
191
|
-
* out at length: the factory answers `replace` only for an object with no update path, and this
|
|
192
|
-
* one has a PUT. Left to delegate, a changed `name` would read absent at the new path, plan as an
|
|
193
|
-
* update, and quietly leave the old alias behind. Everything else — the empty `list`, the read,
|
|
194
|
-
* the read-back-guarded reconcile, the delete — is the factory's, unchanged.
|
|
195
|
-
*/
|
|
145
|
+
/** Named distilled operations preserve create-first identity replacement and explicit adoption. */
|
|
196
146
|
export const ProxmoxFirewallAliasProvider = () =>
|
|
197
147
|
Provider.effect(
|
|
198
148
|
ProxmoxFirewallAlias,
|
|
199
|
-
Effect.succeed(
|
|
200
|
-
ProxmoxFirewallAlias.Provider.of({
|
|
201
|
-
...handlers,
|
|
202
|
-
diff: Effect.fn(function* ({ news, output }) {
|
|
203
|
-
// ⚠️ `isResolved` FIRST: at plan time `news.name` can still be an unresolved Output, and
|
|
204
|
-
// folding a placeholder would report a replace nobody asked for — the worst possible
|
|
205
|
-
// false positive on this family, since a replace here DELETES an alias.
|
|
206
|
-
if (output !== undefined && isResolved(news) && fold(news.name) !== fold(output.name)) {
|
|
207
|
-
return { action: 'replace' } as const;
|
|
208
|
-
}
|
|
209
|
-
return yield* handlers.diff({ news, output });
|
|
210
|
-
}),
|
|
211
|
-
}),
|
|
212
|
-
),
|
|
149
|
+
Effect.succeed(ProxmoxFirewallAlias.Provider.of(firewallAliasHandlers)),
|
|
213
150
|
);
|
|
@@ -96,7 +96,10 @@ describe('removing a container', () => {
|
|
|
96
96
|
`POST nodes/${NODE}/lxc`,
|
|
97
97
|
`DELETE nodes/${NODE}/lxc/${String(VMID)}`,
|
|
98
98
|
]);
|
|
99
|
-
|
|
99
|
+
// PVE APIServer/AnyEvent.pm uri_unescape runs before routing; the SDK encodes labels.
|
|
100
|
+
expect(
|
|
101
|
+
pve.seen.some((call) => /\/tasks\/UPID:.*vzdestroy/.test(decodeURIComponent(call.path))),
|
|
102
|
+
).toBe(true);
|
|
100
103
|
// ⛔ No `force` (stops a running guest), `purge` (drops it from HA and backup jobs) or
|
|
101
104
|
// `destroy-unreferenced-disks` (deletes volumes the config does not even name).
|
|
102
105
|
const del = pve.seen.find((call) => call.method === 'DELETE');
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/** Real SDK HTTP boundary: credential continuity, dynamic fields, digest and task uncertainty. */
|
|
2
|
+
import { expect, test } from 'bun:test';
|
|
3
|
+
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
4
|
+
import * as Deferred from 'effect/Deferred';
|
|
5
|
+
import * as Effect from 'effect/Effect';
|
|
6
|
+
import * as Exit from 'effect/Exit';
|
|
7
|
+
import * as Fiber from 'effect/Fiber';
|
|
8
|
+
import * as Layer from 'effect/Layer';
|
|
9
|
+
import * as TestClock from 'effect/testing/TestClock';
|
|
10
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
11
|
+
import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
|
|
12
|
+
import { createGuest, updateGuest } from './lxc-lifecycle.ts';
|
|
13
|
+
import type { LxcProps } from './lxc-props.ts';
|
|
14
|
+
import { readLive } from './lxc-read.ts';
|
|
15
|
+
import { lxcTask } from './lxc-task.ts';
|
|
16
|
+
|
|
17
|
+
const props: LxcProps = {
|
|
18
|
+
hostname: 'ct-example',
|
|
19
|
+
node: 'pve1',
|
|
20
|
+
vmid: 900,
|
|
21
|
+
target: FAKE_TARGET,
|
|
22
|
+
ostemplate: 'local:vztmpl/test.tar.zst',
|
|
23
|
+
rootfs: 'local-zfs:8',
|
|
24
|
+
net0: 'name=eth0,bridge=vmbr0,ip=dhcp',
|
|
25
|
+
mp0: 'tank:10,mp=/data',
|
|
26
|
+
};
|
|
27
|
+
type Seen = { method: string; path: string; auth: string | null; form: URLSearchParams };
|
|
28
|
+
const UPID = 'UPID:pve1:00000001:0:0:vzcreate:900:test@pve!lease:';
|
|
29
|
+
|
|
30
|
+
const transport = (
|
|
31
|
+
poll: () => Response = () => Response.json({ data: { status: 'stopped', exitstatus: 'OK' } }),
|
|
32
|
+
index: unknown = [],
|
|
33
|
+
) => {
|
|
34
|
+
const seen: Seen[] = [];
|
|
35
|
+
let mints = 0;
|
|
36
|
+
const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
37
|
+
const request =
|
|
38
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
39
|
+
const url = new URL(request.url);
|
|
40
|
+
if (url.pathname.includes('/creds/')) {
|
|
41
|
+
mints++;
|
|
42
|
+
return Response.json({
|
|
43
|
+
data: { secret: `fake-${mints}`, token_id: `test@pve!lease-${mints}` },
|
|
44
|
+
lease_duration: 0,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const body = await request.text();
|
|
48
|
+
if (request.method === 'GET') expect(body).toBe('');
|
|
49
|
+
const path = decodeURIComponent(url.pathname.replace(/^\/api2\/json\//, ''));
|
|
50
|
+
seen.push({
|
|
51
|
+
method: request.method,
|
|
52
|
+
path,
|
|
53
|
+
auth: request.headers.get('Authorization'),
|
|
54
|
+
form: new URLSearchParams(body),
|
|
55
|
+
});
|
|
56
|
+
if (path.endsWith('/status')) return poll();
|
|
57
|
+
if (request.method === 'GET' && path.endsWith('/config'))
|
|
58
|
+
return Response.json(
|
|
59
|
+
{ data: null, message: "Configuration file 'nodes/pve1/lxc/900.conf' does not exist\n" },
|
|
60
|
+
{ status: 500 },
|
|
61
|
+
);
|
|
62
|
+
if (path === 'cluster/resources') {
|
|
63
|
+
expect(url.searchParams.get('type')).toBe('vm');
|
|
64
|
+
return Response.json({ data: index });
|
|
65
|
+
}
|
|
66
|
+
return Response.json({ data: path.endsWith('/config') ? null : UPID });
|
|
67
|
+
};
|
|
68
|
+
const fake = Object.assign(stub, { preconnect: globalThis.fetch.preconnect }) as typeof fetch;
|
|
69
|
+
return {
|
|
70
|
+
seen,
|
|
71
|
+
mints: () => mints,
|
|
72
|
+
layer: FetchHttpClient.layer.pipe(
|
|
73
|
+
Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fake)),
|
|
74
|
+
),
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
test('missing config and permission-filtered guest index use one read credential', async () => {
|
|
79
|
+
const fake = transport();
|
|
80
|
+
const result = await withoutBao(() =>
|
|
81
|
+
Effect.runPromise(readLive(props).pipe(Effect.provide(fake.layer))),
|
|
82
|
+
);
|
|
83
|
+
expect(result).toBeUndefined();
|
|
84
|
+
expect(fake.mints()).toBe(1);
|
|
85
|
+
expect(fake.seen.map((call) => call.path)).toEqual([
|
|
86
|
+
'nodes/pve1/lxc/900/config',
|
|
87
|
+
'cluster/resources',
|
|
88
|
+
]);
|
|
89
|
+
expect(fake.seen[0]?.auth).toBe(fake.seen[1]?.auth);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('each task starts with a fresh credential and every poll keeps its owner', async () => {
|
|
93
|
+
const fake = transport();
|
|
94
|
+
await withoutBao(() =>
|
|
95
|
+
Effect.runPromise(
|
|
96
|
+
Effect.gen(function* () {
|
|
97
|
+
yield* createGuest(props);
|
|
98
|
+
yield* updateGuest(
|
|
99
|
+
props,
|
|
100
|
+
{ put: {}, clear: [], resize: [{ disk: 'rootfs', size: '12G' }], refuse: [], drift: [] },
|
|
101
|
+
'digest-live',
|
|
102
|
+
);
|
|
103
|
+
}).pipe(Effect.provide(fake.layer)),
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
expect(fake.mints()).toBe(2);
|
|
107
|
+
expect(fake.seen.map((call) => call.method)).toEqual(['POST', 'GET', 'PUT', 'GET']);
|
|
108
|
+
expect(fake.seen[0]?.auth).toBe(fake.seen[1]?.auth);
|
|
109
|
+
expect(fake.seen[2]?.auth).toBe(fake.seen[3]?.auth);
|
|
110
|
+
expect(fake.seen[0]?.auth).not.toBe(fake.seen[2]?.auth);
|
|
111
|
+
const form = fake.seen[0]?.form;
|
|
112
|
+
expect(form?.get('net0')).toBe(props.net0);
|
|
113
|
+
expect(form?.get('mp0')).toBe(props.mp0);
|
|
114
|
+
expect(form?.has('net[n]')).toBe(false);
|
|
115
|
+
expect(form?.has('node')).toBe(false);
|
|
116
|
+
expect(fake.seen[2]?.form.get('size')).toBe('12G');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('config updates retain the observed digest and concrete indexed form keys', async () => {
|
|
120
|
+
const fake = transport();
|
|
121
|
+
await withoutBao(() =>
|
|
122
|
+
Effect.runPromise(
|
|
123
|
+
updateGuest(
|
|
124
|
+
props,
|
|
125
|
+
{
|
|
126
|
+
put: { net1: 'name=eth1,bridge=vmbr1,ip=dhcp' },
|
|
127
|
+
clear: ['tags'],
|
|
128
|
+
resize: [],
|
|
129
|
+
refuse: [],
|
|
130
|
+
drift: [],
|
|
131
|
+
},
|
|
132
|
+
'observed-digest',
|
|
133
|
+
).pipe(Effect.provide(fake.layer)),
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
expect(fake.seen).toHaveLength(1);
|
|
137
|
+
const form = fake.seen[0]?.form;
|
|
138
|
+
expect(form?.get('digest')).toBe('observed-digest');
|
|
139
|
+
expect(form?.get('net1')).toBe('name=eth1,bridge=vmbr1,ip=dhcp');
|
|
140
|
+
expect(form?.get('delete')).toBe('tags');
|
|
141
|
+
expect(form?.has('vmid')).toBe(false);
|
|
142
|
+
expect(form?.has('node')).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
for (const [name, response] of [
|
|
146
|
+
['denied', () => Response.json({ data: null, message: 'denied' }, { status: 403 })],
|
|
147
|
+
['malformed', () => Response.json({ data: null })],
|
|
148
|
+
] as const) {
|
|
149
|
+
test(`${name} task polling reports uncertainty with UPID, never repeats POST`, async () => {
|
|
150
|
+
const fake = transport(response);
|
|
151
|
+
const error = await withoutBao(() =>
|
|
152
|
+
Effect.runPromise(createGuest(props).pipe(Effect.flip, Effect.provide(fake.layer))),
|
|
153
|
+
);
|
|
154
|
+
expect(String(error)).toContain(UPID);
|
|
155
|
+
expect(String(error)).toContain('task could not be read');
|
|
156
|
+
expect(fake.seen.map((call) => call.method)).toEqual(['POST', 'GET']);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
test('a still-running task stops at its bound with no second POST', async () => {
|
|
161
|
+
let polls = 0;
|
|
162
|
+
const fake = transport(() => {
|
|
163
|
+
polls++;
|
|
164
|
+
return Response.json({ data: { status: 'running' } });
|
|
165
|
+
});
|
|
166
|
+
const outcome = await withoutBao(() =>
|
|
167
|
+
Effect.runPromise(
|
|
168
|
+
Effect.gen(function* () {
|
|
169
|
+
const ready = yield* Deferred.make<void>();
|
|
170
|
+
// Start the virtual clock only after the SDK transport has completed its first response.
|
|
171
|
+
const observed = fake.layer;
|
|
172
|
+
const worker = yield* lxcTask(
|
|
173
|
+
props,
|
|
174
|
+
nodes.createNodeLxc({
|
|
175
|
+
node: props.node,
|
|
176
|
+
vmid: String(props.vmid),
|
|
177
|
+
ostemplate: props.ostemplate ?? '',
|
|
178
|
+
}),
|
|
179
|
+
'create test CT',
|
|
180
|
+
2,
|
|
181
|
+
).pipe(Effect.exit, Effect.provide(observed), Effect.forkChild);
|
|
182
|
+
const signal = yield* Effect.gen(function* () {
|
|
183
|
+
while (polls < 1) yield* Effect.yieldNow;
|
|
184
|
+
yield* Deferred.succeed(ready, undefined);
|
|
185
|
+
}).pipe(Effect.forkChild);
|
|
186
|
+
yield* Deferred.await(ready);
|
|
187
|
+
yield* TestClock.adjust('2 seconds');
|
|
188
|
+
yield* Fiber.join(signal);
|
|
189
|
+
return yield* Fiber.join(worker);
|
|
190
|
+
}).pipe(Effect.provide(TestClock.layer())),
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
expect(Exit.isFailure(outcome)).toBe(true);
|
|
194
|
+
expect(polls).toBe(2);
|
|
195
|
+
expect(fake.seen.filter((call) => call.method === 'POST')).toHaveLength(1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('a transitional status value keeps polling, never aborts like an unreadable poll', async () => {
|
|
199
|
+
// ⚠️ `GetNodeTaskStatusResponseStatus` TYPES `status` AS "running" | "stopped" BUT VALIDATES IT
|
|
200
|
+
// AT RUNTIME AS A PLAIN STRING — that union is not actually enforced on the wire, so a value
|
|
201
|
+
// this endpoint's real contract was never proven to exclude must be tolerated the same way
|
|
202
|
+
// `awaitTask` (network-apply-read.ts) tolerates it, rather than read as "task could not be
|
|
203
|
+
// read" and abort the deploy. The sibling malformed-answer tests above pin the other half:
|
|
204
|
+
// a response with no `status` field at all still aborts (2026-09-25 adversarial review).
|
|
205
|
+
let polls = 0;
|
|
206
|
+
const fake = transport(() => {
|
|
207
|
+
polls++;
|
|
208
|
+
return Response.json({
|
|
209
|
+
data: { status: polls === 1 ? 'queued' : 'stopped', exitstatus: 'OK' },
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
const outcome = await withoutBao(() =>
|
|
213
|
+
Effect.runPromise(
|
|
214
|
+
lxcTask(
|
|
215
|
+
props,
|
|
216
|
+
nodes.createNodeLxc({
|
|
217
|
+
node: props.node,
|
|
218
|
+
vmid: String(props.vmid),
|
|
219
|
+
ostemplate: props.ostemplate ?? '',
|
|
220
|
+
}),
|
|
221
|
+
'create test CT',
|
|
222
|
+
5,
|
|
223
|
+
).pipe(Effect.provide(fake.layer)),
|
|
224
|
+
),
|
|
225
|
+
);
|
|
226
|
+
expect(outcome).toBeUndefined();
|
|
227
|
+
expect(polls).toBe(2);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
for (const index of [null, [{}], [{ vmid: 'unknown', node: 'pve1', type: 'lxc' }]]) {
|
|
231
|
+
test(`malformed guest index ${JSON.stringify(index)} cannot prove absence`, async () => {
|
|
232
|
+
const fake = transport(undefined, index);
|
|
233
|
+
const error = await withoutBao(() =>
|
|
234
|
+
Effect.runPromise(readLive(props).pipe(Effect.flip, Effect.provide(fake.layer))),
|
|
235
|
+
);
|
|
236
|
+
expect(String(error)).toContain('did not identify its rows');
|
|
237
|
+
expect(fake.seen.every((call) => call.method === 'GET')).toBe(true);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PveTarget } from './credentials.ts';
|
|
2
|
+
|
|
3
|
+
/** A lifecycle refusal. Post-write refusals carry the task diagnostic, never imply rollback. */
|
|
4
|
+
export class LxcRefusedError extends Error {
|
|
5
|
+
override readonly name = 'LxcRefusedError';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Where one guest lives. Its identity, and nothing else. */
|
|
9
|
+
export type LxcWhere = { readonly target: PveTarget; readonly node: string; readonly vmid: number };
|