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

Add CoreOS image handling and update QEMU argument setup

+152 -116
+36 -28
src/subcommands/restart.ts
··· 6 6 import { getInstanceState, updateInstanceState } from "../state.ts"; 7 7 import { 8 8 safeKillQemu, 9 + setupCoreOSArgs, 9 10 setupFirmwareFilesIfNeeded, 10 11 setupNATNetworkArgs, 11 12 } from "../utils.ts"; ··· 27 28 getInstanceState(name), 28 29 Effect.flatMap((vm) => 29 30 vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) 30 - ), 31 + ) 31 32 ); 32 33 33 34 const killQemu = (vm: VirtualMachine) => ··· 36 37 success 37 38 ? Effect.succeed(vm) 38 39 : Effect.fail(new KillQemuError({ vmName: vm.name })) 39 - ), 40 + ) 40 41 ); 41 42 42 43 const sleep = (ms: number) => ··· 54 55 const setupFirmware = () => setupFirmwareFilesIfNeeded(); 55 56 56 57 const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { 57 - const qemu = Deno.build.arch === "aarch64" 58 - ? "qemu-system-aarch64" 59 - : "qemu-system-x86_64"; 58 + const qemu = 59 + Deno.build.arch === "aarch64" 60 + ? "qemu-system-aarch64" 61 + : "qemu-system-x86_64"; 62 + 63 + let coreosArgs: string[] = Effect.runSync(setupCoreOSArgs(vm.drivePath)); 64 + 65 + if (coreosArgs.length > 0) { 66 + coreosArgs = coreosArgs.slice(2); 67 + } 60 68 61 69 return Effect.succeed([ 62 70 ..._.compact([vm.bridge && qemu]), 63 - ...Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"], 64 - ...Deno.build.arch === "aarch64" ? ["-machine", "virt,highmem=on"] : [], 71 + ...(Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"]), 72 + ...(Deno.build.arch === "aarch64" ? ["-machine", "virt,highmem=on"] : []), 65 73 "-cpu", 66 74 vm.cpu, 67 75 "-m", ··· 87 95 vm.drivePath && [ 88 96 "-drive", 89 97 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 90 - ], 98 + ] 91 99 ), 100 + ...coreosArgs, 92 101 ]); 93 102 }; 94 103 95 104 const startQemu = (vm: VirtualMachine, qemuArgs: string[]) => { 96 - const qemu = Deno.build.arch === "aarch64" 97 - ? "qemu-system-aarch64" 98 - : "qemu-system-x86_64"; 105 + const qemu = 106 + Deno.build.arch === "aarch64" 107 + ? "qemu-system-aarch64" 108 + : "qemu-system-x86_64"; 99 109 100 110 const logPath = `${LOGS_DIR}/${vm.name}.log`; 101 111 102 112 const fullCommand = vm.bridge 103 - ? `sudo ${qemu} ${ 104 - qemuArgs.slice(1).join(" ") 105 - } >> "${logPath}" 2>&1 & echo $!` 113 + ? `sudo ${qemu} ${qemuArgs 114 + .slice(1) 115 + .join(" ")} >> "${logPath}" 2>&1 & echo $!` 106 116 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 107 117 108 118 return Effect.tryPromise({ ··· 124 134 const logSuccess = (vm: VirtualMachine, qemuPid: number, logPath: string) => 125 135 Effect.sync(() => { 126 136 console.log( 127 - `${chalk.greenBright(vm.name)} restarted with PID ${ 128 - chalk.greenBright(qemuPid) 129 - }.`, 130 - ); 131 - console.log( 132 - `Logs are being written to ${chalk.blueBright(logPath)}`, 137 + `${chalk.greenBright(vm.name)} restarted with PID ${chalk.greenBright( 138 + qemuPid 139 + )}.` 133 140 ); 141 + console.log(`Logs are being written to ${chalk.blueBright(logPath)}`); 134 142 }); 135 143 136 144 const handleError = ( 137 - error: VmNotFoundError | KillQemuError | CommandError | Error, 145 + error: VmNotFoundError | KillQemuError | CommandError | Error 138 146 ) => 139 147 Effect.sync(() => { 140 148 if (error instanceof VmNotFoundError) { 141 149 console.error( 142 - `Virtual machine with name or ID ${ 143 - chalk.greenBright(error.name) 144 - } not found.`, 150 + `Virtual machine with name or ID ${chalk.greenBright( 151 + error.name 152 + )} not found.` 145 153 ); 146 154 } else if (error instanceof KillQemuError) { 147 155 console.error( 148 - `Failed to stop virtual machine ${chalk.greenBright(error.vmName)}.`, 156 + `Failed to stop virtual machine ${chalk.greenBright(error.vmName)}.` 149 157 ); 150 158 } else { 151 159 console.error(`An error occurred: ${error}`); ··· 171 179 pipe( 172 180 updateInstanceState(vm.id, "RUNNING", qemuPid), 173 181 Effect.flatMap(() => logSuccess(vm, qemuPid, logPath)), 174 - Effect.flatMap(() => sleep(2000)), 182 + Effect.flatMap(() => sleep(2000)) 175 183 ) 176 - ), 184 + ) 177 185 ) 178 186 ), 179 - Effect.catchAll(handleError), 187 + Effect.catchAll(handleError) 180 188 ); 181 189 182 190 export default async function (name: string) {
+56 -47
src/subcommands/start.ts
··· 5 5 import type { VirtualMachine, Volume } from "../db.ts"; 6 6 import { getImage } from "../images.ts"; 7 7 import { getInstanceState, updateInstanceState } from "../state.ts"; 8 - import { setupFirmwareFilesIfNeeded, setupNATNetworkArgs } from "../utils.ts"; 8 + import { 9 + setupCoreOSArgs, 10 + setupFirmwareFilesIfNeeded, 11 + setupNATNetworkArgs, 12 + } from "../utils.ts"; 9 13 import { createVolume, getVolume } from "../volumes.ts"; 10 14 11 15 export class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ ··· 13 17 }> {} 14 18 15 19 export class VmAlreadyRunningError extends Data.TaggedError( 16 - "VmAlreadyRunningError", 20 + "VmAlreadyRunningError" 17 21 )<{ 18 22 name: string; 19 23 }> {} ··· 27 31 getInstanceState(name), 28 32 Effect.flatMap((vm) => 29 33 vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) 30 - ), 34 + ) 31 35 ); 32 36 33 37 const logStarting = (vm: VirtualMachine) => ··· 40 44 export const setupFirmware = () => setupFirmwareFilesIfNeeded(); 41 45 42 46 export const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { 43 - const qemu = Deno.build.arch === "aarch64" 44 - ? "qemu-system-aarch64" 45 - : "qemu-system-x86_64"; 47 + const qemu = 48 + Deno.build.arch === "aarch64" 49 + ? "qemu-system-aarch64" 50 + : "qemu-system-x86_64"; 51 + 52 + let coreosArgs: string[] = Effect.runSync(setupCoreOSArgs(vm.drivePath)); 53 + 54 + if (coreosArgs.length > 0) { 55 + coreosArgs = coreosArgs.slice(2); 56 + } 46 57 47 58 return Effect.succeed([ 48 59 ..._.compact([vm.bridge && qemu]), ··· 73 84 vm.drivePath && [ 74 85 "-drive", 75 86 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 76 - ], 87 + ] 77 88 ), 89 + ...coreosArgs, 78 90 ]); 79 91 }; 80 92 ··· 87 99 export const startDetachedQemu = ( 88 100 name: string, 89 101 vm: VirtualMachine, 90 - qemuArgs: string[], 102 + qemuArgs: string[] 91 103 ) => { 92 - const qemu = Deno.build.arch === "aarch64" 93 - ? "qemu-system-aarch64" 94 - : "qemu-system-x86_64"; 104 + const qemu = 105 + Deno.build.arch === "aarch64" 106 + ? "qemu-system-aarch64" 107 + : "qemu-system-x86_64"; 95 108 96 109 const logPath = `${LOGS_DIR}/${vm.name}.log`; 97 110 98 111 const fullCommand = vm.bridge 99 - ? `sudo ${qemu} ${ 100 - qemuArgs 112 + ? `sudo ${qemu} ${qemuArgs 101 113 .slice(1) 102 - .join(" ") 103 - } >> "${logPath}" 2>&1 & echo $!` 114 + .join(" ")} >> "${logPath}" 2>&1 & echo $!` 104 115 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 105 116 106 117 return Effect.tryPromise({ ··· 131 142 Effect.flatMap(({ qemuPid, logPath }) => 132 143 pipe( 133 144 updateInstanceState(name, "RUNNING", qemuPid), 134 - Effect.map(() => ({ vm, qemuPid, logPath })), 145 + Effect.map(() => ({ vm, qemuPid, logPath })) 135 146 ) 136 - ), 147 + ) 137 148 ); 138 149 }; 139 150 ··· 148 159 }) => 149 160 Effect.sync(() => { 150 161 console.log( 151 - `Virtual machine ${vm.name} started in background (PID: ${qemuPid})`, 162 + `Virtual machine ${vm.name} started in background (PID: ${qemuPid})` 152 163 ); 153 164 console.log(`Logs will be written to: ${logPath}`); 154 165 }); ··· 156 167 const startInteractiveQemu = ( 157 168 name: string, 158 169 vm: VirtualMachine, 159 - qemuArgs: string[], 170 + qemuArgs: string[] 160 171 ) => { 161 - const qemu = Deno.build.arch === "aarch64" 162 - ? "qemu-system-aarch64" 163 - : "qemu-system-x86_64"; 172 + const qemu = 173 + Deno.build.arch === "aarch64" 174 + ? "qemu-system-aarch64" 175 + : "qemu-system-x86_64"; 164 176 165 177 return Effect.tryPromise({ 166 178 try: async () => { ··· 196 208 }); 197 209 198 210 export const createVolumeIfNeeded = ( 199 - vm: VirtualMachine, 211 + vm: VirtualMachine 200 212 ): Effect.Effect<[VirtualMachine, Volume?], Error, never> => 201 213 Effect.gen(function* () { 202 214 const { flags } = parseFlags(Deno.args); ··· 210 222 211 223 if (!vm.drivePath) { 212 224 throw new Error( 213 - `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.`, 225 + `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.` 214 226 ); 215 227 } 216 228 ··· 252 264 drivePath: volume ? volume.path : vm.drivePath, 253 265 diskFormat: volume ? "qcow2" : vm.diskFormat, 254 266 }, 255 - firmwareArgs, 267 + firmwareArgs 256 268 ) 257 269 ), 258 270 Effect.flatMap((qemuArgs) => ··· 260 272 createLogsDir(), 261 273 Effect.flatMap(() => startDetachedQemu(name, vm, qemuArgs)), 262 274 Effect.tap(logDetachedSuccess), 263 - Effect.map(() => 0), // Exit code 0 275 + Effect.map(() => 0) // Exit code 0 264 276 ) 265 - ), 277 + ) 266 278 ) 267 279 ), 268 - Effect.catchAll(handleError), 280 + Effect.catchAll(handleError) 269 281 ); 270 282 271 283 const startInteractiveEffect = (name: string) => ··· 285 297 drivePath: volume ? volume.path : vm.drivePath, 286 298 diskFormat: volume ? "qcow2" : vm.diskFormat, 287 299 }, 288 - firmwareArgs, 300 + firmwareArgs 289 301 ) 290 302 ), 291 303 Effect.flatMap((qemuArgs) => startInteractiveQemu(name, vm, qemuArgs)), 292 - Effect.map((status) => (status.success ? 0 : status.code || 1)), 304 + Effect.map((status) => (status.success ? 0 : status.code || 1)) 293 305 ) 294 306 ), 295 - Effect.catchAll(handleError), 307 + Effect.catchAll(handleError) 296 308 ); 297 309 298 310 export default async function (name: string, detach: boolean = false) { 299 311 const exitCode = await Effect.runPromise( 300 - detach ? startDetachedEffect(name) : startInteractiveEffect(name), 312 + detach ? startDetachedEffect(name) : startInteractiveEffect(name) 301 313 ); 302 314 303 315 if (detach) { ··· 311 323 const { flags } = parseFlags(Deno.args); 312 324 return { 313 325 ...vm, 314 - memory: flags.memory || flags.m 315 - ? String(flags.memory || flags.m) 316 - : vm.memory, 326 + memory: 327 + flags.memory || flags.m ? String(flags.memory || flags.m) : vm.memory, 317 328 cpus: flags.cpus || flags.C ? Number(flags.cpus || flags.C) : vm.cpus, 318 329 cpu: flags.cpu || flags.c ? String(flags.cpu || flags.c) : vm.cpu, 319 330 diskFormat: flags.diskFormat ? String(flags.diskFormat) : vm.diskFormat, 320 - portForward: flags.portForward || flags.p 321 - ? String(flags.portForward || flags.p) 322 - : vm.portForward, 323 - drivePath: flags.image || flags.i 324 - ? String(flags.image || flags.i) 325 - : vm.drivePath, 326 - bridge: flags.bridge || flags.b 327 - ? String(flags.bridge || flags.b) 328 - : vm.bridge, 329 - diskSize: flags.size || flags.s 330 - ? String(flags.size || flags.s) 331 - : vm.diskSize, 331 + portForward: 332 + flags.portForward || flags.p 333 + ? String(flags.portForward || flags.p) 334 + : vm.portForward, 335 + drivePath: 336 + flags.image || flags.i ? String(flags.image || flags.i) : vm.drivePath, 337 + bridge: 338 + flags.bridge || flags.b ? String(flags.bridge || flags.b) : vm.bridge, 339 + diskSize: 340 + flags.size || flags.s ? String(flags.size || flags.s) : vm.diskSize, 332 341 }; 333 342 }
+60 -41
src/utils.ts
··· 62 62 export const isValidISOurl = (url?: string): boolean => { 63 63 return Boolean( 64 64 (url?.startsWith("http://") || url?.startsWith("https://")) && 65 - url?.endsWith(".iso"), 65 + url?.endsWith(".iso") 66 66 ); 67 67 }; 68 68 ··· 88 88 }); 89 89 90 90 export const validateImage = ( 91 - image: string, 91 + image: string 92 92 ): Effect.Effect<string, InvalidImageNameError, never> => { 93 93 const regex = 94 94 /^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/; ··· 99 99 image, 100 100 cause: 101 101 "Image name does not conform to expected format. Should be in the format 'repository/name:tag'.", 102 - }), 102 + }) 103 103 ); 104 104 } 105 105 return Effect.succeed(image); ··· 108 108 export const extractTag = (name: string) => 109 109 pipe( 110 110 validateImage(name), 111 - Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")), 111 + Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")) 112 112 ); 113 113 114 114 export const failOnMissingImage = ( 115 - image: Image | undefined, 115 + image: Image | undefined 116 116 ): Effect.Effect<Image, Error, never> => 117 117 image 118 118 ? Effect.succeed(image) 119 119 : Effect.fail(new NoSuchImageError({ cause: "No such image" })); 120 120 121 121 export const du = ( 122 - path: string, 122 + path: string 123 123 ): Effect.Effect<number, LogCommandError, never> => 124 124 Effect.tryPromise({ 125 125 try: async () => { ··· 151 151 exists 152 152 ? Effect.succeed(true) 153 153 : du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB)) 154 - ), 154 + ) 155 155 ); 156 156 157 157 export const downloadIso = (url: string, options: Options) => ··· 173 173 if (driveSize > EMPTY_DISK_THRESHOLD_KB) { 174 174 console.log( 175 175 chalk.yellowBright( 176 - `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, 177 - ), 176 + `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.` 177 + ) 178 178 ); 179 179 return null; 180 180 } ··· 192 192 if (outputExists) { 193 193 console.log( 194 194 chalk.yellowBright( 195 - `File ${outputPath} already exists, skipping download.`, 196 - ), 195 + `File ${outputPath} already exists, skipping download.` 196 + ) 197 197 ); 198 198 return outputPath; 199 199 } ··· 242 242 if (!success) { 243 243 console.error( 244 244 chalk.redBright( 245 - "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.", 246 - ), 245 + "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew." 246 + ) 247 247 ); 248 248 Deno.exit(1); 249 249 } ··· 256 256 try: () => 257 257 Deno.copyFile( 258 258 `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 259 - edk2VarsAarch64, 259 + edk2VarsAarch64 260 260 ), 261 261 catch: (error) => new LogCommandError({ cause: error }), 262 262 }); ··· 291 291 return `user,id=net0,${portForwarding}`; 292 292 } 293 293 294 - export const setupCoreOSArgs = (imagePath: string | null) => 294 + export const setupCoreOSArgs = (imagePath?: string | null) => 295 295 Effect.gen(function* () { 296 296 if ( 297 297 imagePath && ··· 301 301 const configOK = yield* pipe( 302 302 fileExists("config.ign"), 303 303 Effect.flatMap(() => Effect.succeed(true)), 304 - Effect.catchAll(() => Effect.succeed(false)), 304 + Effect.catchAll(() => Effect.succeed(false)) 305 305 ); 306 306 if (!configOK) { 307 307 console.error( 308 308 chalk.redBright( 309 - "CoreOS image requires a config.ign file in the current directory.", 310 - ), 309 + "CoreOS image requires a config.ign file in the current directory." 310 + ) 311 311 ); 312 312 Deno.exit(1); 313 313 } ··· 327 327 Effect.gen(function* () { 328 328 const macAddress = yield* generateRandomMacAddress(); 329 329 330 - const qemu = Deno.build.arch === "aarch64" 331 - ? "qemu-system-aarch64" 332 - : "qemu-system-x86_64"; 330 + const qemu = 331 + Deno.build.arch === "aarch64" 332 + ? "qemu-system-aarch64" 333 + : "qemu-system-x86_64"; 333 334 334 335 const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 335 336 const coreosArgs: string[] = yield* setupCoreOSArgs(isoPath); ··· 365 366 options.image && [ 366 367 "-drive", 367 368 `file=${options.image},format=${options.diskFormat},if=virtio`, 368 - ], 369 + ] 369 370 ), 370 371 ]; 371 372 ··· 380 381 const logPath = `${LOGS_DIR}/${name}.log`; 381 382 382 383 const fullCommand = options.bridge 383 - ? `sudo ${qemu} ${ 384 - qemuArgs 384 + ? `sudo ${qemu} ${qemuArgs 385 385 .slice(1) 386 - .join(" ") 387 - } >> "${logPath}" 2>&1 & echo $!` 386 + .join(" ")} >> "${logPath}" 2>&1 & echo $!` 388 387 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 389 388 390 389 const { stdout } = yield* Effect.tryPromise({ ··· 410 409 cpus: options.cpus, 411 410 cpu: options.cpu, 412 411 diskSize: options.size || "20G", 413 - diskFormat: options.diskFormat || "raw", 412 + diskFormat: 413 + (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 414 + options.diskFormat || 415 + "raw", 414 416 portForward: options.portForward, 415 - isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 416 - drivePath: options.image ? Deno.realPathSync(options.image) : undefined, 417 + isoPath: 418 + isoPath && !isoPath.includes("coreos") && isoPath.endsWith(".iso") 419 + ? Deno.realPathSync(isoPath) 420 + : undefined, 421 + drivePath: options.image 422 + ? Deno.realPathSync(options.image) 423 + : isoPath?.endsWith("qcow2") 424 + ? Deno.realPathSync(isoPath) 425 + : undefined, 417 426 version: DEFAULT_VERSION, 418 427 status: "RUNNING", 419 428 pid: qemuPid, 420 429 }); 421 430 422 431 console.log( 423 - `Virtual machine ${name} started in background (PID: ${qemuPid})`, 432 + `Virtual machine ${name} started in background (PID: ${qemuPid})` 424 433 ); 425 434 console.log(`Logs will be written to: ${logPath}`); 426 435 ··· 443 452 cpus: options.cpus, 444 453 cpu: options.cpu, 445 454 diskSize: options.size || "20G", 446 - diskFormat: options.diskFormat || "raw", 455 + diskFormat: 456 + (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 457 + options.diskFormat || 458 + "raw", 447 459 portForward: options.portForward, 448 - isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 449 - drivePath: options.image ? Deno.realPathSync(options.image) : undefined, 460 + isoPath: 461 + isoPath && !isoPath.includes("coreos") && isoPath.endsWith(".iso") 462 + ? Deno.realPathSync(isoPath) 463 + : undefined, 464 + drivePath: options.image 465 + ? Deno.realPathSync(options.image) 466 + : isoPath?.endsWith("qcow2") 467 + ? Deno.realPathSync(isoPath) 468 + : undefined, 450 469 version: DEFAULT_VERSION, 451 470 status: "RUNNING", 452 471 pid: cmd.pid, ··· 542 561 if (pathExists) { 543 562 console.log( 544 563 chalk.yellowBright( 545 - `Drive image ${path} already exists, skipping creation.`, 546 - ), 564 + `Drive image ${path} already exists, skipping creation.` 565 + ) 547 566 ); 548 567 return; 549 568 } ··· 570 589 }); 571 590 572 591 export const fileExists = ( 573 - path: string, 592 + path: string 574 593 ): Effect.Effect<void, NoSuchFileError, never> => 575 - Effect.tryPromise({ 576 - try: () => Deno.stat(path), 594 + Effect.try({ 595 + try: () => Deno.statSync(path), 577 596 catch: (error) => new NoSuchFileError({ cause: String(error) }), 578 597 }); 579 598 580 599 export const constructCoreOSImageURL = ( 581 - image: string, 600 + image: string 582 601 ): Effect.Effect<string, InvalidImageNameError, never> => { 583 602 // detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version> 584 603 const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/; ··· 586 605 if (match) { 587 606 const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION; 588 607 return Effect.succeed( 589 - FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version), 608 + FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version) 590 609 ); 591 610 } 592 611 ··· 594 613 new InvalidImageNameError({ 595 614 image, 596 615 cause: "Image name does not match CoreOS naming conventions.", 597 - }), 616 + }) 598 617 ); 599 618 }; 600 619