@homeflare/distilled-proxmox 0.2.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 (78) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +87 -0
  3. package/dist/credentials.d.ts +41 -0
  4. package/dist/credentials.d.ts.map +1 -0
  5. package/dist/credentials.js +67 -0
  6. package/dist/credentials.js.map +1 -0
  7. package/dist/errors.d.ts +128 -0
  8. package/dist/errors.d.ts.map +1 -0
  9. package/dist/errors.js +91 -0
  10. package/dist/errors.js.map +1 -0
  11. package/dist/index.d.ts +30 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +30 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/protocol.d.ts +18 -0
  16. package/dist/protocol.d.ts.map +1 -0
  17. package/dist/protocol.js +167 -0
  18. package/dist/protocol.js.map +1 -0
  19. package/dist/protocol.test.d.ts +2 -0
  20. package/dist/protocol.test.d.ts.map +1 -0
  21. package/dist/protocol.test.js +64 -0
  22. package/dist/protocol.test.js.map +1 -0
  23. package/dist/retry.d.ts +50 -0
  24. package/dist/retry.d.ts.map +1 -0
  25. package/dist/retry.js +43 -0
  26. package/dist/retry.js.map +1 -0
  27. package/dist/services/access.d.ts +975 -0
  28. package/dist/services/access.d.ts.map +1 -0
  29. package/dist/services/access.js +1208 -0
  30. package/dist/services/access.js.map +1 -0
  31. package/dist/services/cluster.d.ts +5940 -0
  32. package/dist/services/cluster.d.ts.map +1 -0
  33. package/dist/services/cluster.js +7405 -0
  34. package/dist/services/cluster.js.map +1 -0
  35. package/dist/services/index.d.ts +7 -0
  36. package/dist/services/index.d.ts.map +1 -0
  37. package/dist/services/index.js +8 -0
  38. package/dist/services/index.js.map +1 -0
  39. package/dist/services/nodes.d.ts +7887 -0
  40. package/dist/services/nodes.d.ts.map +1 -0
  41. package/dist/services/nodes.js +10253 -0
  42. package/dist/services/nodes.js.map +1 -0
  43. package/dist/services/pools.d.ts +133 -0
  44. package/dist/services/pools.d.ts.map +1 -0
  45. package/dist/services/pools.js +183 -0
  46. package/dist/services/pools.js.map +1 -0
  47. package/dist/services/storage.d.ts +317 -0
  48. package/dist/services/storage.d.ts.map +1 -0
  49. package/dist/services/storage.js +239 -0
  50. package/dist/services/storage.js.map +1 -0
  51. package/dist/services/version.d.ts +20 -0
  52. package/dist/services/version.d.ts.map +1 -0
  53. package/dist/services/version.js +27 -0
  54. package/dist/services/version.js.map +1 -0
  55. package/dist/task.d.ts +51 -0
  56. package/dist/task.d.ts.map +1 -0
  57. package/dist/task.js +60 -0
  58. package/dist/task.js.map +1 -0
  59. package/dist/traits.d.ts +12 -0
  60. package/dist/traits.d.ts.map +1 -0
  61. package/dist/traits.js +14 -0
  62. package/dist/traits.js.map +1 -0
  63. package/package.json +81 -0
  64. package/src/credentials.ts +101 -0
  65. package/src/errors.ts +152 -0
  66. package/src/index.ts +33 -0
  67. package/src/protocol.test.ts +101 -0
  68. package/src/protocol.ts +204 -0
  69. package/src/retry.ts +74 -0
  70. package/src/services/access.ts +2722 -0
  71. package/src/services/cluster.ts +18550 -0
  72. package/src/services/index.ts +7 -0
  73. package/src/services/nodes.ts +25537 -0
  74. package/src/services/pools.ts +397 -0
  75. package/src/services/storage.ts +711 -0
  76. package/src/services/version.ts +54 -0
  77. package/src/task.ts +87 -0
  78. package/src/traits.ts +45 -0
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Proxmox VE credentials — hand-written.
3
+ *
4
+ * The `Credentials` service resolves `{ tokenId, secret, apiBaseUrl }` per
5
+ * request; the protocol layer formats the `Authorization:
6
+ * PVEAPIToken=<tokenId>=<secret>` header from it.
7
+ *
8
+ * PVE is self-hosted (a cluster node, not a single-tenant SaaS), so — like
9
+ * `@distilled.cloud/forgejo` — there is no default API root: the instance
10
+ * URL is part of the credential. `tokenId` is the FULL
11
+ * `<user>@<realm>!<tokenid>` string PVE prints when an API token is
12
+ * created (`pveum user token add …`); `secret` is the value shown exactly
13
+ * once at creation time.
14
+ */
15
+ import * as EffectConfig from "effect/Config";
16
+ import * as Context from "effect/Context";
17
+ import * as Effect from "effect/Effect";
18
+ import * as Layer from "effect/Layer";
19
+ import * as Redacted from "effect/Redacted";
20
+ import { ConfigError } from "@distilled.cloud/core/errors";
21
+
22
+ /** PVE's own API root, relative to the node origin. Always port 8006, always `/api2/json`. */
23
+ export const API_PATH = "/api2/json";
24
+
25
+ /**
26
+ * Normalize a node origin (or an already-complete API root) into the API
27
+ * base URL: trailing slashes dropped, {@link API_PATH} appended exactly
28
+ * once. Does NOT default the port — `https://pve.example.com:8006` (or a
29
+ * bare host, which the caller must have already put a scheme+port on) is
30
+ * expected, matching how the kit's own `PveTarget` is built per node/vip.
31
+ *
32
+ * ⛔ THE TRIM IS A LOOP, NEVER `/\/+$/`. That regex backtracks
33
+ * polynomially on a long run of trailing `/` (CodeQL `js/polynomial-
34
+ * redos`) — caller input, so a real DoS surface, not a hypothetical one.
35
+ * Measured the same fix already landed in `@homeflare/distilled-netbox`'s
36
+ * identical trim (`taslabs-net/homeflare-kit` PR 184): 0 mismatches
37
+ * against the old regex over 13 edge cases and 20,000 random inputs, a
38
+ * 100,000-slash input in 0.01ms linear.
39
+ */
40
+ export const normalizeBaseUrl = (baseUrl: string): string => {
41
+ let end = baseUrl.length;
42
+ while (end > 0 && baseUrl.charCodeAt(end - 1) === 47 /* "/" */) end--;
43
+ const trimmed = baseUrl.slice(0, end);
44
+ return trimmed.endsWith(API_PATH) ? trimmed : `${trimmed}${API_PATH}`;
45
+ };
46
+
47
+ export interface Config {
48
+ /** The full `<user>@<realm>!<tokenid>` string, e.g. `root@pam!terraform`. */
49
+ readonly tokenId: string;
50
+ readonly secret: Redacted.Redacted<string>;
51
+ /** Fully-qualified API root, e.g. `https://pve1.example.com:8006/api2/json`. */
52
+ readonly apiBaseUrl: string;
53
+ }
54
+
55
+ export class Credentials extends Context.Service<
56
+ Credentials,
57
+ Effect.Effect<Config>
58
+ >()("ProxmoxCredentials") {}
59
+
60
+ const envConfig = EffectConfig.all({
61
+ tokenId: EffectConfig.String("PROXMOX_TOKEN_ID"),
62
+ secret: EffectConfig.Redacted("PROXMOX_TOKEN_SECRET"),
63
+ baseUrl: EffectConfig.String("PROXMOX_URL"),
64
+ });
65
+
66
+ export const CredentialsFromEnv = Layer.succeed(
67
+ Credentials,
68
+ envConfig.pipe(
69
+ Effect.mapError(
70
+ () =>
71
+ new ConfigError({
72
+ message:
73
+ "PROXMOX_URL, PROXMOX_TOKEN_ID and PROXMOX_TOKEN_SECRET environment variables are required",
74
+ }),
75
+ ),
76
+ Effect.map(({ tokenId, secret, baseUrl }) => ({
77
+ tokenId,
78
+ secret,
79
+ apiBaseUrl: normalizeBaseUrl(baseUrl),
80
+ })),
81
+ Effect.orDie,
82
+ ),
83
+ );
84
+
85
+ /** Convenience layer from a plain token and the node origin (or API root). */
86
+ export const credentials = (config: {
87
+ readonly tokenId: string;
88
+ readonly secret: string | Redacted.Redacted<string>;
89
+ readonly baseUrl: string;
90
+ }): Layer.Layer<Credentials> =>
91
+ Layer.succeed(
92
+ Credentials,
93
+ Effect.succeed({
94
+ tokenId: config.tokenId,
95
+ secret:
96
+ typeof config.secret === "string"
97
+ ? Redacted.make(config.secret)
98
+ : config.secret,
99
+ apiBaseUrl: normalizeBaseUrl(config.baseUrl),
100
+ }),
101
+ );
package/src/errors.ts ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Proxmox VE-specific error types.
3
+ *
4
+ * Re-exports the common HTTP errors from core and adds PVE's own failure
5
+ * shapes — see `src/protocol.ts` for how each is recognized on the wire.
6
+ *
7
+ * ★ TWO DIFFERENT WIRING STYLES, ON PURPOSE:
8
+ * - `ParameterVerificationFailed` (and the plain `BadRequest` fallback
9
+ * for a 400 PVE didn't attach an `errors` object to) is GLOBAL
10
+ * (`commonErrorClasses`) — recognized generically in `protocol.ts`'s
11
+ * `unknownError`, which core's `makeRestProtocol` reaches LAST, after
12
+ * per-op `matchTypedError` and the status map both miss (never
13
+ * "before per-op matching runs" — 400 is deliberately absent from
14
+ * `PROXMOX_STATUS_MAP` so it always lands there). ANY write can fail
15
+ * parameter verification, so patching all 680 generated operations
16
+ * with the same error shape would be the RFC-6902 equivalent of
17
+ * copy-pasting one line 680 times for no additional truth. `BadRequest`
18
+ * matters here specifically because it is NOT retryable
19
+ * (`Category.withBadRequestError`) where `UnknownProxmoxError`'s
20
+ * `Category.withServerError` IS (core's default retry policy treats
21
+ * `ServerError` as transient) — see `protocol.ts`'s `unknownError` for
22
+ * the incident this prevents: a permanent 400 must never be retried.
23
+ * - `ClusterNodeUnreachable` is added via an ACTUAL RFC-6902 patch —
24
+ * `patches/nodes/task-polling.json` — to the four `/nodes/{node}/
25
+ * tasks/…` operations the vendor schema itself marks `proxyto: "node"`
26
+ * (this package's own `src/task.ts` polls one of them). It is NOT
27
+ * global: most PVE calls are answered by the node you connected to
28
+ * directly and never proxy at all, so declaring a 595 possible on
29
+ * every operation would be a claim the schema does not support for
30
+ * most of them. Matched purely by status (never a guessed message —
31
+ * see the patch file).
32
+ */
33
+ export {
34
+ BadGateway,
35
+ BadRequest,
36
+ Conflict,
37
+ ConfigError,
38
+ Forbidden,
39
+ GatewayTimeout,
40
+ InternalServerError,
41
+ Locked,
42
+ NotFound,
43
+ ServiceUnavailable,
44
+ TooManyRequests,
45
+ Unauthorized,
46
+ UnprocessableEntity,
47
+ HTTP_STATUS_MAP,
48
+ DEFAULT_ERRORS,
49
+ API_ERRORS,
50
+ } from "@distilled.cloud/core/errors";
51
+ // `export { X } from "mod"` (above) is a RE-EXPORT ONLY — it creates no local
52
+ // binding for `X`, so `BadRequest` needs its own `import type` to be usable
53
+ // in this file's own `DefaultErrors` union below. Caught by the kit's copy
54
+ // of this package typechecking under `verbatimModuleSyntax`, not by this
55
+ // package's own `tsc -b` — see `docs/distilled-interim.md`'s "prove it in
56
+ // both places" step for why that gap exists at all.
57
+ import type {
58
+ BadRequest,
59
+ DefaultErrors as CoreDefaultErrors,
60
+ } from "@distilled.cloud/core/errors";
61
+
62
+ import * as Schema from "effect/Schema";
63
+ import * as Category from "@distilled.cloud/core/category";
64
+
65
+ /**
66
+ * PVE's generic parameter-validation failure: `400` with `{"data":null,
67
+ * "errors":{"<param>":"<message>", …}, "message":"Parameter verification
68
+ * failed."}` — measured in `taslabs-net/homeflare-kit`'s
69
+ * `codegen/README.md` (the incident that package's whole vendor-schema
70
+ * pipeline exists to prevent): `PVE POST config/verify -> 400: parameter
71
+ * verification failed - comment: value may only be 128 characters long`.
72
+ * `errors` carries the per-field messages verbatim.
73
+ */
74
+ export class ParameterVerificationFailed extends Schema.TaggedError<ParameterVerificationFailed>()(
75
+ "ParameterVerificationFailed",
76
+ {
77
+ message: Schema.String,
78
+ errors: Schema.Record(Schema.String, Schema.String),
79
+ },
80
+ ).pipe(Category.withBadRequestError) {}
81
+
82
+ /**
83
+ * PVE's non-standard status for a cluster-forwarded request whose target
84
+ * node could not be reached (`pveproxy`/`pvedaemon` proxying — every
85
+ * endpoint the vendor schema marks `proxyto` can answer this). Standard
86
+ * HTTP has no 595; this is PVE's own convention, matched purely by status
87
+ * (never guessed message text — see `protocol.ts`). Treated as transient:
88
+ * the node the request was forwarded to may simply be mid-reboot or
89
+ * between cluster-membership changes.
90
+ */
91
+ export class ClusterNodeUnreachable extends Schema.TaggedError<ClusterNodeUnreachable>()(
92
+ "ClusterNodeUnreachable",
93
+ {
94
+ message: Schema.String,
95
+ },
96
+ ).pipe(Category.withServerError, Category.withRetryable()) {}
97
+
98
+ /**
99
+ * Unknown Proxmox error — returned when a failed response's HTTP status has
100
+ * no mapped error class. Carries the raw body for later cataloging.
101
+ */
102
+ export class UnknownProxmoxError extends Schema.TaggedError<UnknownProxmoxError>()(
103
+ "UnknownProxmoxError",
104
+ {
105
+ status: Schema.optional(Schema.Number),
106
+ message: Schema.optional(Schema.String),
107
+ body: Schema.Unknown,
108
+ },
109
+ ).pipe(Category.withServerError) {}
110
+
111
+ /** Schema parse error wrapper. */
112
+ export class ProxmoxParseError extends Schema.TaggedError<ProxmoxParseError>()(
113
+ "ProxmoxParseError",
114
+ {
115
+ body: Schema.Unknown,
116
+ cause: Schema.Unknown,
117
+ },
118
+ ).pipe(Category.withParseError) {}
119
+
120
+ /**
121
+ * A polled task ended with an `exitstatus` other than exactly `"OK"` — see
122
+ * `src/task.ts`. `exitstatus` carries PVE's own failure text verbatim
123
+ * (e.g. `"job errors"`, `"OK (warnings)"` — the latter is why the compare
124
+ * is exact-equality against `"OK"`, never a prefix/substring check).
125
+ */
126
+ export class ProxmoxTaskFailed extends Schema.TaggedError<ProxmoxTaskFailed>()(
127
+ "ProxmoxTaskFailed",
128
+ {
129
+ node: Schema.String,
130
+ upid: Schema.String,
131
+ exitstatus: Schema.String,
132
+ },
133
+ ) {}
134
+
135
+ /**
136
+ * Errors any Proxmox operation may surface in addition to the per-operation
137
+ * typed status errors.
138
+ */
139
+ export type ClientErrors = UnknownProxmoxError | ProxmoxParseError;
140
+
141
+ /**
142
+ * Default Proxmox operation errors: the shared HTTP status errors from
143
+ * core, PVE's global parameter-verification failure, plus the client-level
144
+ * fallback/decode errors. `ClusterNodeUnreachable` is NOT here — see the
145
+ * header: it rides only the specific operations `patches/nodes/
146
+ * task-polling.json` adds it to.
147
+ */
148
+ export type DefaultErrors =
149
+ | CoreDefaultErrors
150
+ | BadRequest
151
+ | ParameterVerificationFailed
152
+ | ClientErrors;
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @distilled.cloud/proxmox — Proxmox VE API SDK for Effect.
3
+ *
4
+ * `./services` is generated by `scripts/generate.ts` from the Smithy models
5
+ * in `.generated-specs` (written by `scripts/convert.ts` from PVE's own
6
+ * `apidata.js`, mirrored under `specs/`). Everything else in this folder is
7
+ * hand-written.
8
+ *
9
+ * One module per PVE top-level path segment — `access`, `cluster`,
10
+ * `nodes`, `pools`, `storage`, `version` — the same split the vendor's own
11
+ * api-viewer uses.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * import * as Proxmox from "@distilled.cloud/proxmox";
16
+ *
17
+ * const status = yield* Proxmox.Services.nodes.getNodeTaskStatus({
18
+ * node: "pve1",
19
+ * upid: "UPID:pve1:00001234:...",
20
+ * });
21
+ * ```
22
+ */
23
+ export * from "./credentials.ts";
24
+ export * from "./errors.ts";
25
+ export * as T from "./traits.ts";
26
+ export {
27
+ ProxmoxProtocol,
28
+ type ProxmoxOpError,
29
+ type ProxmoxOpContext,
30
+ } from "./protocol.ts";
31
+ export * as Retry from "./retry.ts";
32
+ export * as Services from "./services/index.ts";
33
+ export { awaitTask, type TaskRef, type AwaitTaskOptions } from "./task.ts";
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Proves the exact bug an adversarial review of this package caught and
3
+ * this file exists to stop recurring: a bare PVE 400 (no `errors` object)
4
+ * must decode to the non-retryable `BadRequest`, never the
5
+ * `Category.withServerError`-tagged `UnknownProxmoxError` — core's default
6
+ * retry policy treats `ServerError` as transient, so the wrong class would
7
+ * have silently retried a permanent, caller-caused failure several times
8
+ * before surfacing it. See `protocol.ts`'s `unknownError` and
9
+ * `errors.ts`'s module header for the full account.
10
+ */
11
+ import { describe, expect, test } from "bun:test";
12
+ import * as Effect from "effect/Effect";
13
+ import * as Layer from "effect/Layer";
14
+ import * as HttpClient from "effect/unstable/http/HttpClient";
15
+ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
16
+ import { credentials } from "./credentials.ts";
17
+ import {
18
+ BadRequest,
19
+ ParameterVerificationFailed,
20
+ UnknownProxmoxError,
21
+ } from "./errors.ts";
22
+ import * as Retry from "./retry.ts";
23
+ import { getNodeTime } from "./services/nodes.ts";
24
+
25
+ const testCredentials = credentials({
26
+ tokenId: "root@pam!test",
27
+ secret: "test-secret",
28
+ baseUrl: "https://pve.test:8006",
29
+ });
30
+
31
+ const fakePve = (status: number, body: unknown) =>
32
+ Layer.succeed(
33
+ HttpClient.HttpClient,
34
+ HttpClient.make((request) =>
35
+ Effect.sync(() =>
36
+ HttpClientResponse.fromWeb(
37
+ request,
38
+ new Response(JSON.stringify(body), { status }),
39
+ ),
40
+ ),
41
+ ),
42
+ );
43
+
44
+ const call = (status: number, body: unknown) =>
45
+ Effect.runPromise(
46
+ getNodeTime({ node: "pve1" }).pipe(
47
+ Retry.none,
48
+ Effect.provide(Layer.mergeAll(fakePve(status, body), testCredentials)),
49
+ Effect.flip,
50
+ ),
51
+ );
52
+
53
+ describe("protocol.ts error decoding", () => {
54
+ test("a bare 400 (no `errors` object) is BadRequest, never UnknownProxmoxError", async () => {
55
+ const error = await call(400, { data: null, message: "invalid parameter" });
56
+ expect(error).toBeInstanceOf(BadRequest);
57
+ expect(error).not.toBeInstanceOf(UnknownProxmoxError);
58
+ expect(error).toMatchObject({ message: "invalid parameter" });
59
+ });
60
+
61
+ test("a 400 with an `errors` object is ParameterVerificationFailed, carrying the per-field messages", async () => {
62
+ const error = await call(400, {
63
+ data: null,
64
+ message: "Parameter verification failed.",
65
+ errors: { comment: "value may only be 128 characters long" },
66
+ });
67
+ expect(error).toBeInstanceOf(ParameterVerificationFailed);
68
+ expect(error).toMatchObject({
69
+ message: "Parameter verification failed.",
70
+ errors: { comment: "value may only be 128 characters long" },
71
+ });
72
+ });
73
+
74
+ // 418 is neither in PROXMOX_STATUS_MAP nor >= 500 (the unmapped-5xx
75
+ // fallback in core's makeRestProtocol answers a bare `InternalServerError`
76
+ // for those before `unknownError` is ever reached — see that decode step).
77
+ test("a genuinely unmapped, non-5xx status still falls back to UnknownProxmoxError", async () => {
78
+ const error = await call(418, { data: null, message: "weird" });
79
+ expect(error).toBeInstanceOf(UnknownProxmoxError);
80
+ });
81
+
82
+ test('the {"data": ...} envelope is unwrapped before decode', async () => {
83
+ const result = await Effect.runPromise(
84
+ getNodeTime({ node: "pve1" }).pipe(
85
+ Effect.provide(
86
+ Layer.mergeAll(
87
+ fakePve(200, {
88
+ data: {
89
+ time: 1700000000,
90
+ timezone: "UTC",
91
+ localtime: 1700000000,
92
+ },
93
+ }),
94
+ testCredentials,
95
+ ),
96
+ ),
97
+ ),
98
+ );
99
+ expect(result).toMatchObject({ time: 1700000000, timezone: "UTC" });
100
+ });
101
+ });
@@ -0,0 +1,204 @@
1
+ /**
2
+ * ProxmoxProtocol — the shared bearer-REST protocol instantiated for
3
+ * Proxmox VE. Hand-written; `@distilled.cloud/core` is never changed for a
4
+ * provider's own quirks — they all live here.
5
+ *
6
+ * ## The envelope
7
+ *
8
+ * Every PVE response — success AND failure — is wrapped `{"data": …}`
9
+ * (measured, and already relied on by `packages/alchemy/src/proxmox/
10
+ * client.ts` in `taslabs-net/homeflare-kit`: "EVERY PVE ANSWER IS WRAPPED
11
+ * IN `{"data": ...}`, errors included — a failed call can still be HTTP
12
+ * 200 with `{"data": null}`"). `transformResponse` below unwraps it before
13
+ * `core/protocol-rest`'s schema-driven decode ever sees a payload, so a
14
+ * generated operation's output type is the PAYLOAD, not the envelope.
15
+ *
16
+ * ## Authentication
17
+ *
18
+ * The API-token scheme: `Authorization: PVEAPIToken=<user>@<realm>!
19
+ * <tokenid>=<secret>` (no `Bearer`, and the token id is embedded in the
20
+ * header value itself, not just the credential).
21
+ *
22
+ * ## THE THREE 200-TRAPS
23
+ *
24
+ * (a) **Async POST/PUT/DELETE answer 200 with a UPID and can fail later.**
25
+ * PVE hands back a bare task id string (`"UPID:node:...);"`) for any
26
+ * long-running action — a VM start, a backup, a storage scan — and the
27
+ * 200 means "the task was QUEUED", not "the task succeeded". Handled
28
+ * with real code: `src/task.ts`'s `awaitTask`, which polls
29
+ * `GetNodeTaskStatus` (this package's own generated operation) until
30
+ * `exitstatus` is present, then fails with the typed
31
+ * `ProxmoxTaskFailed` unless it is EXACTLY `"OK"` — PVE also answers
32
+ * `"OK (warnings)"`, which is not a bare-prefix match on purpose (see
33
+ * that file).
34
+ *
35
+ * (b) **Some PUTs answer 200 `{"data":null}` even when nothing changed.**
36
+ * PVE's update handlers commonly return `null` unconditionally on
37
+ * success, whether or not any field actually differed from what was
38
+ * already stored — there is no wire signal that distinguishes "applied"
39
+ * from "already exactly this". THIS IS NOT SOMETHING A PROTOCOL LAYER
40
+ * CAN DETECT OR FIX — it is a statement about business semantics no
41
+ * amount of parsing recovers. Documented here so it travels with the
42
+ * protocol rather than being rediscovered per resource: an update
43
+ * operation's caller (a future kit `Resource`) MUST read the value back
44
+ * with a follow-up GET rather than trust a 200 as proof anything
45
+ * changed.
46
+ *
47
+ * (c) **Permission-filtered lists return 200 + `[]` instead of 403.** A
48
+ * list endpoint called with a token that has no visibility into any
49
+ * member of the collection answers an EMPTY array, not a permission
50
+ * error — indistinguishable, on the wire, from "the collection is
51
+ * genuinely empty". Also undetectable here for the same reason as (b):
52
+ * documented so a caller treats an empty list from a narrowly-scoped
53
+ * token as "unknown", never as "confirmed absent".
54
+ *
55
+ * ## Self-signed TLS
56
+ *
57
+ * A fresh PVE node serves its own self-signed certificate. This is
58
+ * EXPLICITLY NOT handled here, or anywhere in this package: the custom
59
+ * CA / certificate trust decision belongs to the `HttpClient.HttpClient`
60
+ * layer the CALLER provides (Effect's platform HTTP client layers take a
61
+ * `NodeHttpClient.layerConfig` / custom `Agent` for this), never to a
62
+ * distilled protocol module, which only ever builds and decodes requests
63
+ * against whatever transport it is handed. Wiring a custom CA at the
64
+ * `HttpClient` layer is a caller decision to prototype and own.
65
+ */
66
+ import * as Effect from "effect/Effect";
67
+ import type * as Layer from "effect/Layer";
68
+ import * as Redacted from "effect/Redacted";
69
+ import type * as HttpClient from "effect/unstable/http/HttpClient";
70
+ import type * as HttpClientError from "effect/unstable/http/HttpClientError";
71
+ import type * as API from "@distilled.cloud/core/api";
72
+ import {
73
+ BadRequest,
74
+ HTTP_STATUS_MAP,
75
+ type ConfigError,
76
+ } from "@distilled.cloud/core/errors";
77
+ import {
78
+ makeRestProtocol,
79
+ type RestErrorEnvelope,
80
+ } from "@distilled.cloud/core/protocol-rest";
81
+ import { Credentials, type Config } from "./credentials.ts";
82
+ import {
83
+ ParameterVerificationFailed,
84
+ UnknownProxmoxError,
85
+ type DefaultErrors,
86
+ } from "./errors.ts";
87
+
88
+ /**
89
+ * Error channel shared by every generated Proxmox operation. Generated
90
+ * service files annotate operations with `API.OperationMethod<I, O,
91
+ * ProxmoxOpError, ProxmoxOpContext>` explicitly so the compiler never
92
+ * infers these back out of the schema generics.
93
+ */
94
+ export type ProxmoxOpError =
95
+ | DefaultErrors
96
+ | ConfigError
97
+ | HttpClientError.HttpClientError;
98
+
99
+ /** Context (requirements) shared by every generated Proxmox operation. */
100
+ export type ProxmoxOpContext = Credentials | HttpClient.HttpClient;
101
+
102
+ /**
103
+ * PVE's failure envelope is `{"data": null, "message": "...", "errors"?:
104
+ * {…}}` — `message` is the only field every failure carries (the `errors`
105
+ * sub-object, PVE's parameter-verification detail, is read straight from
106
+ * the body in `unknownError` below, not here — `RestErrorEnvelope` has no
107
+ * room for a nested object and `errorEnvelope` only feeds per-op typed-error
108
+ * MATCHING, never the classes themselves).
109
+ */
110
+ const errorEnvelope = (body: unknown): RestErrorEnvelope | undefined => {
111
+ if (body === null || typeof body !== "object") return undefined;
112
+ const b = body as Record<string, unknown>;
113
+ return { message: typeof b.message === "string" ? b.message : undefined };
114
+ };
115
+
116
+ /**
117
+ * `HTTP_STATUS_MAP` minus 400: PVE's 400 is not one shape (a generic
118
+ * "bad request" some other providers mean by it) — it is SPECIFICALLY the
119
+ * parameter-verification failure, and answering it with the generic
120
+ * `BadRequest` would throw away the per-field `errors` object the vendor
121
+ * always attaches. Dropping the entry here is what lets a 400 fall through
122
+ * to `unknownError`, which reads that object.
123
+ */
124
+ const PROXMOX_STATUS_MAP: Record<number, new (args: any) => unknown> = {
125
+ ...HTTP_STATUS_MAP,
126
+ };
127
+ delete (PROXMOX_STATUS_MAP as Record<number, unknown>)[400];
128
+
129
+ export const ProxmoxProtocol: Layer.Layer<API.Protocol> =
130
+ makeRestProtocol<Config>({
131
+ // Resolved on the CALLING fiber per request (the layer is memoized per
132
+ // process); the Credentials service holds an effect, so a token rotated
133
+ // between calls is picked up without rebuilding the layer.
134
+ credentials: Effect.gen(function* () {
135
+ const resolve = yield* Credentials;
136
+ return yield* resolve;
137
+ }),
138
+ baseUrl: (creds) => creds.apiBaseUrl,
139
+ headers: (creds) => ({
140
+ Authorization: `PVEAPIToken=${creds.tokenId}=${Redacted.value(creds.secret)}`,
141
+ Accept: "application/json",
142
+ }),
143
+ errorEnvelope,
144
+ statusMap: PROXMOX_STATUS_MAP,
145
+ // Unwrap PVE's `{"data": …}` envelope BEFORE the schema-driven decode —
146
+ // see the module header. `data` is `null`/absent for a Unit-output
147
+ // operation; `?? {}` is core's own convention for "no body".
148
+ transformResponse: (body) => {
149
+ if (
150
+ body !== null &&
151
+ typeof body === "object" &&
152
+ "data" in (body as Record<string, unknown>)
153
+ ) {
154
+ return (body as Record<string, unknown>).data ?? {};
155
+ }
156
+ return body;
157
+ },
158
+ // Reached for a 400 (see PROXMOX_STATUS_MAP above), an operation-declared
159
+ // typed error that didn't match (`ClusterNodeUnreachable`'s 595 — see
160
+ // `patches/nodes/task-polling.json` — when the status is right but the
161
+ // matcher's other fields aren't), or a status with no core mapping at
162
+ // all. `body` is the FULL parsed failure envelope (unlike
163
+ // `errorEnvelope`, which only ever sees `{code?, message?}`), so this is
164
+ // where PVE's per-field `errors` object is actually read.
165
+ //
166
+ // ⛔ A BARE 400 WITHOUT THE `errors` OBJECT MUST NOT FALL INTO
167
+ // `UnknownProxmoxError`. That class is `.pipe(Category.withServerError)`
168
+ // (matching every other provider's Unknown*Error fallback here), and
169
+ // `ServerError` is one of the categories `core/category.ts`'s
170
+ // `isTransientError` retries AUTOMATICALLY — with no caller opt-in —
171
+ // via `makeDefault` (`core/api.ts`: `Option.isSome(opt) ? opt.value :
172
+ // makeDefault` when no retry policy is in context). A malformed
173
+ // request is permanent; retrying it up to `makeDefault`'s cap before
174
+ // finally surfacing would silently multiply every truly-bad call by
175
+ // several attempts and several seconds of backoff for no chance of a
176
+ // different outcome. `BadRequest` (`Category.withBadRequestError`,
177
+ // not retryable) is the honest answer for "PVE said 400 and gave no
178
+ // further structure" — `UnknownProxmoxError` stays for statuses this
179
+ // package genuinely has no mapping for at all (never 400).
180
+ unknownError: ({ status, message, body }) => {
181
+ const b =
182
+ body !== null && typeof body === "object"
183
+ ? (body as Record<string, unknown>)
184
+ : undefined;
185
+ const fieldErrors = b?.errors;
186
+ if (status === 400) {
187
+ if (
188
+ fieldErrors !== null &&
189
+ typeof fieldErrors === "object" &&
190
+ !Array.isArray(fieldErrors)
191
+ ) {
192
+ const errors: Record<string, string> = {};
193
+ for (const [k, v] of Object.entries(
194
+ fieldErrors as Record<string, unknown>,
195
+ )) {
196
+ if (typeof v === "string") errors[k] = v;
197
+ }
198
+ return new ParameterVerificationFailed({ message, errors });
199
+ }
200
+ return new BadRequest({ message });
201
+ }
202
+ return new UnknownProxmoxError({ status, message, body });
203
+ },
204
+ });
package/src/retry.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Proxmox retry surface — a veneer over `@distilled.cloud/core/retry`.
3
+ *
4
+ * The `Retry` service tag is threaded into every generated operation via
5
+ * `API.make({ retry: Retry })`, so a caller-installed policy applies to all
6
+ * Proxmox calls below it and core's `makeDefault` is the fallback when none
7
+ * is provided.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import * as Proxmox from "@distilled.cloud/proxmox";
12
+ *
13
+ * myEffect.pipe(Proxmox.Retry.transient);
14
+ * ```
15
+ */
16
+ import * as Context from "effect/Context";
17
+ import * as Effect from "effect/Effect";
18
+ import * as Layer from "effect/Layer";
19
+ import * as Retries from "@distilled.cloud/core/retry";
20
+
21
+ export type Options = Retries.Options;
22
+ export type Factory = Retries.Factory;
23
+ export type Policy = Retries.Policy;
24
+
25
+ /** Context tag for configuring retry behavior of Proxmox API calls. */
26
+ export class Retry extends Context.Service<Retry, Policy>()("ProxmoxRetry") {}
27
+
28
+ /** Provides a custom retry policy to every Proxmox API call below it. */
29
+ export const policy: {
30
+ (
31
+ options: Options,
32
+ ): <A, E, R>(
33
+ effect: Effect.Effect<A, E, R>,
34
+ ) => Effect.Effect<A, E, Exclude<R, Retry>>;
35
+ (
36
+ factory: Factory,
37
+ ): <A, E, R>(
38
+ effect: Effect.Effect<A, E, R>,
39
+ ) => Effect.Effect<A, E, Exclude<R, Retry>>;
40
+ } = (optionsOrFactory: Options | Factory) =>
41
+ Effect.provide(Layer.succeed(Retry, optionsOrFactory));
42
+
43
+ /** Disables all automatic retries. */
44
+ export const none: <A, E, R>(
45
+ effect: Effect.Effect<A, E, R>,
46
+ ) => Effect.Effect<A, E, Exclude<R, Retry>> = Effect.provide(
47
+ Layer.succeed(Retry, { while: () => false }),
48
+ );
49
+
50
+ /**
51
+ * The default retry policy (core's): transient/throttling/retryable errors,
52
+ * capped exponential backoff with jitter, server `retryAfter` hints honored
53
+ * with precedence.
54
+ */
55
+ export const makeDefault: Factory = Retries.makeDefault;
56
+
57
+ export const jittered = Retries.jittered;
58
+ export const capped = Retries.capped;
59
+
60
+ /** Retry options that retry all throttling errors indefinitely. */
61
+ export const throttlingOptions: Options = Retries.throttlingOptions;
62
+
63
+ /** Retries all throttling errors indefinitely (honoring server hints). */
64
+ export const throttling: <A, E, R>(
65
+ effect: Effect.Effect<A, E, R>,
66
+ ) => Effect.Effect<A, E, Exclude<R, Retry>> = policy(Retries.throttlingFactory);
67
+
68
+ /** Retry options that retry all transient errors indefinitely. */
69
+ export const transientOptions: Options = Retries.transientOptions;
70
+
71
+ /** Retries all transient errors indefinitely (honoring server hints). */
72
+ export const transient: <A, E, R>(
73
+ effect: Effect.Effect<A, E, R>,
74
+ ) => Effect.Effect<A, E, Exclude<R, Retry>> = policy(Retries.transientFactory);