@homeflare/alchemy 0.37.5 → 0.37.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/openbao/auth-role-form.d.ts +0 -6
  2. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  3. package/dist/openbao/auth-role-identity.d.ts +11 -0
  4. package/dist/openbao/auth-role-identity.d.ts.map +1 -0
  5. package/dist/openbao/auth-role-wire.d.ts +23 -0
  6. package/dist/openbao/auth-role-wire.d.ts.map +1 -0
  7. package/dist/openbao/auth-role.d.ts.map +1 -1
  8. package/dist/openbao/bao-http.d.ts +34 -13
  9. package/dist/openbao/bao-http.d.ts.map +1 -1
  10. package/dist/openbao/distilled.d.ts +25 -0
  11. package/dist/openbao/distilled.d.ts.map +1 -0
  12. package/dist/openbao/fake-engines-roles.d.ts +5 -1
  13. package/dist/openbao/fake-engines-roles.d.ts.map +1 -1
  14. package/dist/openbao/index.js +358 -305
  15. package/dist/openbao/index.js.map +13 -9
  16. package/dist/openbao/policy-identity.d.ts +12 -0
  17. package/dist/openbao/policy-identity.d.ts.map +1 -0
  18. package/dist/openbao/policy-wire.d.ts +9 -8
  19. package/dist/openbao/policy-wire.d.ts.map +1 -1
  20. package/dist/openbao/policy.d.ts.map +1 -1
  21. package/dist/openbao/rename.d.ts +5 -0
  22. package/dist/openbao/rename.d.ts.map +1 -1
  23. package/dist/proxmox/firewall-alias-config.d.ts +36 -0
  24. package/dist/proxmox/firewall-alias-config.d.ts.map +1 -0
  25. package/dist/proxmox/firewall-alias-form.d.ts +3 -1
  26. package/dist/proxmox/firewall-alias-form.d.ts.map +1 -1
  27. package/dist/proxmox/firewall-alias-lifecycle.d.ts +43 -0
  28. package/dist/proxmox/firewall-alias-lifecycle.d.ts.map +1 -0
  29. package/dist/proxmox/firewall-alias.d.ts +2 -8
  30. package/dist/proxmox/firewall-alias.d.ts.map +1 -1
  31. package/dist/proxmox/index.js +795 -526
  32. package/dist/proxmox/index.js.map +24 -11
  33. package/dist/proxmox/lxc-errors.d.ts +12 -0
  34. package/dist/proxmox/lxc-errors.d.ts.map +1 -0
  35. package/dist/proxmox/lxc-lifecycle.d.ts +9 -31
  36. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -1
  37. package/dist/proxmox/lxc-read.d.ts +4 -0
  38. package/dist/proxmox/lxc-read.d.ts.map +1 -0
  39. package/dist/proxmox/lxc-task.d.ts +15 -0
  40. package/dist/proxmox/lxc-task.d.ts.map +1 -0
  41. package/dist/proxmox/network-apply-read.d.ts +7 -14
  42. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  43. package/dist/proxmox/network-apply-run.d.ts +7 -0
  44. package/dist/proxmox/network-apply-run.d.ts.map +1 -0
  45. package/dist/proxmox/network-apply.d.ts +2 -3
  46. package/dist/proxmox/network-apply.d.ts.map +1 -1
  47. package/dist/proxmox/node-network-wire.d.ts +14 -10
  48. package/dist/proxmox/node-network-wire.d.ts.map +1 -1
  49. package/dist/proxmox/qemu-errors.d.ts +5 -0
  50. package/dist/proxmox/qemu-errors.d.ts.map +1 -0
  51. package/dist/proxmox/qemu-form.d.ts +22 -0
  52. package/dist/proxmox/qemu-form.d.ts.map +1 -0
  53. package/dist/proxmox/qemu-lifecycle.d.ts +26 -0
  54. package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -0
  55. package/dist/proxmox/qemu-read.d.ts +5 -0
  56. package/dist/proxmox/qemu-read.d.ts.map +1 -0
  57. package/dist/proxmox/qemu-task.d.ts +9 -0
  58. package/dist/proxmox/qemu-task.d.ts.map +1 -0
  59. package/dist/proxmox/qemu.d.ts +9 -14
  60. package/dist/proxmox/qemu.d.ts.map +1 -1
  61. package/dist/proxmox/replication-firewall-fixtures.d.ts +30 -0
  62. package/dist/proxmox/replication-firewall-fixtures.d.ts.map +1 -0
  63. package/dist/proxmox/replication-job-config.d.ts +56 -0
  64. package/dist/proxmox/replication-job-config.d.ts.map +1 -0
  65. package/dist/proxmox/replication-job-lifecycle.d.ts +62 -0
  66. package/dist/proxmox/replication-job-lifecycle.d.ts.map +1 -0
  67. package/dist/proxmox/replication-job.d.ts +5 -6
  68. package/dist/proxmox/replication-job.d.ts.map +1 -1
  69. package/dist/release/archive.d.ts.map +1 -1
  70. package/dist/release/index.js +3 -2
  71. package/dist/release/index.js.map +3 -3
  72. package/docs/proxmox-lxc-distilled.md +56 -0
  73. package/docs/proxmox-replication-firewall-distilled.md +53 -0
  74. package/docs/proxmox-sdk-migration.md +23 -18
  75. package/docs/upstream-conformance.md +5 -3
  76. package/package.json +3 -2
  77. package/src/openbao/auth-role-form.test.ts +11 -9
  78. package/src/openbao/auth-role-form.ts +0 -19
  79. package/src/openbao/auth-role-identity.ts +24 -0
  80. package/src/openbao/auth-role-sdk.test.ts +155 -0
  81. package/src/openbao/auth-role-wire.test.ts +128 -0
  82. package/src/openbao/auth-role-wire.ts +89 -0
  83. package/src/openbao/auth-role.ts +9 -9
  84. package/src/openbao/bao-http.ts +9 -7
  85. package/src/openbao/distilled-retry.test.ts +164 -0
  86. package/src/openbao/distilled.test.ts +146 -0
  87. package/src/openbao/distilled.ts +88 -0
  88. package/src/openbao/fake-engines-roles.ts +9 -2
  89. package/src/openbao/policy-distilled.test.ts +98 -0
  90. package/src/openbao/policy-identity.ts +24 -0
  91. package/src/openbao/policy-rename.test.ts +2 -2
  92. package/src/openbao/policy-wire.test.ts +5 -5
  93. package/src/openbao/policy-wire.ts +31 -11
  94. package/src/openbao/policy.ts +10 -13
  95. package/src/openbao/rename.ts +17 -1
  96. package/src/proxmox/firewall-alias-config.ts +39 -0
  97. package/src/proxmox/firewall-alias-form.ts +1 -1
  98. package/src/proxmox/firewall-alias-lifecycle.ts +100 -0
  99. package/src/proxmox/firewall-alias.ts +4 -67
  100. package/src/proxmox/lxc-create.test.ts +4 -1
  101. package/src/proxmox/lxc-distilled.test.ts +239 -0
  102. package/src/proxmox/lxc-errors.ts +9 -0
  103. package/src/proxmox/lxc-lifecycle.ts +31 -113
  104. package/src/proxmox/lxc-read.test.ts +29 -6
  105. package/src/proxmox/lxc-read.ts +88 -0
  106. package/src/proxmox/lxc-task.ts +71 -0
  107. package/src/proxmox/network-apply-distilled.test.ts +76 -0
  108. package/src/proxmox/network-apply-read.ts +18 -39
  109. package/src/proxmox/network-apply-run.ts +97 -0
  110. package/src/proxmox/network-apply.ts +5 -94
  111. package/src/proxmox/node-network-read-failure.test.ts +24 -0
  112. package/src/proxmox/node-network-wire.ts +15 -17
  113. package/src/proxmox/qemu-errors.ts +7 -0
  114. package/src/proxmox/qemu-form.ts +15 -0
  115. package/src/proxmox/qemu-lifecycle.test.ts +171 -0
  116. package/src/proxmox/qemu-lifecycle.ts +134 -0
  117. package/src/proxmox/qemu-read.ts +73 -0
  118. package/src/proxmox/qemu-task.ts +64 -0
  119. package/src/proxmox/qemu.ts +11 -44
  120. package/src/proxmox/replication-firewall-fixtures.ts +71 -0
  121. package/src/proxmox/replication-firewall-lifecycle.test.ts +109 -0
  122. package/src/proxmox/replication-firewall-wire.test.ts +173 -0
  123. package/src/proxmox/replication-job-config.ts +79 -0
  124. package/src/proxmox/replication-job-lifecycle.ts +113 -0
  125. package/src/proxmox/replication-job.ts +7 -84
  126. package/src/release/archive.ts +4 -1
@@ -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 { deletePolicy, policyPath, readPolicy, writePolicy } from './policy-wire.ts';
14
- import { policyKey } from './rename.ts';
15
- import { guardRename, judgeRename, nameIdentity } from './rename-identity.ts';
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
- // ★ '' means genuinely absent (a 404); a refusal fails. policy-wire.ts has the history.
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.length === 0 ? undefined : attributesOf(olds.name, live, 0);
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* judgeRename(IDENTITY, olds, news, output);
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.length > 0 && sha256(live) === sha256(joined)
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(IDENTITY, news, output);
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.length > 0)
218
+ if (current !== undefined)
222
219
  yield* refuseTakeover({ fqn, instanceId, output }, `Bao.Policy ${name}`);
223
- if (current.length > 0 && sha256(current) === sha256(joined)) {
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.
@@ -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* baoRead(pathOf(declared))) === undefined) return { from: tried, to: declared };
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 { body, cidr, comment, fold } from './firewall-alias-form.ts';
100
- import { type PveRequirements, type WithTarget, pveHandlers } from './resource.ts';
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
- const handlers = pveHandlers<FirewallAliasProps, FirewallAliasAttributes>({
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
- expect(pve.seen.some((call) => /\/tasks\/UPID:.*vzdestroy/.test(call.path))).toBe(true);
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 };