@heyocomputer/hws 0.0.0-stage → 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/CHANGELOG.md +55 -0
- package/README.md +238 -2
- package/dist/client.d.ts +371 -0
- package/dist/client.js +761 -0
- package/dist/errors.d.ts +113 -0
- package/dist/errors.js +228 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +48 -0
- package/dist/obs.d.ts +86 -0
- package/dist/obs.js +115 -0
- package/dist/shell.d.ts +123 -0
- package/dist/shell.js +340 -0
- package/dist/types.d.ts +1312 -0
- package/dist/types.js +13 -0
- package/dist/wait.d.ts +70 -0
- package/dist/wait.js +99 -0
- package/package.json +58 -4
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What can go wrong, as something a caller can branch on.
|
|
3
|
+
*
|
|
4
|
+
* app-lb answers a failed request in one of two ways and a client has to handle
|
|
5
|
+
* both:
|
|
6
|
+
*
|
|
7
|
+
* - `{"error": "…"}` — every handler-level 4xx/5xx.
|
|
8
|
+
* - **plain text** — the `401`, and every axum extractor rejection: `415` for a
|
|
9
|
+
* missing content-type, `400` for malformed JSON, `422` for well-formed JSON
|
|
10
|
+
* of the wrong shape.
|
|
11
|
+
*
|
|
12
|
+
* So {@link fromResponse} tries the envelope, falls back to the body as text,
|
|
13
|
+
* and falls back again to the status. It never assumes JSON.
|
|
14
|
+
*/
|
|
15
|
+
/** What was presented, so a 401 can say something useful about it. */
|
|
16
|
+
export type Credential = "none" | "basic" | "token";
|
|
17
|
+
export declare class HeyctlError extends Error {
|
|
18
|
+
/** The HTTP status behind this, when there was one. */
|
|
19
|
+
readonly status?: number;
|
|
20
|
+
/**
|
|
21
|
+
* The machine-readable reason app-lb put beside `error`, when it gave one —
|
|
22
|
+
* e.g. `plugin_not_installed` or `plugin_disabled` on a namespace plugin's
|
|
23
|
+
* 409. Branch on this rather than on the message.
|
|
24
|
+
*/
|
|
25
|
+
code?: string;
|
|
26
|
+
constructor(message: string, status?: number);
|
|
27
|
+
/** Whether retrying the identical request could plausibly succeed. */
|
|
28
|
+
get retryable(): boolean;
|
|
29
|
+
/** Whether this is a credential problem rather than a request problem. */
|
|
30
|
+
get isAuth(): boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* `401`. Missing, wrong, revoked or expired — app-lb does not distinguish
|
|
34
|
+
* those, deliberately, so token ids cannot be enumerated by watching which
|
|
35
|
+
* failure comes back.
|
|
36
|
+
*/
|
|
37
|
+
export declare class UnauthorizedError extends HeyctlError {
|
|
38
|
+
readonly presented: Credential;
|
|
39
|
+
constructor(presented: Credential);
|
|
40
|
+
get isAuth(): boolean;
|
|
41
|
+
}
|
|
42
|
+
/** `403`. The credential was good and its scope was not. */
|
|
43
|
+
export declare class ForbiddenError extends HeyctlError {
|
|
44
|
+
constructor(message: string);
|
|
45
|
+
get isAuth(): boolean;
|
|
46
|
+
}
|
|
47
|
+
export declare class NotFoundError extends HeyctlError {
|
|
48
|
+
readonly kind: string;
|
|
49
|
+
readonly name_: string;
|
|
50
|
+
constructor(kind: string, name: string);
|
|
51
|
+
}
|
|
52
|
+
/** `409` — a job is already running, or a secret is still referenced. */
|
|
53
|
+
export declare class ConflictError extends HeyctlError {
|
|
54
|
+
constructor(message: string);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* `409` from exec/shell with `wake: false` and nothing running. Separate from
|
|
58
|
+
* {@link ConflictError} because the remedy is specific: retry with `wake`.
|
|
59
|
+
*/
|
|
60
|
+
export declare class NoRunningVmError extends HeyctlError {
|
|
61
|
+
readonly deployment: string;
|
|
62
|
+
constructor(deployment: string);
|
|
63
|
+
}
|
|
64
|
+
/** `503` — asked for a VM, none appeared inside `cold_start_timeout_secs`. */
|
|
65
|
+
export declare class ColdStartTimeoutError extends HeyctlError {
|
|
66
|
+
readonly deployment: string;
|
|
67
|
+
constructor(deployment: string);
|
|
68
|
+
get retryable(): boolean;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* `502` — app-lb reached the daemon and the daemon failed.
|
|
72
|
+
*
|
|
73
|
+
* For `exec` this includes app-lb's own call timing out, in which case **the
|
|
74
|
+
* command is still running in the guest**.
|
|
75
|
+
*/
|
|
76
|
+
export declare class UpstreamError extends HeyctlError {
|
|
77
|
+
constructor(message: string);
|
|
78
|
+
get retryable(): boolean;
|
|
79
|
+
}
|
|
80
|
+
/** Any other `{"error": …}` this package has no specific class for. */
|
|
81
|
+
export declare class ApiError extends HeyctlError {
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A response that could not be interpreted: an extractor rejection, an
|
|
85
|
+
* empty-bodied router 404/405, or an intermediary's error page.
|
|
86
|
+
*/
|
|
87
|
+
export declare class MalformedResponseError extends HeyctlError {
|
|
88
|
+
readonly body: string;
|
|
89
|
+
constructor(status: number, body: string);
|
|
90
|
+
}
|
|
91
|
+
/** The request never got an answer. */
|
|
92
|
+
export declare class TransportError extends HeyctlError {
|
|
93
|
+
readonly cause?: unknown | undefined;
|
|
94
|
+
constructor(message: string, cause?: unknown | undefined);
|
|
95
|
+
get retryable(): boolean;
|
|
96
|
+
}
|
|
97
|
+
/** The WebSocket carrying a shell failed. */
|
|
98
|
+
export declare class ShellError extends HeyctlError {
|
|
99
|
+
}
|
|
100
|
+
/** A `wait*` helper gave up. */
|
|
101
|
+
export declare class TimeoutError extends HeyctlError {
|
|
102
|
+
constructor(what: string, afterMs: number);
|
|
103
|
+
}
|
|
104
|
+
/** Bad input, caught before anything was sent. */
|
|
105
|
+
export declare class InvalidRequestError extends HeyctlError {
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Turn a failed response into a typed error.
|
|
109
|
+
*
|
|
110
|
+
* `kind`/`name` describe what was addressed, so a 404 can say
|
|
111
|
+
* `no deployment "demo"` rather than `HTTP 404`.
|
|
112
|
+
*/
|
|
113
|
+
export declare function fromResponse(status: number, body: string, kind: string, name: string, presented: Credential): HeyctlError;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What can go wrong, as something a caller can branch on.
|
|
3
|
+
*
|
|
4
|
+
* app-lb answers a failed request in one of two ways and a client has to handle
|
|
5
|
+
* both:
|
|
6
|
+
*
|
|
7
|
+
* - `{"error": "…"}` — every handler-level 4xx/5xx.
|
|
8
|
+
* - **plain text** — the `401`, and every axum extractor rejection: `415` for a
|
|
9
|
+
* missing content-type, `400` for malformed JSON, `422` for well-formed JSON
|
|
10
|
+
* of the wrong shape.
|
|
11
|
+
*
|
|
12
|
+
* So {@link fromResponse} tries the envelope, falls back to the body as text,
|
|
13
|
+
* and falls back again to the status. It never assumes JSON.
|
|
14
|
+
*/
|
|
15
|
+
export class HeyctlError extends Error {
|
|
16
|
+
/** The HTTP status behind this, when there was one. */
|
|
17
|
+
status;
|
|
18
|
+
/**
|
|
19
|
+
* The machine-readable reason app-lb put beside `error`, when it gave one —
|
|
20
|
+
* e.g. `plugin_not_installed` or `plugin_disabled` on a namespace plugin's
|
|
21
|
+
* 409. Branch on this rather than on the message.
|
|
22
|
+
*/
|
|
23
|
+
code;
|
|
24
|
+
constructor(message, status) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = new.target.name;
|
|
27
|
+
this.status = status;
|
|
28
|
+
// Required for `instanceof` to work on a subclassed Error when the package
|
|
29
|
+
// is transpiled down to ES5 — without it every subclass collapses to Error.
|
|
30
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
31
|
+
}
|
|
32
|
+
/** Whether retrying the identical request could plausibly succeed. */
|
|
33
|
+
get retryable() {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
/** Whether this is a credential problem rather than a request problem. */
|
|
37
|
+
get isAuth() {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* `401`. Missing, wrong, revoked or expired — app-lb does not distinguish
|
|
43
|
+
* those, deliberately, so token ids cannot be enumerated by watching which
|
|
44
|
+
* failure comes back.
|
|
45
|
+
*/
|
|
46
|
+
export class UnauthorizedError extends HeyctlError {
|
|
47
|
+
presented;
|
|
48
|
+
constructor(presented) {
|
|
49
|
+
super({
|
|
50
|
+
none: "authentication required, and no credential was sent — supply a username and password, or an app-token",
|
|
51
|
+
basic: "the username or password was not accepted",
|
|
52
|
+
token: "the app-token was not accepted — it may be wrong, revoked or expired (app-lb does not say which)",
|
|
53
|
+
}[presented], 401);
|
|
54
|
+
this.presented = presented;
|
|
55
|
+
}
|
|
56
|
+
get isAuth() {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** `403`. The credential was good and its scope was not. */
|
|
61
|
+
export class ForbiddenError extends HeyctlError {
|
|
62
|
+
constructor(message) {
|
|
63
|
+
super(message, 403);
|
|
64
|
+
}
|
|
65
|
+
get isAuth() {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export class NotFoundError extends HeyctlError {
|
|
70
|
+
kind;
|
|
71
|
+
name_;
|
|
72
|
+
constructor(kind, name) {
|
|
73
|
+
super(`no ${kind} ${JSON.stringify(name)}`, 404);
|
|
74
|
+
this.kind = kind;
|
|
75
|
+
this.name_ = name;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** `409` — a job is already running, or a secret is still referenced. */
|
|
79
|
+
export class ConflictError extends HeyctlError {
|
|
80
|
+
constructor(message) {
|
|
81
|
+
super(message, 409);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* `409` from exec/shell with `wake: false` and nothing running. Separate from
|
|
86
|
+
* {@link ConflictError} because the remedy is specific: retry with `wake`.
|
|
87
|
+
*/
|
|
88
|
+
export class NoRunningVmError extends HeyctlError {
|
|
89
|
+
deployment;
|
|
90
|
+
constructor(deployment) {
|
|
91
|
+
super(`deployment ${JSON.stringify(deployment)} has no running VM (retry with wake)`, 409);
|
|
92
|
+
this.deployment = deployment;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** `503` — asked for a VM, none appeared inside `cold_start_timeout_secs`. */
|
|
96
|
+
export class ColdStartTimeoutError extends HeyctlError {
|
|
97
|
+
deployment;
|
|
98
|
+
constructor(deployment) {
|
|
99
|
+
super(`deployment ${JSON.stringify(deployment)} had no VM ready within its cold-start timeout`, 503);
|
|
100
|
+
this.deployment = deployment;
|
|
101
|
+
}
|
|
102
|
+
get retryable() {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* `502` — app-lb reached the daemon and the daemon failed.
|
|
108
|
+
*
|
|
109
|
+
* For `exec` this includes app-lb's own call timing out, in which case **the
|
|
110
|
+
* command is still running in the guest**.
|
|
111
|
+
*/
|
|
112
|
+
export class UpstreamError extends HeyctlError {
|
|
113
|
+
constructor(message) {
|
|
114
|
+
super(message, 502);
|
|
115
|
+
}
|
|
116
|
+
get retryable() {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Any other `{"error": …}` this package has no specific class for. */
|
|
121
|
+
export class ApiError extends HeyctlError {
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* A response that could not be interpreted: an extractor rejection, an
|
|
125
|
+
* empty-bodied router 404/405, or an intermediary's error page.
|
|
126
|
+
*/
|
|
127
|
+
export class MalformedResponseError extends HeyctlError {
|
|
128
|
+
body;
|
|
129
|
+
constructor(status, body) {
|
|
130
|
+
super(body ? `unexpected HTTP ${status}: ${body}` : `unexpected HTTP ${status}`, status);
|
|
131
|
+
this.body = body;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** The request never got an answer. */
|
|
135
|
+
export class TransportError extends HeyctlError {
|
|
136
|
+
cause;
|
|
137
|
+
constructor(message, cause) {
|
|
138
|
+
super(message);
|
|
139
|
+
this.cause = cause;
|
|
140
|
+
}
|
|
141
|
+
get retryable() {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** The WebSocket carrying a shell failed. */
|
|
146
|
+
export class ShellError extends HeyctlError {
|
|
147
|
+
}
|
|
148
|
+
/** A `wait*` helper gave up. */
|
|
149
|
+
export class TimeoutError extends HeyctlError {
|
|
150
|
+
constructor(what, afterMs) {
|
|
151
|
+
super(`${what} did not finish within ${Math.round(afterMs / 1000)}s`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** Bad input, caught before anything was sent. */
|
|
155
|
+
export class InvalidRequestError extends HeyctlError {
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Turn a failed response into a typed error.
|
|
159
|
+
*
|
|
160
|
+
* `kind`/`name` describe what was addressed, so a 404 can say
|
|
161
|
+
* `no deployment "demo"` rather than `HTTP 404`.
|
|
162
|
+
*/
|
|
163
|
+
export function fromResponse(status, body, kind, name, presented) {
|
|
164
|
+
// The envelope if there is one; otherwise the body verbatim, which is where
|
|
165
|
+
// the plain-text rejections live.
|
|
166
|
+
let message;
|
|
167
|
+
let code;
|
|
168
|
+
try {
|
|
169
|
+
const parsed = JSON.parse(body);
|
|
170
|
+
if (parsed && typeof parsed.error === "string" && parsed.error.trim()) {
|
|
171
|
+
message = parsed.error;
|
|
172
|
+
}
|
|
173
|
+
if (parsed && typeof parsed.code === "string" && parsed.code) {
|
|
174
|
+
code = parsed.code;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// Not JSON. Normal — see the module comment.
|
|
179
|
+
}
|
|
180
|
+
const hadEnvelope = message !== undefined;
|
|
181
|
+
if (message === undefined) {
|
|
182
|
+
const trimmed = body.trim();
|
|
183
|
+
message = trimmed || undefined;
|
|
184
|
+
}
|
|
185
|
+
const err = classify(status, message, hadEnvelope, kind, name, presented);
|
|
186
|
+
if (code !== undefined)
|
|
187
|
+
err.code = code;
|
|
188
|
+
return err;
|
|
189
|
+
}
|
|
190
|
+
function classify(status, message, hadEnvelope, kind, name, presented) {
|
|
191
|
+
switch (status) {
|
|
192
|
+
case 401:
|
|
193
|
+
return new UnauthorizedError(presented);
|
|
194
|
+
case 403:
|
|
195
|
+
return new ForbiddenError(message ?? "forbidden");
|
|
196
|
+
case 404:
|
|
197
|
+
// A router-level 404 (an unknown *path*) has no envelope and no useful
|
|
198
|
+
// body; a handler-level one names the thing. Reporting the former as a
|
|
199
|
+
// missing object sends people looking for the wrong bug.
|
|
200
|
+
if (message === undefined || message.startsWith("no ")) {
|
|
201
|
+
return new NotFoundError(kind, name);
|
|
202
|
+
}
|
|
203
|
+
return new MalformedResponseError(status, message);
|
|
204
|
+
case 409:
|
|
205
|
+
// Both shapes are 409 and the remedies differ, so the message is what
|
|
206
|
+
// tells them apart. If app-lb rewords it the fallback is ConflictError,
|
|
207
|
+
// which is less specific rather than wrong.
|
|
208
|
+
return message?.includes("no running VM")
|
|
209
|
+
? new NoRunningVmError(name)
|
|
210
|
+
: new ConflictError(message ?? "conflict");
|
|
211
|
+
case 503:
|
|
212
|
+
return new ColdStartTimeoutError(name);
|
|
213
|
+
case 502:
|
|
214
|
+
return new UpstreamError(message ?? "the daemon did not answer");
|
|
215
|
+
case 400:
|
|
216
|
+
case 415:
|
|
217
|
+
case 422:
|
|
218
|
+
// Without an envelope these are extractor rejections: the request was
|
|
219
|
+
// malformed before a handler saw it.
|
|
220
|
+
if (!hadEnvelope)
|
|
221
|
+
return new MalformedResponseError(status, message ?? "");
|
|
222
|
+
return new ApiError(message, status);
|
|
223
|
+
default:
|
|
224
|
+
return message === undefined
|
|
225
|
+
? new MalformedResponseError(status, "")
|
|
226
|
+
: new ApiError(message, status);
|
|
227
|
+
}
|
|
228
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@heyocomputer/hws` — the TypeScript twin of the `hws` crate: a client for
|
|
3
|
+
* the app-lb admin API.
|
|
4
|
+
*
|
|
5
|
+
* app-lb is a load balancer for heyvm Firecracker/KVM microVMs. This package
|
|
6
|
+
* drives it: register deployments, scale pools, run commands inside a VM, and
|
|
7
|
+
* attach an interactive shell.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { Hws } from "@heyocomputer/hws";
|
|
11
|
+
*
|
|
12
|
+
* const lb = new Hws({ server: "127.0.0.1:9090", token: process.env.APP_LB_TOKEN });
|
|
13
|
+
* const { stdout } = await lb.exec("sb-7f3a9c", "uname -a");
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* # Authentication
|
|
17
|
+
*
|
|
18
|
+
* Prefer an **app-token**: scoped to particular deployments, revocable without
|
|
19
|
+
* restarting app-lb, optionally expiring. Basic auth also works and is unscoped
|
|
20
|
+
* — it is the operator credential, and the one that mints tokens.
|
|
21
|
+
*
|
|
22
|
+
* # Runtimes
|
|
23
|
+
*
|
|
24
|
+
* Node 18+, Bun, Deno and browsers. Everything uses `fetch`. Shells use `ws` on
|
|
25
|
+
* the server and the native `WebSocket` in a browser — and because a browser
|
|
26
|
+
* cannot set headers on an upgrade, a browser shell needs an **app-token**,
|
|
27
|
+
* which travels in the query string. Mint a short-lived one for that.
|
|
28
|
+
*
|
|
29
|
+
* # What this package does not smooth over
|
|
30
|
+
*
|
|
31
|
+
* - A non-zero exit code from {@link Heyctl.exec} resolves, it does not
|
|
32
|
+
* reject. The command ran; it failed.
|
|
33
|
+
* - `exec`'s timeout does not kill anything. It bounds app-lb's call to the
|
|
34
|
+
* daemon — on expiry you get an `UpstreamError` and the command **keeps
|
|
35
|
+
* running in the guest**.
|
|
36
|
+
* - A shell socket has no resume. If it drops the session is gone; reconnecting
|
|
37
|
+
* gives a *new* shell.
|
|
38
|
+
*/
|
|
39
|
+
export { Heyctl, normalizeServer, ASSUMED_COLD_START_MS } from "./client.js";
|
|
40
|
+
/**
|
|
41
|
+
* The client under the package's name. `Heyctl` stays exported for code
|
|
42
|
+
* written against the earlier name; they are the same class.
|
|
43
|
+
*/
|
|
44
|
+
export { Heyctl as Hws } from "./client.js";
|
|
45
|
+
export type { Auth, ExecOptions, Gates, MetricsQuery, NewToken, HeyctlOptions, HeyctlOptions as HwsOptions, } from "./client.js";
|
|
46
|
+
export { ObsClient, OBS_PLUGIN, logQueryString } from "./obs.js";
|
|
47
|
+
export type { LogQuery, NewAlert } from "./obs.js";
|
|
48
|
+
export { Shell, PING_INTERVAL_MS } from "./shell.js";
|
|
49
|
+
export type { ShellExit, ShellOptions } from "./shell.js";
|
|
50
|
+
export { waitForJob, waitForReady, JOB_POLL_MS, POOL_POLL_MS, FIRST_POLL_MS } from "./wait.js";
|
|
51
|
+
export type { JobProgress, PoolProgress, WaitForJobOptions, WaitForReadyOptions, } from "./wait.js";
|
|
52
|
+
export { ApiError, ColdStartTimeoutError, ConflictError, ForbiddenError, InvalidRequestError, MalformedResponseError, NoRunningVmError, NotFoundError, HeyctlError, ShellError, TimeoutError, TransportError, UnauthorizedError, UpstreamError, } from "./errors.js";
|
|
53
|
+
export type { Credential } from "./errors.js";
|
|
54
|
+
export type * from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@heyocomputer/hws` — the TypeScript twin of the `hws` crate: a client for
|
|
3
|
+
* the app-lb admin API.
|
|
4
|
+
*
|
|
5
|
+
* app-lb is a load balancer for heyvm Firecracker/KVM microVMs. This package
|
|
6
|
+
* drives it: register deployments, scale pools, run commands inside a VM, and
|
|
7
|
+
* attach an interactive shell.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { Hws } from "@heyocomputer/hws";
|
|
11
|
+
*
|
|
12
|
+
* const lb = new Hws({ server: "127.0.0.1:9090", token: process.env.APP_LB_TOKEN });
|
|
13
|
+
* const { stdout } = await lb.exec("sb-7f3a9c", "uname -a");
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* # Authentication
|
|
17
|
+
*
|
|
18
|
+
* Prefer an **app-token**: scoped to particular deployments, revocable without
|
|
19
|
+
* restarting app-lb, optionally expiring. Basic auth also works and is unscoped
|
|
20
|
+
* — it is the operator credential, and the one that mints tokens.
|
|
21
|
+
*
|
|
22
|
+
* # Runtimes
|
|
23
|
+
*
|
|
24
|
+
* Node 18+, Bun, Deno and browsers. Everything uses `fetch`. Shells use `ws` on
|
|
25
|
+
* the server and the native `WebSocket` in a browser — and because a browser
|
|
26
|
+
* cannot set headers on an upgrade, a browser shell needs an **app-token**,
|
|
27
|
+
* which travels in the query string. Mint a short-lived one for that.
|
|
28
|
+
*
|
|
29
|
+
* # What this package does not smooth over
|
|
30
|
+
*
|
|
31
|
+
* - A non-zero exit code from {@link Heyctl.exec} resolves, it does not
|
|
32
|
+
* reject. The command ran; it failed.
|
|
33
|
+
* - `exec`'s timeout does not kill anything. It bounds app-lb's call to the
|
|
34
|
+
* daemon — on expiry you get an `UpstreamError` and the command **keeps
|
|
35
|
+
* running in the guest**.
|
|
36
|
+
* - A shell socket has no resume. If it drops the session is gone; reconnecting
|
|
37
|
+
* gives a *new* shell.
|
|
38
|
+
*/
|
|
39
|
+
export { Heyctl, normalizeServer, ASSUMED_COLD_START_MS } from "./client.js";
|
|
40
|
+
/**
|
|
41
|
+
* The client under the package's name. `Heyctl` stays exported for code
|
|
42
|
+
* written against the earlier name; they are the same class.
|
|
43
|
+
*/
|
|
44
|
+
export { Heyctl as Hws } from "./client.js";
|
|
45
|
+
export { ObsClient, OBS_PLUGIN, logQueryString } from "./obs.js";
|
|
46
|
+
export { Shell, PING_INTERVAL_MS } from "./shell.js";
|
|
47
|
+
export { waitForJob, waitForReady, JOB_POLL_MS, POOL_POLL_MS, FIRST_POLL_MS } from "./wait.js";
|
|
48
|
+
export { ApiError, ColdStartTimeoutError, ConflictError, ForbiddenError, InvalidRequestError, MalformedResponseError, NoRunningVmError, NotFoundError, HeyctlError, ShellError, TimeoutError, TransportError, UnauthorizedError, UpstreamError, } from "./errors.js";
|
package/dist/obs.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One namespace's telemetry, read through app-lb's `obs` plugin. Mirrors the
|
|
3
|
+
* `hws` crate's `ObsClient`.
|
|
4
|
+
*
|
|
5
|
+
* Installing `obs` into a namespace makes app-obs collect metrics and logs for
|
|
6
|
+
* every deployment in it. A namespace-scoped credential reads them through
|
|
7
|
+
* app-lb, which checks the caller reaches the namespace and talks to app-obs on
|
|
8
|
+
* its behalf — nothing here needs to know where app-obs lives.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* await lb.installPlugin("team-a", "obs");
|
|
12
|
+
* const obs = lb.obs("team-a");
|
|
13
|
+
* for (const row of (await obs.fleet({ window: "1h" })).deployments) {
|
|
14
|
+
* console.log(row.id, row.log_lines, row.error_logs);
|
|
15
|
+
* }
|
|
16
|
+
* const page = await obs.logs("web", { level: "error", limit: 50 });
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Every method rejects with a `ConflictError` whose `code` is
|
|
20
|
+
* `plugin_not_installed` or `plugin_disabled` when the plugin is not usable in
|
|
21
|
+
* the namespace.
|
|
22
|
+
*/
|
|
23
|
+
import type { Heyctl } from "./client.js";
|
|
24
|
+
import type { ObsAlert, ObsDeployment, ObsFleet, ObsLogs } from "./types.js";
|
|
25
|
+
/** The plugin id of the telemetry plugin. */
|
|
26
|
+
export declare const OBS_PLUGIN = "obs";
|
|
27
|
+
/** Which log lines {@link ObsClient.logs} returns. Every filter is optional. */
|
|
28
|
+
export interface LogQuery {
|
|
29
|
+
/** `15m`, `1h`, `6h`, `1d`, `7d`, … */
|
|
30
|
+
window?: string;
|
|
31
|
+
/** Range start, epoch milliseconds. Overrides `window`'s start. */
|
|
32
|
+
from?: number;
|
|
33
|
+
/** Range end, epoch milliseconds. */
|
|
34
|
+
to?: number;
|
|
35
|
+
level?: string;
|
|
36
|
+
/** One VM (sandbox id) or upstream. */
|
|
37
|
+
backend?: string;
|
|
38
|
+
/** Case-insensitive substring of the message. Sent as `q`. */
|
|
39
|
+
search?: string;
|
|
40
|
+
limit?: number;
|
|
41
|
+
/** Page boundary, epoch milliseconds, inclusive — a page's `next_before_ms`. */
|
|
42
|
+
before?: number;
|
|
43
|
+
}
|
|
44
|
+
/** The body of {@link ObsClient.createAlert}. */
|
|
45
|
+
export interface NewAlert {
|
|
46
|
+
deployment: string;
|
|
47
|
+
threshold: number;
|
|
48
|
+
webhook_url: string;
|
|
49
|
+
/** `errors` when omitted — currently the only metric. */
|
|
50
|
+
metric?: string;
|
|
51
|
+
}
|
|
52
|
+
/** `?window=…&level=…` in the order the crate writes it, or `""`. */
|
|
53
|
+
export declare function logQueryString(q?: LogQuery): string;
|
|
54
|
+
export declare class ObsClient {
|
|
55
|
+
private readonly client;
|
|
56
|
+
readonly namespace: string;
|
|
57
|
+
constructor(client: Heyctl, namespace: string);
|
|
58
|
+
private path;
|
|
59
|
+
/** Every deployment with telemetry in the window, with series and log counts. */
|
|
60
|
+
fleet(opts?: {
|
|
61
|
+
window?: string;
|
|
62
|
+
signal?: AbortSignal;
|
|
63
|
+
}): Promise<ObsFleet>;
|
|
64
|
+
/**
|
|
65
|
+
* One deployment's metrics and log volume. A deployment outside this
|
|
66
|
+
* namespace is a `NotFoundError`, exactly as one that does not exist.
|
|
67
|
+
*/
|
|
68
|
+
deployment(id: string, opts?: {
|
|
69
|
+
window?: string;
|
|
70
|
+
signal?: AbortSignal;
|
|
71
|
+
}): Promise<ObsDeployment>;
|
|
72
|
+
/**
|
|
73
|
+
* One page of a deployment's logs, newest first. To page back, pass
|
|
74
|
+
* `next_before_ms` as `before` until it comes back `null`.
|
|
75
|
+
*/
|
|
76
|
+
logs(id: string, query?: LogQuery, signal?: AbortSignal): Promise<ObsLogs>;
|
|
77
|
+
/** The namespace's alert rules. */
|
|
78
|
+
alerts(signal?: AbortSignal): Promise<ObsAlert[]>;
|
|
79
|
+
/**
|
|
80
|
+
* POST `webhook_url` whenever `deployment` logs more than `threshold` errors
|
|
81
|
+
* in a minute. Needs `admin` in the namespace.
|
|
82
|
+
*/
|
|
83
|
+
createAlert(alert: NewAlert, signal?: AbortSignal): Promise<ObsAlert>;
|
|
84
|
+
/** Delete a rule. Deleting one that does not exist succeeds. */
|
|
85
|
+
deleteAlert(id: string, signal?: AbortSignal): Promise<void>;
|
|
86
|
+
}
|
package/dist/obs.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One namespace's telemetry, read through app-lb's `obs` plugin. Mirrors the
|
|
3
|
+
* `hws` crate's `ObsClient`.
|
|
4
|
+
*
|
|
5
|
+
* Installing `obs` into a namespace makes app-obs collect metrics and logs for
|
|
6
|
+
* every deployment in it. A namespace-scoped credential reads them through
|
|
7
|
+
* app-lb, which checks the caller reaches the namespace and talks to app-obs on
|
|
8
|
+
* its behalf — nothing here needs to know where app-obs lives.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* await lb.installPlugin("team-a", "obs");
|
|
12
|
+
* const obs = lb.obs("team-a");
|
|
13
|
+
* for (const row of (await obs.fleet({ window: "1h" })).deployments) {
|
|
14
|
+
* console.log(row.id, row.log_lines, row.error_logs);
|
|
15
|
+
* }
|
|
16
|
+
* const page = await obs.logs("web", { level: "error", limit: 50 });
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Every method rejects with a `ConflictError` whose `code` is
|
|
20
|
+
* `plugin_not_installed` or `plugin_disabled` when the plugin is not usable in
|
|
21
|
+
* the namespace.
|
|
22
|
+
*/
|
|
23
|
+
/** The plugin id of the telemetry plugin. */
|
|
24
|
+
export const OBS_PLUGIN = "obs";
|
|
25
|
+
/** `?window=…&level=…` in the order the crate writes it, or `""`. */
|
|
26
|
+
export function logQueryString(q = {}) {
|
|
27
|
+
const parts = [];
|
|
28
|
+
const text = (k, v) => {
|
|
29
|
+
if (v)
|
|
30
|
+
parts.push(`${k}=${encodeURIComponent(v)}`);
|
|
31
|
+
};
|
|
32
|
+
text("window", q.window);
|
|
33
|
+
text("level", q.level);
|
|
34
|
+
text("backend", q.backend);
|
|
35
|
+
text("q", q.search);
|
|
36
|
+
for (const [k, v] of [["from", q.from], ["to", q.to], ["before", q.before]]) {
|
|
37
|
+
if (v !== undefined)
|
|
38
|
+
parts.push(`${k}=${v}`);
|
|
39
|
+
}
|
|
40
|
+
if (q.limit !== undefined)
|
|
41
|
+
parts.push(`limit=${q.limit}`);
|
|
42
|
+
return parts.length ? `?${parts.join("&")}` : "";
|
|
43
|
+
}
|
|
44
|
+
const seg = encodeURIComponent;
|
|
45
|
+
export class ObsClient {
|
|
46
|
+
client;
|
|
47
|
+
namespace;
|
|
48
|
+
constructor(client, namespace) {
|
|
49
|
+
this.client = client;
|
|
50
|
+
this.namespace = namespace;
|
|
51
|
+
}
|
|
52
|
+
path(rest) {
|
|
53
|
+
return `/namespaces/${seg(this.namespace)}/plugins/${OBS_PLUGIN}/api/${rest}`;
|
|
54
|
+
}
|
|
55
|
+
/** Every deployment with telemetry in the window, with series and log counts. */
|
|
56
|
+
fleet(opts = {}) {
|
|
57
|
+
const q = opts.window ? `?window=${seg(opts.window)}` : "";
|
|
58
|
+
return this.client.request("GET", this.path(`fleet${q}`), {
|
|
59
|
+
kind: "namespace",
|
|
60
|
+
name: this.namespace,
|
|
61
|
+
signal: opts.signal,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* One deployment's metrics and log volume. A deployment outside this
|
|
66
|
+
* namespace is a `NotFoundError`, exactly as one that does not exist.
|
|
67
|
+
*/
|
|
68
|
+
deployment(id, opts = {}) {
|
|
69
|
+
const q = opts.window ? `?window=${seg(opts.window)}` : "";
|
|
70
|
+
return this.client.request("GET", this.path(`deployments/${seg(id)}${q}`), {
|
|
71
|
+
kind: "deployment",
|
|
72
|
+
name: id,
|
|
73
|
+
signal: opts.signal,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* One page of a deployment's logs, newest first. To page back, pass
|
|
78
|
+
* `next_before_ms` as `before` until it comes back `null`.
|
|
79
|
+
*/
|
|
80
|
+
logs(id, query = {}, signal) {
|
|
81
|
+
return this.client.request("GET", `${this.path(`deployments/${seg(id)}/logs`)}${logQueryString(query)}`, { kind: "deployment", name: id, signal });
|
|
82
|
+
}
|
|
83
|
+
/** The namespace's alert rules. */
|
|
84
|
+
alerts(signal) {
|
|
85
|
+
return this.client.request("GET", this.path("alerts"), { kind: "alert", signal });
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* POST `webhook_url` whenever `deployment` logs more than `threshold` errors
|
|
89
|
+
* in a minute. Needs `admin` in the namespace.
|
|
90
|
+
*/
|
|
91
|
+
createAlert(alert, signal) {
|
|
92
|
+
const body = {
|
|
93
|
+
deployment: alert.deployment,
|
|
94
|
+
threshold: alert.threshold,
|
|
95
|
+
webhook_url: alert.webhook_url,
|
|
96
|
+
};
|
|
97
|
+
if (alert.metric)
|
|
98
|
+
body.metric = alert.metric;
|
|
99
|
+
return this.client.request("POST", this.path("alerts"), {
|
|
100
|
+
body,
|
|
101
|
+
kind: "alert",
|
|
102
|
+
name: alert.deployment,
|
|
103
|
+
signal,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/** Delete a rule. Deleting one that does not exist succeeds. */
|
|
107
|
+
async deleteAlert(id, signal) {
|
|
108
|
+
await this.client.request("DELETE", this.path(`alerts/${seg(id)}`), {
|
|
109
|
+
kind: "alert",
|
|
110
|
+
name: id,
|
|
111
|
+
signal,
|
|
112
|
+
expect: "nothing",
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|