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
5db81a69
da3b4a5c
+70
-71
1 changed file
expand all
collapse all
unified
split
src
utils.ts
+70
-71
src/utils.ts
···
78
78
export const isValidISOurl = (url?: string): boolean => {
79
79
return Boolean(
80
80
(url?.startsWith("http://") || url?.startsWith("https://")) &&
81
81
-
url?.endsWith(".iso")
81
81
+
url?.endsWith(".iso"),
82
82
);
83
83
};
84
84
···
104
104
});
105
105
106
106
export const validateImage = (
107
107
-
image: string
107
107
+
image: string,
108
108
): Effect.Effect<string, InvalidImageNameError, never> => {
109
109
const regex =
110
110
/^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/;
···
115
115
image,
116
116
cause:
117
117
"Image name does not conform to expected format. Should be in the format 'repository/name:tag'.",
118
118
-
})
118
118
+
}),
119
119
);
120
120
}
121
121
return Effect.succeed(image);
···
124
124
export const extractTag = (name: string) =>
125
125
pipe(
126
126
validateImage(name),
127
127
-
Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest"))
127
127
+
Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")),
128
128
);
129
129
130
130
export const failOnMissingImage = (
131
131
-
image: Image | undefined
131
131
+
image: Image | undefined,
132
132
): Effect.Effect<Image, Error, never> =>
133
133
image
134
134
? Effect.succeed(image)
135
135
: Effect.fail(new NoSuchImageError({ cause: "No such image" }));
136
136
137
137
export const du = (
138
138
-
path: string
138
138
+
path: string,
139
139
): Effect.Effect<number, LogCommandError, never> =>
140
140
Effect.tryPromise({
141
141
try: async () => {
···
167
167
exists
168
168
? Effect.succeed(true)
169
169
: du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB))
170
170
-
)
170
170
+
),
171
171
);
172
172
173
173
export const downloadIso = (url: string, options: Options) =>
···
189
189
if (driveSize > EMPTY_DISK_THRESHOLD_KB) {
190
190
console.log(
191
191
chalk.yellowBright(
192
192
-
`Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`
193
193
-
)
192
192
+
`Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`,
193
193
+
),
194
194
);
195
195
return null;
196
196
}
···
208
208
if (outputExists) {
209
209
console.log(
210
210
chalk.yellowBright(
211
211
-
`File ${outputPath} already exists, skipping download.`
212
212
-
)
211
211
+
`File ${outputPath} already exists, skipping download.`,
212
212
+
),
213
213
);
214
214
return outputPath;
215
215
}
···
220
220
chalk.blueBright(
221
221
`Downloading ${
222
222
url.endsWith(".iso") ? "ISO" : "image"
223
223
-
} from ${url}...`
224
224
-
)
223
223
+
} from ${url}...`,
224
224
+
),
225
225
);
226
226
const cmd = new Deno.Command("curl", {
227
227
args: ["-L", "-o", outputPath, url],
···
264
264
if (!success) {
265
265
console.error(
266
266
chalk.redBright(
267
267
-
"Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew."
268
268
-
)
267
267
+
"Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.",
268
268
+
),
269
269
);
270
270
Deno.exit(1);
271
271
}
···
278
278
try: () =>
279
279
Deno.copyFile(
280
280
`${brewPrefix}/share/qemu/edk2-arm-vars.fd`,
281
281
-
edk2VarsAarch64
281
281
+
edk2VarsAarch64,
282
282
),
283
283
catch: (error) => new LogCommandError({ cause: error }),
284
284
});
···
323
323
const configOK = yield* pipe(
324
324
fileExists("config.ign"),
325
325
Effect.flatMap(() => Effect.succeed(true)),
326
326
-
Effect.catchAll(() => Effect.succeed(false))
326
326
+
Effect.catchAll(() => Effect.succeed(false)),
327
327
);
328
328
if (!configOK) {
329
329
console.error(
330
330
chalk.redBright(
331
331
-
"CoreOS image requires a config.ign file in the current directory."
332
332
-
)
331
331
+
"CoreOS image requires a config.ign file in the current directory.",
332
332
+
),
333
333
);
334
334
Deno.exit(1);
335
335
}
···
369
369
imagePath &&
370
370
imagePath.endsWith(".qcow2") &&
371
371
imagePath.startsWith(
372
372
-
`di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-`
372
372
+
`di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-`,
373
373
)
374
374
) {
375
375
return [
···
384
384
385
385
export const setupAlpineArgs = (
386
386
imagePath?: string | null,
387
387
-
seed: string = "seed.iso"
387
387
+
seed: string = "seed.iso",
388
388
) =>
389
389
Effect.sync(() => {
390
390
if (
···
405
405
406
406
export const setupDebianArgs = (
407
407
imagePath?: string | null,
408
408
-
seed: string = "seed.iso"
408
408
+
seed: string = "seed.iso",
409
409
) =>
410
410
Effect.sync(() => {
411
411
if (
···
426
426
427
427
export const setupUbuntuArgs = (
428
428
imagePath?: string | null,
429
429
-
seed: string = "seed.iso"
429
429
+
seed: string = "seed.iso",
430
430
) =>
431
431
Effect.sync(() => {
432
432
if (
···
447
447
448
448
export const setupAlmaLinuxArgs = (
449
449
imagePath?: string | null,
450
450
-
seed: string = "seed.iso"
450
450
+
seed: string = "seed.iso",
451
451
) =>
452
452
Effect.sync(() => {
453
453
if (
···
468
468
469
469
export const setupRockyLinuxArgs = (
470
470
imagePath?: string | null,
471
471
-
seed: string = "seed.iso"
471
471
+
seed: string = "seed.iso",
472
472
) =>
473
473
Effect.sync(() => {
474
474
if (
···
491
491
Effect.gen(function* () {
492
492
const macAddress = yield* generateRandomMacAddress();
493
493
494
494
-
const qemu =
495
495
-
Deno.build.arch === "aarch64"
496
496
-
? "qemu-system-aarch64"
497
497
-
: "qemu-system-x86_64";
494
494
+
const qemu = Deno.build.arch === "aarch64"
495
495
+
? "qemu-system-aarch64"
496
496
+
: "qemu-system-x86_64";
498
497
499
498
const firmwareFiles = yield* setupFirmwareFilesIfNeeded();
500
499
let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image);
501
500
let fedoraArgs: string[] = yield* setupFedoraArgs(
502
501
isoPath || options.image,
503
503
-
options.seed
502
502
+
options.seed,
504
503
);
505
504
let gentooArgs: string[] = yield* setupGentooArgs(
506
505
isoPath || options.image,
507
507
-
options.seed
506
506
+
options.seed,
508
507
);
509
508
let alpineArgs: string[] = yield* setupAlpineArgs(
510
509
isoPath || options.image,
511
511
-
options.seed
510
510
+
options.seed,
512
511
);
513
512
let debianArgs: string[] = yield* setupDebianArgs(
514
513
isoPath || options.image,
515
515
-
options.seed
514
514
+
options.seed,
516
515
);
517
516
let ubuntuArgs: string[] = yield* setupUbuntuArgs(
518
517
isoPath || options.image,
519
519
-
options.seed
518
518
+
options.seed,
520
519
);
521
520
let almalinuxArgs: string[] = yield* setupAlmaLinuxArgs(
522
521
isoPath || options.image,
523
523
-
options.seed
522
522
+
options.seed,
524
523
);
525
524
let rockylinuxArgs: string[] = yield* setupRockyLinuxArgs(
526
525
isoPath || options.image,
527
527
-
options.seed
526
526
+
options.seed,
528
527
);
529
528
530
529
if (coreosArgs.length > 0 && !isoPath) {
···
597
596
options.image && [
598
597
"-drive",
599
598
`file=${options.image},format=${options.diskFormat},if=virtio`,
600
600
-
]
599
599
+
],
601
600
),
602
601
];
603
602
···
612
611
const logPath = `${LOGS_DIR}/${name}.log`;
613
612
614
613
const fullCommand = options.bridge
615
615
-
? `sudo ${qemu} ${qemuArgs
614
614
+
? `sudo ${qemu} ${
615
615
+
qemuArgs
616
616
.slice(1)
617
617
-
.join(" ")} >> "${logPath}" 2>&1 & echo $!`
617
617
+
.join(" ")
618
618
+
} >> "${logPath}" 2>&1 & echo $!`
618
619
: `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`;
619
620
620
621
const { stdout } = yield* Effect.tryPromise({
···
640
641
cpus: options.cpus,
641
642
cpu: options.cpu,
642
643
diskSize: options.size || "20G",
643
643
-
diskFormat:
644
644
-
(isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
644
644
+
diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
645
645
options.diskFormat ||
646
646
"raw",
647
647
portForward: options.portForward,
···
661
661
});
662
662
663
663
console.log(
664
664
-
`Virtual machine ${name} started in background (PID: ${qemuPid})`
664
664
+
`Virtual machine ${name} started in background (PID: ${qemuPid})`,
665
665
);
666
666
console.log(`Logs will be written to: ${logPath}`);
667
667
···
684
684
cpus: options.cpus,
685
685
cpu: options.cpu,
686
686
diskSize: options.size || "20G",
687
687
-
diskFormat:
688
688
-
(isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
687
687
+
diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
689
688
options.diskFormat ||
690
689
"raw",
691
690
portForward: options.portForward,
···
794
793
if (pathExists) {
795
794
console.log(
796
795
chalk.yellowBright(
797
797
-
`Drive image ${path} already exists, skipping creation.`
798
798
-
)
796
796
+
`Drive image ${path} already exists, skipping creation.`,
797
797
+
),
799
798
);
800
799
return;
801
800
}
···
822
821
});
823
822
824
823
export const fileExists = (
825
825
-
path: string
824
824
+
path: string,
826
825
): Effect.Effect<void, NoSuchFileError, never> =>
827
826
Effect.try({
828
827
try: () => Deno.statSync(path),
···
830
829
});
831
830
832
831
export const constructCoreOSImageURL = (
833
833
-
image: string
832
832
+
image: string,
834
833
): Effect.Effect<string, InvalidImageNameError, never> => {
835
834
// detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version>
836
835
const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/;
···
838
837
if (match) {
839
838
const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION;
840
839
return Effect.succeed(
841
841
-
FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version)
840
840
+
FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version),
842
841
);
843
842
}
844
843
···
846
845
new InvalidImageNameError({
847
846
image,
848
847
cause: "Image name does not match CoreOS naming conventions.",
849
849
-
})
848
848
+
}),
850
849
);
851
850
};
852
851
···
875
874
});
876
875
877
876
export const constructNixOSImageURL = (
878
878
-
image: string
877
877
+
image: string,
879
878
): Effect.Effect<string, InvalidImageNameError, never> => {
880
879
// detect with regex if image matches NixOS pattern: nixos or nixos-<version>
881
880
const nixosRegex = /^(nixos)(-(\d+\.\d+))?$/;
···
883
882
if (match) {
884
883
const version = match[3] || NIXOS_DEFAULT_VERSION;
885
884
return Effect.succeed(
886
886
-
NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version)
885
885
+
NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version),
887
886
);
888
887
}
889
888
···
891
890
new InvalidImageNameError({
892
891
image,
893
892
cause: "Image name does not match NixOS naming conventions.",
894
894
-
})
893
893
+
}),
895
894
);
896
895
};
897
896
898
897
export const constructFedoraImageURL = (
899
898
image: string,
900
900
-
cloud: boolean = false
899
899
+
cloud: boolean = false,
901
900
): Effect.Effect<string, InvalidImageNameError, never> => {
902
901
// detect with regex if image matches Fedora pattern: fedora
903
902
const fedoraRegex = /^(fedora)$/;
···
910
909
new InvalidImageNameError({
911
910
image,
912
911
cause: "Image name does not match Fedora naming conventions.",
913
913
-
})
912
912
+
}),
914
913
);
915
914
};
916
915
917
916
export const constructGentooImageURL = (
918
918
-
image: string
917
917
+
image: string,
919
918
): Effect.Effect<string, InvalidImageNameError, never> => {
920
919
// detect with regex if image matches genroo pattern: gentoo-20251116T161545Z or gentoo
921
920
const gentooRegex = /^(gentoo)(-(\d{8}T\d{6}Z))?$/;
···
924
923
return Effect.succeed(
925
924
GENTOO_IMG_URL.replaceAll("20251116T161545Z", match[3]).replaceAll(
926
925
"20251116T233105Z",
927
927
-
match[3]
928
928
-
)
926
926
+
match[3],
927
927
+
),
929
928
);
930
929
}
931
930
···
937
936
new InvalidImageNameError({
938
937
image,
939
938
cause: "Image name does not match Gentoo naming conventions.",
940
940
-
})
939
939
+
}),
941
940
);
942
941
};
943
942
944
943
export const constructDebianImageURL = (
945
944
image: string,
946
946
-
cloud: boolean = false
945
945
+
cloud: boolean = false,
947
946
): Effect.Effect<string, InvalidImageNameError, never> => {
948
947
if (cloud && image === "debian") {
949
948
return Effect.succeed(DEBIAN_CLOUD_IMG_URL);
···
954
953
const match = image.match(debianRegex);
955
954
if (match?.[3]) {
956
955
return Effect.succeed(
957
957
-
DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3])
956
956
+
DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3]),
958
957
);
959
958
}
960
959
···
966
965
new InvalidImageNameError({
967
966
image,
968
967
cause: "Image name does not match Debian naming conventions.",
969
969
-
})
968
968
+
}),
970
969
);
971
970
};
972
971
973
972
export const constructAlpineImageURL = (
974
974
-
image: string
973
973
+
image: string,
975
974
): Effect.Effect<string, InvalidImageNameError, never> => {
976
975
// detect with regex if image matches alpine pattern: alpine-<version> or alpine
977
976
const alpineRegex = /^(alpine)(-(\d+\.\d+(\.\d+)?))?$/;
978
977
const match = image.match(alpineRegex);
979
978
if (match?.[3]) {
980
979
return Effect.succeed(
981
981
-
ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3])
980
980
+
ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3]),
982
981
);
983
982
}
984
983
···
990
989
new InvalidImageNameError({
991
990
image,
992
991
cause: "Image name does not match Alpine naming conventions.",
993
993
-
})
992
992
+
}),
994
993
);
995
994
};
996
995
997
996
export const constructUbuntuImageURL = (
998
997
image: string,
999
999
-
cloud: boolean = false
998
998
+
cloud: boolean = false,
1000
999
): Effect.Effect<string, InvalidImageNameError, never> => {
1001
1000
// detect with regex if image matches ubuntu pattern: ubuntu
1002
1001
const ubuntuRegex = /^(ubuntu)$/;
···
1012
1011
new InvalidImageNameError({
1013
1012
image,
1014
1013
cause: "Image name does not match Ubuntu naming conventions.",
1015
1015
-
})
1014
1014
+
}),
1016
1015
);
1017
1016
};
1018
1017
1019
1018
export const constructAlmaLinuxImageURL = (
1020
1019
image: string,
1021
1021
-
cloud: boolean = false
1020
1020
+
cloud: boolean = false,
1022
1021
): Effect.Effect<string, InvalidImageNameError, never> => {
1023
1022
// detect with regex if image matches almalinux pattern: ubuntu
1024
1023
const almaLinuxRegex = /^(almalinux)$/;
···
1034
1033
new InvalidImageNameError({
1035
1034
image,
1036
1035
cause: "Image name does not match AlmaLinux naming conventions.",
1037
1037
-
})
1036
1036
+
}),
1038
1037
);
1039
1038
};
1040
1039
1041
1040
export const constructRockyLinuxImageURL = (
1042
1041
image: string,
1043
1043
-
cloud: boolean = false
1042
1042
+
cloud: boolean = false,
1044
1043
): Effect.Effect<string, InvalidImageNameError, never> => {
1045
1044
// detect with regex if image matches rockylinux pattern: ubuntu
1046
1045
const rockyLinuxRegex = /^(rockylinux)$/;
···
1056
1055
new InvalidImageNameError({
1057
1056
image,
1058
1057
cause: "Image name does not match RockyLinux naming conventions.",
1059
1059
-
})
1058
1058
+
}),
1060
1059
);
1061
1060
};