@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.
- package/LICENSE +201 -0
- package/README.md +87 -0
- package/dist/credentials.d.ts +41 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +67 -0
- package/dist/credentials.js.map +1 -0
- package/dist/errors.d.ts +128 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +91 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol.d.ts +18 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +167 -0
- package/dist/protocol.js.map +1 -0
- package/dist/protocol.test.d.ts +2 -0
- package/dist/protocol.test.d.ts.map +1 -0
- package/dist/protocol.test.js +64 -0
- package/dist/protocol.test.js.map +1 -0
- package/dist/retry.d.ts +50 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +43 -0
- package/dist/retry.js.map +1 -0
- package/dist/services/access.d.ts +975 -0
- package/dist/services/access.d.ts.map +1 -0
- package/dist/services/access.js +1208 -0
- package/dist/services/access.js.map +1 -0
- package/dist/services/cluster.d.ts +5940 -0
- package/dist/services/cluster.d.ts.map +1 -0
- package/dist/services/cluster.js +7405 -0
- package/dist/services/cluster.js.map +1 -0
- package/dist/services/index.d.ts +7 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/index.js +8 -0
- package/dist/services/index.js.map +1 -0
- package/dist/services/nodes.d.ts +7887 -0
- package/dist/services/nodes.d.ts.map +1 -0
- package/dist/services/nodes.js +10253 -0
- package/dist/services/nodes.js.map +1 -0
- package/dist/services/pools.d.ts +133 -0
- package/dist/services/pools.d.ts.map +1 -0
- package/dist/services/pools.js +183 -0
- package/dist/services/pools.js.map +1 -0
- package/dist/services/storage.d.ts +317 -0
- package/dist/services/storage.d.ts.map +1 -0
- package/dist/services/storage.js +239 -0
- package/dist/services/storage.js.map +1 -0
- package/dist/services/version.d.ts +20 -0
- package/dist/services/version.d.ts.map +1 -0
- package/dist/services/version.js +27 -0
- package/dist/services/version.js.map +1 -0
- package/dist/task.d.ts +51 -0
- package/dist/task.d.ts.map +1 -0
- package/dist/task.js +60 -0
- package/dist/task.js.map +1 -0
- package/dist/traits.d.ts +12 -0
- package/dist/traits.d.ts.map +1 -0
- package/dist/traits.js +14 -0
- package/dist/traits.js.map +1 -0
- package/package.json +81 -0
- package/src/credentials.ts +101 -0
- package/src/errors.ts +152 -0
- package/src/index.ts +33 -0
- package/src/protocol.test.ts +101 -0
- package/src/protocol.ts +204 -0
- package/src/retry.ts +74 -0
- package/src/services/access.ts +2722 -0
- package/src/services/cluster.ts +18550 -0
- package/src/services/index.ts +7 -0
- package/src/services/nodes.ts +25537 -0
- package/src/services/pools.ts +397 -0
- package/src/services/storage.ts +711 -0
- package/src/services/version.ts +54 -0
- package/src/task.ts +87 -0
- 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
|
+
});
|
package/src/protocol.ts
ADDED
|
@@ -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);
|