A Docker-like CLI and HTTP API for managing headless VMs

run format

+66 -68
+27 -34
src/api/machines.ts
··· 38 38 }> {} 39 39 40 40 export class RemoveRunningVmError extends Data.TaggedError( 41 - "RemoveRunningVmError" 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 - presentation(c) 56 - ) 57 - ) 58 - ); 55 + presentation(c), 56 + ), 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 - new ImageNotFoundError({ id: params.image }) 68 + new ImageNotFoundError({ id: params.image }), 70 69 ); 71 70 } 72 71 ··· 100 99 sshPwauth: false, 101 100 }, 102 101 }, 103 - tempDir 102 + tempDir, 104 103 ); 105 104 } 106 105 ··· 125 124 seed: _.get( 126 125 params, 127 126 "seed", 128 - params.users ? `${SEED_DIR}/seed-${name}.iso` : undefined 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 - Effect.catchAll((error) => handleError(error, c)) 139 - ) 140 - ) 141 - ); 137 + Effect.catchAll((error) => handleError(error, c)), 138 + ), 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 - presentation(c) 149 - ) 150 - ) 151 - ); 146 + presentation(c), 147 + ), 148 + )); 152 149 153 150 app.delete("/:id", (c) => 154 151 Effect.runPromise( ··· 167 164 }) 168 165 ), 169 166 presentation(c), 170 - Effect.catchAll((error) => handleError(error, c)) 171 - ) 172 - ) 173 - ); 167 + Effect.catchAll((error) => handleError(error, c)), 168 + ), 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 - firmwareArgs 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 - Effect.catchAll((error) => handleError(error, c)) 207 - ) 208 - ) 209 - ); 202 + Effect.catchAll((error) => handleError(error, c)), 203 + ), 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 - Effect.catchAll((error) => handleError(error, c)) 220 - ) 221 - ) 222 - ); 214 + Effect.catchAll((error) => handleError(error, c)), 215 + ), 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 - firmwareArgs 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 - Effect.catchAll((error) => handleError(error, c)) 258 - ) 259 - ) 260 - ); 251 + Effect.catchAll((error) => handleError(error, c)), 252 + ), 253 + )); 261 254 262 255 export default app;
+10 -9
src/api/utils.ts
··· 40 40 | FileSystemError 41 41 | XorrisoError 42 42 | Error, 43 - c: Context 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 - 500 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 - 400 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 - 400 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 - 404 85 + 404, 86 86 ); 87 87 } 88 88 89 89 if (error instanceof RemoveRunningVmError) { 90 90 return c.json( 91 91 { 92 - message: `Cannot remove running VM with ID ${error.id}. Please stop it first.`, 92 + message: 93 + `Cannot remove running VM with ID ${error.id}. Please stop it first.`, 93 94 code: "REMOVE_RUNNING_VM_ERROR", 94 95 }, 95 - 400 96 + 400, 96 97 ); 97 98 } 98 99 99 100 return c.json( 100 101 { message: error instanceof Error ? error.message : String(error) }, 101 - 500 102 + 500, 102 103 ); 103 104 }); 104 105 ··· 131 132 export const createVolumeIfNeeded = ( 132 133 image: Image, 133 134 volumeName: string, 134 - size?: string 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 - .regex(/^\d+:\d+$/) 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 - /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/ 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 - /^\/(usr\/bin|bin|usr\/local\/bin|usr\/pkg\/bin)\/[a-zA-Z0-9_-]+$/ 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 - /^(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 + /^(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 - .trim() 70 + .trim(), 71 71 ) 72 72 .min(1), 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 - /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/ 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 + ]), 43 43 ); 44 44 } 45 45 return obj; ··· 58 58 stringify(snakeCase(seed.metaData), { 59 59 flowLevel: -1, 60 60 lineWidth: -1, 61 - }) 61 + }), 62 62 ), 63 63 catch: (error) => new FileSystemError(error), 64 64 }); ··· 68 68 try: () => 69 69 Deno.writeTextFile( 70 70 outputPath, 71 - `#cloud-config\n${stringify(snakeCase(seed.userData), { 72 - flowLevel: -1, 73 - lineWidth: -1, 74 - })}` 71 + `#cloud-config\n${ 72 + stringify(snakeCase(seed.userData), { 73 + flowLevel: -1, 74 + lineWidth: -1, 75 + }) 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 - `xorriso failed with code ${status.code}. Please ensure ${chalk.green( 104 - "xorriso" 105 - )} is installed and accessible in your PATH.` 105 + `xorriso failed with code ${status.code}. Please ensure ${ 106 + chalk.green( 107 + "xorriso", 108 + ) 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 - }` 121 + }`, 118 122 ); 119 123 }, 120 124 }); ··· 141 145 if (!status.success) { 142 146 throw new XorrisoError( 143 147 status.code, 144 - `genisoimage failed with code ${ 145 - status.code 146 - }. Please ensure ${chalk.green( 147 - "genisoimage" 148 - )} is installed and accessible in your PATH.` 148 + `genisoimage failed with code ${status.code}. Please ensure ${ 149 + chalk.green( 150 + "genisoimage", 151 + ) 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 - }` 164 + }`, 161 165 ); 162 166 }, 163 167 }); ··· 165 169 export const createSeedIso = ( 166 170 outputPath: string, 167 171 seed: Seed, 168 - seedDir: string = "seed" 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 - ) 186 + ), 183 187 ); 184 188 185 189 export default (outputPath: string, seed: Seed, seedDir: string = "seed") =>