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
7c9246ce
034ff262
2/2
fmt.yml
success
3s
test.yml
success
8s
+58
-59
1 changed file
expand all
collapse all
unified
split
src
utils.ts
+58
-59
src/utils.ts
···
76
76
export const isValidISOurl = (url?: string): boolean => {
77
77
return Boolean(
78
78
(url?.startsWith("http://") || url?.startsWith("https://")) &&
79
79
-
url?.endsWith(".iso")
79
79
+
url?.endsWith(".iso"),
80
80
);
81
81
};
82
82
···
102
102
});
103
103
104
104
export const validateImage = (
105
105
-
image: string
105
105
+
image: string,
106
106
): Effect.Effect<string, InvalidImageNameError, never> => {
107
107
const regex =
108
108
/^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/;
···
113
113
image,
114
114
cause:
115
115
"Image name does not conform to expected format. Should be in the format 'repository/name:tag'.",
116
116
-
})
116
116
+
}),
117
117
);
118
118
}
119
119
return Effect.succeed(image);
···
122
122
export const extractTag = (name: string) =>
123
123
pipe(
124
124
validateImage(name),
125
125
-
Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest"))
125
125
+
Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")),
126
126
);
127
127
128
128
export const failOnMissingImage = (
129
129
-
image: Image | undefined
129
129
+
image: Image | undefined,
130
130
): Effect.Effect<Image, Error, never> =>
131
131
image
132
132
? Effect.succeed(image)
133
133
: Effect.fail(new NoSuchImageError({ cause: "No such image" }));
134
134
135
135
export const du = (
136
136
-
path: string
136
136
+
path: string,
137
137
): Effect.Effect<number, LogCommandError, never> =>
138
138
Effect.tryPromise({
139
139
try: async () => {
···
165
165
exists
166
166
? Effect.succeed(true)
167
167
: du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB))
168
168
-
)
168
168
+
),
169
169
);
170
170
171
171
export const downloadIso = (url: string, options: Options) =>
···
187
187
if (driveSize > EMPTY_DISK_THRESHOLD_KB) {
188
188
console.log(
189
189
chalk.yellowBright(
190
190
-
`Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`
191
191
-
)
190
190
+
`Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`,
191
191
+
),
192
192
);
193
193
return null;
194
194
}
···
206
206
if (outputExists) {
207
207
console.log(
208
208
chalk.yellowBright(
209
209
-
`File ${outputPath} already exists, skipping download.`
210
210
-
)
209
209
+
`File ${outputPath} already exists, skipping download.`,
210
210
+
),
211
211
);
212
212
return outputPath;
213
213
}
···
256
256
if (!success) {
257
257
console.error(
258
258
chalk.redBright(
259
259
-
"Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew."
260
260
-
)
259
259
+
"Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.",
260
260
+
),
261
261
);
262
262
Deno.exit(1);
263
263
}
···
270
270
try: () =>
271
271
Deno.copyFile(
272
272
`${brewPrefix}/share/qemu/edk2-arm-vars.fd`,
273
273
-
edk2VarsAarch64
273
273
+
edk2VarsAarch64,
274
274
),
275
275
catch: (error) => new LogCommandError({ cause: error }),
276
276
});
···
315
315
const configOK = yield* pipe(
316
316
fileExists("config.ign"),
317
317
Effect.flatMap(() => Effect.succeed(true)),
318
318
-
Effect.catchAll(() => Effect.succeed(false))
318
318
+
Effect.catchAll(() => Effect.succeed(false)),
319
319
);
320
320
if (!configOK) {
321
321
console.error(
322
322
chalk.redBright(
323
323
-
"CoreOS image requires a config.ign file in the current directory."
324
324
-
)
323
323
+
"CoreOS image requires a config.ign file in the current directory.",
324
324
+
),
325
325
);
326
326
Deno.exit(1);
327
327
}
···
356
356
imagePath &&
357
357
imagePath.endsWith(".qcow2") &&
358
358
imagePath.startsWith(
359
359
-
`di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-`
359
359
+
`di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-`,
360
360
)
361
361
) {
362
362
return ["-drive", `file=${imagePath},format=qcow2,if=virtio`];
···
459
459
Effect.gen(function* () {
460
460
const macAddress = yield* generateRandomMacAddress();
461
461
462
462
-
const qemu =
463
463
-
Deno.build.arch === "aarch64"
464
464
-
? "qemu-system-aarch64"
465
465
-
: "qemu-system-x86_64";
462
462
+
const qemu = Deno.build.arch === "aarch64"
463
463
+
? "qemu-system-aarch64"
464
464
+
: "qemu-system-x86_64";
466
465
467
466
const firmwareFiles = yield* setupFirmwareFilesIfNeeded();
468
467
let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image);
···
472
471
let debianArgs: string[] = yield* setupDebianArgs(isoPath || options.image);
473
472
let ubuntuArgs: string[] = yield* setupUbuntuArgs(isoPath || options.image);
474
473
let almalinuxArgs: string[] = yield* setupAlmaLinuxArgs(
475
475
-
isoPath || options.image
474
474
+
isoPath || options.image,
476
475
);
477
476
let rockylinuxArgs: string[] = yield* setupRockyLinuxArgs(
478
478
-
isoPath || options.image
477
477
+
isoPath || options.image,
479
478
);
480
479
481
480
if (coreosArgs.length > 0 && !isoPath) {
···
548
547
options.image && [
549
548
"-drive",
550
549
`file=${options.image},format=${options.diskFormat},if=virtio`,
551
551
-
]
550
550
+
],
552
551
),
553
552
];
554
553
···
563
562
const logPath = `${LOGS_DIR}/${name}.log`;
564
563
565
564
const fullCommand = options.bridge
566
566
-
? `sudo ${qemu} ${qemuArgs
565
565
+
? `sudo ${qemu} ${
566
566
+
qemuArgs
567
567
.slice(1)
568
568
-
.join(" ")} >> "${logPath}" 2>&1 & echo $!`
568
568
+
.join(" ")
569
569
+
} >> "${logPath}" 2>&1 & echo $!`
569
570
: `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`;
570
571
571
572
const { stdout } = yield* Effect.tryPromise({
···
591
592
cpus: options.cpus,
592
593
cpu: options.cpu,
593
594
diskSize: options.size || "20G",
594
594
-
diskFormat:
595
595
-
(isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
595
595
+
diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
596
596
options.diskFormat ||
597
597
"raw",
598
598
portForward: options.portForward,
···
611
611
});
612
612
613
613
console.log(
614
614
-
`Virtual machine ${name} started in background (PID: ${qemuPid})`
614
614
+
`Virtual machine ${name} started in background (PID: ${qemuPid})`,
615
615
);
616
616
console.log(`Logs will be written to: ${logPath}`);
617
617
···
634
634
cpus: options.cpus,
635
635
cpu: options.cpu,
636
636
diskSize: options.size || "20G",
637
637
-
diskFormat:
638
638
-
(isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
637
637
+
diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
639
638
options.diskFormat ||
640
639
"raw",
641
640
portForward: options.portForward,
···
743
742
if (pathExists) {
744
743
console.log(
745
744
chalk.yellowBright(
746
746
-
`Drive image ${path} already exists, skipping creation.`
747
747
-
)
745
745
+
`Drive image ${path} already exists, skipping creation.`,
746
746
+
),
748
747
);
749
748
return;
750
749
}
···
771
770
});
772
771
773
772
export const fileExists = (
774
774
-
path: string
773
773
+
path: string,
775
774
): Effect.Effect<void, NoSuchFileError, never> =>
776
775
Effect.try({
777
776
try: () => Deno.statSync(path),
···
779
778
});
780
779
781
780
export const constructCoreOSImageURL = (
782
782
-
image: string
781
781
+
image: string,
783
782
): Effect.Effect<string, InvalidImageNameError, never> => {
784
783
// detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version>
785
784
const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/;
···
787
786
if (match) {
788
787
const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION;
789
788
return Effect.succeed(
790
790
-
FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version)
789
789
+
FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version),
791
790
);
792
791
}
793
792
···
795
794
new InvalidImageNameError({
796
795
image,
797
796
cause: "Image name does not match CoreOS naming conventions.",
798
798
-
})
797
797
+
}),
799
798
);
800
799
};
801
800
···
824
823
});
825
824
826
825
export const constructNixOSImageURL = (
827
827
-
image: string
826
826
+
image: string,
828
827
): Effect.Effect<string, InvalidImageNameError, never> => {
829
828
// detect with regex if image matches NixOS pattern: nixos or nixos-<version>
830
829
const nixosRegex = /^(nixos)(-(\d+\.\d+))?$/;
···
832
831
if (match) {
833
832
const version = match[3] || NIXOS_DEFAULT_VERSION;
834
833
return Effect.succeed(
835
835
-
NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version)
834
834
+
NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version),
836
835
);
837
836
}
838
837
···
840
839
new InvalidImageNameError({
841
840
image,
842
841
cause: "Image name does not match NixOS naming conventions.",
843
843
-
})
842
842
+
}),
844
843
);
845
844
};
846
845
847
846
export const constructFedoraImageURL = (
848
848
-
image: string
847
847
+
image: string,
849
848
): Effect.Effect<string, InvalidImageNameError, never> => {
850
849
// detect with regex if image matches Fedora pattern: fedora
851
850
const fedoraRegex = /^(fedora)$/;
···
858
857
new InvalidImageNameError({
859
858
image,
860
859
cause: "Image name does not match Fedora naming conventions.",
861
861
-
})
860
860
+
}),
862
861
);
863
862
};
864
863
865
864
export const constructGentooImageURL = (
866
866
-
image: string
865
865
+
image: string,
867
866
): Effect.Effect<string, InvalidImageNameError, never> => {
868
867
// detect with regex if image matches genroo pattern: gentoo-20251116T161545Z or gentoo
869
868
const gentooRegex = /^(gentoo)(-(\d{8}T\d{6}Z))?$/;
···
872
871
return Effect.succeed(
873
872
GENTOO_IMG_URL.replaceAll("20251116T161545Z", match[3]).replaceAll(
874
873
"20251116T233105Z",
875
875
-
match[3]
876
876
-
)
874
874
+
match[3],
875
875
+
),
877
876
);
878
877
}
879
878
···
885
884
new InvalidImageNameError({
886
885
image,
887
886
cause: "Image name does not match Gentoo naming conventions.",
888
888
-
})
887
887
+
}),
889
888
);
890
889
};
891
890
892
891
export const constructDebianImageURL = (
893
892
image: string,
894
894
-
cloud: boolean = false
893
893
+
cloud: boolean = false,
895
894
): Effect.Effect<string, InvalidImageNameError, never> => {
896
895
if (cloud && image === "debian") {
897
896
return Effect.succeed(DEBIAN_CLOUD_IMG_URL);
···
902
901
const match = image.match(debianRegex);
903
902
if (match?.[3]) {
904
903
return Effect.succeed(
905
905
-
DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3])
904
904
+
DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3]),
906
905
);
907
906
}
908
907
···
914
913
new InvalidImageNameError({
915
914
image,
916
915
cause: "Image name does not match Debian naming conventions.",
917
917
-
})
916
916
+
}),
918
917
);
919
918
};
920
919
921
920
export const constructAlpineImageURL = (
922
922
-
image: string
921
921
+
image: string,
923
922
): Effect.Effect<string, InvalidImageNameError, never> => {
924
923
// detect with regex if image matches alpine pattern: alpine-<version> or alpine
925
924
const alpineRegex = /^(alpine)(-(\d+\.\d+(\.\d+)?))?$/;
926
925
const match = image.match(alpineRegex);
927
926
if (match?.[3]) {
928
927
return Effect.succeed(
929
929
-
ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3])
928
928
+
ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3]),
930
929
);
931
930
}
932
931
···
938
937
new InvalidImageNameError({
939
938
image,
940
939
cause: "Image name does not match Alpine naming conventions.",
941
941
-
})
940
940
+
}),
942
941
);
943
942
};
944
943
945
944
export const constructUbuntuImageURL = (
946
945
image: string,
947
947
-
cloud: boolean = false
946
946
+
cloud: boolean = false,
948
947
): Effect.Effect<string, InvalidImageNameError, never> => {
949
948
// detect with regex if image matches ubuntu pattern: ubuntu
950
949
const ubuntuRegex = /^(ubuntu)$/;
···
960
959
new InvalidImageNameError({
961
960
image,
962
961
cause: "Image name does not match Ubuntu naming conventions.",
963
963
-
})
962
962
+
}),
964
963
);
965
964
};
966
965
967
966
export const constructAlmaLinuxImageURL = (
968
967
image: string,
969
969
-
cloud: boolean = false
968
968
+
cloud: boolean = false,
970
969
): Effect.Effect<string, InvalidImageNameError, never> => {
971
970
// detect with regex if image matches almalinux pattern: ubuntu
972
971
const almaLinuxRegex = /^(almalinux)$/;
···
982
981
new InvalidImageNameError({
983
982
image,
984
983
cause: "Image name does not match AlmaLinux naming conventions.",
985
985
-
})
984
984
+
}),
986
985
);
987
986
};
988
987
989
988
export const constructRockyLinuxImageURL = (
990
989
image: string,
991
991
-
cloud: boolean = false
990
990
+
cloud: boolean = false,
992
991
): Effect.Effect<string, InvalidImageNameError, never> => {
993
992
// detect with regex if image matches rockylinux pattern: ubuntu
994
993
const rockyLinuxRegex = /^(rockylinux)$/;
···
1004
1003
new InvalidImageNameError({
1005
1004
image,
1006
1005
cause: "Image name does not match RockyLinux naming conventions.",
1007
1007
-
})
1006
1006
+
}),
1008
1007
);
1009
1008
};