tangled
alpha
login
or
join now
tsiry-sandratraina.com
/
vmx
1
fork
atom
A Docker-like CLI and HTTP API for managing headless VMs
1
fork
atom
overview
issues
pulls
pipelines
run format
tsiry-sandratraina.com
3 months ago
76c6261a
ccee09d8
+66
-68
4 changed files
expand all
collapse all
unified
split
src
api
machines.ts
utils.ts
types.ts
xorriso.ts
+27
-34
src/api/machines.ts
···
38
38
}> {}
39
39
40
40
export class RemoveRunningVmError extends Data.TaggedError(
41
41
-
"RemoveRunningVmError"
41
41
+
"RemoveRunningVmError",
42
42
)<{
43
43
id: string;
44
44
}> {}
···
52
52
Effect.flatMap((params) =>
53
53
listInstances(params.all === "true" || params.all === "1")
54
54
),
55
55
-
presentation(c)
56
56
-
)
57
57
-
)
58
58
-
);
55
55
+
presentation(c),
56
56
+
),
57
57
+
));
59
58
60
59
app.post("/", (c) =>
61
60
Effect.runPromise(
···
66
65
const image = yield* getImage(params.image);
67
66
if (!image) {
68
67
return yield* Effect.fail(
69
69
-
new ImageNotFoundError({ id: params.image })
68
68
+
new ImageNotFoundError({ id: params.image }),
70
69
);
71
70
}
72
71
···
100
99
sshPwauth: false,
101
100
},
102
101
},
103
103
-
tempDir
102
102
+
tempDir,
104
103
);
105
104
}
106
105
···
125
124
seed: _.get(
126
125
params,
127
126
"seed",
128
128
-
params.users ? `${SEED_DIR}/seed-${name}.iso` : undefined
127
127
+
params.users ? `${SEED_DIR}/seed-${name}.iso` : undefined,
129
128
),
130
129
pid: 0,
131
130
});
···
135
134
})
136
135
),
137
136
presentation(c),
138
138
-
Effect.catchAll((error) => handleError(error, c))
139
139
-
)
140
140
-
)
141
141
-
);
137
137
+
Effect.catchAll((error) => handleError(error, c)),
138
138
+
),
139
139
+
));
142
140
143
141
app.get("/:id", (c) =>
144
142
Effect.runPromise(
145
143
pipe(
146
144
parseParams(c),
147
145
Effect.flatMap(({ id }) => getInstanceState(id)),
148
148
-
presentation(c)
149
149
-
)
150
150
-
)
151
151
-
);
146
146
+
presentation(c),
147
147
+
),
148
148
+
));
152
149
153
150
app.delete("/:id", (c) =>
154
151
Effect.runPromise(
···
167
164
})
168
165
),
169
166
presentation(c),
170
170
-
Effect.catchAll((error) => handleError(error, c))
171
171
-
)
172
172
-
)
173
173
-
);
167
167
+
Effect.catchAll((error) => handleError(error, c)),
168
168
+
),
169
169
+
));
174
170
175
171
app.post("/:id/start", (c) =>
176
172
Effect.runPromise(
···
195
191
? startRequest.portForward.join(",")
196
192
: vm.portForward,
197
193
},
198
198
-
firmwareArgs
194
194
+
firmwareArgs,
199
195
);
200
196
yield* createLogsDir();
201
197
yield* startDetachedQemu(vm.id, vm, qemuArgs);
···
203
199
})
204
200
),
205
201
presentation(c),
206
206
-
Effect.catchAll((error) => handleError(error, c))
207
207
-
)
208
208
-
)
209
209
-
);
202
202
+
Effect.catchAll((error) => handleError(error, c)),
203
203
+
),
204
204
+
));
210
205
211
206
app.post("/:id/stop", (c) =>
212
207
Effect.runPromise(
···
216
211
Effect.flatMap(killProcess),
217
212
Effect.flatMap(updateToStopped),
218
213
presentation(c),
219
219
-
Effect.catchAll((error) => handleError(error, c))
220
220
-
)
221
221
-
)
222
222
-
);
214
214
+
Effect.catchAll((error) => handleError(error, c)),
215
215
+
),
216
216
+
));
223
217
224
218
app.post("/:id/restart", (c) =>
225
219
Effect.runPromise(
···
246
240
? startRequest.portForward.join(",")
247
241
: vm.portForward,
248
242
},
249
249
-
firmwareArgs
243
243
+
firmwareArgs,
250
244
);
251
245
yield* createLogsDir();
252
246
yield* startDetachedQemu(vm.id, vm, qemuArgs);
···
254
248
})
255
249
),
256
250
presentation(c),
257
257
-
Effect.catchAll((error) => handleError(error, c))
258
258
-
)
259
259
-
)
260
260
-
);
251
251
+
Effect.catchAll((error) => handleError(error, c)),
252
252
+
),
253
253
+
));
261
254
262
255
export default app;
+10
-9
src/api/utils.ts
···
40
40
| FileSystemError
41
41
| XorrisoError
42
42
| Error,
43
43
-
c: Context
43
43
+
c: Context,
44
44
) =>
45
45
Effect.sync(() => {
46
46
if (error instanceof VmNotFoundError) {
···
52
52
message: error.message || `Failed to stop VM ${error.vmName}`,
53
53
code: "STOP_COMMAND_ERROR",
54
54
},
55
55
-
500
55
55
+
500,
56
56
);
57
57
}
58
58
···
62
62
message: error.message || "Failed to parse request body",
63
63
code: "PARSE_BODY_ERROR",
64
64
},
65
65
-
400
65
65
+
400,
66
66
);
67
67
}
68
68
···
72
72
message: `VM ${error.name} is already running`,
73
73
code: "VM_ALREADY_RUNNING",
74
74
},
75
75
-
400
75
75
+
400,
76
76
);
77
77
}
78
78
···
82
82
message: `Image ${error.id} not found`,
83
83
code: "IMAGE_NOT_FOUND",
84
84
},
85
85
-
404
85
85
+
404,
86
86
);
87
87
}
88
88
89
89
if (error instanceof RemoveRunningVmError) {
90
90
return c.json(
91
91
{
92
92
-
message: `Cannot remove running VM with ID ${error.id}. Please stop it first.`,
92
92
+
message:
93
93
+
`Cannot remove running VM with ID ${error.id}. Please stop it first.`,
93
94
code: "REMOVE_RUNNING_VM_ERROR",
94
95
},
95
95
-
400
96
96
+
400,
96
97
);
97
98
}
98
99
99
100
return c.json(
100
101
{ message: error instanceof Error ? error.message : String(error) },
101
101
-
500
102
102
+
500,
102
103
);
103
104
});
104
105
···
131
132
export const createVolumeIfNeeded = (
132
133
image: Image,
133
134
volumeName: string,
134
134
-
size?: string
135
135
+
size?: string,
135
136
): Effect.Effect<Volume, Error, never> =>
136
137
Effect.gen(function* () {
137
138
const volume = yield* getVolume(volumeName);
+7
-7
src/types.ts
···
20
20
z
21
21
.string()
22
22
.trim()
23
23
-
.regex(/^\d+:\d+$/)
23
23
+
.regex(/^\d+:\d+$/),
24
24
)
25
25
.optional(),
26
26
cpu: z.string().trim().default("host").optional(),
···
35
35
.string()
36
36
.trim()
37
37
.regex(
38
38
-
/^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/
38
38
+
/^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/,
39
39
),
40
40
volume: z.string().trim().optional(),
41
41
bridge: z.string().trim().optional(),
···
51
51
shell: z
52
52
.string()
53
53
.regex(
54
54
-
/^\/(usr\/bin|bin|usr\/local\/bin|usr\/pkg\/bin)\/[a-zA-Z0-9_-]+$/
54
54
+
/^\/(usr\/bin|bin|usr\/local\/bin|usr\/pkg\/bin)\/[a-zA-Z0-9_-]+$/,
55
55
)
56
56
.trim()
57
57
.default("/bin/bash")
···
65
65
z
66
66
.string()
67
67
.regex(
68
68
-
/^(ssh-(rsa|ed25519|dss|ecdsa) AAAA[0-9A-Za-z+/]+[=]{0,3}( [^\n\r]*)?|ecdsa-sha2-nistp(256|384|521) AAAA[0-9A-Za-z+/]+[=]{0,3}( [^\n\r]*)?)$/
68
68
+
/^(ssh-(rsa|ed25519|dss|ecdsa) AAAA[0-9A-Za-z+/]+[=]{0,3}( [^\n\r]*)?|ecdsa-sha2-nistp(256|384|521) AAAA[0-9A-Za-z+/]+[=]{0,3}( [^\n\r]*)?)$/,
69
69
)
70
70
-
.trim()
70
70
+
.trim(),
71
71
)
72
72
.min(1),
73
73
-
})
73
73
+
}),
74
74
)
75
75
.optional(),
76
76
instanceId: z.string().trim().optional(),
···
85
85
baseImage: z
86
86
.string()
87
87
.regex(
88
88
-
/^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/
88
88
+
/^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/,
89
89
),
90
90
size: z
91
91
.string()
+22
-18
src/xorriso.ts
···
39
39
Object.entries(obj).map(([key, value]) => [
40
40
_.snakeCase(key),
41
41
snakeCase(value),
42
42
-
])
42
42
+
]),
43
43
);
44
44
}
45
45
return obj;
···
58
58
stringify(snakeCase(seed.metaData), {
59
59
flowLevel: -1,
60
60
lineWidth: -1,
61
61
-
})
61
61
+
}),
62
62
),
63
63
catch: (error) => new FileSystemError(error),
64
64
});
···
68
68
try: () =>
69
69
Deno.writeTextFile(
70
70
outputPath,
71
71
-
`#cloud-config\n${stringify(snakeCase(seed.userData), {
72
72
-
flowLevel: -1,
73
73
-
lineWidth: -1,
74
74
-
})}`
71
71
+
`#cloud-config\n${
72
72
+
stringify(snakeCase(seed.userData), {
73
73
+
flowLevel: -1,
74
74
+
lineWidth: -1,
75
75
+
})
76
76
+
}`,
75
77
),
76
78
catch: (error) => new FileSystemError(error),
77
79
});
···
100
102
if (!status.success) {
101
103
throw new XorrisoError(
102
104
status.code,
103
103
-
`xorriso failed with code ${status.code}. Please ensure ${chalk.green(
104
104
-
"xorriso"
105
105
-
)} is installed and accessible in your PATH.`
105
105
+
`xorriso failed with code ${status.code}. Please ensure ${
106
106
+
chalk.green(
107
107
+
"xorriso",
108
108
+
)
109
109
+
} is installed and accessible in your PATH.`,
106
110
);
107
111
}
108
112
···
114
118
null,
115
119
`Unexpected error: ${
116
120
error instanceof Error ? error.message : String(error)
117
117
-
}`
121
121
+
}`,
118
122
);
119
123
},
120
124
});
···
141
145
if (!status.success) {
142
146
throw new XorrisoError(
143
147
status.code,
144
144
-
`genisoimage failed with code ${
145
145
-
status.code
146
146
-
}. Please ensure ${chalk.green(
147
147
-
"genisoimage"
148
148
-
)} is installed and accessible in your PATH.`
148
148
+
`genisoimage failed with code ${status.code}. Please ensure ${
149
149
+
chalk.green(
150
150
+
"genisoimage",
151
151
+
)
152
152
+
} is installed and accessible in your PATH.`,
149
153
);
150
154
}
151
155
···
157
161
null,
158
162
`Unexpected error: ${
159
163
error instanceof Error ? error.message : String(error)
160
160
-
}`
164
164
+
}`,
161
165
);
162
166
},
163
167
});
···
165
169
export const createSeedIso = (
166
170
outputPath: string,
167
171
seed: Seed,
168
168
-
seedDir: string = "seed"
172
172
+
seedDir: string = "seed",
169
173
) =>
170
174
pipe(
171
175
createSeedDirectory,
···
179
183
Deno.build.os === "linux"
180
184
? runGenisoimage(outputPath, seedDir)
181
185
: runXorriso(outputPath, seedDir)
182
182
-
)
186
186
+
),
183
187
);
184
188
185
189
export default (outputPath: string, seed: Seed, seedDir: string = "seed") =>