-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathbootstrap.bb
More file actions
executable file
·894 lines (811 loc) · 43 KB
/
Copy pathbootstrap.bb
File metadata and controls
executable file
·894 lines (811 loc) · 43 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
#!/usr/bin/env bb
;; nixos-vm-template bootstrap
;; Create and manage NixOS VMs.
;; Requires only babashka (bb) to be pre-installed.
;;
;; Two modes are offered when `nix` is available on the machine:
;; - Production: download pre-built images from S3 (no build, no nix needed).
;; - Development: clone the repo to ~/git/vendor/enigmacurry/nixos-vm-template
;; and build your own images locally from source (a frontend
;; for `just create` / `just upgrade`).
;; When `nix` is not installed, only Production mode is available. So the
;; one-liner works from any machine, and from a machine with nix you can
;; develop and build images for all the rest.
;;
;; One-liner (NIXOS_VM_BRANCH selects the branch for BOTH the fetched script and
;; the checkout it re-execs into; it can't be inferred from the slurp'd URL, so
;; it must be passed explicitly — defaults to master):
;; bb -e '(load-string (slurp (str "https://github.com/EnigmaCurry/nixos-vm-template/raw/refs/heads/" (or (System/getenv "NIXOS_VM_BRANCH") "master") "/bootstrap.bb")))'
;;
;; To bootstrap from a non-default branch, set NIXOS_VM_BRANCH, e.g.:
;; NIXOS_VM_BRANCH=dev bb -e '(load-string (slurp (str "https://github.com/EnigmaCurry/nixos-vm-template/raw/refs/heads/" (or (System/getenv "NIXOS_VM_BRANCH") "master") "/bootstrap.bb")))'
;;
;; Or from a cloned repo:
;; bb bootstrap.bb
;; just bootstrap
(require '[babashka.process :as proc]
'[babashka.pods :as pods]
'[babashka.http-client :as http]
'[clojure.java.io :as io]
'[clojure.string :as str]
'[cheshire.core :as json])
;; ─── Constants ──────────────────────────────────────────────────────────────
(def repo-url "https://github.com/EnigmaCurry/nixos-vm-template.git")
(def default-branch "master")
(def default-repo-dir (str (System/getenv "HOME") "/.cache/nixos-vm-template"))
;; Development mode clones to a vendor-neutral, persistent path you can hack on,
;; rather than the throwaway cache dir used for the production bootstrap.
(def default-dev-dir
(or (System/getenv "NIXOS_VM_DEV_DIR")
(str (System/getenv "HOME") "/git/vendor/enigmacurry/nixos-vm-template")))
(def default-machines-base
(or (System/getenv "NIXOS_VM_MACHINES_DIR")
(str (or (System/getenv "XDG_CONFIG_HOME")
(str (System/getenv "HOME") "/.config"))
"/nixos-vm-template/machines")))
(def manifest-url
(or (System/getenv "NIXOS_MANIFEST_URL")
"https://nixos-vm-template.nyc3.digitaloceanspaces.com/manifest.json"))
;; ─── Repo bootstrap ────────────────────────────────────────────────────────
;; When loaded from a URL, this section clones/updates the repo and re-execs
;; the local copy so the user always runs the latest version.
(defn in-repo?
"Check if a directory has the files needed to create VMs."
[dir]
(and dir
(.exists (io/file dir "Justfile"))
(.exists (io/file dir "bb.edn"))
(.exists (io/file dir "bootstrap.bb"))))
(let [file-dir (when-not (= *file* "NO_SOURCE_PATH")
(try
(let [f (io/file *file*)]
(-> (if (.isAbsolute f) f (.getCanonicalFile f))
.getParentFile .getPath))
(catch Exception _ nil)))]
(when-not (in-repo? file-dir)
;; Not running from a repo checkout — clone/update, then hand off
(let [dir default-repo-dir
branch (or (System/getenv "NIXOS_VM_BRANCH") default-branch)
dir-file (io/file dir)
git-repo? (.exists (io/file dir ".git"))
;; A directory that exists and isn't empty but isn't a git
;; checkout (e.g. a stale/partial clone) would make `git clone`
;; fail with "destination path already exists". Bail clearly.
stale? (and (.exists dir-file)
(not git-repo?)
(seq (.list dir-file)))]
(when stale?
(println (format "error: %s exists but is not a git checkout." dir))
(println "Remove it and re-run, or set NIXOS_VM_BRANCH/HOME to a clean path:")
(println (format " rm -rf %s" dir))
(System/exit 1))
(if git-repo?
(do
(proc/shell {:dir dir :out :string :err :string}
"git" "fetch" "origin" branch)
(proc/shell {:dir dir :out :string :err :string}
"git" "checkout" branch)
(proc/shell {:dir dir :out :string :err :string}
"git" "reset" "--hard" (str "origin/" branch)))
(do
(.mkdirs (.getParentFile dir-file))
(proc/shell {:out :string :err :string}
"git" "clone" "--branch" branch repo-url dir)))
(let [sha (str/trim (:out (proc/shell {:dir dir :out :string :err :string}
"git" "rev-parse" "--short" "HEAD")))]
(println (format "Using %s (branch: %s, commit: %s)" dir branch sha)))
;; Re-exec the repo's local copy (guarantees we run the latest code)
(load-file (str dir "/bootstrap.bb"))
(System/exit 0))))
;; ─── From here on, we are always running from within a repo checkout ───────
;; ─── script-wizard (pod) ─────────────────────────────────────────────────────
;; Auto-downloads the script-wizard binary via babashka pod registry.
(pods/load-pod 'enigmacurry/script-wizard "0.3.0")
(require '[pod.enigmacurry.script-wizard :as wiz])
(def repo-dir
(-> (io/file *file*) .getCanonicalFile .getParentFile .getPath))
;; ─── Utilities ──────────────────────────────────────────────────────────────
(defn sh
"Run a shell command in repo dir. Throws on non-zero exit."
[& args]
(let [cmd (str/join " " args)]
(proc/shell {:out :string :err :string :dir repo-dir} "bash" "-c" cmd)))
(defn sh-ok
"Run a shell command, return stdout trimmed."
[& args]
(str/trim (:out (apply sh args))))
(defn sh-ok?
"Run a shell command, return true if exit 0."
[& args]
(try (apply sh args) true
(catch Exception _ false)))
(defn sh-inherit!
"Run a shell command with inherited stdout/stderr (visible to user)."
[& args]
(let [cmd (str/join " " args)]
(proc/shell {:dir repo-dir} "bash" "-c" cmd)))
(defn section-break!
"Print a visual separator and flush terminal input buffer."
[]
(println)
(println " ──────────────────────────────────")
(println)
;; Drain any stale bytes from terminal input buffer
(try (proc/shell {:out :string :err :string}
"bash" "-c" "read -t 0.1 -n 10000 < /dev/tty 2>/dev/null || true")
(catch Exception _ nil)))
(defn- env-vars
"Strip non-string keys from an env map (keeps only real env vars)."
[env]
(into {} (filter (fn [[k _]] (string? k)) env)))
(defn- backend-env
[backend env]
(merge {"SKIP_BUILD" "true" "BACKEND" backend
"MACHINES_DIR" (get env :machines-dir)
"HOST" (get env :host)}
(env-vars env)))
(defn backend-cli!
"Run `bb -m vm.cli <args...>` with backend env (inherited stdio). An optional
trailing opts map may supply :in (stdin). Returns the process result."
[backend env & args]
(let [opts (when (map? (last args)) (last args))
cmd-args (if opts (butlast args) args)]
(apply proc/shell
(merge {:dir repo-dir :extra-env (backend-env backend env)}
(select-keys opts [:in]))
"bb" "-m" "vm.cli" cmd-args)))
(defn backend-cli-ok
"Run `bb -m vm.cli <args...>` with backend env, return stdout trimmed."
[backend env & args]
(str/trim (:out (apply proc/shell
{:dir repo-dir :out :string :err :string
:extra-env (backend-env backend env)}
"bb" "-m" "vm.cli" args))))
(defn fetch-json
"Fetch and parse JSON from a URL."
[url]
(let [resp (http/get url {:headers {"Accept" "application/json"}})]
(json/parse-string (:body resp) true)))
(defn format-size
"Format byte count as human-readable size."
[bytes]
(cond
(>= bytes (* 1024 1024 1024)) (format "%.1f GB" (/ bytes (* 1024.0 1024 1024)))
(>= bytes (* 1024 1024)) (format "%.0f MB" (/ bytes (* 1024.0 1024)))
:else (format "%d bytes" bytes)))
(defn debian?
"Check if running on Debian/Ubuntu."
[]
(sh-ok? "test -f /etc/debian_version"))
(defn command-exists?
"Check if a command exists on PATH."
[cmd]
(sh-ok? (format "command -v %s" cmd)))
;; ─── Dependency checking ────────────────────────────────────────────────────
(def libvirt-deps
{"curl" {:debian "curl"}
"qemu-img" {:debian "qemu-utils"}
"guestfish" {:debian "libguestfs-tools"}
"virsh" {:debian "libvirt-clients"}
"readlink" {:debian "coreutils"}})
(def proxmox-deps
{"curl" {:debian "curl"}
"qemu-img" {:debian "qemu-utils"}
"guestfish" {:debian "libguestfs-tools"}
"ssh" {:debian "openssh-client"}
"rsync" {:debian "rsync"}
"readlink" {:debian "coreutils"}})
(defn check-deps!
"Check that all required commands exist. Exit with install instructions if not."
[backend]
(let [deps (if (= backend "proxmox") proxmox-deps libvirt-deps)
missing (vec (filter (fn [[cmd _]] (not (command-exists? cmd))) deps))]
(when (seq missing)
(println)
(println "Missing required commands:")
(doseq [[cmd _] missing]
(println (format " - %s" cmd)))
(when (debian?)
(let [pkgs (str/join " " (distinct (map (fn [[_ info]] (:debian info)) missing)))]
(println)
(println "Install on Debian/Ubuntu:")
(println (format " sudo apt-get install %s" pkgs))))
(println)
(System/exit 1))))
;; ─── Machine listing ────────────────────────────────────────────────────────
(defn list-machines
"Return a vector of {:name :profile} maps for existing machine configs."
[machines-dir]
(let [dir (io/file machines-dir)]
(if (.isDirectory dir)
(->> (.listFiles dir)
(filter #(.isDirectory %))
(mapv (fn [d]
(let [name (.getName d)
profile (try (str/trim (slurp (str d "/profile")))
(catch Exception _ "unknown"))]
{:name name :profile profile})))
(sort-by :name))
[])))
(defn profile-component-set
"Split a comma-separated profile string into a set of trimmed,
non-empty components, ignoring order and duplicates."
[profile]
(->> (str/split (or profile "") #",")
(map str/trim)
(remove str/blank?)
set))
(defn resolve-profile-info
"Look up a profile in the manifest's profiles map, tolerant of differently
ordered profile lists. Returns [matched-key profile-info] or nil if no match.
Tries an exact key match first, then falls back to matching by the set of
profile components."
[profiles profile]
(or (when-let [info (get profiles (keyword profile))]
[profile info])
(let [target (profile-component-set profile)]
(some (fn [[k info]]
(when (= target (profile-component-set (name k)))
[(name k) info]))
profiles))))
(defn get-vm-states
"Query backend for state of each VM. Returns {name state} map."
[backend env vm-names]
(if (empty? vm-names)
{}
(let [output (try (apply backend-cli-ok backend env "vm-states" vm-names)
(catch Exception _ ""))]
(into {}
(for [line (str/split-lines output)
:let [parts (str/split line #":" 2)]
:when (= 2 (count parts))]
[(first parts) (second parts)])))))
(defn get-vm-versions
"Query image version from running VMs. Returns {name version-string} map."
[backend env running-names]
(if (empty? running-names)
{}
(let [output (try (apply backend-cli-ok backend env "vm-versions" running-names)
(catch Exception _ ""))]
(into {}
(for [line (str/split-lines output)
:let [parts (str/split line #":" 2)]
:when (= 2 (count parts))]
[(first parts) (second parts)])))))
;; ─── Image download ─────────────────────────────────────────────────────────
(defn download-profile!
"Download a profile image if needed. Returns the local image path."
[profile-key profile-info]
(let [image-url (:url profile-info)
image-sha256 (:sha256 profile-info)
profile-dir (str repo-dir "/output/profiles/" profile-key)
image-path (str profile-dir "/nixos.qcow2")
needs-download? (atom true)]
(when (.exists (io/file image-path))
(print " Checking existing image... ")
(flush)
(let [actual (first (str/split (sh-ok (format "sha256sum '%s'" image-path)) #"\s+"))]
(if (= actual image-sha256)
(do (println "OK (checksum matches)")
(reset! needs-download? false))
(println "stale (re-downloading)"))))
(when @needs-download?
(.mkdirs (io/file profile-dir))
(println (format "Downloading %s (%s)..."
(:filename profile-info)
(format-size (:size profile-info))))
(sh-inherit! (format "curl -fL --progress-bar -o '%s' '%s'" image-path image-url))
(print " Verifying checksum... ")
(flush)
(let [actual (first (str/split (sh-ok (format "sha256sum '%s'" image-path)) #"\s+"))]
(if (= actual image-sha256)
(println "OK")
(do (println "FAILED")
(println (format " Expected: %s" image-sha256))
(println (format " Actual: %s" actual))
(io/delete-file image-path true)
(System/exit 1)))))
image-path))
;; ─── PVE per-VM config ───────────────────────────────────────────────────────
(defn pve-prompt-storage-bridge
"Prompt for PVE storage and bridge, returns env map with PVE_STORAGE, PVE_BRIDGE, PVE_DISK_FORMAT."
[pve-env]
(let [storage-info (:pve-storage-info pve-env)
bridges (:pve-bridges pve-env)
env-storage (some-> (System/getenv "PVE_STORAGE") str/trim not-empty)
env-bridge (some-> (System/getenv "PVE_BRIDGE") str/trim not-empty)
;; Storage selection
pve-storage (cond
env-storage
(do (println (format " Storage: %s" env-storage)) env-storage)
(= 1 (count storage-info))
(do (println (format " Storage: %s (%s)"
(:name (first storage-info))
(:type (first storage-info))))
(:name (first storage-info)))
(seq storage-info)
(let [choices (mapv #(format "%s (%s)" (:name %) (:type %)) storage-info)
choice (wiz/choose "PVE storage:" choices)]
(:name (nth storage-info (.indexOf choices choice))))
:else (wiz/ask "PVE storage:" :default "local"))
;; Detect disk format from storage type
storage-type (:type (first (filter #(= (:name %) pve-storage) storage-info)))
pve-disk-format (if (or (= storage-type "lvmthin") (= storage-type "lvm"))
"raw" "qcow2")
;; Bridge selection
pve-bridge (cond
env-bridge
(do (println (format " Bridge: %s" env-bridge)) env-bridge)
(= 1 (count bridges))
(do (println (format " Bridge: %s" (first bridges)))
(first bridges))
(seq bridges) (wiz/choose "PVE bridge:" bridges)
:else (wiz/ask "PVE bridge:" :default "vmbr0"))]
(merge pve-env
{"PVE_STORAGE" pve-storage
"PVE_BRIDGE" pve-bridge
"PVE_DISK_FORMAT" pve-disk-format})))
;; ─── Actions ────────────────────────────────────────────────────────────────
(defn action-create-vm!
"Create a new VM from a pre-built image."
[backend pve-env machines-dir]
;; Fetch manifest
(print "Fetching image manifest... ")
(flush)
(let [manifest (try (fetch-json manifest-url)
(catch Exception e
(println "FAILED")
(println (format " %s" (.getMessage e)))
(System/exit 1)))
_ (println "OK")
profiles (:profiles manifest)
profile-keys (vec (sort (map name (keys profiles))))]
(when (empty? profile-keys)
(println "No profiles available in manifest.")
(System/exit 1))
;; Display available profiles
(println)
(doseq [k profile-keys]
(let [p (get profiles (keyword k))]
(println (format " %-35s %s commit %s %s"
k (:date p) (:commit p) (format-size (:size p))))))
(println)
;; Choose profile
(let [profile-key (wiz/choose "Create VM from profile combination:" profile-keys)
profile-info (get profiles (keyword profile-key))]
;; VM name
(let [vm-name (wiz/ask "VM name:" :default "nixos")]
;; VM specs
(println)
(let [memory (wiz/ask "Memory (MB):" :default "2048")
vcpus (wiz/ask "vCPUs:" :default "2")
var-size (wiz/ask "/var disk size:" :default "30G")
network (if (= backend "libvirt")
(let [net-choice (wiz/choose "Network:"
["NAT (default libvirt network)"
"Bridge (specify name)"])]
(if (str/starts-with? net-choice "NAT")
"nat"
(str "bridge:" (wiz/ask "Bridge name:" :default "virbr0"))))
"nat")]
;; PVE per-VM config (storage, bridge, VMID)
(let [vm-env (if (= backend "proxmox")
(let [env (pve-prompt-storage-bridge pve-env)
pve-ssh (:pve-ssh pve-env)
machine-dir (str machines-dir "/" vm-name)]
;; Pre-allocate VMID
(when (and pve-ssh (not (.exists (io/file machine-dir "vmid"))))
(let [next-id (try (pve-ssh "pvesh get /cluster/nextid")
(catch Exception _ "100"))
vmid (loop []
(let [id (wiz/ask "VMID:" :default next-id)
existing (try
(pve-ssh (format "qm config %s --current 2>/dev/null | grep '^name:' | sed 's/^name: //'" id))
(catch Exception _ ""))]
(if (and (not (str/blank? existing))
(not= existing vm-name))
(do (println (format " VMID %s is already in use by VM '%s'." id existing))
(recur))
id)))]
(.mkdirs (io/file machine-dir))
(spit (str machine-dir "/vmid") vmid)))
env)
pve-env)]
;; Download image
(println)
(download-profile! profile-key profile-info)
;; Create VM
(println)
(println (format "Creating VM '%s' with profile '%s' on %s..." vm-name profile-key backend))
(println)
(let [result (backend-cli! backend vm-env "create-batch"
vm-name profile-key memory vcpus var-size network)]
(when (not= 0 (:exit result))
(System/exit (:exit result))))))))))
(defn action-manage-vms!
"Manage existing VMs — upgrade or destroy."
[backend pve-env machines-dir]
(let [machines (list-machines machines-dir)]
(if (empty? machines)
(do (println "No existing VMs found.")
(println "Use 'Create VM' to create one."))
;; Show machines and pick one
(let [states (get-vm-states backend pve-env (mapv :name machines))
running-names (filterv #(= "running" (get states %)) (mapv :name machines))
_ (when (seq running-names) (print " Querying image versions... ") (flush))
versions (get-vm-versions backend pve-env running-names)
_ (when (seq running-names) (println "OK"))
name-width (apply max 0 (map #(count (:name %)) machines))
choices (mapv (fn [m]
(let [state (get states (:name m) "unknown")
ver (get versions (:name m))
ver-str (when (and ver (not= ver "unknown")) (str " " ver))]
(format (str "%-" name-width "s [%s] (profile: %s%s)")
(:name m) state (:profile m) (or ver-str ""))))
machines)
choice (wiz/choose "Select VM:" choices)
vm-name (:name (nth machines (.indexOf choices choice)))]
;; Action submenu
(section-break!)
(let [action (wiz/choose (format "Action for '%s':" vm-name)
["Upgrade (new image, preserve /var data)"
"Destroy (delete VM and disks, keep config)"
"Purge (delete VM, disks, and config)"])]
(cond
;; ── Upgrade ──
(str/starts-with? action "Upgrade")
(do
;; Fetch manifest for the latest image
(print "\nFetching image manifest... ")
(flush)
(let [manifest (try (fetch-json manifest-url)
(catch Exception e
(println "FAILED")
(println (format " %s" (.getMessage e)))
(System/exit 1)))
_ (println "OK")
;; Read the VM's current profile
profile (str/trim (slurp (str machines-dir "/" vm-name "/profile")))
[matched-key profile-info] (resolve-profile-info (:profiles manifest) profile)]
(if (nil? profile-info)
(do (println (format "No pre-built image available for profile '%s'." profile))
(println "Available profiles in manifest:")
(doseq [k (sort (map name (keys (:profiles manifest))))]
(println (format " %s" k))))
(do
(println (format "\nUpgrading '%s' to latest '%s' image..." vm-name matched-key))
(download-profile! matched-key profile-info)
(println)
(let [result (backend-cli! backend pve-env "upgrade" vm-name)]
(when (not= 0 (:exit result))
(System/exit (:exit result))))))))
;; ── Destroy ──
(str/starts-with? action "Destroy")
(when (wiz/confirm (format "Destroy VM '%s'? All disk data will be lost." vm-name)
:default :no)
(println)
(let [result (backend-cli! backend pve-env "destroy" vm-name {:in "y\n"})]
(when (not= 0 (:exit result))
(System/exit (:exit result)))))
;; ── Purge ──
(str/starts-with? action "Purge")
(when (wiz/confirm (format "Purge VM '%s'? All data AND config will be permanently deleted." vm-name)
:default :no)
(println)
(let [result (backend-cli! backend pve-env "purge" vm-name {:in "y\n"})]
(when (not= 0 (:exit result))
(System/exit (:exit result)))))))))))
;; ─── Backend discovery ────────────────────────────────────────────────────────
(defn discover-backend!
"Prompt for the backend and discover its connection details. For proxmox this
SSHes to the node to detect its name, storage and bridges. Returns
{:backend <str> :env <map>}, where env carries the string env vars to export
to the CLI plus helper keys (:machines-dir :host :pve-ssh ...)."
[]
(let [env-backend (some-> (System/getenv "BACKEND") str/lower-case str/trim)
backend (cond
(contains? #{"libvirt" "proxmox"} env-backend) env-backend
:else (wiz/choose "Backend:" ["libvirt" "proxmox"]))
pve-env (when (= backend "proxmox")
(let [env-pve-host (some-> (System/getenv "PVE_HOST") str/trim not-empty)
ssh-hosts (when-not env-pve-host
(try
(->> (slurp (str (System/getenv "HOME") "/.ssh/config"))
str/split-lines
(keep #(second (re-find #"(?i)^\s*Host\s+(.+)" %)))
(mapcat #(str/split % #"\s+"))
(remove #(str/includes? % "*"))
vec)
(catch Exception _ [])))
pve-host (cond
env-pve-host env-pve-host
(seq ssh-hosts)
(let [options (conj ssh-hosts "Other (enter manually)")
choice (wiz/choose "PVE host:" options)]
(if (= choice "Other (enter manually)")
(wiz/ask "PVE host (hostname or IP):")
choice))
:else (wiz/ask "PVE host (hostname or IP):"))
_ (do (print (format " Connecting to %s ... " pve-host))
(flush))
pve-ssh (fn [cmd]
(str/trim (:out (proc/shell
{:out :string :err :string}
"ssh" "-o" "BatchMode=yes"
"-o" "ConnectTimeout=10"
pve-host cmd))))
;; Test connection and detect node name
pve-node-detected (try
(let [n (pve-ssh "hostname")]
(println "OK")
n)
(catch Exception _
(println "FAILED")
(println (format " Could not SSH to %s." pve-host))
(println " Ensure SSH is configured: ssh-copy-id root@<host>")
(System/exit 1)))
_ (println (format " Node: %s" pve-node-detected))
pve-node pve-node-detected
;; Pre-discover storage and bridge options (prompted per VM later)
pve-storage-info (try
(let [out (pve-ssh "pvesm status --content images 2>/dev/null | awk 'NR>1 && $3==\"active\" {print $1, $2}'")]
(->> (str/split-lines out)
(remove str/blank?)
(mapv (fn [line]
(let [[name type] (str/split (str/trim line) #"\s+")]
{:name name :type type})))))
(catch Exception _ []))
pve-bridges (try
(let [out (pve-ssh "ip -br link show type bridge | awk '{print $1}'")]
(vec (remove str/blank? (str/split-lines out))))
(catch Exception _ []))
;; Auto-detect default storage and disk format
default-storage (or (:name (first pve-storage-info)) "local-lvm")
default-format (let [stype (:type (first pve-storage-info))]
(if (or (= stype "lvmthin") (= stype "lvm"))
"raw" "qcow2"))]
(println (format " Storage: %s" default-storage))
{"PVE_HOST" pve-host
"PVE_NODE" pve-node
"PVE_STORAGE" default-storage
"PVE_DISK_FORMAT" default-format
:pve-ssh pve-ssh
:pve-storage-info pve-storage-info
:pve-bridges pve-bridges}))]
;; Compute host and machines-dir
(let [host (if (= backend "proxmox")
(get pve-env "PVE_NODE")
(str/trim (:out (proc/shell {:out :string :err :string} "hostname" "-s"))))
machines-dir (str default-machines-base "/" backend "/" host)
env (merge (or pve-env {}) {:machines-dir machines-dir :host host})]
{:backend backend :env env})))
;; ─── Production mode ──────────────────────────────────────────────────────────
;; Create/manage VMs from pre-built images downloaded from S3 (no local build).
(defn run-production! []
(let [{:keys [backend env]} (discover-backend!)
machines-dir (:machines-dir env)]
;; Check the host has the disk tools (in dev mode they come from nix instead).
(check-deps! backend)
(loop []
(section-break!)
(let [action (try (wiz/choose "What would you like to do?"
["Create VM" "Manage VMs" "Exit"])
(catch Exception _ "Exit"))]
(section-break!)
(case action
"Create VM" (do (action-create-vm! backend env machines-dir) (recur))
"Manage VMs" (do (action-manage-vms! backend env machines-dir) (recur))
"Exit" (println "Bye."))))))
;; ─── Development mode ─────────────────────────────────────────────────────────
;; Build images locally from source. A thin frontend over `just create` /
;; `just upgrade`, run inside the flake dev shell so all tooling comes from nix.
(defn ensure-dev-checkout!
"Clone the repo to the vendor-neutral development path, or reuse it if already
present. Unlike the production bootstrap clone, this never resets the tree —
it's your working copy to hack on. Returns the path."
[]
(let [dir default-dev-dir
branch (or (System/getenv "NIXOS_VM_BRANCH") default-branch)
dir-file (io/file dir)]
(cond
(.exists (io/file dir ".git"))
(let [sha (str/trim (:out (proc/shell {:dir dir :out :string :err :string}
"git" "rev-parse" "--short" "HEAD")))
dirty? (seq (str/trim (:out (proc/shell {:dir dir :out :string :err :string}
"git" "status" "--porcelain"))))]
(println (format "Using existing development checkout: %s (commit: %s)" dir sha))
(if dirty?
(println " (working tree has local changes; skipping pull.)")
;; Attempt a fast-forward pull; fails cleanly if non-ff or no upstream.
(let [res (proc/shell {:dir dir :out :string :err :string :continue true}
"git" "pull" "--ff-only" "--quiet")]
(if (zero? (:exit res))
(let [new-sha (str/trim (:out (proc/shell {:dir dir :out :string :err :string}
"git" "rev-parse" "--short" "HEAD")))]
(when (not= sha new-sha)
(println (format " Fast-forwarded to %s." new-sha))))
(println " (couldn't fast-forward — non-ff or no upstream; leaving tree as-is.)"))))
dir)
(and (.exists dir-file) (seq (.list dir-file)))
(do (println (format "error: %s exists but is not a git checkout." dir))
(println "Remove it and re-run, or set NIXOS_VM_DEV_DIR to a clean path:")
(println (format " rm -rf %s" dir))
(System/exit 1)
dir)
:else
(do (println (format "Cloning %s" repo-url))
(println (format " -> %s ..." dir))
(.mkdirs (.getParentFile dir-file))
(proc/shell {:out :string :err :string} "git" "clone" "--branch" branch repo-url dir)
dir))))
(defn resolve-dev-dir!
"Return a development checkout to build from. If bootstrap is already running
from a real git checkout (not the throwaway production cache clone), use that
in place; otherwise clone/reuse the vendor-neutral development path."
[]
(let [cache (.getCanonicalPath (io/file default-repo-dir))]
(if (and (.exists (io/file repo-dir ".git"))
(not= (.getCanonicalPath (io/file repo-dir)) cache))
(do (println (format "Using development checkout: %s" repo-dir)) repo-dir)
(ensure-dev-checkout!))))
(defn dev-just!
"Run `just <args...>` inside the development checkout, entering the flake dev
shell so just and the disk tools (qemu-img, guestfish, virsh, ...) are on PATH
without any host install. An optional trailing opts map may supply :in (stdin).
Returns the process result."
[dev-dir backend env & args]
(let [opts (when (map? (last args)) (last args))
cmd-args (if opts (butlast args) args)]
(apply proc/shell
(merge {:dir dev-dir
:extra-env (merge {"BACKEND" backend
"MACHINES_DIR" (get env :machines-dir)
"HOST" (get env :host)
;; Tools are already on PATH inside the dev
;; shell, so run the CLI directly instead of
;; a nested `nix develop`.
"VM_CLI" "bb -m vm.cli"}
(env-vars env))}
(select-keys opts [:in]))
"nix" "develop" "--command" "just" cmd-args)))
(defn dev-create-vm!
"Development create: configure + build a VM locally from source via `just create`."
[dev-dir backend env]
(let [env-name (some-> (System/getenv "NIXOS_VM_NAME") str/trim not-empty)
vm-name (or env-name (wiz/ask "VM name:" :default "nixos"))
env-vmid (some-> (System/getenv "PVE_VMID") str/trim not-empty)
machine-dir (str (get env :machines-dir) "/" vm-name)]
;; Pre-seed the vmid file so vm.cli skips its own VMID prompt.
(when (and (= backend "proxmox") env-vmid
(not (.exists (io/file machine-dir "vmid"))))
(.mkdirs (io/file machine-dir))
(spit (str machine-dir "/vmid") env-vmid))
(println)
(println (format "Configuring VM '%s' on %s (building from source)..." vm-name backend))
(println "(Entering the flake dev shell; the first build may take a while.)")
(println)
(let [result (dev-just! dev-dir backend env "create" vm-name)]
(when (not= 0 (:exit result))
(System/exit (:exit result))))))
(defn dev-cloud-template!
"Development cloud-template: build a Proxmox template (cloud-init + mutable
+ optional extra profiles) via `just cloud-template <name> <extras>`. Skips
identity injection — each clone gets its own via cloud-init's seed drive."
[dev-dir backend env]
(when-not (= backend "proxmox")
(println "Error: cloud-template is only supported on the proxmox backend.")
(println (format " BACKEND=%s" backend))
(System/exit 1))
(let [env-name (some-> (System/getenv "NIXOS_VM_NAME") str/trim not-empty)
tmpl-name (or env-name (wiz/ask "Template name:" :default "nixos"))
env-extras (or (some-> (System/getenv "NIXOS_VM_PROFILE") str/trim not-empty) "")
env-vmid (some-> (System/getenv "PVE_VMID") str/trim not-empty)
machine-dir (str (get env :machines-dir) "/" tmpl-name)]
(when (and env-vmid (not (.exists (io/file machine-dir "vmid"))))
(.mkdirs (io/file machine-dir))
(spit (str machine-dir "/vmid") env-vmid))
(println)
(println (format "Building Proxmox template '%s' (cloud-init,mutable%s)..."
tmpl-name (if (str/blank? env-extras) "" (str "," env-extras))))
(println "(Entering the flake dev shell; the first build may take a while.)")
(println)
(let [result (dev-just! dev-dir backend env "cloud-template" tmpl-name env-extras)]
(when (not= 0 (:exit result))
(System/exit (:exit result))))))
(defn dev-manage-vms!
"Development manage: upgrade (rebuild from source), destroy, or purge a VM."
[dev-dir backend env]
(let [machines (list-machines (:machines-dir env))]
(if (empty? machines)
(do (println "No existing VMs found.")
(println "Use 'Create VM' to create one."))
(let [name-width (apply max 0 (map #(count (:name %)) machines))
choices (mapv (fn [m]
(format (str "%-" name-width "s (profile: %s)")
(:name m) (:profile m)))
machines)
choice (wiz/choose "Select VM:" choices)
vm-name (:name (nth machines (.indexOf choices choice)))]
(section-break!)
(let [action (wiz/choose (format "Action for '%s':" vm-name)
["Upgrade (rebuild image from source, preserve /var data)"
"Destroy (delete VM and disks, keep config)"
"Purge (delete VM, disks, and config)"])]
(cond
(str/starts-with? action "Upgrade")
(do (println (format "\nUpgrading '%s' (rebuilding from source)..." vm-name))
(println)
(let [result (dev-just! dev-dir backend env "upgrade" vm-name)]
(when (not= 0 (:exit result))
(System/exit (:exit result)))))
(str/starts-with? action "Destroy")
(when (wiz/confirm (format "Destroy VM '%s'? All disk data will be lost." vm-name)
:default :no)
(println)
(let [result (dev-just! dev-dir backend env "destroy" vm-name {:in "y\n"})]
(when (not= 0 (:exit result))
(System/exit (:exit result)))))
(str/starts-with? action "Purge")
(when (wiz/confirm (format "Purge VM '%s'? All data AND config will be permanently deleted." vm-name)
:default :no)
(println)
(let [result (dev-just! dev-dir backend env "purge" vm-name {:in "y\n"})]
(when (not= 0 (:exit result))
(System/exit (:exit result)))))))))))
(defn- resolve-env-action
"Map NIXOS_VM_ACTION values (e.g. \"create\", \"cloud-template\", \"manage\",
\"exit\") to the wizard's canonical action strings, or nil if the env var is
unset."
[]
(when-let [v (some-> (System/getenv "NIXOS_VM_ACTION") str/lower-case str/trim not-empty)]
(cond
(or (= v "cloud-template") (= v "cloud") (= v "template")) "Cloud template"
(str/starts-with? v "create") "Create VM"
(str/starts-with? v "manage") "Manage VMs"
(str/starts-with? v "exit") "Exit"
:else (do (println (format "Unknown NIXOS_VM_ACTION: %s" v))
(System/exit 1)))))
(defn run-development! []
(let [dev-dir (resolve-dev-dir!)
{:keys [backend env]} (discover-backend!)
env-action (resolve-env-action)]
(if env-action
(case env-action
"Create VM" (dev-create-vm! dev-dir backend env)
"Cloud template" (dev-cloud-template! dev-dir backend env)
"Manage VMs" (dev-manage-vms! dev-dir backend env)
"Exit" (println "Bye."))
(loop []
(section-break!)
(let [action (try (wiz/choose "What would you like to do?"
["Create VM" "Cloud template" "Manage VMs" "Exit"])
(catch Exception _ "Exit"))]
(section-break!)
(case action
"Create VM" (do (dev-create-vm! dev-dir backend env) (recur))
"Cloud template" (do (dev-cloud-template! dev-dir backend env) (recur))
"Manage VMs" (do (dev-manage-vms! dev-dir backend env) (recur))
"Exit" (println "Bye.")))))))
;; ─── Main ───────────────────────────────────────────────────────────────────
(defn -main []
(println)
(println " nixos-vm-template")
(println " ~~~~~~~~~~~~~~~~~~")
(println)
;; When nix is available, offer to build locally from source (Development);
;; otherwise only the download-pre-built-images path (Production) is possible.
;; NIXOS_VM_MODE=development|production skips the prompt (case-insensitive,
;; prefix match: "dev"/"prod" also work).
(let [env-mode (some-> (System/getenv "NIXOS_VM_MODE") str/lower-case)
mode (cond
(and env-mode (str/starts-with? env-mode "dev")) "Development"
(and env-mode (str/starts-with? env-mode "prod")) "Production"
(command-exists? "nix")
(wiz/choose "Mode:"
["Production (download pre-built images, no build)"
"Development (build images locally from source)"])
:else "Production")]
(if (str/starts-with? mode "Development")
(run-development!)
(run-production!))))
(-main)