@boxd-sh/convex 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +264 -0
- package/dist/client/_generated/_ignore.d.ts +1 -0
- package/dist/client/_generated/_ignore.d.ts.map +1 -0
- package/dist/client/_generated/_ignore.js +3 -0
- package/dist/client/_generated/_ignore.js.map +1 -0
- package/dist/client/index.d.ts +327 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +126 -0
- package/dist/client/index.js.map +1 -0
- package/dist/component/_generated/api.d.ts +52 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +357 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +135 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +80 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/boxd.d.ts +35 -0
- package/dist/component/boxd.d.ts.map +1 -0
- package/dist/component/boxd.js +144 -0
- package/dist/component/boxd.js.map +1 -0
- package/dist/component/convex.config.d.ts +6 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +9 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/errors.d.ts +25 -0
- package/dist/component/errors.d.ts.map +1 -0
- package/dist/component/errors.js +75 -0
- package/dist/component/errors.js.map +1 -0
- package/dist/component/exec.d.ts +25 -0
- package/dist/component/exec.d.ts.map +1 -0
- package/dist/component/exec.js +100 -0
- package/dist/component/exec.js.map +1 -0
- package/dist/component/executions.d.ts +70 -0
- package/dist/component/executions.d.ts.map +1 -0
- package/dist/component/executions.js +106 -0
- package/dist/component/executions.js.map +1 -0
- package/dist/component/files.d.ts +44 -0
- package/dist/component/files.d.ts.map +1 -0
- package/dist/component/files.js +105 -0
- package/dist/component/files.js.map +1 -0
- package/dist/component/limits.d.ts +33 -0
- package/dist/component/limits.d.ts.map +1 -0
- package/dist/component/limits.js +44 -0
- package/dist/component/limits.js.map +1 -0
- package/dist/component/machines.d.ts +337 -0
- package/dist/component/machines.d.ts.map +1 -0
- package/dist/component/machines.js +411 -0
- package/dist/component/machines.js.map +1 -0
- package/dist/component/records.d.ts +87 -0
- package/dist/component/records.d.ts.map +1 -0
- package/dist/component/records.js +51 -0
- package/dist/component/records.js.map +1 -0
- package/dist/component/schema.d.ts +138 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +70 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/component/sessions.d.ts +21 -0
- package/dist/component/sessions.d.ts.map +1 -0
- package/dist/component/sessions.js +59 -0
- package/dist/component/sessions.js.map +1 -0
- package/dist/component/validate.d.ts +15 -0
- package/dist/component/validate.d.ts.map +1 -0
- package/dist/component/validate.js +44 -0
- package/dist/component/validate.js.map +1 -0
- package/package.json +99 -0
- package/src/client/_generated/_ignore.ts +1 -0
- package/src/client/index.ts +199 -0
- package/src/component/_generated/api.ts +68 -0
- package/src/component/_generated/component.ts +430 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +171 -0
- package/src/component/boxd.ts +188 -0
- package/src/component/convex.config.ts +9 -0
- package/src/component/errors.ts +105 -0
- package/src/component/exec.ts +134 -0
- package/src/component/executions.ts +115 -0
- package/src/component/files.ts +128 -0
- package/src/component/limits.ts +48 -0
- package/src/component/machines.ts +500 -0
- package/src/component/records.ts +71 -0
- package/src/component/schema.ts +77 -0
- package/src/component/sessions.ts +61 -0
- package/src/component/validate.ts +58 -0
- package/src/test.ts +28 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machines: reactive queries over the `machines` table, the bookkeeping
|
|
3
|
+
* mutations behind them, and the lifecycle actions that call boxd and write
|
|
4
|
+
* back what they observed.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Boxd, Machine } from "@boxd-sh/sdk/web";
|
|
8
|
+
import { ConvexError, v } from "convex/values";
|
|
9
|
+
import { internal } from "./_generated/api.js";
|
|
10
|
+
import {
|
|
11
|
+
action,
|
|
12
|
+
internalMutation,
|
|
13
|
+
internalQuery,
|
|
14
|
+
query,
|
|
15
|
+
type ActionCtx,
|
|
16
|
+
} from "./_generated/server.js";
|
|
17
|
+
import { withBoxd } from "./boxd.js";
|
|
18
|
+
import {
|
|
19
|
+
boxdError,
|
|
20
|
+
errorMessage,
|
|
21
|
+
isNotFound,
|
|
22
|
+
toConvexError,
|
|
23
|
+
} from "./errors.js";
|
|
24
|
+
import {
|
|
25
|
+
DEFAULT_READY_TIMEOUT_MS,
|
|
26
|
+
MAX_READY_TIMEOUT_MS,
|
|
27
|
+
clampLimit,
|
|
28
|
+
} from "./limits.js";
|
|
29
|
+
import {
|
|
30
|
+
machineDoc,
|
|
31
|
+
observed,
|
|
32
|
+
recordFailure,
|
|
33
|
+
requireMachine,
|
|
34
|
+
type MachineDoc,
|
|
35
|
+
} from "./records.js";
|
|
36
|
+
import {
|
|
37
|
+
boundedTimeout,
|
|
38
|
+
requireNonEmpty,
|
|
39
|
+
requirePort,
|
|
40
|
+
requireSubdomainLabel,
|
|
41
|
+
} from "./validate.js";
|
|
42
|
+
|
|
43
|
+
// ---- Reactive reads -------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
export const get = query({
|
|
46
|
+
args: { machineId: v.string(), ownerId: v.optional(v.string()) },
|
|
47
|
+
returns: v.union(v.null(), machineDoc),
|
|
48
|
+
handler: async (ctx, { machineId, ownerId }) => {
|
|
49
|
+
const row = await ctx.db
|
|
50
|
+
.query("machines")
|
|
51
|
+
.withIndex("by_machine_id", (q) => q.eq("machineId", machineId))
|
|
52
|
+
.unique();
|
|
53
|
+
return row && row.ownerId === ownerId ? row : null;
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** The owner's machines, newest first. Destroyed machines stay listed. */
|
|
58
|
+
export const list = query({
|
|
59
|
+
args: { ownerId: v.optional(v.string()), limit: v.optional(v.number()) },
|
|
60
|
+
returns: v.array(machineDoc),
|
|
61
|
+
handler: async (ctx, { ownerId, limit }) => {
|
|
62
|
+
return await ctx.db
|
|
63
|
+
.query("machines")
|
|
64
|
+
.withIndex("by_owner", (q) => q.eq("ownerId", ownerId))
|
|
65
|
+
.order("desc")
|
|
66
|
+
.take(clampLimit(limit));
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ---- Bookkeeping ----------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
export const record = internalQuery({
|
|
73
|
+
args: { machineId: v.string() },
|
|
74
|
+
returns: v.union(v.null(), machineDoc),
|
|
75
|
+
handler: async (ctx, { machineId }) => {
|
|
76
|
+
return await ctx.db
|
|
77
|
+
.query("machines")
|
|
78
|
+
.withIndex("by_machine_id", (q) => q.eq("machineId", machineId))
|
|
79
|
+
.unique();
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Insert a machine's row or refresh it with what boxd reported. `ownerId`
|
|
85
|
+
* and `forkedFrom` are set once, on insert. A success clears `lastError`.
|
|
86
|
+
* "destroyed" is final: an action that read the machine before a concurrent
|
|
87
|
+
* destroy finished must not bring the row back to life.
|
|
88
|
+
*/
|
|
89
|
+
export const upsert = internalMutation({
|
|
90
|
+
args: {
|
|
91
|
+
machineId: v.string(),
|
|
92
|
+
name: v.string(),
|
|
93
|
+
status: v.string(),
|
|
94
|
+
image: v.optional(v.string()),
|
|
95
|
+
url: v.optional(v.string()),
|
|
96
|
+
vcpu: v.optional(v.number()),
|
|
97
|
+
memoryBytes: v.optional(v.number()),
|
|
98
|
+
ownerId: v.optional(v.string()),
|
|
99
|
+
forkedFrom: v.optional(v.string()),
|
|
100
|
+
},
|
|
101
|
+
returns: machineDoc,
|
|
102
|
+
handler: async (ctx, { ownerId, forkedFrom, ...fields }) => {
|
|
103
|
+
const row = await ctx.db
|
|
104
|
+
.query("machines")
|
|
105
|
+
.withIndex("by_machine_id", (q) => q.eq("machineId", fields.machineId))
|
|
106
|
+
.unique();
|
|
107
|
+
const updatedAt = Date.now();
|
|
108
|
+
if (row?.status === "destroyed") return row;
|
|
109
|
+
if (row) {
|
|
110
|
+
await ctx.db.patch("machines", row._id, {
|
|
111
|
+
...fields,
|
|
112
|
+
lastError: undefined,
|
|
113
|
+
updatedAt,
|
|
114
|
+
});
|
|
115
|
+
return { ...row, ...fields, lastError: undefined, updatedAt };
|
|
116
|
+
}
|
|
117
|
+
const doc = { ...fields, ownerId, forkedFrom, updatedAt };
|
|
118
|
+
const id = await ctx.db.insert("machines", doc);
|
|
119
|
+
return (await ctx.db.get("machines", id))!;
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
export const setError = internalMutation({
|
|
124
|
+
args: { machineId: v.string(), error: v.string() },
|
|
125
|
+
returns: v.null(),
|
|
126
|
+
handler: async (ctx, { machineId, error }) => {
|
|
127
|
+
const row = await ctx.db
|
|
128
|
+
.query("machines")
|
|
129
|
+
.withIndex("by_machine_id", (q) => q.eq("machineId", machineId))
|
|
130
|
+
.unique();
|
|
131
|
+
if (row) {
|
|
132
|
+
await ctx.db.patch("machines", row._id, {
|
|
133
|
+
lastError: error,
|
|
134
|
+
updatedAt: Date.now(),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/** Keep the row, and with it the execution history, as a record. */
|
|
142
|
+
export const markDestroyed = internalMutation({
|
|
143
|
+
args: { machineId: v.string() },
|
|
144
|
+
returns: v.union(v.null(), machineDoc),
|
|
145
|
+
handler: async (ctx, { machineId }) => {
|
|
146
|
+
const row = await ctx.db
|
|
147
|
+
.query("machines")
|
|
148
|
+
.withIndex("by_machine_id", (q) => q.eq("machineId", machineId))
|
|
149
|
+
.unique();
|
|
150
|
+
if (!row) return null;
|
|
151
|
+
const patch = {
|
|
152
|
+
status: "destroyed",
|
|
153
|
+
lastError: undefined,
|
|
154
|
+
updatedAt: Date.now(),
|
|
155
|
+
};
|
|
156
|
+
await ctx.db.patch("machines", row._id, patch);
|
|
157
|
+
return { ...row, ...patch };
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ---- Actions --------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
const target = { machineId: v.string(), ownerId: v.optional(v.string()) };
|
|
164
|
+
|
|
165
|
+
const readiness = {
|
|
166
|
+
/** Wait until the machine accepts an exec. Default true. */
|
|
167
|
+
waitUntilReady: v.optional(v.boolean()),
|
|
168
|
+
/** How long to wait, in ms. Default 120s, capped at 480s. */
|
|
169
|
+
readyTimeoutMs: v.optional(v.number()),
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
function readyTimeout(args: {
|
|
173
|
+
waitUntilReady?: boolean;
|
|
174
|
+
readyTimeoutMs?: number;
|
|
175
|
+
}): number | undefined {
|
|
176
|
+
if (args.waitUntilReady === false) return undefined;
|
|
177
|
+
return boundedTimeout(
|
|
178
|
+
args.readyTimeoutMs,
|
|
179
|
+
DEFAULT_READY_TIMEOUT_MS,
|
|
180
|
+
MAX_READY_TIMEOUT_MS,
|
|
181
|
+
"readyTimeoutMs",
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Run a boxd operation that ends in a fresh `Machine`, and store it. A
|
|
187
|
+
* failure lands on the row; a machine boxd no longer knows is marked
|
|
188
|
+
* destroyed.
|
|
189
|
+
*/
|
|
190
|
+
async function settle(
|
|
191
|
+
ctx: ActionCtx,
|
|
192
|
+
machineId: string,
|
|
193
|
+
operation: (boxd: Boxd) => Promise<Machine>,
|
|
194
|
+
): Promise<MachineDoc> {
|
|
195
|
+
let machine: Machine;
|
|
196
|
+
try {
|
|
197
|
+
machine = await withBoxd(ctx, operation);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (isNotFound(error)) {
|
|
200
|
+
await ctx.runMutation(internal.machines.markDestroyed, { machineId });
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
return await recordFailure(ctx, machineId, error);
|
|
204
|
+
}
|
|
205
|
+
return await ctx.runMutation(internal.machines.upsert, observed(machine));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Store a machine boxd just created. If the row can't be written, destroy
|
|
210
|
+
* the machine: a machine no row tracks would run, and bill, unseen.
|
|
211
|
+
*/
|
|
212
|
+
async function adopt(
|
|
213
|
+
ctx: ActionCtx,
|
|
214
|
+
machine: Machine,
|
|
215
|
+
extra: { ownerId?: string; forkedFrom?: string },
|
|
216
|
+
): Promise<MachineDoc> {
|
|
217
|
+
try {
|
|
218
|
+
return await ctx.runMutation(internal.machines.upsert, {
|
|
219
|
+
...observed(machine),
|
|
220
|
+
...extra,
|
|
221
|
+
});
|
|
222
|
+
} catch (error) {
|
|
223
|
+
await withBoxd(ctx, (boxd) => boxd.machines.delete(machine.id)).catch(
|
|
224
|
+
(cleanup: unknown) =>
|
|
225
|
+
console.error(
|
|
226
|
+
`boxd: could not destroy untracked machine ${machine.id}: ${errorMessage(cleanup)}`,
|
|
227
|
+
),
|
|
228
|
+
);
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Wait for a machine `create` or `fork` just made. On failure the machine
|
|
235
|
+
* still exists and bills, so the error carries its `machineId`: the caller
|
|
236
|
+
* can destroy it, or wait longer, instead of creating another one.
|
|
237
|
+
*/
|
|
238
|
+
async function ready(
|
|
239
|
+
ctx: ActionCtx,
|
|
240
|
+
row: MachineDoc,
|
|
241
|
+
timeout: number | undefined,
|
|
242
|
+
): Promise<MachineDoc> {
|
|
243
|
+
if (timeout === undefined) return row;
|
|
244
|
+
try {
|
|
245
|
+
return await settle(ctx, row.machineId, (boxd) =>
|
|
246
|
+
boxd.machines.waitUntilReady(row.machineId, { timeout }),
|
|
247
|
+
);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
const { data } = toConvexError(error);
|
|
250
|
+
throw new ConvexError({ ...data, machineId: row.machineId });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export const create = action({
|
|
255
|
+
args: {
|
|
256
|
+
ownerId: v.optional(v.string()),
|
|
257
|
+
/** Unique in the key's org. Omit for a generated name. */
|
|
258
|
+
name: v.optional(v.string()),
|
|
259
|
+
/** OCI image. Omit for the boxd default image. */
|
|
260
|
+
image: v.optional(v.string()),
|
|
261
|
+
/** Environment variables for the machine. Not stored by the component. */
|
|
262
|
+
env: v.optional(v.record(v.string(), v.string())),
|
|
263
|
+
/** Size class: 1, 2 or 4 vCPU. The memory follows (4, 8 or 16 GiB). */
|
|
264
|
+
vcpu: v.optional(v.number()),
|
|
265
|
+
/** Size class by memory: "4G", "8G" or "16G". */
|
|
266
|
+
memory: v.optional(v.string()),
|
|
267
|
+
/** Idle seconds before boxd suspends the machine. 0 disables. */
|
|
268
|
+
autoSuspendSeconds: v.optional(v.number()),
|
|
269
|
+
/** Idle seconds before boxd destroys the machine. 0 disables. */
|
|
270
|
+
autoDestroySeconds: v.optional(v.number()),
|
|
271
|
+
/** Reachable only through explicit networks; no in-VM boxd CLI. */
|
|
272
|
+
isolated: v.optional(v.boolean()),
|
|
273
|
+
...readiness,
|
|
274
|
+
},
|
|
275
|
+
returns: machineDoc,
|
|
276
|
+
handler: async (ctx, args) => {
|
|
277
|
+
const timeout = readyTimeout(args);
|
|
278
|
+
if (args.name !== undefined) requireNonEmpty(args.name, "name");
|
|
279
|
+
const machine = await withBoxd(ctx, (boxd) =>
|
|
280
|
+
boxd.machines.create({
|
|
281
|
+
name: args.name,
|
|
282
|
+
image: args.image,
|
|
283
|
+
env: args.env,
|
|
284
|
+
isolated: args.isolated,
|
|
285
|
+
config: {
|
|
286
|
+
vcpu: args.vcpu,
|
|
287
|
+
memory: args.memory,
|
|
288
|
+
autoSuspendTimeout: args.autoSuspendSeconds,
|
|
289
|
+
autoDestroyTimeout: args.autoDestroySeconds,
|
|
290
|
+
},
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
const row = await adopt(ctx, machine, { ownerId: args.ownerId });
|
|
294
|
+
return await ready(ctx, row, timeout);
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Fork a running machine: a copy of its disk and memory, booted as a new
|
|
300
|
+
* machine. The fork has the source's owner and records `forkedFrom`.
|
|
301
|
+
*/
|
|
302
|
+
export const fork = action({
|
|
303
|
+
args: { ...target, name: v.optional(v.string()), ...readiness },
|
|
304
|
+
returns: machineDoc,
|
|
305
|
+
handler: async (ctx, args) => {
|
|
306
|
+
const timeout = readyTimeout(args);
|
|
307
|
+
if (args.name !== undefined) requireNonEmpty(args.name, "name");
|
|
308
|
+
const source = await requireMachine(ctx, args.machineId, args.ownerId);
|
|
309
|
+
let machine: Machine;
|
|
310
|
+
try {
|
|
311
|
+
machine = await withBoxd(ctx, (boxd) =>
|
|
312
|
+
boxd.machines.fork(source.machineId, { name: args.name }),
|
|
313
|
+
);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
return await recordFailure(ctx, source.machineId, error);
|
|
316
|
+
}
|
|
317
|
+
const row = await adopt(ctx, machine, {
|
|
318
|
+
ownerId: source.ownerId,
|
|
319
|
+
forkedFrom: source.machineId,
|
|
320
|
+
});
|
|
321
|
+
return await ready(ctx, row, timeout);
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
/** Re-read the machine from boxd into its row. */
|
|
326
|
+
export const refresh = action({
|
|
327
|
+
args: target,
|
|
328
|
+
returns: machineDoc,
|
|
329
|
+
handler: async (ctx, { machineId, ownerId }) => {
|
|
330
|
+
await requireMachine(ctx, machineId, ownerId);
|
|
331
|
+
return await settle(ctx, machineId, (boxd) => boxd.machines.get(machineId));
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
type Transition = "start" | "stop" | "pause" | "resume" | "hibernate" | "wake";
|
|
336
|
+
|
|
337
|
+
/** The status each transition ends in. */
|
|
338
|
+
const TARGET: Record<Transition, string> = {
|
|
339
|
+
start: "running",
|
|
340
|
+
resume: "running",
|
|
341
|
+
wake: "running",
|
|
342
|
+
stop: "stopped",
|
|
343
|
+
pause: "suspended",
|
|
344
|
+
hibernate: "hibernated",
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
/** How long a sleeping transition polls for its target status. */
|
|
348
|
+
const SETTLE_TIMEOUT_MS = 60_000;
|
|
349
|
+
const SETTLE_POLL_MS = 500;
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Ask boxd for `verb`. A machine already in the target status is not an
|
|
353
|
+
* error: boxd refuses `resume` on a running machine with a conflict, but
|
|
354
|
+
* the caller's goal is reached.
|
|
355
|
+
*/
|
|
356
|
+
async function request(
|
|
357
|
+
boxd: Boxd,
|
|
358
|
+
machineId: string,
|
|
359
|
+
verb: Transition,
|
|
360
|
+
): Promise<void> {
|
|
361
|
+
try {
|
|
362
|
+
await boxd.machines[verb](machineId);
|
|
363
|
+
} catch (error) {
|
|
364
|
+
if (toConvexError(error).data.code !== "CONFLICT") throw error;
|
|
365
|
+
const machine = await boxd.machines.get(machineId);
|
|
366
|
+
if (machine.status !== TARGET[verb]) throw error;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Poll until the machine reports `status`, or return the last observed
|
|
372
|
+
* machine after `timeoutMs`: a transition still in progress is not a
|
|
373
|
+
* failure, and the row shows exactly what boxd reported.
|
|
374
|
+
*/
|
|
375
|
+
async function reach(
|
|
376
|
+
boxd: Boxd,
|
|
377
|
+
machineId: string,
|
|
378
|
+
status: string,
|
|
379
|
+
timeoutMs: number,
|
|
380
|
+
): Promise<Machine> {
|
|
381
|
+
const deadline = Date.now() + timeoutMs;
|
|
382
|
+
let machine = await boxd.machines.get(machineId);
|
|
383
|
+
while (machine.status !== status && Date.now() < deadline) {
|
|
384
|
+
await new Promise((resolve) => setTimeout(resolve, SETTLE_POLL_MS));
|
|
385
|
+
machine = await boxd.machines.get(machineId);
|
|
386
|
+
}
|
|
387
|
+
return machine;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* A transition that ends with the machine asleep or off. Returns once boxd
|
|
392
|
+
* reports the target status, or after 60 seconds with the status it reports
|
|
393
|
+
* then.
|
|
394
|
+
*/
|
|
395
|
+
function sleeping(verb: Transition) {
|
|
396
|
+
return action({
|
|
397
|
+
args: target,
|
|
398
|
+
returns: machineDoc,
|
|
399
|
+
handler: async (ctx, { machineId, ownerId }) => {
|
|
400
|
+
await requireMachine(ctx, machineId, ownerId);
|
|
401
|
+
return await settle(ctx, machineId, async (boxd) => {
|
|
402
|
+
await request(boxd, machineId, verb);
|
|
403
|
+
return await reach(boxd, machineId, TARGET[verb], SETTLE_TIMEOUT_MS);
|
|
404
|
+
});
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* A transition that ends with the machine running. By default it returns
|
|
411
|
+
* once the machine accepts an exec.
|
|
412
|
+
*/
|
|
413
|
+
function waking(verb: Transition) {
|
|
414
|
+
return action({
|
|
415
|
+
args: { ...target, ...readiness },
|
|
416
|
+
returns: machineDoc,
|
|
417
|
+
handler: async (ctx, args) => {
|
|
418
|
+
const timeout = readyTimeout(args);
|
|
419
|
+
await requireMachine(ctx, args.machineId, args.ownerId);
|
|
420
|
+
return await settle(ctx, args.machineId, async (boxd) => {
|
|
421
|
+
await request(boxd, args.machineId, verb);
|
|
422
|
+
return timeout === undefined
|
|
423
|
+
? await boxd.machines.get(args.machineId)
|
|
424
|
+
: await boxd.machines.waitUntilReady(args.machineId, { timeout });
|
|
425
|
+
});
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Boot a stopped machine (cold boot: disk kept, memory not). */
|
|
431
|
+
export const start = waking("start");
|
|
432
|
+
/** Shut the machine down. The disk is kept. */
|
|
433
|
+
export const stop = sleeping("stop");
|
|
434
|
+
/** Suspend to RAM: frozen in place, resumes in milliseconds. */
|
|
435
|
+
export const pause = sleeping("pause");
|
|
436
|
+
/** Resume a paused machine. */
|
|
437
|
+
export const resume = waking("resume");
|
|
438
|
+
/** Suspend to disk: frees the host's memory, `wake` restores it. */
|
|
439
|
+
export const hibernate = sleeping("hibernate");
|
|
440
|
+
/** Restore a hibernated machine, memory included. */
|
|
441
|
+
export const wake = waking("wake");
|
|
442
|
+
|
|
443
|
+
/** Destroy the machine. Its row stays, with status "destroyed". */
|
|
444
|
+
export const destroy = action({
|
|
445
|
+
args: target,
|
|
446
|
+
returns: machineDoc,
|
|
447
|
+
handler: async (ctx, { machineId, ownerId }): Promise<MachineDoc> => {
|
|
448
|
+
await requireMachine(ctx, machineId, ownerId);
|
|
449
|
+
try {
|
|
450
|
+
await withBoxd(ctx, (boxd) => boxd.machines.delete(machineId));
|
|
451
|
+
} catch (error) {
|
|
452
|
+
// Already gone on boxd: the goal is reached.
|
|
453
|
+
if (!isNotFound(error)) return await recordFailure(ctx, machineId, error);
|
|
454
|
+
}
|
|
455
|
+
const row: MachineDoc | null = await ctx.runMutation(
|
|
456
|
+
internal.machines.markDestroyed,
|
|
457
|
+
{ machineId },
|
|
458
|
+
);
|
|
459
|
+
return row!;
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Route public HTTPS to a port inside the machine. Without `name`, this
|
|
465
|
+
* points the machine's default URL (`https://<machine>.<zone>`) at `port`.
|
|
466
|
+
* With `name`, it adds `https://<name>.<machine>.<zone>` beside it.
|
|
467
|
+
*/
|
|
468
|
+
export const expose = action({
|
|
469
|
+
args: { ...target, port: v.number(), name: v.optional(v.string()) },
|
|
470
|
+
returns: v.object({ url: v.string(), port: v.number() }),
|
|
471
|
+
handler: async (ctx, { machineId, ownerId, port, name }) => {
|
|
472
|
+
requirePort(port);
|
|
473
|
+
if (name !== undefined) requireSubdomainLabel(name);
|
|
474
|
+
const row = await requireMachine(ctx, machineId, ownerId);
|
|
475
|
+
try {
|
|
476
|
+
return await withBoxd(ctx, async (boxd) => {
|
|
477
|
+
const base = row.url ?? (await boxd.machines.get(machineId)).access.url;
|
|
478
|
+
if (!base) {
|
|
479
|
+
throw boxdError(
|
|
480
|
+
"BOXD_ERROR",
|
|
481
|
+
`machine ${machineId} has no HTTPS URL to route`,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
if (name === undefined) {
|
|
485
|
+
await boxd.machines.proxies.setPort(machineId, port);
|
|
486
|
+
return { url: base, port };
|
|
487
|
+
}
|
|
488
|
+
const created = await boxd.machines.proxies.create(
|
|
489
|
+
machineId,
|
|
490
|
+
name,
|
|
491
|
+
port,
|
|
492
|
+
);
|
|
493
|
+
const host = new URL(base).host;
|
|
494
|
+
return { url: `https://${created.name}.${host}`, port: created.port };
|
|
495
|
+
});
|
|
496
|
+
} catch (error) {
|
|
497
|
+
return await recordFailure(ctx, machineId, error);
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers shared by the action modules: turning an SDK `Machine` into row
|
|
3
|
+
* fields, the tenant check, and failure bookkeeping.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Machine } from "@boxd-sh/sdk/web";
|
|
7
|
+
import type { Infer } from "convex/values";
|
|
8
|
+
import { v } from "convex/values";
|
|
9
|
+
import { internal } from "./_generated/api.js";
|
|
10
|
+
import type { ActionCtx } from "./_generated/server.js";
|
|
11
|
+
import { boxdError, errorMessage } from "./errors.js";
|
|
12
|
+
import schema from "./schema.js";
|
|
13
|
+
|
|
14
|
+
export const machineDoc = schema.tables.machines.validator.extend({
|
|
15
|
+
_id: v.id("machines"),
|
|
16
|
+
_creationTime: v.number(),
|
|
17
|
+
});
|
|
18
|
+
export type MachineDoc = Infer<typeof machineDoc>;
|
|
19
|
+
|
|
20
|
+
export const executionDoc = schema.tables.executions.validator.extend({
|
|
21
|
+
_id: v.id("executions"),
|
|
22
|
+
_creationTime: v.number(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** The row fields boxd owns, read off an SDK `Machine`. */
|
|
26
|
+
export function observed(machine: Machine) {
|
|
27
|
+
return {
|
|
28
|
+
machineId: machine.id,
|
|
29
|
+
name: machine.name,
|
|
30
|
+
status: machine.status,
|
|
31
|
+
image: machine.imageRef || undefined,
|
|
32
|
+
url: machine.access.url || undefined,
|
|
33
|
+
vcpu: machine.resources.vcpu || undefined,
|
|
34
|
+
memoryBytes: machine.resources.memoryBytes || undefined,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The machine's row, if it exists and belongs to `ownerId`.
|
|
40
|
+
*
|
|
41
|
+
* `ownerId` must match exactly: a machine created without one is only
|
|
42
|
+
* reachable without one. A mismatch reads as NOT_FOUND, so a caller can't
|
|
43
|
+
* probe for other tenants' machines.
|
|
44
|
+
*/
|
|
45
|
+
export async function requireMachine(
|
|
46
|
+
ctx: ActionCtx,
|
|
47
|
+
machineId: string,
|
|
48
|
+
ownerId: string | undefined,
|
|
49
|
+
): Promise<MachineDoc> {
|
|
50
|
+
const row = await ctx.runQuery(internal.machines.record, { machineId });
|
|
51
|
+
if (!row || row.ownerId !== ownerId) {
|
|
52
|
+
throw boxdError(
|
|
53
|
+
"NOT_FOUND",
|
|
54
|
+
`no machine ${machineId} is managed by this component for this owner`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return row;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Store the failure on the machine's row, then rethrow it. */
|
|
61
|
+
export async function recordFailure(
|
|
62
|
+
ctx: ActionCtx,
|
|
63
|
+
machineId: string,
|
|
64
|
+
error: unknown,
|
|
65
|
+
): Promise<never> {
|
|
66
|
+
await ctx.runMutation(internal.machines.setError, {
|
|
67
|
+
machineId,
|
|
68
|
+
error: errorMessage(error),
|
|
69
|
+
});
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { defineSchema, defineTable } from "convex/server";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A reactive mirror of one boxd machine this component created or forked.
|
|
6
|
+
*
|
|
7
|
+
* boxd stays the source of truth: every lifecycle action writes back the
|
|
8
|
+
* status it observed, and `machines.refresh` re-reads it on demand. The row
|
|
9
|
+
* outlives the machine (status `"destroyed"`) so its execution history stays
|
|
10
|
+
* readable.
|
|
11
|
+
*/
|
|
12
|
+
export const machineFields = {
|
|
13
|
+
/** boxd machine id. External, not a Convex document id. */
|
|
14
|
+
machineId: v.string(),
|
|
15
|
+
name: v.string(),
|
|
16
|
+
/**
|
|
17
|
+
* Opaque tenant key from the host app. Components can't read `ctx.auth`, so
|
|
18
|
+
* the app derives this from its own auth and every call must present the
|
|
19
|
+
* same value to touch the machine.
|
|
20
|
+
*/
|
|
21
|
+
ownerId: v.optional(v.string()),
|
|
22
|
+
/** Last observed boxd status: `running`, `suspended`, `hibernated`, ... */
|
|
23
|
+
status: v.string(),
|
|
24
|
+
image: v.optional(v.string()),
|
|
25
|
+
/** `https://<name>.<zone>`, the machine's default HTTPS route. */
|
|
26
|
+
url: v.optional(v.string()),
|
|
27
|
+
vcpu: v.optional(v.number()),
|
|
28
|
+
memoryBytes: v.optional(v.number()),
|
|
29
|
+
/** Set when this machine is a fork of another machine. */
|
|
30
|
+
forkedFrom: v.optional(v.string()),
|
|
31
|
+
/** The last failed operation's message. Cleared by the next success. */
|
|
32
|
+
lastError: v.optional(v.string()),
|
|
33
|
+
updatedAt: v.number(),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const executionStatus = v.union(
|
|
37
|
+
v.literal("running"),
|
|
38
|
+
v.literal("completed"),
|
|
39
|
+
v.literal("failed"),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/** One row per `exec`, so the app can render live status and history. */
|
|
43
|
+
export const executionFields = {
|
|
44
|
+
machineId: v.string(),
|
|
45
|
+
ownerId: v.optional(v.string()),
|
|
46
|
+
/** The command line that ran, truncated for storage. */
|
|
47
|
+
command: v.string(),
|
|
48
|
+
cwd: v.optional(v.string()),
|
|
49
|
+
status: executionStatus,
|
|
50
|
+
exitCode: v.optional(v.number()),
|
|
51
|
+
/** Truncated for storage; the action's return value carries more. */
|
|
52
|
+
stdout: v.optional(v.string()),
|
|
53
|
+
stderr: v.optional(v.string()),
|
|
54
|
+
error: v.optional(v.string()),
|
|
55
|
+
startedAt: v.number(),
|
|
56
|
+
finishedAt: v.optional(v.number()),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export default defineSchema({
|
|
60
|
+
machines: defineTable(machineFields)
|
|
61
|
+
.index("by_machine_id", ["machineId"])
|
|
62
|
+
.index("by_owner", ["ownerId"]),
|
|
63
|
+
executions: defineTable(executionFields).index("by_machine", ["machineId"]),
|
|
64
|
+
/**
|
|
65
|
+
* Cached session tokens, keyed by a SHA-256 of the API key and endpoint.
|
|
66
|
+
* boxd rate-limits the key-for-token exchange per source IP, and every
|
|
67
|
+
* Convex deployment in a region shares a few egress IPs, so each action
|
|
68
|
+
* must reuse the token rather than exchange the key again. Internal only:
|
|
69
|
+
* nothing in the component's public API returns these rows.
|
|
70
|
+
*/
|
|
71
|
+
sessions: defineTable({
|
|
72
|
+
keyHash: v.string(),
|
|
73
|
+
token: v.string(),
|
|
74
|
+
/** Epoch milliseconds. */
|
|
75
|
+
expiresAt: v.number(),
|
|
76
|
+
}).index("by_key_hash", ["keyHash"]),
|
|
77
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session-token cache behind every boxd call. Internal functions only:
|
|
3
|
+
* the app can't read a token through the component's API.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { v } from "convex/values";
|
|
7
|
+
import { internalMutation, internalQuery } from "./_generated/server.js";
|
|
8
|
+
|
|
9
|
+
const session = v.object({ token: v.string(), expiresAt: v.number() });
|
|
10
|
+
|
|
11
|
+
export const get = internalQuery({
|
|
12
|
+
args: { keyHash: v.string() },
|
|
13
|
+
returns: v.union(v.null(), session),
|
|
14
|
+
handler: async (ctx, { keyHash }) => {
|
|
15
|
+
const row = await ctx.db
|
|
16
|
+
.query("sessions")
|
|
17
|
+
.withIndex("by_key_hash", (q) => q.eq("keyHash", keyHash))
|
|
18
|
+
.unique();
|
|
19
|
+
return row ? { token: row.token, expiresAt: row.expiresAt } : null;
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const put = internalMutation({
|
|
24
|
+
args: { keyHash: v.string(), token: v.string(), expiresAt: v.number() },
|
|
25
|
+
returns: v.null(),
|
|
26
|
+
handler: async (ctx, args) => {
|
|
27
|
+
const row = await ctx.db
|
|
28
|
+
.query("sessions")
|
|
29
|
+
.withIndex("by_key_hash", (q) => q.eq("keyHash", args.keyHash))
|
|
30
|
+
.unique();
|
|
31
|
+
if (row) {
|
|
32
|
+
// Two actions can exchange at the same time. Keep the token that lives
|
|
33
|
+
// longer, so a slow writer never replaces a fresher one.
|
|
34
|
+
if (row.expiresAt < args.expiresAt) {
|
|
35
|
+
await ctx.db.patch("sessions", row._id, {
|
|
36
|
+
token: args.token,
|
|
37
|
+
expiresAt: args.expiresAt,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
await ctx.db.insert("sessions", args);
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** Forget a token boxd rejected, so the next call exchanges the key again. */
|
|
48
|
+
export const drop = internalMutation({
|
|
49
|
+
args: { keyHash: v.string(), token: v.string() },
|
|
50
|
+
returns: v.null(),
|
|
51
|
+
handler: async (ctx, { keyHash, token }) => {
|
|
52
|
+
const row = await ctx.db
|
|
53
|
+
.query("sessions")
|
|
54
|
+
.withIndex("by_key_hash", (q) => q.eq("keyHash", keyHash))
|
|
55
|
+
.unique();
|
|
56
|
+
// Only drop the token that failed: another action may already have
|
|
57
|
+
// stored a fresh one.
|
|
58
|
+
if (row && row.token === token) await ctx.db.delete("sessions", row._id);
|
|
59
|
+
return null;
|
|
60
|
+
},
|
|
61
|
+
});
|