+1
deno.json
+1
deno.json
+5
deno.lock
+5
deno.lock
···
41
41
"npm:chalk@^5.6.2": "5.6.2",
42
42
"npm:dayjs@^1.11.19": "1.11.19",
43
43
"npm:effect@^3.19.2": "3.19.2",
44
+
"npm:hono@^4.10.6": "4.10.6",
44
45
"npm:kysely@0.27.6": "0.27.6",
45
46
"npm:kysely@~0.27.2": "0.27.6",
46
47
"npm:moniker@~0.1.2": "0.1.2"
···
230
231
"pure-rand"
231
232
]
232
233
},
234
+
"hono@4.10.6": {
235
+
"integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g=="
236
+
},
233
237
"kysely@0.27.6": {
234
238
"integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ=="
235
239
},
···
261
265
"npm:chalk@^5.6.2",
262
266
"npm:dayjs@^1.11.19",
263
267
"npm:effect@^3.19.2",
268
+
"npm:hono@^4.10.6",
264
269
"npm:kysely@0.27.6",
265
270
"npm:moniker@~0.1.2"
266
271
]
+6
-1
main.ts
+6
-1
main.ts
···
25
25
import stop from "./src/subcommands/stop.ts";
26
26
import tag from "./src/subcommands/tag.ts";
27
27
import * as volumes from "./src/subcommands/volume.ts";
28
-
28
+
import serve from "./src/api/mod.ts";
29
29
import { getImage } from "./src/images.ts";
30
30
import { getImageArchivePath } from "./src/mod.ts";
31
31
import {
···
406
406
}),
407
407
)
408
408
.description("Manage volumes")
409
+
.command("serve", "Start the dflybsd-up HTTP API server")
410
+
.option("-p, --port <port:number>", "Port to listen on", { default: 8893 })
411
+
.action(() => {
412
+
serve();
413
+
})
409
414
.parse(Deno.args);
410
415
}
+34
src/api/images.ts
+34
src/api/images.ts
···
1
+
import { Hono } from "hono";
2
+
import { Effect, pipe } from "effect";
3
+
import { parseParams, presentation } from "./utils.ts";
4
+
import { getImage, listImages } from "../images.ts";
5
+
6
+
const app = new Hono();
7
+
8
+
app.get("/", (c) =>
9
+
Effect.runPromise(
10
+
pipe(
11
+
listImages(),
12
+
presentation(c),
13
+
),
14
+
));
15
+
16
+
app.get("/:id", (c) =>
17
+
Effect.runPromise(
18
+
pipe(
19
+
parseParams(c),
20
+
Effect.flatMap(({ id }) => getImage(id)),
21
+
presentation(c),
22
+
),
23
+
));
24
+
25
+
app.post("/", (c) => {
26
+
return c.json({ message: "New image created" });
27
+
});
28
+
29
+
app.delete("/:id", (c) => {
30
+
const { id } = c.req.param();
31
+
return c.json({ message: `Image with ID ${id} deleted` });
32
+
});
33
+
34
+
export default app;
+219
src/api/machines.ts
+219
src/api/machines.ts
···
1
+
import { Hono } from "hono";
2
+
import { Data, Effect, pipe } from "effect";
3
+
import {
4
+
createVolumeIfNeeded,
5
+
handleError,
6
+
parseCreateMachineRequest,
7
+
parseParams,
8
+
parseQueryParams,
9
+
parseStartRequest,
10
+
presentation,
11
+
} from "./utils.ts";
12
+
import { DEFAULT_VERSION, getInstanceState, LOGS_DIR } from "../mod.ts";
13
+
import {
14
+
listInstances,
15
+
removeInstanceState,
16
+
saveInstanceState,
17
+
updateInstanceState,
18
+
} from "../state.ts";
19
+
import { findVm, killProcess, updateToStopped } from "../subcommands/stop.ts";
20
+
import {
21
+
buildDetachedCommand,
22
+
buildQemuArgs,
23
+
createLogsDir,
24
+
failIfVMRunning,
25
+
startDetachedQemu,
26
+
} from "../subcommands/start.ts";
27
+
import type { NewMachine } from "../types.ts";
28
+
import { createId } from "@paralleldrive/cuid2";
29
+
import { generateRandomMacAddress } from "../network.ts";
30
+
import Moniker from "moniker";
31
+
import { getImage } from "../images.ts";
32
+
33
+
export class ImageNotFoundError extends Data.TaggedError("ImageNotFoundError")<{
34
+
id: string;
35
+
}> {}
36
+
37
+
export class RemoveRunningVmError extends Data.TaggedError(
38
+
"RemoveRunningVmError",
39
+
)<{
40
+
id: string;
41
+
}> {}
42
+
43
+
const app = new Hono();
44
+
45
+
app.get("/", (c) =>
46
+
Effect.runPromise(
47
+
pipe(
48
+
parseQueryParams(c),
49
+
Effect.flatMap((params) =>
50
+
listInstances(
51
+
params.all === "true" || params.all === "1",
52
+
)
53
+
),
54
+
presentation(c),
55
+
),
56
+
));
57
+
58
+
app.post("/", (c) =>
59
+
Effect.runPromise(
60
+
pipe(
61
+
parseCreateMachineRequest(c),
62
+
Effect.flatMap((params: NewMachine) =>
63
+
Effect.gen(function* () {
64
+
const image = yield* getImage(params.image);
65
+
if (!image) {
66
+
return yield* Effect.fail(
67
+
new ImageNotFoundError({ id: params.image }),
68
+
);
69
+
}
70
+
71
+
const volume = params.volume
72
+
? yield* createVolumeIfNeeded(image, params.volume)
73
+
: undefined;
74
+
75
+
const macAddress = yield* generateRandomMacAddress();
76
+
const id = createId();
77
+
yield* saveInstanceState({
78
+
id,
79
+
name: Moniker.choose(),
80
+
bridge: params.bridge,
81
+
macAddress,
82
+
memory: params.memory || "2G",
83
+
cpus: params.cpus || 8,
84
+
cpu: params.cpu || "host",
85
+
diskSize: "20G",
86
+
diskFormat: volume ? "qcow2" : "raw",
87
+
portForward: params.portForward
88
+
? params.portForward.join(",")
89
+
: undefined,
90
+
drivePath: volume ? volume.path : image.path,
91
+
version: image.tag ?? DEFAULT_VERSION,
92
+
status: "STOPPED",
93
+
pid: 0,
94
+
});
95
+
96
+
const createdVm = yield* findVm(id);
97
+
return createdVm;
98
+
})
99
+
),
100
+
presentation(c),
101
+
Effect.catchAll((error) => handleError(error, c)),
102
+
),
103
+
));
104
+
105
+
app.get("/:id", (c) =>
106
+
Effect.runPromise(
107
+
pipe(
108
+
parseParams(c),
109
+
Effect.flatMap(({ id }) => getInstanceState(id)),
110
+
presentation(c),
111
+
),
112
+
));
113
+
114
+
app.delete("/:id", (c) =>
115
+
Effect.runPromise(
116
+
pipe(
117
+
parseParams(c),
118
+
Effect.flatMap(({ id }) => findVm(id)),
119
+
Effect.flatMap((vm) =>
120
+
vm.status === "RUNNING"
121
+
? Effect.fail(new RemoveRunningVmError({ id: vm.id }))
122
+
: Effect.succeed(vm)
123
+
),
124
+
Effect.flatMap((vm) =>
125
+
Effect.gen(function* () {
126
+
yield* removeInstanceState(vm.id);
127
+
return vm;
128
+
})
129
+
),
130
+
presentation(c),
131
+
Effect.catchAll((error) => handleError(error, c)),
132
+
),
133
+
));
134
+
135
+
app.post("/:id/start", (c) =>
136
+
Effect.runPromise(
137
+
pipe(
138
+
Effect.all([parseParams(c), parseStartRequest(c)]),
139
+
Effect.flatMap((
140
+
[{ id }, startRequest],
141
+
) => Effect.all([findVm(id), Effect.succeed(startRequest)])),
142
+
Effect.flatMap(([vm, startRequest]) =>
143
+
Effect.gen(function* () {
144
+
yield* failIfVMRunning(vm);
145
+
const mergedVm = {
146
+
...vm,
147
+
cpu: String(startRequest.cpu ?? vm.cpu),
148
+
cpus: startRequest.cpus ?? vm.cpus,
149
+
memory: startRequest.memory ?? vm.memory,
150
+
portForward: startRequest.portForward
151
+
? startRequest.portForward.join(",")
152
+
: vm.portForward,
153
+
};
154
+
const qemuArgs = buildQemuArgs(mergedVm);
155
+
const logPath = `${LOGS_DIR}/${vm.name}.log`;
156
+
const fullCommand = buildDetachedCommand(vm, qemuArgs, logPath);
157
+
158
+
yield* createLogsDir();
159
+
const qemuPid = yield* startDetachedQemu(fullCommand);
160
+
yield* updateInstanceState(vm.id, "RUNNING", qemuPid);
161
+
return { ...vm, status: "RUNNING" };
162
+
})
163
+
),
164
+
presentation(c),
165
+
Effect.catchAll((error) => handleError(error, c)),
166
+
),
167
+
));
168
+
169
+
app.post("/:id/stop", (c) =>
170
+
Effect.runPromise(
171
+
pipe(
172
+
parseParams(c),
173
+
Effect.flatMap(({ id }) => findVm(id)),
174
+
Effect.flatMap(killProcess),
175
+
Effect.flatMap(updateToStopped),
176
+
presentation(c),
177
+
Effect.catchAll((error) => handleError(error, c)),
178
+
),
179
+
));
180
+
181
+
app.post("/:id/restart", (c) =>
182
+
Effect.runPromise(
183
+
pipe(
184
+
parseParams(c),
185
+
Effect.flatMap(({ id }) => findVm(id)),
186
+
Effect.flatMap(killProcess),
187
+
Effect.flatMap(updateToStopped),
188
+
Effect.flatMap(() => Effect.all([parseParams(c), parseStartRequest(c)])),
189
+
Effect.flatMap((
190
+
[{ id }, startRequest],
191
+
) => Effect.all([findVm(id), Effect.succeed(startRequest)])),
192
+
Effect.flatMap(([vm, startRequest]) =>
193
+
Effect.gen(function* () {
194
+
yield* failIfVMRunning(vm);
195
+
const mergedVm = {
196
+
...vm,
197
+
cpu: String(startRequest.cpu ?? vm.cpu),
198
+
cpus: startRequest.cpus ?? vm.cpus,
199
+
memory: startRequest.memory ?? vm.memory,
200
+
portForward: startRequest.portForward
201
+
? startRequest.portForward.join(",")
202
+
: vm.portForward,
203
+
};
204
+
const qemuArgs = buildQemuArgs(mergedVm);
205
+
const logPath = `${LOGS_DIR}/${vm.name}.log`;
206
+
const fullCommand = buildDetachedCommand(vm, qemuArgs, logPath);
207
+
208
+
yield* createLogsDir();
209
+
const qemuPid = yield* startDetachedQemu(fullCommand);
210
+
yield* updateInstanceState(vm.id, "RUNNING", qemuPid);
211
+
return { ...vm, status: "RUNNING" };
212
+
})
213
+
),
214
+
presentation(c),
215
+
Effect.catchAll((error) => handleError(error, c)),
216
+
),
217
+
));
218
+
219
+
export default app;
+46
src/api/mod.ts
+46
src/api/mod.ts
···
1
+
import machines from "./machines.ts";
2
+
import images from "./images.ts";
3
+
import volumes from "./volumes.ts";
4
+
import { Hono } from "hono";
5
+
import { logger } from "hono/logger";
6
+
import { cors } from "hono/cors";
7
+
import { bearerAuth } from "hono/bearer-auth";
8
+
import { parseFlags } from "@cliffy/flags";
9
+
10
+
export { images, machines, volumes };
11
+
12
+
export default function () {
13
+
const token = Deno.env.get("DFLYBSD_UP_API_TOKEN") ||
14
+
crypto.randomUUID();
15
+
const { flags } = parseFlags(Deno.args);
16
+
17
+
if (!Deno.env.get("DFLYBSD_UP_API_TOKEN")) {
18
+
console.log(`Using API token: ${token}`);
19
+
} else {
20
+
console.log(
21
+
`Using provided API token from environment variable DFLYBSD_UP_API_TOKEN`,
22
+
);
23
+
}
24
+
25
+
const app = new Hono();
26
+
27
+
app.use(logger());
28
+
app.use(cors());
29
+
30
+
app.use("/images/*", bearerAuth({ token }));
31
+
app.use("/machines/*", bearerAuth({ token }));
32
+
app.use("/volumes/*", bearerAuth({ token }));
33
+
34
+
app.route("/images", images);
35
+
app.route("/machines", machines);
36
+
app.route("/volumes", volumes);
37
+
38
+
const port = Number(
39
+
flags.port || flags.p ||
40
+
(Deno.env.get("DFLYBSD_UP_PORT")
41
+
? Number(Deno.env.get("DFLYBSD_UP_PORT"))
42
+
: 8893),
43
+
);
44
+
45
+
Deno.serve({ port }, app.fetch);
46
+
}
+158
src/api/utils.ts
+158
src/api/utils.ts
···
1
+
import { Data, Effect } from "effect";
2
+
import type { Context } from "hono";
3
+
import {
4
+
type CommandError,
5
+
StopCommandError,
6
+
VmNotFoundError,
7
+
} from "../subcommands/stop.ts";
8
+
import { VmAlreadyRunningError } from "../subcommands/start.ts";
9
+
import {
10
+
MachineParamsSchema,
11
+
NewMachineSchema,
12
+
NewVolumeSchema,
13
+
} from "../types.ts";
14
+
import type { Image, Volume } from "../db.ts";
15
+
import { createVolume, getVolume } from "../volumes.ts";
16
+
import { ImageNotFoundError, RemoveRunningVmError } from "./machines.ts";
17
+
18
+
export const parseQueryParams = (c: Context) => Effect.succeed(c.req.query());
19
+
20
+
export const parseParams = (c: Context) => Effect.succeed(c.req.param());
21
+
22
+
export const presentation = (c: Context) =>
23
+
Effect.flatMap((data) => Effect.succeed(c.json(data)));
24
+
25
+
export class ParseRequestError extends Data.TaggedError("ParseRequestError")<{
26
+
cause?: unknown;
27
+
message: string;
28
+
}> {}
29
+
30
+
export const handleError = (
31
+
error:
32
+
| VmNotFoundError
33
+
| StopCommandError
34
+
| CommandError
35
+
| ParseRequestError
36
+
| VmAlreadyRunningError
37
+
| ImageNotFoundError
38
+
| RemoveRunningVmError
39
+
| Error,
40
+
c: Context,
41
+
) =>
42
+
Effect.sync(() => {
43
+
if (error instanceof VmNotFoundError) {
44
+
return c.json(
45
+
{ message: "VM not found", code: "VM_NOT_FOUND" },
46
+
404,
47
+
);
48
+
}
49
+
if (error instanceof StopCommandError) {
50
+
return c.json(
51
+
{
52
+
message: error.message ||
53
+
`Failed to stop VM ${error.vmName}`,
54
+
code: "STOP_COMMAND_ERROR",
55
+
},
56
+
500,
57
+
);
58
+
}
59
+
60
+
if (error instanceof ParseRequestError) {
61
+
return c.json(
62
+
{
63
+
message: error.message || "Failed to parse request body",
64
+
code: "PARSE_BODY_ERROR",
65
+
},
66
+
400,
67
+
);
68
+
}
69
+
70
+
if (error instanceof VmAlreadyRunningError) {
71
+
return c.json(
72
+
{
73
+
message: `VM ${error.name} is already running`,
74
+
code: "VM_ALREADY_RUNNING",
75
+
},
76
+
400,
77
+
);
78
+
}
79
+
80
+
if (error instanceof ImageNotFoundError) {
81
+
return c.json(
82
+
{
83
+
message: `Image ${error.id} not found`,
84
+
code: "IMAGE_NOT_FOUND",
85
+
},
86
+
404,
87
+
);
88
+
}
89
+
90
+
if (error instanceof RemoveRunningVmError) {
91
+
return c.json(
92
+
{
93
+
message:
94
+
`Cannot remove running VM with ID ${error.id}. Please stop it first.`,
95
+
code: "REMOVE_RUNNING_VM_ERROR",
96
+
},
97
+
400,
98
+
);
99
+
}
100
+
101
+
return c.json(
102
+
{ message: error instanceof Error ? error.message : String(error) },
103
+
500,
104
+
);
105
+
});
106
+
107
+
export const parseStartRequest = (c: Context) =>
108
+
Effect.tryPromise({
109
+
try: async () => {
110
+
const body = await c.req.json();
111
+
return MachineParamsSchema.parse(body);
112
+
},
113
+
catch: (error) =>
114
+
new ParseRequestError({
115
+
cause: error,
116
+
message: error instanceof Error ? error.message : String(error),
117
+
}),
118
+
});
119
+
120
+
export const parseCreateMachineRequest = (c: Context) =>
121
+
Effect.tryPromise({
122
+
try: async () => {
123
+
const body = await c.req.json();
124
+
return NewMachineSchema.parse(body);
125
+
},
126
+
catch: (error) =>
127
+
new ParseRequestError({
128
+
cause: error,
129
+
message: error instanceof Error ? error.message : String(error),
130
+
}),
131
+
});
132
+
133
+
export const createVolumeIfNeeded = (
134
+
image: Image,
135
+
volumeName: string,
136
+
size?: string,
137
+
): Effect.Effect<Volume, Error, never> =>
138
+
Effect.gen(function* () {
139
+
const volume = yield* getVolume(volumeName);
140
+
if (volume) {
141
+
return volume;
142
+
}
143
+
144
+
return yield* createVolume(volumeName, image, size);
145
+
});
146
+
147
+
export const parseCreateVolumeRequest = (c: Context) =>
148
+
Effect.tryPromise({
149
+
try: async () => {
150
+
const body = await c.req.json();
151
+
return NewVolumeSchema.parse(body);
152
+
},
153
+
catch: (error) =>
154
+
new ParseRequestError({
155
+
cause: error,
156
+
message: error instanceof Error ? error.message : String(error),
157
+
}),
158
+
});
+71
src/api/volumes.ts
+71
src/api/volumes.ts
···
1
+
import { Hono } from "hono";
2
+
import { Effect, pipe } from "effect";
3
+
import {
4
+
createVolumeIfNeeded,
5
+
handleError,
6
+
parseCreateVolumeRequest,
7
+
parseParams,
8
+
presentation,
9
+
} from "./utils.ts";
10
+
import { listVolumes } from "../mod.ts";
11
+
import { deleteVolume, getVolume } from "../volumes.ts";
12
+
import type { NewVolume } from "../types.ts";
13
+
import { getImage } from "../images.ts";
14
+
import { ImageNotFoundError } from "./machines.ts";
15
+
16
+
const app = new Hono();
17
+
18
+
app.get("/", (c) =>
19
+
Effect.runPromise(
20
+
pipe(
21
+
listVolumes(),
22
+
presentation(c),
23
+
),
24
+
));
25
+
26
+
app.get("/:id", (c) =>
27
+
Effect.runPromise(
28
+
pipe(
29
+
parseParams(c),
30
+
Effect.flatMap(({ id }) => getVolume(id)),
31
+
presentation(c),
32
+
),
33
+
));
34
+
35
+
app.delete("/:id", (c) =>
36
+
Effect.runPromise(
37
+
pipe(
38
+
parseParams(c),
39
+
Effect.flatMap(({ id }) =>
40
+
Effect.gen(function* () {
41
+
const volume = yield* getVolume(id);
42
+
yield* deleteVolume(id);
43
+
return volume;
44
+
})
45
+
),
46
+
presentation(c),
47
+
),
48
+
));
49
+
50
+
app.post("/", (c) =>
51
+
Effect.runPromise(
52
+
pipe(
53
+
parseCreateVolumeRequest(c),
54
+
Effect.flatMap((params: NewVolume) =>
55
+
Effect.gen(function* () {
56
+
const image = yield* getImage(params.baseImage);
57
+
if (!image) {
58
+
return yield* Effect.fail(
59
+
new ImageNotFoundError({ id: params.baseImage }),
60
+
);
61
+
}
62
+
63
+
return yield* createVolumeIfNeeded(image, params.name, params.size);
64
+
})
65
+
),
66
+
presentation(c),
67
+
Effect.catchAll((error) => handleError(error, c)),
68
+
),
69
+
));
70
+
71
+
export default app;
+1
src/mod.ts
+1
src/mod.ts
+17
src/state.ts
+17
src/state.ts
···
92
92
}),
93
93
),
94
94
);
95
+
96
+
export const listInstances = (
97
+
all: boolean,
98
+
): Effect.Effect<VirtualMachine[], DbError, never> =>
99
+
Effect.tryPromise({
100
+
try: () =>
101
+
ctx.db.selectFrom("virtual_machines")
102
+
.selectAll()
103
+
.where((eb) => {
104
+
if (all) {
105
+
return eb("id", "!=", "");
106
+
}
107
+
return eb("status", "=", "RUNNING");
108
+
})
109
+
.execute(),
110
+
catch: (error) => new DbError({ cause: error }),
111
+
});
+8
-8
src/subcommands/run.ts
+8
-8
src/subcommands/run.ts
···
84
84
function mergeFlags(image: Image): Options {
85
85
const { flags } = parseFlags(Deno.args);
86
86
return {
87
-
cpu: flags.cpu ? flags.cpu : Deno.build.arch === "aarch64" ? "max" : "host",
88
-
cpus: flags.cpus ? flags.cpus : 2,
89
-
memory: flags.memory ? flags.memory : "2G",
87
+
cpu: (flags.cpu || flags.c) ? (flags.cpu || flags.c) : "host",
88
+
cpus: (flags.cpus || flags.C) ? (flags.cpus || flags.C) : 2,
89
+
memory: (flags.memory || flags.m) ? (flags.memory || flags.m) : "2G",
90
90
image: image.path,
91
-
bridge: flags.bridge,
92
-
portForward: flags.portForward,
93
-
detach: flags.detach,
91
+
bridge: flags.bridge || flags.b,
92
+
portForward: flags.portForward || flags.p,
93
+
detach: flags.detach || flags.d,
94
94
install: false,
95
95
diskFormat: image.format,
96
-
size: flags.size ? flags.size : "20G",
97
-
volume: flags.volume,
96
+
volume: flags.volume || flags.v,
97
+
size: flags.size || flags.s,
98
98
};
99
99
}
+46
-17
src/subcommands/start.ts
+46
-17
src/subcommands/start.ts
···
1
1
import { parseFlags } from "@cliffy/flags";
2
2
import _ from "@es-toolkit/es-toolkit/compat";
3
-
import { Effect, pipe } from "effect";
3
+
import { Data, Effect, pipe } from "effect";
4
4
import { LOGS_DIR } from "../constants.ts";
5
5
import type { VirtualMachine } from "../db.ts";
6
6
import { getImage } from "../images.ts";
···
8
8
import { setupNATNetworkArgs } from "../utils.ts";
9
9
import { createVolume, getVolume } from "../volumes.ts";
10
10
11
+
export class VmAlreadyRunningError
12
+
extends Data.TaggedError("VmAlreadyRunningError")<{
13
+
name: string;
14
+
}> {}
15
+
11
16
const logStartingMessage = (vm: VirtualMachine) =>
12
17
Effect.sync(() => {
13
18
console.log(`Starting virtual machine ${vm.name} (ID: ${vm.id})...`);
14
19
});
15
20
16
-
const buildQemuArgs = (vm: VirtualMachine) => [
21
+
export const buildQemuArgs = (vm: VirtualMachine) => [
17
22
..._.compact([vm.bridge && "qemu-system-x86_64"]),
18
23
...Deno.build.os === "linux" ? ["-enable-kvm"] : [],
19
24
"-cpu",
···
47
52
),
48
53
];
49
54
50
-
const createLogsDirectory = () =>
55
+
export const createLogsDir = () =>
51
56
Effect.tryPromise({
52
57
try: () => Deno.mkdir(LOGS_DIR, { recursive: true }),
53
58
catch: (cause) => new Error(`Failed to create logs directory: ${cause}`),
54
59
});
55
60
56
-
const buildDetachedCommand = (
61
+
export const buildDetachedCommand = (
57
62
vm: VirtualMachine,
58
63
qemuArgs: string[],
59
64
logPath: string,
···
64
69
} >> "${logPath}" 2>&1 & echo $!`
65
70
: `qemu-system-x86_64 ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`;
66
71
67
-
const startDetachedQemu = (fullCommand: string) =>
72
+
export const startDetachedQemu = (fullCommand: string) =>
68
73
Effect.tryPromise({
69
74
try: async () => {
70
75
const cmd = new Deno.Command("sh", {
71
76
args: ["-c", fullCommand],
72
77
stdin: "null",
73
78
stdout: "piped",
74
-
});
79
+
}).spawn();
80
+
81
+
await new Promise((resolve) => setTimeout(resolve, 2000));
75
82
76
-
const { stdout } = await cmd.spawn().output();
83
+
const { stdout } = await cmd.output();
77
84
return parseInt(new TextDecoder().decode(stdout).trim(), 10);
78
85
},
79
86
catch: (cause) => new Error(`Failed to start QEMU: ${cause}`),
···
94
101
95
102
const startVirtualMachineDetached = (name: string, vm: VirtualMachine) =>
96
103
Effect.gen(function* () {
104
+
yield* failIfVMRunning(vm);
97
105
const volume = yield* createVolumeIfNeeded(vm);
98
106
const qemuArgs = buildQemuArgs({
99
107
...vm,
···
103
111
const logPath = `${LOGS_DIR}/${vm.name}.log`;
104
112
const fullCommand = buildDetachedCommand(vm, qemuArgs, logPath);
105
113
106
-
return pipe(
107
-
createLogsDirectory(),
114
+
return yield* pipe(
115
+
createLogsDir(),
108
116
Effect.flatMap(() => startDetachedQemu(fullCommand)),
109
117
Effect.flatMap((qemuPid) =>
110
118
pipe(
···
154
162
}
155
163
});
156
164
165
+
export const failIfVMRunning = (vm: VirtualMachine) =>
166
+
Effect.gen(function* () {
167
+
if (vm.status === "RUNNING") {
168
+
return yield* Effect.fail(
169
+
new VmAlreadyRunningError({ name: vm.name }),
170
+
);
171
+
}
172
+
return vm;
173
+
});
174
+
157
175
const createVolumeIfNeeded = (vm: VirtualMachine) =>
158
176
Effect.gen(function* () {
159
177
const { flags } = parseFlags(Deno.args);
···
186
204
187
205
const startVirtualMachineAttached = (name: string, vm: VirtualMachine) => {
188
206
return pipe(
189
-
createVolumeIfNeeded(vm),
207
+
failIfVMRunning(vm),
208
+
Effect.flatMap(() => createVolumeIfNeeded(vm)),
190
209
Effect.flatMap((volume) =>
191
210
Effect.succeed(
192
211
buildQemuArgs({
···
243
262
const { flags } = parseFlags(Deno.args);
244
263
return {
245
264
...vm,
246
-
memory: flags.memory ? String(flags.memory) : vm.memory,
247
-
cpus: flags.cpus ? Number(flags.cpus) : vm.cpus,
248
-
cpu: flags.cpu ? String(flags.cpu) : vm.cpu,
265
+
memory: (flags.memory || flags.m)
266
+
? String(flags.memory || flags.m)
267
+
: vm.memory,
268
+
cpus: (flags.cpus || flags.C) ? Number(flags.cpus || flags.C) : vm.cpus,
269
+
cpu: (flags.cpu || flags.c) ? String(flags.cpu || flags.c) : vm.cpu,
249
270
diskFormat: flags.diskFormat ? String(flags.diskFormat) : vm.diskFormat,
250
-
portForward: flags.portForward ? String(flags.portForward) : vm.portForward,
251
-
drivePath: flags.image ? String(flags.image) : vm.drivePath,
252
-
bridge: flags.bridge ? String(flags.bridge) : vm.bridge,
253
-
diskSize: flags.size ? String(flags.size) : vm.diskSize,
271
+
portForward: (flags.portForward || flags.p)
272
+
? String(flags.portForward || flags.p)
273
+
: vm.portForward,
274
+
drivePath: (flags.image || flags.i)
275
+
? String(flags.image || flags.i)
276
+
: vm.drivePath,
277
+
bridge: (flags.bridge || flags.b)
278
+
? String(flags.bridge || flags.b)
279
+
: vm.bridge,
280
+
diskSize: (flags.size || flags.s)
281
+
? String(flags.size || flags.s)
282
+
: vm.diskSize,
254
283
};
255
284
}
+7
-7
src/subcommands/stop.ts
+7
-7
src/subcommands/stop.ts
···
4
4
import type { VirtualMachine } from "../db.ts";
5
5
import { getInstanceState, updateInstanceState } from "../state.ts";
6
6
7
-
class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{
7
+
export class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{
8
8
name: string;
9
9
}> {}
10
10
11
-
class StopCommandError extends Data.TaggedError("StopCommandError")<{
11
+
export class StopCommandError extends Data.TaggedError("StopCommandError")<{
12
12
vmName: string;
13
13
exitCode: number;
14
14
}> {}
15
15
16
-
class CommandError extends Data.TaggedError("CommandError")<{
16
+
export class CommandError extends Data.TaggedError("CommandError")<{
17
17
cause?: unknown;
18
18
}> {}
19
19
20
-
const findVm = (name: string) =>
20
+
export const findVm = (name: string) =>
21
21
pipe(
22
22
getInstanceState(name),
23
23
Effect.flatMap((vm) =>
···
34
34
);
35
35
});
36
36
37
-
const killProcess = (vm: VirtualMachine) =>
37
+
export const killProcess = (vm: VirtualMachine) =>
38
38
Effect.tryPromise({
39
39
try: async () => {
40
40
const cmd = new Deno.Command(vm.bridge ? "sudo" : "kill", {
···
63
63
),
64
64
);
65
65
66
-
const updateToStopped = (vm: VirtualMachine) =>
66
+
export const updateToStopped = (vm: VirtualMachine) =>
67
67
pipe(
68
68
updateInstanceState(vm.name, "STOPPED"),
69
-
Effect.map(() => vm),
69
+
Effect.map(() => ({ ...vm, status: "STOPPED" } as VirtualMachine)),
70
70
);
71
71
72
72
const logSuccess = (vm: VirtualMachine) =>
+35
src/types.ts
+35
src/types.ts
···
1
+
import z from "@zod/zod";
2
+
1
3
export type STATUS = "RUNNING" | "STOPPED";
4
+
5
+
export const MachineParamsSchema = z.object({
6
+
portForward: z.array(z.string().regex(/^\d+:\d+$/)).optional(),
7
+
cpu: z.string().optional(),
8
+
cpus: z.number().min(1).optional(),
9
+
memory: z.string().regex(/^\d+(M|G)$/).optional(),
10
+
});
11
+
12
+
export type MachineParams = z.infer<typeof MachineParamsSchema>;
13
+
14
+
export const NewMachineSchema = MachineParamsSchema.extend({
15
+
portForward: z.array(z.string().regex(/^\d+:\d+$/)).optional(),
16
+
cpu: z.string().default("host").optional(),
17
+
cpus: z.number().min(1).default(8).optional(),
18
+
memory: z.string().regex(/^\d+(M|G)$/).default("2G").optional(),
19
+
image: z.string().regex(
20
+
/^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/,
21
+
),
22
+
volume: z.string().optional(),
23
+
bridge: z.string().optional(),
24
+
});
25
+
26
+
export type NewMachine = z.infer<typeof NewMachineSchema>;
27
+
28
+
export const NewVolumeSchema = z.object({
29
+
name: z.string(),
30
+
baseImage: z.string().regex(
31
+
/^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/,
32
+
),
33
+
size: z.string().regex(/^\d+(M|G|T)$/).optional(),
34
+
});
35
+
36
+
export type NewVolume = z.infer<typeof NewVolumeSchema>;
+25
-24
src/volumes.ts
+25
-24
src/volumes.ts
···
74
74
export const createVolume = (
75
75
name: string,
76
76
baseImage: Image,
77
+
size?: string,
77
78
): Effect.Effect<Volume, VolumeError, never> =>
78
79
Effect.tryPromise({
79
80
try: async () => {
80
81
const path = `${VOLUME_DIR}/${name}.qcow2`;
81
82
82
-
if ((await Deno.stat(path).catch(() => false))) {
83
-
throw new Error(`Volume with name ${name} already exists`);
83
+
if (!(await Deno.stat(path).catch(() => false))) {
84
+
await Deno.mkdir(VOLUME_DIR, { recursive: true });
85
+
const qemu = new Deno.Command("qemu-img", {
86
+
args: [
87
+
"create",
88
+
"-F",
89
+
"raw",
90
+
"-f",
91
+
"qcow2",
92
+
"-b",
93
+
baseImage.path,
94
+
path,
95
+
...(size ? [size] : []),
96
+
],
97
+
stdout: "inherit",
98
+
stderr: "inherit",
99
+
})
100
+
.spawn();
101
+
const status = await qemu.status;
102
+
if (!status.success) {
103
+
throw new Error(
104
+
`Failed to create volume: qemu-img exited with code ${status.code}`,
105
+
);
106
+
}
84
107
}
85
108
86
-
await Deno.mkdir(VOLUME_DIR, { recursive: true });
87
-
const qemu = new Deno.Command("qemu-img", {
88
-
args: [
89
-
"create",
90
-
"-F",
91
-
"raw",
92
-
"-f",
93
-
"qcow2",
94
-
"-b",
95
-
baseImage.path,
96
-
path,
97
-
],
98
-
stdout: "inherit",
99
-
stderr: "inherit",
100
-
})
101
-
.spawn();
102
-
const status = await qemu.status;
103
-
if (!status.success) {
104
-
throw new Error(
105
-
`Failed to create volume: qemu-img exited with code ${status.code}`,
106
-
);
107
-
}
108
109
ctx.db.insertInto("volumes").values({
109
110
id: createId(),
110
111
name,