@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/client.js
ADDED
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
import { InvalidRequestError, HeyctlError, TransportError, fromResponse, } from "./errors.js";
|
|
2
|
+
import { ObsClient } from "./obs.js";
|
|
3
|
+
/** app-lb's own default `cold_start_timeout_secs`. */
|
|
4
|
+
export const ASSUMED_COLD_START_MS = 120_000;
|
|
5
|
+
/** Margin so the client is never the one that gives up first. */
|
|
6
|
+
const EXEC_MARGIN_MS = 15_000;
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
8
|
+
/** Percent-encode one path segment. */
|
|
9
|
+
/** `?namespace=…` for the secret routes, or nothing for the default namespace. */
|
|
10
|
+
function nsQuery(namespace) {
|
|
11
|
+
return namespace ? `?namespace=${encodeURIComponent(namespace)}` : "";
|
|
12
|
+
}
|
|
13
|
+
function seg(s) {
|
|
14
|
+
return encodeURIComponent(s);
|
|
15
|
+
}
|
|
16
|
+
/** Accept `host:port` as well as a URL, and drop a trailing slash. */
|
|
17
|
+
export function normalizeServer(server) {
|
|
18
|
+
const s = server.trim().replace(/\/+$/, "");
|
|
19
|
+
return /^https?:\/\//.test(s) ? s : `http://${s}`;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A client for one app-lb.
|
|
23
|
+
*
|
|
24
|
+
* ```ts
|
|
25
|
+
* const lb = new Heyctl({ server: "127.0.0.1:9090", token: process.env.APP_LB_TOKEN });
|
|
26
|
+
* const { stdout } = await lb.exec("sb-7f3a9c", "uname -a");
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export class Heyctl {
|
|
30
|
+
server;
|
|
31
|
+
/** @internal */ auth;
|
|
32
|
+
timeoutMs;
|
|
33
|
+
doFetch;
|
|
34
|
+
constructor(opts) {
|
|
35
|
+
this.server = normalizeServer(opts.server);
|
|
36
|
+
this.auth =
|
|
37
|
+
opts.auth ??
|
|
38
|
+
(opts.token
|
|
39
|
+
? { kind: "token", token: opts.token }
|
|
40
|
+
: opts.user && opts.password
|
|
41
|
+
? { kind: "basic", user: opts.user, password: opts.password }
|
|
42
|
+
: { kind: "none" });
|
|
43
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
44
|
+
this.doFetch = opts.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
45
|
+
if (!this.doFetch) {
|
|
46
|
+
throw new InvalidRequestError("no global fetch — pass one via `fetch`, or run on Node 18+, Bun or Deno");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The exact `Authorization` header, or undefined.
|
|
51
|
+
*
|
|
52
|
+
* @internal app-lb compares the Basic header **byte for byte** against a
|
|
53
|
+
* string it precomputed at startup — it never base64-decodes it. So this must
|
|
54
|
+
* be standard base64 with padding, one space after `Basic`, that
|
|
55
|
+
* capitalisation. A re-encoded-but-equivalent header is rejected.
|
|
56
|
+
*/
|
|
57
|
+
authHeader() {
|
|
58
|
+
switch (this.auth.kind) {
|
|
59
|
+
case "none":
|
|
60
|
+
return undefined;
|
|
61
|
+
case "token":
|
|
62
|
+
return `Bearer ${this.auth.token}`;
|
|
63
|
+
case "basic":
|
|
64
|
+
return `Basic ${base64(`${this.auth.user}:${this.auth.password}`)}`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** @internal */
|
|
68
|
+
credential() {
|
|
69
|
+
return this.auth.kind;
|
|
70
|
+
}
|
|
71
|
+
async send(method, path, opts = {}) {
|
|
72
|
+
const headers = {};
|
|
73
|
+
const auth = this.authHeader();
|
|
74
|
+
if (auth)
|
|
75
|
+
headers.authorization = auth;
|
|
76
|
+
if (opts.body !== undefined)
|
|
77
|
+
headers["content-type"] = "application/json";
|
|
78
|
+
// A caller's own signal *and* our deadline: whichever fires first wins.
|
|
79
|
+
const timer = new AbortController();
|
|
80
|
+
const ms = opts.timeoutMs ?? this.timeoutMs;
|
|
81
|
+
const timeout = setTimeout(() => timer.abort(), ms);
|
|
82
|
+
const onAbort = () => timer.abort();
|
|
83
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
84
|
+
try {
|
|
85
|
+
const res = await this.doFetch(`${this.server}${path}`, {
|
|
86
|
+
method,
|
|
87
|
+
headers,
|
|
88
|
+
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
|
89
|
+
signal: timer.signal,
|
|
90
|
+
});
|
|
91
|
+
return { status: res.status, text: await res.text() };
|
|
92
|
+
}
|
|
93
|
+
catch (cause) {
|
|
94
|
+
if (opts.signal?.aborted)
|
|
95
|
+
throw cause;
|
|
96
|
+
throw new TransportError(`could not reach ${this.server}${path}`, cause);
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
clearTimeout(timeout);
|
|
100
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** @internal Raw JSON for a route, with failures raised as typed errors. */
|
|
104
|
+
async request(method, path, opts = {}) {
|
|
105
|
+
const { status, text } = await this.send(method, path, opts);
|
|
106
|
+
if (status < 200 || status >= 300) {
|
|
107
|
+
throw fromResponse(status, text, opts.kind ?? "resource", opts.name ?? "", this.credential());
|
|
108
|
+
}
|
|
109
|
+
if (opts.expect === "nothing" || !text.trim())
|
|
110
|
+
return undefined;
|
|
111
|
+
try {
|
|
112
|
+
return JSON.parse(text);
|
|
113
|
+
}
|
|
114
|
+
catch (cause) {
|
|
115
|
+
throw new HeyctlError(`could not read the response from ${path}: ${String(cause)}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// -- health and discovery ------------------------------------------------
|
|
119
|
+
/** Never gated, so this proves reachability without a credential. */
|
|
120
|
+
async healthz() {
|
|
121
|
+
await this.request("GET", "/healthz", { kind: "server", expect: "nothing" });
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Which tiers this server gates, probed **anonymously** — the question is
|
|
125
|
+
* what an unauthenticated caller is refused, which is what says whether the
|
|
126
|
+
* gate is on at all.
|
|
127
|
+
*/
|
|
128
|
+
async gates() {
|
|
129
|
+
const anon = new Heyctl({ server: this.server, fetch: this.doFetch });
|
|
130
|
+
const refused = async (path) => (await anon.send("GET", path)).status === 401;
|
|
131
|
+
return { view: await refused("/metrics"), crud: await refused("/deployments") };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* What the server makes of this client's credential: its tier, whether it is
|
|
135
|
+
* confined, and to which namespace. Needs no tier, so it answers even for a
|
|
136
|
+
* token every other route refuses.
|
|
137
|
+
*/
|
|
138
|
+
whoami(signal) {
|
|
139
|
+
return this.request("GET", "/whoami", { kind: "server", signal });
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The status a `GET` answers with, treating a 4xx as an answer rather than an
|
|
143
|
+
* error, plus whatever the server said about it (`error — detail`).
|
|
144
|
+
*/
|
|
145
|
+
async probe(path, signal) {
|
|
146
|
+
const { status, text } = await this.send("GET", path, { signal });
|
|
147
|
+
let detail;
|
|
148
|
+
try {
|
|
149
|
+
const v = JSON.parse(text);
|
|
150
|
+
const field = (k) => typeof v?.[k] === "string" && v[k].trim() ? v[k].trim() : undefined;
|
|
151
|
+
const e = field("error");
|
|
152
|
+
const d = field("detail");
|
|
153
|
+
detail = e && d ? `${e} — ${d}` : (e ?? d);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Not JSON: no detail to report.
|
|
157
|
+
}
|
|
158
|
+
return { status, detail };
|
|
159
|
+
}
|
|
160
|
+
// -- deployments ---------------------------------------------------------
|
|
161
|
+
deployments(signal) {
|
|
162
|
+
return this.request("GET", "/deployments", { kind: "deployment", signal });
|
|
163
|
+
}
|
|
164
|
+
deployment(id, signal) {
|
|
165
|
+
return this.request("GET", `/deployments/${seg(id)}`, {
|
|
166
|
+
kind: "deployment",
|
|
167
|
+
name: id,
|
|
168
|
+
signal,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/** Whether a deployment exists, without treating absence as an error. */
|
|
172
|
+
async deploymentExists(id) {
|
|
173
|
+
try {
|
|
174
|
+
await this.deployment(id);
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
if (e instanceof HeyctlError && e.status === 404)
|
|
179
|
+
return false;
|
|
180
|
+
throw e;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Register or replace a deployment.
|
|
185
|
+
*
|
|
186
|
+
* app-lb answers `201` even when this replaced an existing one, so the status
|
|
187
|
+
* does not distinguish create from update. Certificate issuance is
|
|
188
|
+
* asynchronous: success here does not mean a certificate exists yet.
|
|
189
|
+
*/
|
|
190
|
+
createDeployment(spec, signal) {
|
|
191
|
+
return this.request("POST", "/deployments", {
|
|
192
|
+
body: spec,
|
|
193
|
+
kind: "deployment",
|
|
194
|
+
name: spec.id,
|
|
195
|
+
signal,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Replace a whole spec.
|
|
200
|
+
*
|
|
201
|
+
* `PUT` replaces everything, so read with {@link deployment}, edit
|
|
202
|
+
* `status.spec`, and pass that back — anything dropped in between is
|
|
203
|
+
* genuinely dropped.
|
|
204
|
+
*/
|
|
205
|
+
replaceDeployment(id, spec, signal) {
|
|
206
|
+
return this.request("PUT", `/deployments/${seg(id)}`, {
|
|
207
|
+
body: spec,
|
|
208
|
+
kind: "deployment",
|
|
209
|
+
name: id,
|
|
210
|
+
signal,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/** A shallow merge onto the current scaling policy. Managed pools only. */
|
|
214
|
+
patchScaling(id, patch, signal) {
|
|
215
|
+
return this.request("PATCH", `/deployments/${seg(id)}/scaling`, {
|
|
216
|
+
body: patch,
|
|
217
|
+
kind: "deployment",
|
|
218
|
+
name: id,
|
|
219
|
+
signal,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
async deleteDeployment(id, signal) {
|
|
223
|
+
await this.request("DELETE", `/deployments/${seg(id)}`, {
|
|
224
|
+
kind: "deployment",
|
|
225
|
+
name: id,
|
|
226
|
+
signal,
|
|
227
|
+
expect: "nothing",
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
/** Evict one VM. `force` kills immediately; otherwise it drains. */
|
|
231
|
+
evictVm(id, sandboxId, force = false, signal) {
|
|
232
|
+
// app-lb parses query booleans with Rust's `str::parse::<bool>()`, which
|
|
233
|
+
// takes only `true`/`false` — `?force=1` is a 400, not a truthy value.
|
|
234
|
+
return this.request("DELETE", `/deployments/${seg(id)}/vms/${seg(sandboxId)}?force=${force ? "true" : "false"}`, { kind: "vm", name: sandboxId, signal });
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Stop new requests to one static upstream. Existing requests finish; watch
|
|
238
|
+
* `in_flight` on the result to observe the drain.
|
|
239
|
+
*/
|
|
240
|
+
cordonUpstream(id, upstream, opts = {}) {
|
|
241
|
+
const body = { force: opts.force ?? false };
|
|
242
|
+
if (opts.reason !== undefined)
|
|
243
|
+
body.reason = opts.reason;
|
|
244
|
+
return this.request("PUT", `/deployments/${seg(id)}/upstreams/${seg(upstream)}/drain`, {
|
|
245
|
+
body,
|
|
246
|
+
kind: "upstream",
|
|
247
|
+
name: upstream,
|
|
248
|
+
signal: opts.signal,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
/** Remove an administrative drain. An unhealthy upstream stays excluded until it recovers. */
|
|
252
|
+
uncordonUpstream(id, upstream, signal) {
|
|
253
|
+
return this.request("DELETE", `/deployments/${seg(id)}/upstreams/${seg(upstream)}/drain`, {
|
|
254
|
+
kind: "upstream",
|
|
255
|
+
name: upstream,
|
|
256
|
+
signal,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Replace a managed deployment's spec by rolling a fresh pool beside the old
|
|
261
|
+
* one, verifying it, and only then draining the old one.
|
|
262
|
+
*
|
|
263
|
+
* `expectedRevision` is the deployment's `rollout_revision` as last read; the
|
|
264
|
+
* rollout is refused (409) if anything changed it since. `operationId` makes
|
|
265
|
+
* the call idempotent — retry a lost reply with the same id. Answers `202`;
|
|
266
|
+
* poll {@link rollout} until `status` is no longer `running`.
|
|
267
|
+
*/
|
|
268
|
+
startRollout(id, req, signal) {
|
|
269
|
+
return this.request("POST", `/deployments/${seg(id)}/rollouts`, {
|
|
270
|
+
body: {
|
|
271
|
+
operation_id: req.operationId,
|
|
272
|
+
expected_revision: req.expectedRevision,
|
|
273
|
+
spec: req.spec,
|
|
274
|
+
},
|
|
275
|
+
kind: "deployment",
|
|
276
|
+
name: id,
|
|
277
|
+
signal,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
rollout(id, operationId, signal) {
|
|
281
|
+
return this.request("GET", `/deployments/${seg(id)}/rollouts/${seg(operationId)}`, {
|
|
282
|
+
kind: "rollout",
|
|
283
|
+
name: operationId,
|
|
284
|
+
signal,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* What this gateway publishes for the deployment to discovery. `staged` asks
|
|
289
|
+
* about the spec a pending change would publish instead of the live one.
|
|
290
|
+
*/
|
|
291
|
+
discoveryStatus(id, opts = {}) {
|
|
292
|
+
const q = opts.staged ? "?staged=true" : "";
|
|
293
|
+
return this.request("GET", `/deployments/${seg(id)}/discovery-status${q}`, {
|
|
294
|
+
kind: "deployment",
|
|
295
|
+
name: id,
|
|
296
|
+
signal: opts.signal,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
// -- running things inside a VM ------------------------------------------
|
|
300
|
+
/**
|
|
301
|
+
* Run a command in the deployment's VM and wait for it to finish.
|
|
302
|
+
*
|
|
303
|
+
* Two things to know:
|
|
304
|
+
*
|
|
305
|
+
* - **A non-zero exit resolves.** The command ran; it failed. Only an
|
|
306
|
+
* inability to run it rejects. Check `exitCode`.
|
|
307
|
+
* - **The timeout does not kill anything.** `timeoutSecs` bounds app-lb's own
|
|
308
|
+
* call to the daemon; when it expires you get an {@link UpstreamError} and
|
|
309
|
+
* the command **keeps running in the guest**. The daemon offers no
|
|
310
|
+
* streaming and no cancellation, so output is buffered until it exits.
|
|
311
|
+
*/
|
|
312
|
+
exec(id, command, opts = {}) {
|
|
313
|
+
if (!command.trim()) {
|
|
314
|
+
throw new InvalidRequestError("a command to exec must not be blank");
|
|
315
|
+
}
|
|
316
|
+
const wake = opts.wake ?? true;
|
|
317
|
+
const body = { command, wake };
|
|
318
|
+
if (opts.cwd)
|
|
319
|
+
body.cwd = opts.cwd;
|
|
320
|
+
if (opts.env)
|
|
321
|
+
body.env = opts.env;
|
|
322
|
+
if (opts.timeoutSecs !== undefined)
|
|
323
|
+
body.timeout_secs = opts.timeoutSecs;
|
|
324
|
+
// Must outlast the server's worst case, which is the command timeout plus a
|
|
325
|
+
// cold start when waking — otherwise the client abandons a request app-lb is
|
|
326
|
+
// still serving and the caller gets a transport error instead of an answer.
|
|
327
|
+
// A deployment's real `cold_start_timeout_secs` is not knowable without
|
|
328
|
+
// another round trip, so this assumes app-lb's default.
|
|
329
|
+
const commandMs = Math.min(Math.max(opts.timeoutSecs ?? 60, 1), 3600) * 1000;
|
|
330
|
+
const patienceMs = opts.patienceMs ?? commandMs + (wake ? ASSUMED_COLD_START_MS : 0) + EXEC_MARGIN_MS;
|
|
331
|
+
return this.request("POST", `/deployments/${seg(id)}/exec`, {
|
|
332
|
+
body,
|
|
333
|
+
kind: "deployment",
|
|
334
|
+
name: id,
|
|
335
|
+
timeoutMs: patienceMs,
|
|
336
|
+
signal: opts.signal,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
// -- workflows ------------------------------------------------------------
|
|
340
|
+
/** Every CI workflow object. */
|
|
341
|
+
async workflows(signal) {
|
|
342
|
+
const list = await this.request("GET", "/workflows", { kind: "workflow", signal });
|
|
343
|
+
return list?.workflows ?? [];
|
|
344
|
+
}
|
|
345
|
+
workflow(id, signal) {
|
|
346
|
+
return this.request("GET", `/workflows/${seg(id)}`, { kind: "workflow", name: id, signal });
|
|
347
|
+
}
|
|
348
|
+
/** Create or replace. Sends the object as given, unknown fields included. */
|
|
349
|
+
createWorkflow(spec, signal) {
|
|
350
|
+
return this.request("POST", "/workflows", {
|
|
351
|
+
body: spec,
|
|
352
|
+
kind: "workflow",
|
|
353
|
+
name: String(spec.id ?? ""),
|
|
354
|
+
signal,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
replaceWorkflow(id, spec, signal) {
|
|
358
|
+
return this.request("PUT", `/workflows/${seg(id)}`, { body: spec, kind: "workflow", name: id, signal });
|
|
359
|
+
}
|
|
360
|
+
async deleteWorkflow(id, signal) {
|
|
361
|
+
await this.request("DELETE", `/workflows/${seg(id)}`, {
|
|
362
|
+
kind: "workflow",
|
|
363
|
+
name: id,
|
|
364
|
+
signal,
|
|
365
|
+
expect: "nothing",
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
// -- auth providers -------------------------------------------------------
|
|
369
|
+
/**
|
|
370
|
+
* The declared auth providers this credential can see, or those of one
|
|
371
|
+
* namespace. Narrows itself server-side rather than refusing.
|
|
372
|
+
*/
|
|
373
|
+
authProviders(namespace, signal) {
|
|
374
|
+
return this.request("GET", `/auth-providers${nsQuery(namespace)}`, { kind: "auth provider", signal });
|
|
375
|
+
}
|
|
376
|
+
/** One provider. Unique within its namespace, so both halves are required. */
|
|
377
|
+
authProvider(namespace, name, signal) {
|
|
378
|
+
return this.request("GET", `/auth-providers/${seg(namespace)}/${seg(name)}`, {
|
|
379
|
+
kind: "auth provider",
|
|
380
|
+
name,
|
|
381
|
+
signal,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
async authProviderExists(namespace, name) {
|
|
385
|
+
try {
|
|
386
|
+
await this.authProvider(namespace, name);
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
catch (e) {
|
|
390
|
+
if (e instanceof HeyctlError && e.status === 404)
|
|
391
|
+
return false;
|
|
392
|
+
throw e;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Declare or replace a provider (upserts, keeping `created_at`). The body may
|
|
397
|
+
* carry request-only `preset`/`secret` conveniences the server expands.
|
|
398
|
+
*/
|
|
399
|
+
createAuthProvider(spec, signal) {
|
|
400
|
+
return this.request("POST", "/auth-providers", {
|
|
401
|
+
body: spec,
|
|
402
|
+
kind: "auth provider",
|
|
403
|
+
name: String(spec.name ?? ""),
|
|
404
|
+
signal,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
/** Refused (409) while a deployment's gate still inherits it. */
|
|
408
|
+
async deleteAuthProvider(namespace, name, signal) {
|
|
409
|
+
await this.request("DELETE", `/auth-providers/${seg(namespace)}/${seg(name)}`, {
|
|
410
|
+
kind: "auth provider",
|
|
411
|
+
name,
|
|
412
|
+
signal,
|
|
413
|
+
expect: "nothing",
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
// -- namespaces -----------------------------------------------------------
|
|
417
|
+
/** The namespaces this credential can see, narrowed server-side. */
|
|
418
|
+
namespaces(signal) {
|
|
419
|
+
return this.request("GET", "/namespaces", { kind: "namespace", signal });
|
|
420
|
+
}
|
|
421
|
+
/** Declare a namespace. Idempotent; fleet scope and `admin` server-side. */
|
|
422
|
+
createNamespace(spec, signal) {
|
|
423
|
+
return this.request("POST", "/namespaces", { body: spec, kind: "namespace", name: spec.name, signal });
|
|
424
|
+
}
|
|
425
|
+
/** Undeclare a namespace. Refused while deployments are still in it. */
|
|
426
|
+
async deleteNamespace(name, signal) {
|
|
427
|
+
await this.request("DELETE", `/namespaces/${seg(name)}`, {
|
|
428
|
+
kind: "namespace",
|
|
429
|
+
name,
|
|
430
|
+
signal,
|
|
431
|
+
expect: "nothing",
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
// -- namespace plugins and telemetry --------------------------------------
|
|
435
|
+
/**
|
|
436
|
+
* The plugins that install per namespace, and whether each is switched on for
|
|
437
|
+
* the fleet (`enabled`) and installed here (`installed`).
|
|
438
|
+
*/
|
|
439
|
+
namespacePlugins(namespace, signal) {
|
|
440
|
+
return this.request("GET", `/namespaces/${seg(namespace)}/plugins`, {
|
|
441
|
+
kind: "namespace",
|
|
442
|
+
name: namespace,
|
|
443
|
+
signal,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Install a plugin into a namespace, or replace its per-namespace config.
|
|
448
|
+
* Idempotent. Needs `admin` over the whole namespace; a `ConflictError` with
|
|
449
|
+
* `code: "plugin_disabled"` while the operator has it off for the fleet.
|
|
450
|
+
*/
|
|
451
|
+
installPlugin(namespace, id, config, signal) {
|
|
452
|
+
const body = {};
|
|
453
|
+
if (config !== undefined)
|
|
454
|
+
body.config = config;
|
|
455
|
+
return this.request("PUT", `/namespaces/${seg(namespace)}/plugins/${seg(id)}`, {
|
|
456
|
+
body,
|
|
457
|
+
kind: "plugin",
|
|
458
|
+
name: id,
|
|
459
|
+
signal,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
/** Uninstall. For `obs` this stops collection; what was collected ages out. */
|
|
463
|
+
uninstallPlugin(namespace, id, signal) {
|
|
464
|
+
return this.request("DELETE", `/namespaces/${seg(namespace)}/plugins/${seg(id)}`, {
|
|
465
|
+
kind: "plugin",
|
|
466
|
+
name: id,
|
|
467
|
+
signal,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
/** Every namespace a plugin is installed in. Fleet scope only. */
|
|
471
|
+
pluginInstalls(id, signal) {
|
|
472
|
+
return this.request("GET", `/api/plugins/${seg(id)}/installs`, { kind: "plugin", name: id, signal });
|
|
473
|
+
}
|
|
474
|
+
/** One namespace's telemetry through the `obs` plugin. Makes no request. */
|
|
475
|
+
obs(namespace) {
|
|
476
|
+
return new ObsClient(this, namespace);
|
|
477
|
+
}
|
|
478
|
+
// -- secrets --------------------------------------------------------------
|
|
479
|
+
/**
|
|
480
|
+
* The secrets the credential may see: one namespace when `namespace` is
|
|
481
|
+
* given, else every namespace within its reach. Secrets are walled by
|
|
482
|
+
* namespace exactly as deployments are.
|
|
483
|
+
*/
|
|
484
|
+
secrets(namespace, signal) {
|
|
485
|
+
const q = namespace ? `?namespace=${encodeURIComponent(namespace)}` : "";
|
|
486
|
+
return this.request("GET", `/secrets${q}`, { kind: "secret", signal });
|
|
487
|
+
}
|
|
488
|
+
secret(id, namespace, signal) {
|
|
489
|
+
return this.request("GET", `/secrets/${seg(id)}${nsQuery(namespace)}`, {
|
|
490
|
+
kind: "secret",
|
|
491
|
+
name: id,
|
|
492
|
+
signal,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Values enter here and are never readable again. `namespace` defaults to
|
|
497
|
+
* `default`; a confined credential must reach it as an admin.
|
|
498
|
+
*/
|
|
499
|
+
putSecret(spec, signal) {
|
|
500
|
+
return this.request("POST", "/secrets", {
|
|
501
|
+
body: spec,
|
|
502
|
+
kind: "secret",
|
|
503
|
+
name: spec.id,
|
|
504
|
+
signal,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
/** `null` for a value deletes that key; absent keys are left alone. */
|
|
508
|
+
patchSecret(id, patch, namespace, signal) {
|
|
509
|
+
return this.request("PATCH", `/secrets/${seg(id)}${nsQuery(namespace)}`, {
|
|
510
|
+
body: patch,
|
|
511
|
+
kind: "secret",
|
|
512
|
+
name: id,
|
|
513
|
+
signal,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
async deleteSecret(id, force = false, namespace, signal) {
|
|
517
|
+
const q = nsQuery(namespace);
|
|
518
|
+
await this.request("DELETE", `/secrets/${seg(id)}${q ? `${q}&` : "?"}force=${force ? "true" : "false"}`, { kind: "secret", name: id, signal, expect: "nothing" });
|
|
519
|
+
}
|
|
520
|
+
// -- ingress --------------------------------------------------------------
|
|
521
|
+
/** Where DNS should point a deployment's hostname — this LB's public addresses. */
|
|
522
|
+
ingress(signal) {
|
|
523
|
+
return this.request("GET", "/ingress", { kind: "ingress", signal });
|
|
524
|
+
}
|
|
525
|
+
// -- app-tokens -----------------------------------------------------------
|
|
526
|
+
/**
|
|
527
|
+
* Mint a token.
|
|
528
|
+
*
|
|
529
|
+
* **The secret in the reply is shown once** — app-lb keeps only its hash and
|
|
530
|
+
* no endpoint reads it back. Store it now or mint another.
|
|
531
|
+
*
|
|
532
|
+
* Both scope fields default to nothing: a token minted with no scope can do
|
|
533
|
+
* nothing, which is a harmless mistake. The other default would turn a
|
|
534
|
+
* forgotten field into fleet-wide credentials.
|
|
535
|
+
*/
|
|
536
|
+
mintToken(req, signal) {
|
|
537
|
+
if (!req.name.trim()) {
|
|
538
|
+
throw new InvalidRequestError("a token needs a name — it is how you know what to revoke");
|
|
539
|
+
}
|
|
540
|
+
const body = {
|
|
541
|
+
name: req.name,
|
|
542
|
+
admin: req.admin ?? "none",
|
|
543
|
+
deployments: req.deployments ?? [],
|
|
544
|
+
};
|
|
545
|
+
if (req.namespace !== undefined)
|
|
546
|
+
body.namespace = req.namespace;
|
|
547
|
+
if (req.expiresInSecs !== undefined)
|
|
548
|
+
body.expires_in_secs = req.expiresInSecs;
|
|
549
|
+
if (req.allServers)
|
|
550
|
+
body.fleet = true;
|
|
551
|
+
return this.request("POST", "/tokens", {
|
|
552
|
+
body,
|
|
553
|
+
kind: "token",
|
|
554
|
+
name: req.name,
|
|
555
|
+
signal,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
// -- plugins ---------------------------------------------------------------
|
|
559
|
+
plugins(signal) {
|
|
560
|
+
return this.request("GET", "/api/plugins", { kind: "plugin", signal });
|
|
561
|
+
}
|
|
562
|
+
plugin(id, signal) {
|
|
563
|
+
return this.request("GET", `/api/plugins/${seg(id)}`, { kind: "plugin", name: id, signal });
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Write a plugin's record. Omit `config` to keep the stored one. Resolves
|
|
567
|
+
* when the record is saved even if applying it failed — check `last_error`.
|
|
568
|
+
*/
|
|
569
|
+
setPlugin(id, enabled, config, signal) {
|
|
570
|
+
const body = { enabled };
|
|
571
|
+
if (config !== undefined)
|
|
572
|
+
body.config = config;
|
|
573
|
+
return this.request("PUT", `/api/plugins/${seg(id)}`, { body, kind: "plugin", name: id, signal });
|
|
574
|
+
}
|
|
575
|
+
tokens(signal) {
|
|
576
|
+
return this.request("GET", "/tokens", { kind: "token", signal });
|
|
577
|
+
}
|
|
578
|
+
token(id, signal) {
|
|
579
|
+
return this.request("GET", `/tokens/${seg(id)}`, { kind: "token", name: id, signal });
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Re-scope a token **without changing its secret**, so narrowing a credential
|
|
583
|
+
* does not mean redistributing it. Pass `expires_at: null` to clear an expiry.
|
|
584
|
+
*/
|
|
585
|
+
patchToken(id, patch, signal) {
|
|
586
|
+
return this.request("PATCH", `/tokens/${seg(id)}`, {
|
|
587
|
+
body: patch,
|
|
588
|
+
kind: "token",
|
|
589
|
+
name: id,
|
|
590
|
+
signal,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
/** Effective on the next request — verification is a lookup, not a signature. */
|
|
594
|
+
async revokeToken(id, signal) {
|
|
595
|
+
await this.request("DELETE", `/tokens/${seg(id)}`, {
|
|
596
|
+
kind: "token",
|
|
597
|
+
name: id,
|
|
598
|
+
signal,
|
|
599
|
+
expect: "nothing",
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
// -- the event feed --------------------------------------------------------
|
|
603
|
+
/** The namespaces that have feed events, narrowed to this credential. */
|
|
604
|
+
feeds(signal) {
|
|
605
|
+
return this.request("GET", "/feeds", { signal });
|
|
606
|
+
}
|
|
607
|
+
/** A namespace's feed events as structured data, newest first. */
|
|
608
|
+
feedEvents(namespace, signal) {
|
|
609
|
+
return this.request("GET", `/feeds/${seg(namespace)}?format=json`, {
|
|
610
|
+
kind: "feed",
|
|
611
|
+
name: namespace,
|
|
612
|
+
signal,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
/** A namespace's feed as the RSS document a reader would fetch, verbatim. */
|
|
616
|
+
async feedRss(namespace, signal) {
|
|
617
|
+
const { status, text } = await this.send("GET", `/feeds/${seg(namespace)}`, { signal });
|
|
618
|
+
if (status < 200 || status >= 300) {
|
|
619
|
+
throw fromResponse(status, text, "feed", namespace, this.credential());
|
|
620
|
+
}
|
|
621
|
+
return text;
|
|
622
|
+
}
|
|
623
|
+
// -- jobs -----------------------------------------------------------------
|
|
624
|
+
startBuild(id, ref, signal) {
|
|
625
|
+
// Always a body with a content-type, even when empty: app-lb takes these as
|
|
626
|
+
// an optional JSON body, and axum silently swallows a malformed or untyped
|
|
627
|
+
// one into the default rather than rejecting it.
|
|
628
|
+
return this.request("POST", `/deployments/${seg(id)}/build`, {
|
|
629
|
+
body: ref ? { ref } : {},
|
|
630
|
+
kind: "deployment",
|
|
631
|
+
name: id,
|
|
632
|
+
signal,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
startPull(id, ref, force = false, signal) {
|
|
636
|
+
const body = { force };
|
|
637
|
+
if (ref)
|
|
638
|
+
body.ref = ref;
|
|
639
|
+
return this.request("POST", `/deployments/${seg(id)}/pull`, {
|
|
640
|
+
body,
|
|
641
|
+
kind: "deployment",
|
|
642
|
+
name: id,
|
|
643
|
+
signal,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Unpack a managed deployment's guest mounts and roll the pool onto them.
|
|
648
|
+
* One job covers every mount, so there is no `ref` override.
|
|
649
|
+
*/
|
|
650
|
+
startMountPull(id, force = false, signal) {
|
|
651
|
+
return this.request("POST", `/deployments/${seg(id)}/mounts/pull`, {
|
|
652
|
+
body: { force },
|
|
653
|
+
kind: "deployment",
|
|
654
|
+
name: id,
|
|
655
|
+
signal,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
startUpdate(id, signal) {
|
|
659
|
+
return this.request("POST", `/deployments/${seg(id)}/update`, {
|
|
660
|
+
body: {},
|
|
661
|
+
kind: "deployment",
|
|
662
|
+
name: id,
|
|
663
|
+
signal,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
jobs(signal) {
|
|
667
|
+
return this.request("GET", "/jobs", { kind: "job", signal });
|
|
668
|
+
}
|
|
669
|
+
deploymentJobs(id, signal) {
|
|
670
|
+
return this.request("GET", `/deployments/${seg(id)}/jobs`, {
|
|
671
|
+
kind: "deployment",
|
|
672
|
+
name: id,
|
|
673
|
+
signal,
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
/** A 404 can mean the job aged out of the bounded history, not that it never existed. */
|
|
677
|
+
job(jobId, signal) {
|
|
678
|
+
return this.request("GET", `/jobs/${seg(jobId)}`, { kind: "job", name: jobId, signal });
|
|
679
|
+
}
|
|
680
|
+
// -- observability --------------------------------------------------------
|
|
681
|
+
/**
|
|
682
|
+
* The unfiltered response is megabytes at fleet scale, so prefer `summary`
|
|
683
|
+
* and paging. `fleet`, `global` and `host` always describe everything the
|
|
684
|
+
* credential can see, never the page.
|
|
685
|
+
*/
|
|
686
|
+
metrics(query = {}, signal) {
|
|
687
|
+
const parts = [];
|
|
688
|
+
if (query.deployment)
|
|
689
|
+
parts.push(`deployment=${seg(query.deployment)}`);
|
|
690
|
+
if (query.prefix)
|
|
691
|
+
parts.push(`prefix=${seg(query.prefix)}`);
|
|
692
|
+
// Only `true`/`false` parse server-side.
|
|
693
|
+
if (query.summary)
|
|
694
|
+
parts.push("summary=true");
|
|
695
|
+
if (query.limit !== undefined)
|
|
696
|
+
parts.push(`limit=${query.limit}`);
|
|
697
|
+
if (query.offset)
|
|
698
|
+
parts.push(`offset=${query.offset}`);
|
|
699
|
+
const qs = parts.length ? `?${parts.join("&")}` : "";
|
|
700
|
+
return this.request("GET", `/metrics${qs}`, { kind: "server", signal });
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Attach an interactive shell.
|
|
704
|
+
*
|
|
705
|
+
* Everything that can fail with a status does so *before* the upgrade, so a
|
|
706
|
+
* socket that opens is a shell that attached: a 404, 403, 409 or 503 arrives
|
|
707
|
+
* as a typed error, never as an unexplained close.
|
|
708
|
+
*/
|
|
709
|
+
async shell(id, opts = {}) {
|
|
710
|
+
// Imported here rather than at the top so that a runtime with no WebSocket
|
|
711
|
+
// can still use every HTTP route in this file.
|
|
712
|
+
const { Shell: S } = await import("./shell.js");
|
|
713
|
+
return S.open(this, id, opts);
|
|
714
|
+
}
|
|
715
|
+
/** Poll a job until it finishes. See {@link waitForJob}. */
|
|
716
|
+
async waitForJob(jobId, opts) {
|
|
717
|
+
const { waitForJob } = await import("./wait.js");
|
|
718
|
+
return waitForJob(this, jobId, opts);
|
|
719
|
+
}
|
|
720
|
+
/** Poll a deployment until its pool has converged. See {@link waitForReady}. */
|
|
721
|
+
async waitForReady(id, opts) {
|
|
722
|
+
const { waitForReady } = await import("./wait.js");
|
|
723
|
+
return waitForReady(this, id, opts);
|
|
724
|
+
}
|
|
725
|
+
certs(signal) {
|
|
726
|
+
return this.request("GET", "/certs", { kind: "certificate", signal });
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* The host's disk inventory. Check `complete` before acting on it: an
|
|
730
|
+
* incomplete inventory classified nothing.
|
|
731
|
+
*/
|
|
732
|
+
disks(signal) {
|
|
733
|
+
return this.request("GET", "/disks", { kind: "disk", signal });
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Every deployment id, a page at a time.
|
|
737
|
+
*
|
|
738
|
+
* Walks `/metrics` rather than `GET /deployments`, which is unpaged and
|
|
739
|
+
* returns whole specs — megabytes at fleet scale.
|
|
740
|
+
*/
|
|
741
|
+
async deploymentIds(pageSize = 200, signal) {
|
|
742
|
+
const out = [];
|
|
743
|
+
let offset = 0;
|
|
744
|
+
for (;;) {
|
|
745
|
+
const page = await this.metrics({ summary: true, limit: pageSize, offset }, signal);
|
|
746
|
+
out.push(...page.deployments.map((d) => d.id));
|
|
747
|
+
offset += pageSize;
|
|
748
|
+
if (offset >= page.matched || page.deployments.length === 0)
|
|
749
|
+
return out;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
/** base64 that works in Node, Bun, Deno and browsers. */
|
|
754
|
+
function base64(s) {
|
|
755
|
+
if (typeof globalThis.btoa === "function") {
|
|
756
|
+
// btoa is latin1-only; encode first so non-ASCII passwords survive.
|
|
757
|
+
return globalThis.btoa(String.fromCharCode(...new TextEncoder().encode(s)));
|
|
758
|
+
}
|
|
759
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
760
|
+
return globalThis.Buffer.from(s, "utf8").toString("base64");
|
|
761
|
+
}
|