@kvbox/server 0.5.5 → 0.5.6
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/dist/kvbox-server.js +415 -267
- package/dist/kvbox-server.js.map +1 -1
- package/package.json +1 -1
package/dist/kvbox-server.js
CHANGED
|
@@ -102,7 +102,7 @@ function paths(home = kvboxHome()) {
|
|
|
102
102
|
|
|
103
103
|
// src/core/config.ts
|
|
104
104
|
import { chmod, readFile, rename, writeFile } from "node:fs/promises";
|
|
105
|
-
import { z } from "zod";
|
|
105
|
+
import { z as z2 } from "zod";
|
|
106
106
|
|
|
107
107
|
// ../shared/src/sizes.ts
|
|
108
108
|
var SIZES = {
|
|
@@ -116,6 +116,107 @@ function isSizeName(value) {
|
|
|
116
116
|
return value in SIZES;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
// ../shared/src/usage.ts
|
|
120
|
+
import { z } from "zod";
|
|
121
|
+
var PricingSchema = z.object({
|
|
122
|
+
currency: z.string().min(1),
|
|
123
|
+
vcpuHour: z.number().nonnegative(),
|
|
124
|
+
ramGibHour: z.number().nonnegative(),
|
|
125
|
+
diskGibHour: z.number().nonnegative()
|
|
126
|
+
});
|
|
127
|
+
var HOUR_MS = 36e5;
|
|
128
|
+
var round = (n, places) => Math.round(n * 10 ** places) / 10 ** places;
|
|
129
|
+
function computeUsage(events, w) {
|
|
130
|
+
const sorted = [...events].sort((a, b) => a.at < b.at ? -1 : a.at > b.at ? 1 : 0);
|
|
131
|
+
const from = Date.parse(w.from);
|
|
132
|
+
const to = Date.parse(w.to);
|
|
133
|
+
const ms = { running: 0, standby: 0, hibernated: 0, stopped: 0 };
|
|
134
|
+
let vcpuHours = 0;
|
|
135
|
+
let ramGibHours = 0;
|
|
136
|
+
let diskGibHours = 0;
|
|
137
|
+
sorted.forEach((e, i) => {
|
|
138
|
+
const start = Math.max(Date.parse(e.at), from);
|
|
139
|
+
const next = sorted[i + 1];
|
|
140
|
+
const end = Math.min(next === void 0 ? to : Date.parse(next.at), to);
|
|
141
|
+
if (end <= start) return;
|
|
142
|
+
const h = (end - start) / HOUR_MS;
|
|
143
|
+
ms[e.state] += end - start;
|
|
144
|
+
if (e.state === "running") vcpuHours += h * e.vcpu;
|
|
145
|
+
if (e.state === "running" || e.state === "standby") ramGibHours += h * e.memMib / 1024;
|
|
146
|
+
diskGibHours += h * e.diskGib;
|
|
147
|
+
});
|
|
148
|
+
const totals = {
|
|
149
|
+
seconds: {
|
|
150
|
+
running: Math.round(ms.running / 1e3),
|
|
151
|
+
standby: Math.round(ms.standby / 1e3),
|
|
152
|
+
hibernated: Math.round(ms.hibernated / 1e3),
|
|
153
|
+
stopped: Math.round(ms.stopped / 1e3)
|
|
154
|
+
},
|
|
155
|
+
vcpuHours: round(vcpuHours, 3),
|
|
156
|
+
ramGibHours: round(ramGibHours, 3),
|
|
157
|
+
diskGibHours: round(diskGibHours, 3),
|
|
158
|
+
estimatedStops: sorted.filter((e) => e.state === "stopped" && e.by === "server" && e.at >= w.from).length,
|
|
159
|
+
historyFrom: sorted[0]?.at ?? null
|
|
160
|
+
};
|
|
161
|
+
if (w.pricing !== void 0) totals.cost = { amount: round(costOf(totals, w.pricing), 4), currency: w.pricing.currency };
|
|
162
|
+
return totals;
|
|
163
|
+
}
|
|
164
|
+
function costOf(t, pricing) {
|
|
165
|
+
return t.vcpuHours * pricing.vcpuHour + t.ramGibHours * pricing.ramGibHour + t.diskGibHours * pricing.diskGibHour;
|
|
166
|
+
}
|
|
167
|
+
function sumUsage(items, pricing) {
|
|
168
|
+
const sum = {
|
|
169
|
+
seconds: { running: 0, standby: 0, hibernated: 0, stopped: 0 },
|
|
170
|
+
vcpuHours: 0,
|
|
171
|
+
ramGibHours: 0,
|
|
172
|
+
diskGibHours: 0
|
|
173
|
+
};
|
|
174
|
+
for (const t of items) {
|
|
175
|
+
for (const s of Object.keys(sum.seconds)) sum.seconds[s] += t.seconds[s];
|
|
176
|
+
sum.vcpuHours += t.vcpuHours;
|
|
177
|
+
sum.ramGibHours += t.ramGibHours;
|
|
178
|
+
sum.diskGibHours += t.diskGibHours;
|
|
179
|
+
}
|
|
180
|
+
sum.vcpuHours = round(sum.vcpuHours, 3);
|
|
181
|
+
sum.ramGibHours = round(sum.ramGibHours, 3);
|
|
182
|
+
sum.diskGibHours = round(sum.diskGibHours, 3);
|
|
183
|
+
if (pricing !== void 0) sum.cost = { amount: round(costOf(sum, pricing), 4), currency: pricing.currency };
|
|
184
|
+
return sum;
|
|
185
|
+
}
|
|
186
|
+
function usageWindow(since, until, now = /* @__PURE__ */ new Date()) {
|
|
187
|
+
const parse = (name, value) => {
|
|
188
|
+
const t = Date.parse(value);
|
|
189
|
+
if (Number.isNaN(t)) throw new Error(`${name}: "${value}" is not a date; write it like 2026-10-19 or 2026-10-19T08:00:00Z`);
|
|
190
|
+
return new Date(t).toISOString();
|
|
191
|
+
};
|
|
192
|
+
const from = since === void 0 ? new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString() : parse("since", since);
|
|
193
|
+
const to = until === void 0 ? now.toISOString() : parse("until", until);
|
|
194
|
+
return { from, to };
|
|
195
|
+
}
|
|
196
|
+
function groupUsage(rows, window, pricing, live = /* @__PURE__ */ new Map()) {
|
|
197
|
+
const byBox = /* @__PURE__ */ new Map();
|
|
198
|
+
for (const r of rows) {
|
|
199
|
+
const key = r.ledger === void 0 ? r.boxId : `${r.ledger}:${r.boxId}`;
|
|
200
|
+
const list = byBox.get(key);
|
|
201
|
+
if (list === void 0) byBox.set(key, [r]);
|
|
202
|
+
else list.push(r);
|
|
203
|
+
}
|
|
204
|
+
const out = [];
|
|
205
|
+
for (const events of byBox.values()) {
|
|
206
|
+
events.sort((a, b) => a.at < b.at ? -1 : a.at > b.at ? 1 : 0);
|
|
207
|
+
const first = events[0];
|
|
208
|
+
const last = events[events.length - 1];
|
|
209
|
+
const id = last.boxId;
|
|
210
|
+
const removed = last.by === "rm" && !live.has(id);
|
|
211
|
+
const to = removed && last.at < window.to ? last.at : window.to;
|
|
212
|
+
if (first.at >= to || removed && last.at <= window.from) continue;
|
|
213
|
+
const totals = computeUsage(events, { from: window.from, to, pricing });
|
|
214
|
+
const status = live.get(id) ?? (removed ? "removed" : last.state);
|
|
215
|
+
out.push({ id, name: last.name, owner: last.owner, status, ...totals });
|
|
216
|
+
}
|
|
217
|
+
return out.sort((a, b) => a.owner.localeCompare(b.owner) || a.name.localeCompare(b.name) || (a.historyFrom ?? "").localeCompare(b.historyFrom ?? ""));
|
|
218
|
+
}
|
|
219
|
+
|
|
119
220
|
// src/core/refs.ts
|
|
120
221
|
import { userInfo } from "node:os";
|
|
121
222
|
function shown(ref) {
|
|
@@ -161,41 +262,35 @@ function refKey(ref) {
|
|
|
161
262
|
}
|
|
162
263
|
|
|
163
264
|
// src/core/config.ts
|
|
164
|
-
var
|
|
165
|
-
|
|
166
|
-
vcpuHour: z.number().nonnegative(),
|
|
167
|
-
ramGibHour: z.number().nonnegative(),
|
|
168
|
-
diskGibHour: z.number().nonnegative()
|
|
169
|
-
});
|
|
170
|
-
var ControlPlaneSchema = z.object({
|
|
171
|
-
url: z.string().url(),
|
|
265
|
+
var ControlPlaneSchema = z2.object({
|
|
266
|
+
url: z2.string().url(),
|
|
172
267
|
/** The realm name: boxes the control plane creates are owned by `<user>@<name>`, a directory name on disk. */
|
|
173
|
-
name:
|
|
268
|
+
name: z2.string().regex(REALM_RE),
|
|
174
269
|
/** Minted at join; every HTTPS call from the control plane carries it as a bearer. */
|
|
175
|
-
secret:
|
|
270
|
+
secret: z2.string().min(1),
|
|
176
271
|
/** The address the control plane reaches this host at. */
|
|
177
|
-
advertise:
|
|
178
|
-
port:
|
|
272
|
+
advertise: z2.string().min(1),
|
|
273
|
+
port: z2.number().int().positive().default(7777)
|
|
179
274
|
});
|
|
180
|
-
var RelaySchema =
|
|
181
|
-
name:
|
|
182
|
-
publicIp:
|
|
183
|
-
tunnelIp:
|
|
275
|
+
var RelaySchema = z2.object({
|
|
276
|
+
name: z2.string().min(1),
|
|
277
|
+
publicIp: z2.string().min(1),
|
|
278
|
+
tunnelIp: z2.string().min(1),
|
|
184
279
|
/** `<relay-ip>:<port>` the tunnel dials. */
|
|
185
|
-
endpoint:
|
|
280
|
+
endpoint: z2.string().min(1)
|
|
186
281
|
});
|
|
187
|
-
var ConfigSchema =
|
|
188
|
-
kernel:
|
|
189
|
-
rootfs:
|
|
190
|
-
firecracker:
|
|
191
|
-
snapshotEditor:
|
|
282
|
+
var ConfigSchema = z2.object({
|
|
283
|
+
kernel: z2.string(),
|
|
284
|
+
rootfs: z2.string(),
|
|
285
|
+
firecracker: z2.string(),
|
|
286
|
+
snapshotEditor: z2.string().optional(),
|
|
192
287
|
// default: bin/snapshot-editor next to firecracker
|
|
193
|
-
bridge:
|
|
194
|
-
subnet:
|
|
195
|
-
gateway:
|
|
196
|
-
defaultSize:
|
|
288
|
+
bridge: z2.string().default("kvbox0"),
|
|
289
|
+
subnet: z2.string().default("172.30.0.0/24"),
|
|
290
|
+
gateway: z2.string().default("172.30.0.1"),
|
|
291
|
+
defaultSize: z2.enum(SIZE_NAMES).default(DEFAULT_SIZE),
|
|
197
292
|
/** Who boxes made over the local socket belong to: the Unix user running the server unless set here. No `@`, no `/`. */
|
|
198
|
-
owner:
|
|
293
|
+
owner: z2.string().regex(LOCAL_OWNER_RE, "owner: letters, digits, '.', '_', '-'; no '@' or '/'").default(localOwner),
|
|
199
294
|
pricing: PricingSchema.optional(),
|
|
200
295
|
/** Absent until the host joins a control plane; then the server also listens on HTTPS. */
|
|
201
296
|
controlPlane: ControlPlaneSchema.optional(),
|
|
@@ -239,247 +334,283 @@ import { hostname as osHostname } from "node:os";
|
|
|
239
334
|
import { request } from "undici";
|
|
240
335
|
|
|
241
336
|
// ../shared/src/api.ts
|
|
242
|
-
import { z as
|
|
243
|
-
var SizeSchema =
|
|
244
|
-
var BoxStatusSchema =
|
|
245
|
-
var MachineSchema =
|
|
337
|
+
import { z as z3 } from "zod";
|
|
338
|
+
var SizeSchema = z3.enum(SIZE_NAMES);
|
|
339
|
+
var BoxStatusSchema = z3.enum(["running", "standby", "hibernated", "stopped", "unknown"]);
|
|
340
|
+
var MachineSchema = z3.object({
|
|
246
341
|
/** Host-unique short id (the tap is `kv-<id>`). Missing only on a box made before ids that has not restarted since. */
|
|
247
|
-
id:
|
|
248
|
-
name:
|
|
342
|
+
id: z3.string(),
|
|
343
|
+
name: z3.string(),
|
|
249
344
|
/** Who the box belongs to; names are unique per owner. A bare name is local to the host, `user@control-plane` later. */
|
|
250
|
-
owner:
|
|
251
|
-
ip:
|
|
252
|
-
mac:
|
|
253
|
-
pid:
|
|
254
|
-
vcpu:
|
|
255
|
-
memMib:
|
|
256
|
-
diskBytes:
|
|
345
|
+
owner: z3.string(),
|
|
346
|
+
ip: z3.string(),
|
|
347
|
+
mac: z3.string(),
|
|
348
|
+
pid: z3.number().int().nullable(),
|
|
349
|
+
vcpu: z3.number().int(),
|
|
350
|
+
memMib: z3.number().int(),
|
|
351
|
+
diskBytes: z3.number().int(),
|
|
257
352
|
// the box's own writable disk, as the guest sees it
|
|
258
|
-
diskUsedBytes:
|
|
353
|
+
diskUsedBytes: z3.number().int(),
|
|
259
354
|
// what that sparse file really occupies on the host
|
|
260
|
-
base:
|
|
355
|
+
base: z3.string(),
|
|
261
356
|
// base image version the box's overlay stacks on
|
|
262
|
-
createdAt:
|
|
263
|
-
source:
|
|
357
|
+
createdAt: z3.string(),
|
|
358
|
+
source: z3.string(),
|
|
264
359
|
// "base" or "snapshot:<name>"
|
|
265
360
|
status: BoxStatusSchema,
|
|
266
361
|
/**
|
|
267
362
|
* Public TCP port on the host that reaches the box's SSH while it runs (#49). Only boxes made
|
|
268
363
|
* through a control plane have one; the control plane adds the host's address for `connect` (#21).
|
|
269
364
|
*/
|
|
270
|
-
sshPort:
|
|
365
|
+
sshPort: z3.number().int().optional(),
|
|
271
366
|
/**
|
|
272
367
|
* Where `ssh` reaches the box from outside: the host's public address and that port (#21). Only
|
|
273
368
|
* the control plane fills it in (a host does not know its own public address); absent for a box
|
|
274
369
|
* whose host reported no port or did not answer.
|
|
275
370
|
*/
|
|
276
|
-
ssh:
|
|
371
|
+
ssh: z3.object({ host: z3.string(), port: z3.number().int(), user: z3.string() }).optional(),
|
|
277
372
|
/** Whether the owner slug is a user or an org. Only the control plane knows; it fills it in on `GET /machines`. */
|
|
278
|
-
ownerKind:
|
|
373
|
+
ownerKind: z3.enum(["user", "org"]).optional()
|
|
279
374
|
});
|
|
280
|
-
var CreateMachineRequest =
|
|
281
|
-
name:
|
|
375
|
+
var CreateMachineRequest = z3.object({
|
|
376
|
+
name: z3.string().optional(),
|
|
282
377
|
/** Ignored over the local socket (the caller is the host owner); the control plane sets it over HTTPS. */
|
|
283
|
-
owner:
|
|
378
|
+
owner: z3.string().optional(),
|
|
284
379
|
size: SizeSchema.optional(),
|
|
285
|
-
from:
|
|
286
|
-
force:
|
|
380
|
+
from: z3.string().optional(),
|
|
381
|
+
force: z3.boolean().optional(),
|
|
287
382
|
/**
|
|
288
383
|
* Extra authorized_keys lines for the box, on top of the host's own key: the control plane sends
|
|
289
384
|
* its SSH CA as one `cert-authority …` line (#51). Written into the box's authorized_keys at every
|
|
290
385
|
* boot (#49); a host older than that ignores them.
|
|
291
386
|
*/
|
|
292
|
-
sshKeys:
|
|
387
|
+
sshKeys: z3.array(z3.string()).optional()
|
|
293
388
|
});
|
|
294
|
-
var SetSshKeysRequest =
|
|
295
|
-
sshKeys:
|
|
389
|
+
var SetSshKeysRequest = z3.object({
|
|
390
|
+
sshKeys: z3.array(z3.string())
|
|
296
391
|
});
|
|
297
|
-
var RescaleMachineRequest =
|
|
392
|
+
var RescaleMachineRequest = z3.object({
|
|
298
393
|
size: SizeSchema.optional(),
|
|
299
|
-
vcpu:
|
|
300
|
-
memMib:
|
|
301
|
-
diskGib:
|
|
394
|
+
vcpu: z3.number().int().min(1).max(64).optional(),
|
|
395
|
+
memMib: z3.number().int().min(256).optional(),
|
|
396
|
+
diskGib: z3.number().int().min(1).optional()
|
|
302
397
|
});
|
|
303
|
-
var SnapshotSchema =
|
|
304
|
-
name:
|
|
305
|
-
owner:
|
|
306
|
-
source:
|
|
398
|
+
var SnapshotSchema = z3.object({
|
|
399
|
+
name: z3.string(),
|
|
400
|
+
owner: z3.string(),
|
|
401
|
+
source: z3.string(),
|
|
307
402
|
// the box it was taken from, as owner/name
|
|
308
|
-
base:
|
|
403
|
+
base: z3.string(),
|
|
309
404
|
// base image version the overlay stacks on; a restore needs the same one
|
|
310
|
-
vcpu:
|
|
311
|
-
memMib:
|
|
312
|
-
sizeBytes:
|
|
405
|
+
vcpu: z3.number().int(),
|
|
406
|
+
memMib: z3.number().int(),
|
|
407
|
+
sizeBytes: z3.number().int(),
|
|
313
408
|
// what vmstate + mem + overlay really occupy on the host
|
|
314
|
-
createdAt:
|
|
409
|
+
createdAt: z3.string()
|
|
315
410
|
});
|
|
316
|
-
var CreateSnapshotRequest =
|
|
317
|
-
box:
|
|
318
|
-
name:
|
|
411
|
+
var CreateSnapshotRequest = z3.object({
|
|
412
|
+
box: z3.string(),
|
|
413
|
+
name: z3.string()
|
|
319
414
|
});
|
|
320
|
-
var CapacitySchema =
|
|
321
|
-
hostMemMib:
|
|
322
|
-
hostCores:
|
|
323
|
-
reservedMemMib:
|
|
324
|
-
reservedVcpu:
|
|
325
|
-
running:
|
|
415
|
+
var CapacitySchema = z3.object({
|
|
416
|
+
hostMemMib: z3.number().int(),
|
|
417
|
+
hostCores: z3.number().int(),
|
|
418
|
+
reservedMemMib: z3.number().int(),
|
|
419
|
+
reservedVcpu: z3.number().int(),
|
|
420
|
+
running: z3.number().int()
|
|
326
421
|
});
|
|
327
|
-
var HealthSchema =
|
|
328
|
-
ok:
|
|
329
|
-
version:
|
|
330
|
-
pid:
|
|
422
|
+
var HealthSchema = z3.object({
|
|
423
|
+
ok: z3.literal(true),
|
|
424
|
+
version: z3.string(),
|
|
425
|
+
pid: z3.number().int(),
|
|
331
426
|
/** Base image version new boxes get (null when the host has no usable base image yet). */
|
|
332
|
-
base:
|
|
427
|
+
base: z3.string().nullable(),
|
|
333
428
|
/** Owner that boxes made over the local socket get (the Unix user running the server). */
|
|
334
|
-
owner:
|
|
429
|
+
owner: z3.string(),
|
|
335
430
|
/** The control plane this host joined (null when none). */
|
|
336
|
-
controlPlane:
|
|
431
|
+
controlPlane: z3.object({
|
|
337
432
|
/** Realm name, e.g. kvbox.sh. */
|
|
338
|
-
name:
|
|
339
|
-
url:
|
|
433
|
+
name: z3.string(),
|
|
434
|
+
url: z3.string(),
|
|
340
435
|
/** Where this host answers the control plane. */
|
|
341
|
-
hostUrl:
|
|
436
|
+
hostUrl: z3.string()
|
|
342
437
|
}).nullable(),
|
|
343
438
|
/** The relay this host reaches the internet through (#50); null when it has its own public address. Older servers omit it. */
|
|
344
|
-
relay:
|
|
345
|
-
name:
|
|
439
|
+
relay: z3.object({
|
|
440
|
+
name: z3.string(),
|
|
346
441
|
/** The public IP the relay owns for this host. */
|
|
347
|
-
publicIp:
|
|
442
|
+
publicIp: z3.string(),
|
|
348
443
|
/** The relay's WireGuard endpoint the tunnel dials. */
|
|
349
|
-
endpoint:
|
|
444
|
+
endpoint: z3.string()
|
|
350
445
|
}).nullable().optional()
|
|
351
446
|
});
|
|
352
|
-
var MachineUsageSchema =
|
|
353
|
-
name:
|
|
354
|
-
owner:
|
|
447
|
+
var MachineUsageSchema = z3.object({
|
|
448
|
+
name: z3.string(),
|
|
449
|
+
owner: z3.string(),
|
|
355
450
|
status: BoxStatusSchema,
|
|
356
|
-
window:
|
|
357
|
-
seconds:
|
|
358
|
-
running:
|
|
359
|
-
standby:
|
|
360
|
-
hibernated:
|
|
361
|
-
stopped:
|
|
451
|
+
window: z3.object({ from: z3.string(), to: z3.string() }),
|
|
452
|
+
seconds: z3.object({
|
|
453
|
+
running: z3.number().int(),
|
|
454
|
+
standby: z3.number().int(),
|
|
455
|
+
hibernated: z3.number().int(),
|
|
456
|
+
stopped: z3.number().int()
|
|
362
457
|
}),
|
|
363
|
-
vcpuHours:
|
|
364
|
-
ramGibHours:
|
|
365
|
-
diskGibHours:
|
|
366
|
-
cost:
|
|
458
|
+
vcpuHours: z3.number(),
|
|
459
|
+
ramGibHours: z3.number(),
|
|
460
|
+
diskGibHours: z3.number(),
|
|
461
|
+
cost: z3.object({ amount: z3.number(), currency: z3.string() }).optional(),
|
|
367
462
|
/** Stops the server did not witness (Firecracker died, host rebooted) and dated to when it noticed. */
|
|
368
|
-
estimatedStops:
|
|
369
|
-
historyFrom:
|
|
463
|
+
estimatedStops: z3.number().int(),
|
|
464
|
+
historyFrom: z3.string().nullable()
|
|
465
|
+
});
|
|
466
|
+
var UsageStatusSchema = z3.enum([...BoxStatusSchema.options, "removed"]);
|
|
467
|
+
var BoxUsageSchema = MachineUsageSchema.omit({ window: true, status: true }).extend({
|
|
468
|
+
/** The host's box id; a name removed and recreated appears twice, with two ids. */
|
|
469
|
+
id: z3.string(),
|
|
470
|
+
status: UsageStatusSchema,
|
|
471
|
+
/** Through a control plane: whether the owner slug is a user's or an org's (what `kvbox ls` shows). */
|
|
472
|
+
ownerKind: z3.enum(["user", "org"]).optional()
|
|
370
473
|
});
|
|
371
|
-
var
|
|
372
|
-
|
|
474
|
+
var UsageReportSchema = z3.object({
|
|
475
|
+
window: z3.object({ from: z3.string(), to: z3.string() }),
|
|
476
|
+
boxes: z3.array(BoxUsageSchema),
|
|
477
|
+
totals: z3.object({
|
|
478
|
+
seconds: MachineUsageSchema.shape.seconds,
|
|
479
|
+
vcpuHours: z3.number(),
|
|
480
|
+
ramGibHours: z3.number(),
|
|
481
|
+
diskGibHours: z3.number(),
|
|
482
|
+
cost: z3.object({ amount: z3.number(), currency: z3.string() }).optional()
|
|
483
|
+
})
|
|
373
484
|
});
|
|
374
|
-
var
|
|
485
|
+
var UsageEventRowSchema = z3.object({
|
|
486
|
+
id: z3.number().int(),
|
|
487
|
+
boxId: z3.string(),
|
|
488
|
+
owner: z3.string(),
|
|
489
|
+
name: z3.string(),
|
|
490
|
+
at: z3.string(),
|
|
491
|
+
state: z3.enum(["running", "standby", "hibernated", "stopped"]),
|
|
492
|
+
vcpu: z3.number().int(),
|
|
493
|
+
memMib: z3.number().int(),
|
|
494
|
+
diskGib: z3.number().int(),
|
|
495
|
+
by: z3.string()
|
|
496
|
+
});
|
|
497
|
+
var UsageEventsResponseSchema = z3.object({
|
|
498
|
+
ledger: z3.string().min(1),
|
|
499
|
+
events: z3.array(UsageEventRowSchema),
|
|
500
|
+
next: z3.number().int()
|
|
501
|
+
});
|
|
502
|
+
var ApiErrorSchema = z3.object({
|
|
503
|
+
error: z3.string()
|
|
504
|
+
});
|
|
505
|
+
var CloudUserSchema = z3.object({
|
|
375
506
|
/** The handle: what hosts file boxes under (`<username>@<control plane>`). Lowercase, no `@` or `/`. */
|
|
376
|
-
username:
|
|
507
|
+
username: z3.string(),
|
|
377
508
|
/** Display name, free text ("Alexandru Ghiura"). */
|
|
378
|
-
name:
|
|
509
|
+
name: z3.string(),
|
|
379
510
|
/** Login identity, unique. */
|
|
380
|
-
email:
|
|
381
|
-
role:
|
|
382
|
-
createdAt:
|
|
511
|
+
email: z3.string(),
|
|
512
|
+
role: z3.enum(["admin", "user"]),
|
|
513
|
+
createdAt: z3.string()
|
|
383
514
|
});
|
|
384
515
|
var ORG_ROLES = ["owner", "admin", "member"];
|
|
385
|
-
var OrgRoleSchema =
|
|
386
|
-
var CloudOrgSchema =
|
|
387
|
-
slug:
|
|
516
|
+
var OrgRoleSchema = z3.enum(ORG_ROLES);
|
|
517
|
+
var CloudOrgSchema = z3.object({
|
|
518
|
+
slug: z3.string(),
|
|
388
519
|
/** Display name; the slug never changes, this may. */
|
|
389
|
-
name:
|
|
390
|
-
createdAt:
|
|
520
|
+
name: z3.string(),
|
|
521
|
+
createdAt: z3.string()
|
|
391
522
|
});
|
|
392
523
|
var OrgMembershipSchema = CloudOrgSchema.extend({ role: OrgRoleSchema });
|
|
393
|
-
var OrgMemberSchema =
|
|
394
|
-
username:
|
|
395
|
-
name:
|
|
524
|
+
var OrgMemberSchema = z3.object({
|
|
525
|
+
username: z3.string(),
|
|
526
|
+
name: z3.string(),
|
|
396
527
|
role: OrgRoleSchema,
|
|
397
|
-
addedAt:
|
|
528
|
+
addedAt: z3.string()
|
|
398
529
|
});
|
|
399
|
-
var OrgDetailSchema = CloudOrgSchema.extend({ members:
|
|
400
|
-
var CreateOrgRequestSchema =
|
|
401
|
-
slug:
|
|
402
|
-
name:
|
|
530
|
+
var OrgDetailSchema = CloudOrgSchema.extend({ members: z3.array(OrgMemberSchema) });
|
|
531
|
+
var CreateOrgRequestSchema = z3.object({
|
|
532
|
+
slug: z3.string().min(1),
|
|
533
|
+
name: z3.string().min(1)
|
|
403
534
|
});
|
|
404
|
-
var RenameOrgRequestSchema =
|
|
405
|
-
var AddMemberRequestSchema =
|
|
406
|
-
username:
|
|
535
|
+
var RenameOrgRequestSchema = z3.object({ name: z3.string().min(1) });
|
|
536
|
+
var AddMemberRequestSchema = z3.object({
|
|
537
|
+
username: z3.string().min(1),
|
|
407
538
|
role: OrgRoleSchema.default("member")
|
|
408
539
|
});
|
|
409
|
-
var SetRoleRequestSchema =
|
|
410
|
-
var MeResponseSchema = CloudUserSchema.extend({ orgs:
|
|
411
|
-
var LoginRequestSchema =
|
|
412
|
-
email:
|
|
413
|
-
password:
|
|
414
|
-
publicKey:
|
|
540
|
+
var SetRoleRequestSchema = z3.object({ role: OrgRoleSchema });
|
|
541
|
+
var MeResponseSchema = CloudUserSchema.extend({ orgs: z3.array(OrgMembershipSchema) });
|
|
542
|
+
var LoginRequestSchema = z3.object({
|
|
543
|
+
email: z3.string().min(1),
|
|
544
|
+
password: z3.string().min(1),
|
|
545
|
+
publicKey: z3.string().optional()
|
|
415
546
|
});
|
|
416
|
-
var SignupRequestSchema =
|
|
417
|
-
username:
|
|
418
|
-
name:
|
|
419
|
-
email:
|
|
420
|
-
password:
|
|
547
|
+
var SignupRequestSchema = z3.object({
|
|
548
|
+
username: z3.string().min(1),
|
|
549
|
+
name: z3.string().min(1),
|
|
550
|
+
email: z3.string().min(1),
|
|
551
|
+
password: z3.string().min(1)
|
|
421
552
|
});
|
|
422
|
-
var LoginResponseSchema =
|
|
423
|
-
token:
|
|
553
|
+
var LoginResponseSchema = z3.object({
|
|
554
|
+
token: z3.string(),
|
|
424
555
|
user: CloudUserSchema,
|
|
425
|
-
sshCert:
|
|
426
|
-
sshCertExpires:
|
|
556
|
+
sshCert: z3.string().optional(),
|
|
557
|
+
sshCertExpires: z3.string().optional(),
|
|
427
558
|
/** Whose boxes the certificate opens: `<slug>@<realm>` for the user and each org they are in (orgs phase 4). */
|
|
428
|
-
sshCertPrincipals:
|
|
559
|
+
sshCertPrincipals: z3.array(z3.string()).optional()
|
|
429
560
|
});
|
|
430
|
-
var SshCertRequestSchema =
|
|
431
|
-
publicKey:
|
|
561
|
+
var SshCertRequestSchema = z3.object({
|
|
562
|
+
publicKey: z3.string().min(1)
|
|
432
563
|
});
|
|
433
|
-
var SshCertResponseSchema =
|
|
434
|
-
cert:
|
|
435
|
-
expires:
|
|
436
|
-
principals:
|
|
564
|
+
var SshCertResponseSchema = z3.object({
|
|
565
|
+
cert: z3.string(),
|
|
566
|
+
expires: z3.string(),
|
|
567
|
+
principals: z3.array(z3.string())
|
|
437
568
|
});
|
|
438
|
-
var SshCaResponseSchema =
|
|
439
|
-
publicKey:
|
|
569
|
+
var SshCaResponseSchema = z3.object({
|
|
570
|
+
publicKey: z3.string(),
|
|
440
571
|
/** CAs replaced by `ca rotate` (#55), newest first; a box still holding one of these has not had `kvbox server trust-ca` run on its host. */
|
|
441
|
-
retired:
|
|
572
|
+
retired: z3.array(z3.object({ publicKey: z3.string(), retiredAt: z3.string() })).optional()
|
|
442
573
|
});
|
|
443
|
-
var HostSshCaResponseSchema =
|
|
444
|
-
publicKey:
|
|
574
|
+
var HostSshCaResponseSchema = z3.object({
|
|
575
|
+
publicKey: z3.string()
|
|
445
576
|
});
|
|
446
|
-
var CloudCaSchema =
|
|
447
|
-
controlPlane:
|
|
448
|
-
publicKey:
|
|
449
|
-
fingerprint:
|
|
577
|
+
var CloudCaSchema = z3.object({
|
|
578
|
+
controlPlane: z3.string(),
|
|
579
|
+
publicKey: z3.string(),
|
|
580
|
+
fingerprint: z3.string()
|
|
450
581
|
});
|
|
451
|
-
var TrustCaRequestSchema =
|
|
452
|
-
publicKey:
|
|
582
|
+
var TrustCaRequestSchema = z3.object({
|
|
583
|
+
publicKey: z3.string().min(1)
|
|
453
584
|
});
|
|
454
|
-
var SshKeysAppliedSchema =
|
|
455
|
-
var TrustCaReportSchema =
|
|
456
|
-
publicKey:
|
|
457
|
-
fingerprint:
|
|
585
|
+
var SshKeysAppliedSchema = z3.enum(["now", "start", "wake", "resume", "later"]);
|
|
586
|
+
var TrustCaReportSchema = z3.object({
|
|
587
|
+
publicKey: z3.string(),
|
|
588
|
+
fingerprint: z3.string(),
|
|
458
589
|
/** The guest trusts the CA now. */
|
|
459
|
-
updated:
|
|
590
|
+
updated: z3.array(z3.string()),
|
|
460
591
|
/** meta.json has the line; the guest gets it at `when`. */
|
|
461
|
-
pending:
|
|
462
|
-
failed:
|
|
592
|
+
pending: z3.array(z3.object({ box: z3.string(), when: SshKeysAppliedSchema, why: z3.string().optional() })),
|
|
593
|
+
failed: z3.array(z3.object({ box: z3.string(), error: z3.string() })),
|
|
463
594
|
/** Local boxes, untouched: they trust the host's key only. */
|
|
464
|
-
local:
|
|
595
|
+
local: z3.number().int()
|
|
465
596
|
});
|
|
466
|
-
var HostJoinRequestSchema =
|
|
467
|
-
token:
|
|
468
|
-
name:
|
|
469
|
-
url:
|
|
470
|
-
fingerprint:
|
|
471
|
-
publicAddress:
|
|
597
|
+
var HostJoinRequestSchema = z3.object({
|
|
598
|
+
token: z3.string().min(1),
|
|
599
|
+
name: z3.string().min(1),
|
|
600
|
+
url: z3.string().url(),
|
|
601
|
+
fingerprint: z3.string().regex(/^([0-9A-F]{2}:){31}[0-9A-F]{2}$/),
|
|
602
|
+
publicAddress: z3.string().min(1)
|
|
472
603
|
});
|
|
473
|
-
var HostJoinResponseSchema =
|
|
474
|
-
hostSecret:
|
|
475
|
-
controlPlane:
|
|
604
|
+
var HostJoinResponseSchema = z3.object({
|
|
605
|
+
hostSecret: z3.string(),
|
|
606
|
+
controlPlane: z3.object({ name: z3.string() })
|
|
476
607
|
});
|
|
477
|
-
var CloudHostSchema =
|
|
478
|
-
name:
|
|
479
|
-
url:
|
|
480
|
-
publicAddress:
|
|
481
|
-
joinedAt:
|
|
482
|
-
lastSeenAt:
|
|
608
|
+
var CloudHostSchema = z3.object({
|
|
609
|
+
name: z3.string(),
|
|
610
|
+
url: z3.string(),
|
|
611
|
+
publicAddress: z3.string(),
|
|
612
|
+
joinedAt: z3.string(),
|
|
613
|
+
lastSeenAt: z3.string().nullable(),
|
|
483
614
|
capacity: CapacitySchema.nullable()
|
|
484
615
|
});
|
|
485
616
|
|
|
@@ -809,24 +940,24 @@ function macFor(ip) {
|
|
|
809
940
|
import { chmod as chmod3, mkdir as mkdir2, rm, writeFile as writeFile2 } from "node:fs/promises";
|
|
810
941
|
|
|
811
942
|
// ../shared/src/relay.ts
|
|
812
|
-
import { z as
|
|
943
|
+
import { z as z4 } from "zod";
|
|
813
944
|
var RELAY_TUNNEL_GATEWAY = "10.77.0.1";
|
|
814
945
|
var IPV4_RE = /^(\d{1,3})(\.\d{1,3}){3}$/;
|
|
815
946
|
var WG_KEY_RE = /^[A-Za-z0-9+/]{43}=$/;
|
|
816
|
-
var RelayJoinSchema =
|
|
817
|
-
v:
|
|
947
|
+
var RelayJoinSchema = z4.object({
|
|
948
|
+
v: z4.literal(1),
|
|
818
949
|
/** How the relay lists this host. */
|
|
819
|
-
name:
|
|
950
|
+
name: z4.string().regex(/^[a-z0-9][a-z0-9-]{0,30}$/),
|
|
820
951
|
/** `<relay-primary-ip>:<port>`, the WireGuard endpoint the host dials. */
|
|
821
|
-
relay:
|
|
952
|
+
relay: z4.string().regex(/^(\d{1,3})(\.\d{1,3}){3}:\d{1,5}$/),
|
|
822
953
|
/** The relay's WireGuard public key. */
|
|
823
|
-
relayKey:
|
|
954
|
+
relayKey: z4.string().regex(WG_KEY_RE),
|
|
824
955
|
/** This host's address inside the tunnel. */
|
|
825
|
-
tunnelIp:
|
|
956
|
+
tunnelIp: z4.string().regex(IPV4_RE),
|
|
826
957
|
/** This host's WireGuard private key, generated on the relay, kept nowhere else. */
|
|
827
|
-
privateKey:
|
|
958
|
+
privateKey: z4.string().regex(WG_KEY_RE),
|
|
828
959
|
/** The extra public IPv4 the relay owns for this host; what the host advertises to the control plane. */
|
|
829
|
-
publicIp:
|
|
960
|
+
publicIp: z4.string().regex(IPV4_RE)
|
|
830
961
|
});
|
|
831
962
|
function decodeRelayJoin(text) {
|
|
832
963
|
let parsed;
|
|
@@ -900,44 +1031,44 @@ import { createAdaptorServer } from "@hono/node-server";
|
|
|
900
1031
|
// src/core/state.ts
|
|
901
1032
|
import { randomBytes } from "node:crypto";
|
|
902
1033
|
import { mkdir as mkdir3, readFile as readFile4, readdir, writeFile as writeFile3 } from "node:fs/promises";
|
|
903
|
-
import { z as
|
|
904
|
-
var MachineMetaSchema =
|
|
1034
|
+
import { z as z5 } from "zod";
|
|
1035
|
+
var MachineMetaSchema = z5.object({
|
|
905
1036
|
/** Host-unique, minted at `kvbox new` (fresh or restored) and kept for the box's life; the tap is `kv-<id>`. */
|
|
906
|
-
id:
|
|
907
|
-
name:
|
|
1037
|
+
id: z5.string(),
|
|
1038
|
+
name: z5.string(),
|
|
908
1039
|
/** Part of the box's identity: machines/<owner>/<name>. See refs.ts. */
|
|
909
|
-
owner:
|
|
910
|
-
ip:
|
|
911
|
-
mac:
|
|
912
|
-
pid:
|
|
1040
|
+
owner: z5.string(),
|
|
1041
|
+
ip: z5.string(),
|
|
1042
|
+
mac: z5.string(),
|
|
1043
|
+
pid: z5.number().int().nullable(),
|
|
913
1044
|
// null: stopped on purpose, disk kept
|
|
914
|
-
vcpu:
|
|
915
|
-
memMib:
|
|
916
|
-
createdAt:
|
|
917
|
-
source:
|
|
1045
|
+
vcpu: z5.number().int(),
|
|
1046
|
+
memMib: z5.number().int(),
|
|
1047
|
+
createdAt: z5.string(),
|
|
1048
|
+
source: z5.string(),
|
|
918
1049
|
/** Base image version (assets/rootfs-<version>.ext4) the box's overlay stacks on. */
|
|
919
|
-
base:
|
|
1050
|
+
base: z5.string(),
|
|
920
1051
|
/**
|
|
921
1052
|
* Snapshots taken since this VM started (boot or restore). Firecracker resets its dirty-page bitmap at
|
|
922
1053
|
* each one, so when this is > 0 the next diff is only standalone merged onto `mem.base`; if that file
|
|
923
1054
|
* is missing the lineage is broken and the next snapshot has to be a full dump. Absent: 0.
|
|
924
1055
|
*/
|
|
925
|
-
memSnapshots:
|
|
1056
|
+
memSnapshots: z5.number().int().optional(),
|
|
926
1057
|
/** `kvbox pause`: Firecracker is alive but the VM is frozen (standby). Absent: not paused. */
|
|
927
|
-
paused:
|
|
1058
|
+
paused: z5.boolean().optional(),
|
|
928
1059
|
/**
|
|
929
1060
|
* authorized_keys lines the box gets on top of the host's own key, as the control plane sent them
|
|
930
1061
|
* at `new` (#49). kvbox-init rewrites authorized_keys from the kernel cmdline at every boot, so
|
|
931
1062
|
* `start` has to send the same lines again. Absent: host key only.
|
|
932
1063
|
*/
|
|
933
|
-
sshKeys:
|
|
1064
|
+
sshKeys: z5.array(z5.string()).optional(),
|
|
934
1065
|
/**
|
|
935
1066
|
* The guest's authorized_keys may not match `sshKeys`: they changed while the box was not running,
|
|
936
1067
|
* or its agent could not take them (#55, #63). Cleared by a push through kvbox-agent or a boot.
|
|
937
1068
|
*/
|
|
938
|
-
sshKeysStale:
|
|
1069
|
+
sshKeysStale: z5.boolean().optional(),
|
|
939
1070
|
/** Public port on the host DNATed to the box's SSH while it runs (#49); fixed for the box's life. Absent: none. */
|
|
940
|
-
sshPort:
|
|
1071
|
+
sshPort: z5.number().int().optional()
|
|
941
1072
|
});
|
|
942
1073
|
function newBoxId(taken) {
|
|
943
1074
|
for (; ; ) {
|
|
@@ -945,16 +1076,16 @@ function newBoxId(taken) {
|
|
|
945
1076
|
if (!taken.has(id)) return id;
|
|
946
1077
|
}
|
|
947
1078
|
}
|
|
948
|
-
var SnapshotMetaSchema =
|
|
949
|
-
name:
|
|
950
|
-
owner:
|
|
1079
|
+
var SnapshotMetaSchema = z5.object({
|
|
1080
|
+
name: z5.string(),
|
|
1081
|
+
owner: z5.string(),
|
|
951
1082
|
/** The box it was taken from, as owner/name. */
|
|
952
|
-
source:
|
|
953
|
-
base:
|
|
954
|
-
vcpu:
|
|
955
|
-
memMib:
|
|
956
|
-
sizeBytes:
|
|
957
|
-
createdAt:
|
|
1083
|
+
source: z5.string(),
|
|
1084
|
+
base: z5.string(),
|
|
1085
|
+
vcpu: z5.number().int(),
|
|
1086
|
+
memMib: z5.number().int(),
|
|
1087
|
+
sizeBytes: z5.number().int(),
|
|
1088
|
+
createdAt: z5.string()
|
|
958
1089
|
});
|
|
959
1090
|
async function ensureLayout(p = paths()) {
|
|
960
1091
|
for (const dir of [p.home, p.ssh, p.bin, p.assets, p.machines, p.snapshots]) {
|
|
@@ -1016,7 +1147,7 @@ function pidAlive(pid) {
|
|
|
1016
1147
|
}
|
|
1017
1148
|
|
|
1018
1149
|
// src/app.ts
|
|
1019
|
-
import { Hono as
|
|
1150
|
+
import { Hono as Hono6 } from "hono";
|
|
1020
1151
|
|
|
1021
1152
|
// ../shared/src/version.ts
|
|
1022
1153
|
import { createRequire } from "node:module";
|
|
@@ -1031,7 +1162,7 @@ function versionOf(srcFileUrl, injected) {
|
|
|
1031
1162
|
}
|
|
1032
1163
|
|
|
1033
1164
|
// src/version.ts
|
|
1034
|
-
var VERSION = versionOf(import.meta.url, true ? "0.5.
|
|
1165
|
+
var VERSION = versionOf(import.meta.url, true ? "0.5.6" : void 0);
|
|
1035
1166
|
|
|
1036
1167
|
// src/caller.ts
|
|
1037
1168
|
import { createHash as createHash2, timingSafeEqual } from "node:crypto";
|
|
@@ -1214,7 +1345,12 @@ var MIGRATIONS = [
|
|
|
1214
1345
|
disk_gib integer not null,
|
|
1215
1346
|
by text not null
|
|
1216
1347
|
);
|
|
1217
|
-
create index usage_events_box_at on usage_events (box_id, at)
|
|
1348
|
+
create index usage_events_box_at on usage_events (box_id, at);`,
|
|
1349
|
+
// The ledger's identity (#42): a control plane keys pulled rows by it, so a re-join is recognised
|
|
1350
|
+
// and a fresh ledger (KVBOX_HOME wiped) restarts its cursor instead of waiting for ids it already saw.
|
|
1351
|
+
`create table ledger (id text not null);
|
|
1352
|
+
insert into ledger (id) values (lower(hex(randomblob(16))));
|
|
1353
|
+
create index usage_events_at on usage_events (at);`
|
|
1218
1354
|
];
|
|
1219
1355
|
var handles = /* @__PURE__ */ new Map();
|
|
1220
1356
|
function openDb(p = paths()) {
|
|
@@ -1277,6 +1413,22 @@ function usageEventsFor(boxId, since, p = paths()) {
|
|
|
1277
1413
|
const rows = openDb(p).prepare("select * from usage_events where box_id = ? and at >= ? order by at, id").all(boxId, since);
|
|
1278
1414
|
return rows.map(fromRaw);
|
|
1279
1415
|
}
|
|
1416
|
+
var withId = (r) => ({ ...fromRaw(r), id: r.id });
|
|
1417
|
+
function ledgerId(p = paths()) {
|
|
1418
|
+
return openDb(p).prepare("select id from ledger").get().id;
|
|
1419
|
+
}
|
|
1420
|
+
function usageEventsAfter(after, limit, scope, p = paths()) {
|
|
1421
|
+
const db = openDb(p);
|
|
1422
|
+
const rows = scope === null ? db.prepare("select * from usage_events where id > ? order by id limit ?").all(after, limit) : typeof scope === "string" ? db.prepare("select * from usage_events where id > ? and owner = ? order by id limit ?").all(after, scope, limit) : db.prepare("select * from usage_events where id > ? and substr(owner, -length(?)) = ? order by id limit ?").all(after, `@${scope.realm}`, `@${scope.realm}`, limit);
|
|
1423
|
+
return rows.map(withId);
|
|
1424
|
+
}
|
|
1425
|
+
function usageEventsInScope(scope, window, p = paths()) {
|
|
1426
|
+
const rows = openDb(p).prepare(
|
|
1427
|
+
`select * from usage_events where at < ? and (at >= ? or id in (select max(id) from usage_events where at < ? group by box_id))
|
|
1428
|
+
order by at, id`
|
|
1429
|
+
).all(window.to, window.from, window.from);
|
|
1430
|
+
return rows.filter((r) => inScope(r.owner, scope)).map(withId);
|
|
1431
|
+
}
|
|
1280
1432
|
|
|
1281
1433
|
// src/core/firecracker.ts
|
|
1282
1434
|
import { Agent, request as request2 } from "undici";
|
|
@@ -2105,46 +2257,6 @@ function capacityRoutes() {
|
|
|
2105
2257
|
import { Hono as Hono2 } from "hono";
|
|
2106
2258
|
|
|
2107
2259
|
// src/core/usage.ts
|
|
2108
|
-
var HOUR_MS = 36e5;
|
|
2109
|
-
var round = (n, places) => Math.round(n * 10 ** places) / 10 ** places;
|
|
2110
|
-
function computeUsage(events, w) {
|
|
2111
|
-
const sorted = [...events].sort((a, b) => a.at < b.at ? -1 : a.at > b.at ? 1 : 0);
|
|
2112
|
-
const from = Date.parse(w.from);
|
|
2113
|
-
const to = Date.parse(w.to);
|
|
2114
|
-
const ms = { running: 0, standby: 0, hibernated: 0, stopped: 0 };
|
|
2115
|
-
let vcpuHours = 0;
|
|
2116
|
-
let ramGibHours = 0;
|
|
2117
|
-
let diskGibHours = 0;
|
|
2118
|
-
sorted.forEach((e, i) => {
|
|
2119
|
-
const start = Math.max(Date.parse(e.at), from);
|
|
2120
|
-
const next = sorted[i + 1];
|
|
2121
|
-
const end = Math.min(next === void 0 ? to : Date.parse(next.at), to);
|
|
2122
|
-
if (end <= start) return;
|
|
2123
|
-
const h = (end - start) / HOUR_MS;
|
|
2124
|
-
ms[e.state] += end - start;
|
|
2125
|
-
if (e.state === "running") vcpuHours += h * e.vcpu;
|
|
2126
|
-
if (e.state === "running" || e.state === "standby") ramGibHours += h * e.memMib / 1024;
|
|
2127
|
-
diskGibHours += h * e.diskGib;
|
|
2128
|
-
});
|
|
2129
|
-
const totals = {
|
|
2130
|
-
seconds: {
|
|
2131
|
-
running: Math.round(ms.running / 1e3),
|
|
2132
|
-
standby: Math.round(ms.standby / 1e3),
|
|
2133
|
-
hibernated: Math.round(ms.hibernated / 1e3),
|
|
2134
|
-
stopped: Math.round(ms.stopped / 1e3)
|
|
2135
|
-
},
|
|
2136
|
-
vcpuHours: round(vcpuHours, 3),
|
|
2137
|
-
ramGibHours: round(ramGibHours, 3),
|
|
2138
|
-
diskGibHours: round(diskGibHours, 3),
|
|
2139
|
-
estimatedStops: sorted.filter((e) => e.state === "stopped" && e.by === "server" && e.at >= w.from).length,
|
|
2140
|
-
historyFrom: sorted[0]?.at ?? null
|
|
2141
|
-
};
|
|
2142
|
-
if (w.pricing !== void 0) {
|
|
2143
|
-
const amount = vcpuHours * w.pricing.vcpuHour + ramGibHours * w.pricing.ramGibHour + diskGibHours * w.pricing.diskGibHour;
|
|
2144
|
-
totals.cost = { amount: round(amount, 4), currency: w.pricing.currency };
|
|
2145
|
-
}
|
|
2146
|
-
return totals;
|
|
2147
|
-
}
|
|
2148
2260
|
async function machineUsage(ref, since, p = paths()) {
|
|
2149
2261
|
const box = await getMachine(ref, p);
|
|
2150
2262
|
const cfg = await loadConfig(p);
|
|
@@ -2153,6 +2265,12 @@ async function machineUsage(ref, since, p = paths()) {
|
|
|
2153
2265
|
const window = { from: since ?? box.createdAt, to: now };
|
|
2154
2266
|
return { name: box.name, owner: box.owner, status: box.status, window, ...computeUsage(events, { ...window, pricing: cfg.pricing }) };
|
|
2155
2267
|
}
|
|
2268
|
+
async function usageReport(scope, window, p = paths()) {
|
|
2269
|
+
const cfg = await loadConfig(p);
|
|
2270
|
+
const live = new Map((await listMachines(scope, p)).map((m) => [m.id, m.status]));
|
|
2271
|
+
const boxes = groupUsage(usageEventsInScope(scope, window, p), window, cfg.pricing, live);
|
|
2272
|
+
return { window, boxes, totals: sumUsage(boxes, cfg.pricing) };
|
|
2273
|
+
}
|
|
2156
2274
|
|
|
2157
2275
|
// src/routes/machines.ts
|
|
2158
2276
|
function machineRoutes() {
|
|
@@ -2350,9 +2468,38 @@ function sshRoutes(opts = {}) {
|
|
|
2350
2468
|
return r;
|
|
2351
2469
|
}
|
|
2352
2470
|
|
|
2471
|
+
// src/routes/usage.ts
|
|
2472
|
+
import { Hono as Hono5 } from "hono";
|
|
2473
|
+
var PULL_LIMIT = 1e3;
|
|
2474
|
+
function usageRoutes(opts = {}) {
|
|
2475
|
+
const r = new Hono5();
|
|
2476
|
+
r.get("/", async (c) => {
|
|
2477
|
+
let window;
|
|
2478
|
+
try {
|
|
2479
|
+
window = usageWindow(c.req.query("since"), c.req.query("until"));
|
|
2480
|
+
} catch (err) {
|
|
2481
|
+
throw new KvboxError(err.message);
|
|
2482
|
+
}
|
|
2483
|
+
return c.json(await usageReport(c.get("listScope"), window));
|
|
2484
|
+
});
|
|
2485
|
+
r.get("/events", async (c) => {
|
|
2486
|
+
const after = whole(c.req.query("after") ?? "0", "after");
|
|
2487
|
+
const limit = Math.min(whole(c.req.query("limit") ?? String(PULL_LIMIT), "limit") || PULL_LIMIT, PULL_LIMIT);
|
|
2488
|
+
const scope = opts.controlPlane === void 0 ? null : c.get("listScope");
|
|
2489
|
+
const events = usageEventsAfter(after, limit, scope);
|
|
2490
|
+
return c.json({ ledger: ledgerId(), events, next: events.length === 0 ? after : events[events.length - 1].id });
|
|
2491
|
+
});
|
|
2492
|
+
return r;
|
|
2493
|
+
}
|
|
2494
|
+
function whole(value, name) {
|
|
2495
|
+
const n = Number(value);
|
|
2496
|
+
if (!Number.isInteger(n) || n < 0) throw new KvboxError(`${name}: expected a whole number, got "${value}"`);
|
|
2497
|
+
return n;
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2353
2500
|
// src/app.ts
|
|
2354
2501
|
function createApp(opts = {}) {
|
|
2355
|
-
const app = new
|
|
2502
|
+
const app = new Hono6();
|
|
2356
2503
|
app.use(resolveCaller(opts));
|
|
2357
2504
|
app.get("/health", async (c) => {
|
|
2358
2505
|
const cfg = await loadConfig();
|
|
@@ -2371,6 +2518,7 @@ function createApp(opts = {}) {
|
|
|
2371
2518
|
app.route("/snapshots", snapshotRoutes());
|
|
2372
2519
|
app.route("/capacity", capacityRoutes());
|
|
2373
2520
|
app.route("/ssh", sshRoutes(opts));
|
|
2521
|
+
app.route("/usage", usageRoutes(opts));
|
|
2374
2522
|
app.notFound((c) => c.json({ error: `no route: ${c.req.method} ${c.req.path}` }, 404));
|
|
2375
2523
|
app.onError((err, c) => {
|
|
2376
2524
|
if (err instanceof NotFoundError) return c.json({ error: err.message }, 404);
|