feat: vbox — categorized create UI with GPU/device/mount/port presets

- category hub with basket counts, review screen, single confirm
- GPU: nvidia-smi → /proc/driver/nvidia → vendor scan detection
- host devices: lsusb/tty/video/snd/lsblk + manual input, dedupe
- dir mounts with (system disk — careful) labels
- SHOULD tier: image/ports/cpus/mem, flag contract --gpu/--device/--dir/--port/--cpus/--memory/--network
- zero-flag run byte-identical to pre-edit
- cmd_unpersist not-found exit 0 → return 0 in both clients
- DOC/howto/docker.md: vbox categorized create section
This commit is contained in:
Your Name
2026-08-25 08:39:42 -04:00
parent 4173fc61e3
commit 0aaa25150c
2 changed files with 895 additions and 17 deletions
+14
View File
@@ -136,6 +136,8 @@ pos docker vbox create lab1 # default dir ~/lab1
pos docker vbox create lab1 --dir . # files land in cwd
pos docker vbox create lab1 --dir /mnt/data/lab1
pos docker vbox create kali kalilinux/kali-rolling # custom image
pos docker vbox create ai --gpu --cpus 4 --memory 8g
pos docker vbox create iot --device /dev/ttyUSB0 --port 8080:80
pos docker vbox enter lab1
pos docker vbox stop lab1
pos docker vbox start lab1
@@ -143,6 +145,16 @@ pos docker vbox rm lab1
pos docker vbox ls
```
**Interactive create:** bare `pos docker vbox` (or the menu's "Create a VM")
walks a name prompt → category hub → review screen that renders the exact
`docker create` plan before anything is pulled; confirming runs the same
`create` verb as the CLI. Categories: image quick-picks, GPU/Nvidia (offers
`--gpus all` when the Nvidia container toolkit is present, explicit device
nodes otherwise, info line when no GPU exists), host devices (USB, serial,
video/sound, disks — system disks labelled), extra host-dir mounts, port
publishes, CPU/RAM limits. Quitting or EOF at any point discards — nothing is
created without an explicit `y` at the review.
**Recipe:** a disposable browsing/download box:
```bash
pos docker vbox create dl --dir /mnt/data/dl
@@ -154,6 +166,8 @@ pos docker vbox rm dl # container gone, files kept
- `enter` needs a shell/SSH-capable image; `kalilinux/kali-rolling` works.
- If files "disappear" after `rm`, check you used `--dir` on a real path — the
container image changes are lost, only the mounted dir persists.
- `--gpu` needs `nvidia-container-toolkit`; without it, pass explicit nodes
instead (`--device /dev/nvidia0 --device /dev/nvidiactl --device /dev/nvidia-uvm`).
---
+881 -17
View File
@@ -2,13 +2,14 @@
set -euo pipefail
# POS: docker vbox — Disposable Docker-based VMs (create/enter/start/stop/rm/ls)
# POS_SUBCMDS: create enter stop start rm ls menu
# POS_FLAGS: --dir --gpu --device --port --cpus --memory --network
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null || source "$(dirname "$0")/menu-lib.sh"
usage() {
cat <<EOF
Usage:
pos docker vbox create <name> [image] [--dir <path>]
pos docker vbox create <name> [image] [options]
pos docker vbox enter <name>
pos docker vbox stop <name>
pos docker vbox start <name>
@@ -18,20 +19,33 @@ Usage:
Manage disposable Docker containers as lightweight VMs.
Bare \`pos docker vbox\` on a terminal (or \`pos docker vbox menu\`) opens an
interactive menu wrapping these verbs; arguments stay scriptable.
interactive menu wrapping these verbs; arguments stay scriptable. The
interactive create flow picks image, GPU, host devices, dir mounts, ports
and CPU/RAM from categorized menus, shows a review screen, then runs the
same \`create\` verb shown below.
Each container gets a bind-mounted host directory so files persist
on the host even after the container is removed.
Options:
--dir <path> Use custom directory instead of default ~/<name>
Use "." for current directory
--dir <path> Bind-mount a host directory at the same path inside
the VM (repeatable). The first --dir is the VM's
working directory and defaults to ~/<name>;
use "." for the current directory.
--gpu Full GPU access (--gpus all)
--device </dev/node> Pass a host device through to the VM (repeatable)
--port HOST:CONTAINER Publish a port mapping (repeatable, e.g. 8080:80)
--cpus N Limit CPUs (e.g. 2 or 1.5; default: Docker default)
--memory SIZE Limit memory (e.g. 512m, 2g; default: Docker default)
--network MODE Docker network mode (bridge/host/none)
Examples:
pos docker vbox create lab1
pos docker vbox create lab1 --dir .
pos docker vbox create lab1 --dir /mnt/data/lab1
pos docker vbox create kali kalilinux/kali-rolling
pos docker vbox create ai --gpu --cpus 4 --memory 8g
pos docker vbox create iot --device /dev/ttyUSB0 --port 8080:80
pos docker vbox enter lab1
pos docker vbox stop lab1
pos docker vbox start lab1
@@ -70,19 +84,796 @@ menu_pick_vm() { # $1 = prompt → picked VM name on stdout · rc 1 = cancelle
printf '%s\n' "${vms[$((idx - 1))]}"
}
menu_vbox_create() {
local name image dir
name="$(menu_ask_value "VM name")" || return 0
image="$(menu_ask_value "Image" "ubuntu:22.04")" || return 0
dir="$(menu_ask_value "Host directory (empty = ~/$name)")" || true
if [ -n "${dir:-}" ]; then
confirm "Create VM '$name' from $image (host dir: $dir)?" n \
|| { log "Cancelled"; return 0; }
menu_self create "$name" "$image" --dir "$dir"
# ── Categorized interactive create ────────────────────────────────────────
# Pure sugar over the scripted 'create' verb (single execution path): this
# flow composes arguments through Name → category hub → Review, then re-enters
# 'menu_self create …'. All state lives in C_*/CC_* globals for the lifetime
# of the flow only.
#
# Quit/EOF discipline (binding): nothing is ever created unless the review
# confirm is answered 'y', and EVERY teardown ends in ONE
# "[!] setup discarded — nothing was created" line, back at the main vbox
# menu, exit clean. Helpers never print the line themselves — they unwind
# with rc 77, and the orchestrator converts that into the single print.
# menu_run/menu_pick/confirm report a typed quit and a dead stream
# identically (rc 1), and no probe can always tell them apart, so
# arbitration is streak-based: TWO consecutive quit signals with no
# successful interaction between them tear down (a vanished terminal fails
# every read instantly; a live user quitting twice gets decisive teardown),
# while a single quit keeps gentle per-site handling (hub quit asks first).
# Raw `read` calls need no arbitration — their rc 1 IS a definitive EOF.
# Confirmed discards (hub quit answered 'y') are self-explanatory and silent;
# at the NAME prompt an empty answer and a dead stream are indistinguishable
# (menu_ask_value has no default there), so both tear down loudly.
C_discarded="setup discarded — nothing was created"
cc_log() { # stderr twin of log() — capture-safe inside $( )
printf '%s\n' "${GREEN}[+]${RESET} $*" >&2
}
cc_warn() { # stderr twin of warn() — capture-safe inside $( )
printf '%s\n' "${YELLOW}[!]${RESET} $*" >&2
}
cc_info() { # stderr [i] info line
printf '%s\n' "${CYAN:-}[i]${RESET} $*" >&2
}
cc_die() { # print the discard line once; caller returns 77
# Best-effort emit: on a vanished terminal even this write fails (EIO)
# and must not trip set -e — the cleanup contract is exit-clean.
cc_warn "$C_discarded" || true
}
# Quit-vs-EOF arbitration: menu_run/menu_pick/confirm report a typed quit and
# a dead input stream identically (rc 1), and no read/write probe can always
# distinguish them (bash `read -t` reports success on a dead pty; write-EIO is
# kernel-dependent). Rule instead: TWO consecutive quit signals with no
# successful interaction in between tear the setup down — a dead terminal
# fails every read instantly, so it tears down at once; a live user quitting
# twice in a row gets the same decisive treatment; a single quit keeps the
# gentle per-site handling. Successful interactions reset the streak.
cc_quit_tick() { # rc 0 = streak reached ⇒ caller tears down · rc 1 = keep going
C_quits=$(( ${C_quits:-0} + 1 ))
[ "${C_quits}" -ge 2 ]
}
cc_quit_reset() { C_quits=0; }
cc_reset() {
C_image="ubuntu:22.04"
C_image_set=0 # 0 = Image never visited (hub shows "(default)")
C_gpu_mode="" # "" | all | nodes
C_gpu_devs=() # explicit Nvidia/exotic nodes (mode=nodes)
C_devs=() # host devices basket (raw paths)
C_mounts=() # host dir mounts basket (validated absolutes)
C_ports=() # ports basket (HOST:CONTAINER)
C_cpus=""
C_mem=""
}
# ── device helpers ────────────────────────────────────────────────────────
cc_perm_suffix() { # unreadable-but-present nodes stay offered (dockerd is root)
[ -r "$1" ] && return 0
printf '%s' " (perm-restricted for you — dockerd may still access)"
}
cc_node_label() { # <node> → display label on stdout · rc 1 = unusable, drop
[ -e "$1" ] || return 1
stat "$1" &>/dev/null || return 1
printf '%s%s' "$1" "$(cc_perm_suffix "$1")"
}
cc_root_disk() { # basename of the disk hosting / — best effort, may fail
local src pk
src="$(findmnt -n -o SOURCE / 2>/dev/null)"
[ -n "$src" ] || return 1
pk="$(lsblk -sno PKNAME "$src" 2>/dev/null | tail -n1)"
[ -n "$pk" ] || pk="${src##*/}"
printf '%s' "$pk"
}
cc_is_system_disk() { # <node> rc 0 iff it is (a partition of) the root disk
local root base="${1##*/}"
root="$(cc_root_disk)" || return 1
[ "$base" = "$root" ] && return 0
[[ "$base" =~ ^${root}(p[0-9]+|[0-9]+)$ ]] && return 0
return 1
}
# Candidate host devices → parallel arrays (names + labels). Every source is
# optional: a missing binary means one info line, never an error. Block
# devices list ALL disks, system disks labelled — never hidden.
cc_collect_devices() { # $1 names-out · $2 labels-out (global array names)
local -n o_names="$1"
local -n o_labels="$2"
local line bus dev node lbl typ size rm
o_names=()
o_labels=()
if command -v lsusb &>/dev/null; then
while IFS= read -r line; do
[[ "$line" =~ ^Bus\ ([0-9]+)\ Device\ ([0-9]+): ]] || continue
bus="$(printf '%03d' "${BASH_REMATCH[1]}")"
dev="$(printf '%03d' "${BASH_REMATCH[2]}")"
node="/dev/bus/usb/$bus/$dev"
lbl="$(cc_node_label "$node")" || continue
o_names+=("$node")
o_labels+=("$lbl")
done < <(lsusb 2>/dev/null)
else
confirm "Create VM '$name' from $image?" n || { log "Cancelled"; return 0; }
menu_self create "$name" "$image"
cc_info "lsusb not available — skipping USB device scan"
fi
for node in /dev/ttyUSB* /dev/ttyACM* /dev/video*; do
[ -e "$node" ] || continue
lbl="$(cc_node_label "$node")" || continue
o_names+=("$node")
o_labels+=("$lbl")
done
# sound offered as ONE entry (fine-grained snd nodes are out of scope)
if [ -e /dev/snd ]; then
o_names+=("/dev/snd")
o_labels+=("/dev/snd (sound subsystem)")
fi
if command -v lsblk &>/dev/null; then
while read -r node typ size rm; do
[ -e "$node" ] || continue
stat "$node" &>/dev/null || continue
lbl="$node ($typ, $size"
[ "$rm" = "1" ] && lbl+=", removable"
cc_is_system_disk "$node" && lbl+=", system disk — careful"
lbl+="$(cc_perm_suffix "$node")"
lbl+=")"
o_names+=("$node")
o_labels+=("$lbl")
done < <(lsblk -rnpo NAME,TYPE,SIZE,RM 2>/dev/null)
else
cc_info "lsblk not available — skipping block-device scan"
fi
}
# ── GPU / Nvidia detection (cheap-first, once per category entry) ──────────
cc_nvidia_nodes() { # fills C_nv_nodes · rc 0 iff /dev/nvidiactl anchor exists
C_nv_nodes=()
local f
[ -e /dev/nvidiactl ] || return 1
for f in /dev/nvidia*; do
[ -e "$f" ] || continue
[[ "${f##*/}" =~ ^nvidia[0-9]+$ ]] || continue
cc_node_label "$f" >/dev/null && C_nv_nodes+=("$f")
done
for f in /dev/nvidiactl /dev/nvidia-uvm; do
[ -e "$f" ] || continue
cc_node_label "$f" >/dev/null && C_nv_nodes+=("$f")
done
[ "${#C_nv_nodes[@]}" -gt 0 ]
}
cc_gpu_toolkit() { # rc 0 iff an Nvidia container runtime is plausibly present
[[ "$(docker info --format '{{json .Runtimes}}' 2>/dev/null)" == *nvidia* ]] && return 0
command -v nvidia-container-runtime &>/dev/null && return 0
command -v nvidia-container-ctk &>/dev/null
}
# ── shared basket mechanics (loop-of-single-picks, no lib changes) ────────
# Baskets are addressed by GLOBAL array name, passed verbatim down the chain
# and bound one hop deep with local -n (never nameref-to-nameref).
cc_add_loop() { # $1 basket-name · $2 noun · $3 picker prompt · $4 manual prompt
local -n basket="$1"
local noun="$2" add_prompt="$3" manual_prompt="$4"
local idx sel p q skip joined ci clbl
local -a items disp
while true; do
if [ "${#basket[@]}" -gt 0 ]; then
joined="$(
IFS=','
echo "${basket[*]}"
)"
printf '[i] %s — selected: %d: %s\n' "$noun" "${#basket[@]}" "$joined" >&2
else
printf '[i] %s — selected: 0: (none yet)\n' "$noun" >&2
fi
items=()
disp=()
for p in "${CC_CAND_NAMES[@]}"; do
skip=0
for q in "${basket[@]}"; do
[ "$q" = "$p" ] && {
skip=1
break
}
done
[ "$skip" -eq 1 ] && continue # already selected → excluded
items+=("$p")
clbl=""
for ((ci = 0; ci < ${#CC_CAND_NAMES[@]}; ci++)); do
if [ "${CC_CAND_NAMES[$ci]}" = "$p" ]; then
clbl="${CC_CAND_LABELS[$ci]}"
break
fi
done
disp+=("${clbl:-$p}")
done
items+=("__manual__")
disp+=("Type a device path manually")
items+=("__done__")
disp+=("✓ Done adding")
if ! idx="$(menu_pick "$add_prompt" "${disp[@]}")"; then
cc_quit_tick && return 77 # second consecutive quit ⇒ teardown
return 0 # back/Done ends the loop, basket kept
fi
cc_quit_reset
sel="${items[$((idx - 1))]}"
case "$sel" in
__done__)
return 0
;;
__manual__)
while true; do
if ! read -rp "$manual_prompt: " p; then
return 77 # raw read rc 1 = definitive EOF
fi
[ -z "$p" ] && break # empty = cancel
[[ "$p" == /dev/* ]] || {
cc_warn "device path must start with /dev/ — try again"
continue
}
skip=0
for q in "${basket[@]}"; do
[ "$q" = "$p" ] && skip=1
done
if [ "$skip" -eq 1 ]; then
cc_warn "$p is already selected — skipped"
break
fi
if ! stat "$p" &>/dev/null; then
cc_warn "cannot stat $p — skipped"
break
fi
basket+=("$p")
cc_log "added $p — pick another, or choose ✓ Done"
break
done
;;
*)
basket+=("$sel")
cc_log "added $sel — pick another, or choose ✓ Done"
;;
esac
done
}
cc_remove_one() { # $1 basket-name · $2 remove prompt
local -n basket="$1"
local rm_prompt="$2"
local idx p i
local -a disp keep
if [ "${#basket[@]}" -eq 0 ]; then
cc_log "nothing selected yet"
return 0
fi
disp=()
for p in "${basket[@]}"; do disp+=("$p"); done
if ! idx="$(menu_pick "$rm_prompt" "${disp[@]}")"; then
cc_quit_tick && return 77
return 0
fi
cc_quit_reset
p="${basket[$((idx - 1))]}"
keep=()
for ((i = 0; i < ${#basket[@]}; i++)); do
[ "$i" -ne $((idx - 1)) ] && keep+=("${basket[i]}")
done
basket=("${keep[@]}")
cc_log "removed $p"
}
cc_clear_basket() { # $1 basket-name · $2 noun phrase ("device(s)")
local -n basket="$1"
local word="$2" n="${#basket[@]}"
if [ "$n" -eq 0 ]; then
cc_log "nothing selected yet"
return 0
fi
if ! confirm "Remove all $n selected $word?" n; then
cc_quit_tick && return 77
return 0
fi
cc_quit_reset
basket=()
cc_log "all $n $word removed"
}
# Shared basket shape: Add … / Remove one (N selected) / Clear all / Back.
# $5 = add function; remaining args forwarded to it verbatim.
cc_basket_menu() { # $1 basket-name · $2 noun · $3 remove-prompt · $4 clear-word · $5 add-fn …
local -n bref="$1"
local bname="$1" noun="$2" rm_prompt="$3" clear_word="$4" add_fn="$5"
shift 5
local idx
while true; do
if [ "${#bref[@]}" -eq 0 ]; then
"$add_fn" "$bname" "$@" || return 77
return 0 # empty basket → straight to adding
fi
if ! idx="$(menu_run "$noun" \
"Add …" \
"Remove one (${#bref[@]} selected)" \
"Clear all")"; then
cc_quit_tick && return 77
return 0 # Back
fi
cc_quit_reset
case "$idx" in
1) "$add_fn" "$bname" "$@" || return 77 ;;
2) cc_remove_one "$bname" "$rm_prompt" || return 77 ;;
3) cc_clear_basket "$bname" "$clear_word" || return 77 ;;
esac
done
}
# Typed-value add loop (mounts, ports). Raw read — NOT menu_ask_value — so an
# EMPTY answer ("done") stays distinguishable from EOF (discard everything).
# Validator contract: $1 raw input · rest = basket values · normalized value
# on stdout · rc 1 = rejected (diagnostics printed by the validator).
cc_add_typed_loop() { # $1 basket-name · $2 prompt · $3 validator fn
local -n basket="$1"
local prompt="$2" vfn="$3"
local v norm
local -a vals
while true; do
if ! read -rp "$prompt: " v; then
return 77 # raw read rc 1 = definitive EOF
fi
[ -z "$v" ] && return 0 # empty = done
vals=("${basket[@]}")
if norm="$("$vfn" "$v" ${vals[@]+"${vals[@]}"})"; then
basket+=("$norm")
fi
done
}
cc_validate_mount() { # stdout: normalized absolute path · rc 1 = rejected
local raw="$1" v
shift
v="${raw/#\~/$HOME}"
case "$v" in
/*) ;;
*) cc_warn "not an absolute path: $raw — try again"; return 1 ;;
esac
[ -d "$v" ] || {
cc_warn "directory not found: $raw — try again"
return 1
}
local q
for q in "$@"; do
[ "$q" = "$v" ] && {
cc_warn "$v is already mounted — skipped"
return 1
}
done
printf '%s\n' "$v"
cc_log "will mount $v:$v"
}
cc_validate_port() { # stdout: validated HOST:CONTAINER · rc 1 = rejected
local v="$1"
shift
[[ "$v" =~ ^[0-9]+(:[0-9]+){1,2}$ ]] || {
cc_warn "not a HOST:CONTAINER pair: $v — try again (e.g. 8080:80)"
return 1
}
local hp="${v%%:*}" q hq
for q in "$@"; do
hq="${q%%:*}"
[ "$hq" = "$hp" ] && {
cc_warn "host port $hp already mapped — rejected"
return 1
}
done
printf '%s\n' "$v"
cc_log "will publish $v"
}
# ── categories ────────────────────────────────────────────────────────────
cc_category_image() {
local -a picks=("ubuntu:22.04" "ubuntu:24.04" "debian:12" "kalilinux/kali-rolling" "archlinux" "fedora:latest" "alpine:latest" "Other (type image ref)")
local idx ref
while true; do
if ! idx="$(menu_pick "Pick image" "${picks[@]}")"; then
cc_quit_tick && return 77
return 0 # back keeps the current image
fi
cc_quit_reset
if [ "$idx" -lt "${#picks[@]}" ]; then
C_image="${picks[$((idx - 1))]}"
C_image_set=1
cc_log "image set to $C_image"
return 0
fi
if ! ref="$(menu_ask_value "Image ref")"; then
cc_quit_tick && return 77
continue # cancelled typing → picker again
fi
cc_quit_reset
if [[ "$ref" =~ [[:space:]] ]]; then
cc_warn "not a valid image ref — spaces not allowed"
continue
fi
C_image="$ref"
C_image_set=1
cc_log "image set to $C_image"
return 0
done
}
cc_category_gpu() {
local nodes_present=0 toolkit_present=0 pci_line="" choice sem nl
if cc_nvidia_nodes; then nodes_present=1; fi
if cc_gpu_toolkit; then toolkit_present=1; fi
if command -v lspci &>/dev/null; then
pci_line="$(lspci 2>/dev/null | grep -Ei 'vga|3d controller' | grep -i nvidia | head -n1)" || pci_line=""
[ -n "$pci_line" ] && cc_log "Nvidia hardware: ${pci_line#* }"
fi
if [ "$nodes_present" -eq 0 ]; then
# Path C — no GPU found: informational, never an error, never blocking.
# Category stays enterable via the manual escape hatch.
cc_info "no Nvidia driver/GPU detected on this host — skipping GPU setup"
cc_info "an exotic device path can still be added manually"
CC_CAND_NAMES=()
CC_CAND_LABELS=()
cc_add_loop C_gpu_devs "GPU (manual)" \
"Pick device to ADD" \
"Device path (must start with /dev/, empty = cancel)" || return 77
[ "${#C_gpu_devs[@]}" -gt 0 ] && C_gpu_mode="nodes"
return 0
fi
local i_all="Full GPU access (--gpus all)"
local i_nodes="Explicit Nvidia device nodes"
local i_none="none (clears GPU configuration)"
local -a opts=() sems=()
if [ "$toolkit_present" -eq 1 ]; then
# Path A — full support: recommend --gpus all (highlight ≠ preselect).
cc_info "Nvidia container toolkit detected — --gpus all available"
opts=("$i_all (recommended)" "$i_nodes" "$i_none")
sems=("all" "nodes" "none")
else
# Path B — nodes without toolkit: steer to explicit nodes first.
cc_info "nvidia-container-toolkit not detected — --gpus all will likely fail; offering explicit device nodes instead (install nvidia-container-toolkit for CUDA workloads)"
opts=("$i_nodes (recommended here)" "Configure anyway (--gpus all)" "$i_none")
sems=("nodes" "all" "none")
fi
case "$C_gpu_mode" in
all) opts[0]+=" (current)" ;;
nodes) opts[1]+=" (current)" ;;
*) opts[2]+=" (current)" ;;
esac
if ! choice="$(menu_run "GPU / Nvidia" "${opts[@]}")"; then
cc_quit_tick && return 77
return 0
fi
cc_quit_reset
sem="${sems[$((choice - 1))]}"
case "$sem" in
all)
C_gpu_mode="all"
C_gpu_devs=()
cc_log "GPU: --gpus all"
;;
nodes)
C_gpu_mode="nodes"
C_gpu_devs=()
cc_info "a working set is usually /dev/nvidia0 + /dev/nvidiactl + /dev/nvidia-uvm"
CC_CAND_NAMES=("${C_nv_nodes[@]}")
CC_CAND_LABELS=()
for nl in "${C_nv_nodes[@]}"; do
lbl="$(cc_node_label "$nl")" || lbl="$nl" # raced-away node: fall back to the bare path
CC_CAND_LABELS+=("$lbl")
done
cc_add_loop C_gpu_devs "Nvidia devices" \
"Pick device to ADD" \
"Device path (must start with /dev/, empty = cancel)" || return 77
[ "${#C_gpu_devs[@]}" -eq 0 ] && C_gpu_mode="" # nothing picked ⇒ back to unconfigured
;;
none)
C_gpu_mode=""
C_gpu_devs=()
cc_log "GPU configuration cleared"
;;
esac
return 0
}
cc_category_devices() {
cc_collect_devices CC_CAND_NAMES CC_CAND_LABELS
if [ "${#CC_CAND_NAMES[@]}" -eq 0 ]; then
cc_info "no candidate devices found — type a path manually"
fi
cc_basket_menu C_devs "Host devices" \
"Remove which device?" "device(s)" cc_add_loop \
"Host devices" \
"Pick device to ADD" \
"Device path (must start with /dev/, empty = cancel)"
}
cc_category_mounts() {
cc_basket_menu C_mounts "Host dir mounts" \
"Remove which mount?" "mount(s)" cc_add_typed_loop \
"Host directory to mount (empty = done)" cc_validate_mount
}
cc_category_ports() {
cc_basket_menu C_ports "Ports" \
"Remove which port?" "port mapping(s)" cc_add_typed_loop \
"Publish port HOST:CONTAINER (empty = done)" cc_validate_port
}
cc_category_resources() {
local v
cc_info "press Enter to keep Docker defaults"
while true; do
v=""
[ -n "$C_cpus" ] && v=" (current: $C_cpus)"
if ! read -rp "--cpus$v — blank = Docker default: " v; then
return 77 # raw read rc 1 = definitive EOF
fi
if [ -z "$v" ]; then
C_cpus=""
break
fi
[[ "$v" =~ ^[0-9]+(\.[0-9]+)?$ ]] || {
cc_warn "CPUs must be a number (e.g. 2 or 1.5)"
continue
}
C_cpus="$v"
break
done
while true; do
v=""
[ -n "$C_mem" ] && v=" (current: $C_mem)"
if ! read -rp "--memory$v — blank = Docker default (e.g. 512m, 2g): " v; then
return 77 # raw read rc 1 = definitive EOF
fi
if [ -z "$v" ]; then
C_mem=""
break
fi
[[ "$v" =~ ^[0-9]+(b|k|m|g|mb|gb)?$ ]] || {
cc_warn "memory formats: 500b, 100k, 512m, 2g (mb/gb accepted)"
continue
}
C_mem="$v"
break
done
cc_log "resources: ${C_cpus:-Docker default} cpu(s), ${C_mem:-Docker default} memory"
return 0
}
# ── compose + review ──────────────────────────────────────────────────────
cc_compose() { # fills CC_ARGS (argv for menu_self) + CC_FLAGS_TXT (display)
# The UI never asks for the primary dir — it stays ~/<name> (the verb's
# default). Pass it as the FIRST --dir explicitly, so mount baskets map
# to the repeatable --dir form without displacing the working directory.
# It must exist before the verb resolves it (cd && pwd), so create it here.
mkdir -p "$HOME/$C_name"
CC_ARGS=(create "$C_name" "$C_image" --dir "$HOME/$C_name")
CC_FLAGS_TXT=""
local d p m
if [ "$C_gpu_mode" = "all" ]; then
CC_ARGS+=(--gpu)
CC_FLAGS_TXT="--gpus all"
fi
for d in "${C_gpu_devs[@]}"; do
CC_ARGS+=(--device "$d")
CC_FLAGS_TXT+=" --device $d"
done
for d in "${C_devs[@]}"; do
CC_ARGS+=(--device "$d")
CC_FLAGS_TXT+=" --device $d"
done
for p in "${C_ports[@]}"; do
CC_ARGS+=(--port "$p")
CC_FLAGS_TXT+=" --port $p"
done
[ -n "$C_cpus" ] && {
CC_ARGS+=(--cpus "$C_cpus")
CC_FLAGS_TXT+=" --cpus $C_cpus"
}
[ -n "$C_mem" ] && {
CC_ARGS+=(--memory "$C_mem")
CC_FLAGS_TXT+=" --memory $C_mem"
}
for m in "${C_mounts[@]}"; do
CC_ARGS+=(--dir "$m")
CC_FLAGS_TXT+=" --dir $m"
done
}
cc_render_group() { # $1 label · rest = rendered values · truncates at 6 rows
local label="$1"
shift
local max=6 shown=0 v n="$#"
if [ "$n" -eq 0 ]; then
printf ' %-10s %s\n' "$label" "none" >&2
return 0
fi
for v in "$@"; do
[ "$shown" -lt "$max" ] || break
if [ "$shown" -eq 0 ]; then
printf ' %-10s %s\n' "$label" "$v" >&2
else
printf ' %-10s %s\n' "" "$v" >&2
fi
shown=$((shown + 1))
done
if [ "$n" -gt "$max" ]; then
printf ' %-10s … (+%d more)\n' "" "$((n - max))" >&2
fi
}
cc_review() { # rc 0 = confirmed (CC_ARGS ready) · rc 1 = back to hub · rc 77 = discarded
local -a gpu_vals=() dev_vals=() mnt_vals=() port_vals=() rv=()
local d p m res
case "$C_gpu_mode" in
all) gpu_vals+=("--gpus all") ;;
nodes)
for d in "${C_gpu_devs[@]}"; do gpu_vals+=("--device $d"); done
;;
esac
for d in "${C_devs[@]}"; do dev_vals+=("--device $d"); done
for m in "${C_mounts[@]}"; do mnt_vals+=("$m:$m"); done
for p in "${C_ports[@]}"; do port_vals+=("-p $p"); done
{
echo
echo "${CYAN}════════════════════════════════════════════${RESET}"
echo "${CYAN} Create VM '$C_name' — review plan${RESET}"
echo "${CYAN}════════════════════════════════════════════${RESET}"
} >&2
cc_render_group "image" "$C_image"
cc_render_group "host dir" "~/$C_name (bind-mounted at same path, cwd inside VM)"
cc_render_group "gpus" ${gpu_vals[@]+"${gpu_vals[@]}"}
cc_render_group "devices" ${dev_vals[@]+"${dev_vals[@]}"}
cc_render_group "mounts" ${mnt_vals[@]+"${mnt_vals[@]}"}
cc_render_group "ports" ${port_vals[@]+"${port_vals[@]}"}
[ -n "$C_cpus" ] && rv+=("--cpus $C_cpus")
[ -n "$C_mem" ] && rv+=("--memory $C_mem")
if [ "${#rv[@]}" -gt 0 ]; then
res="$(
IFS=' '
echo "${rv[*]}"
)"
else
res="Docker defaults"
fi
cc_render_group "cpu/ram" "$res"
cc_render_group "network" "Docker default (bridge)"
cc_compose
{
echo "----------------------------------------" >&2
echo " docker create -it --name $C_name --label linux_post_install.vbox=true \\" >&2
echo " ${CC_FLAGS_TXT} $C_image bash" >&2
}
# A CUDA image is ever only a hint — never forced, never auto-applied.
if [ -n "$C_gpu_mode" ] && [[ "$C_image" != *cuda* && "$C_image" != nvidia/* ]]; then
cc_info "tip: for CUDA inside the VM try an image like nvidia/cuda:12.4-base-ubuntu22.04 (set it under Image & distro)"
fi
if confirm "Create?" n; then
cc_quit_reset
return 0
fi
cc_quit_tick && return 77 # second consecutive quit ⇒ teardown
return 1 # 'n' → back to the hub, edits preserved
}
cc_hub_items() { # rebuilds CC_ITEMS with live basket counts
local img gpu dev mnt prt res
if [ "$C_image_set" -eq 1 ]; then
img="$C_image"
else
img="$C_image (default)"
fi
case "$C_gpu_mode" in
all) gpu="--gpus all" ;;
nodes) gpu="${#C_gpu_devs[@]} node(s)" ;;
*) gpu="not configured" ;;
esac
if [ "${#C_devs[@]}" -gt 0 ]; then
dev="${#C_devs[@]} selected"
else
dev="none"
fi
if [ "${#C_mounts[@]}" -gt 0 ]; then
mnt="~/$C_name (auto) + ${#C_mounts[@]} more"
else
mnt="~/$C_name (auto)"
fi
if [ "${#C_ports[@]}" -gt 0 ]; then
prt="${#C_ports[@]} mapped"
else
prt="none"
fi
local -a rv=()
[ -n "$C_cpus" ] && rv+=("cpus=$C_cpus")
[ -n "$C_mem" ] && rv+=("mem=$C_mem")
if [ "${#rv[@]}" -gt 0 ]; then
res="$(
IFS=' '
echo "${rv[*]}"
)"
else
res="Docker defaults"
fi
CC_ITEMS=(
"Image & distro ....... $img"
"GPU / Nvidia ......... $gpu"
"Host devices ......... $dev"
"Host dir mounts ...... $mnt"
"Ports ................ $prt"
"CPU / RAM ............ $res"
"Review & create"
)
}
menu_vbox_create() {
local name choice
local crc=0 rrc=0
# §1: an EMPTY answer at the identity prompt backs out with nothing
# created. menu_ask_value has no default here, so an empty answer and a
# dead stream are indistinguishable (both rc 1) — both take the loud
# teardown (one discard line), matching §3's "EOF at ANY depth" rule.
if ! name="$(menu_ask_value "VM name")"; then
cc_die
return 0
fi
C_name="$name"
cc_reset
cc_quit_reset
while true; do
cc_hub_items
if ! choice="$(menu_run "Configure VM '$C_name' — capabilities" "${CC_ITEMS[@]}")"; then
cc_quit_tick && { cc_die; return 0; } # dead input / second quit
if confirm "Discard this VM setup?" n; then
return 0 # confirmed discard → main menu
fi
continue # single quit → guarded redraw
fi
cc_quit_reset
# Categories honour a strict rc contract: 0 = normal · 77 = EOF
# teardown somewhere below. Calls are ||-captured because a bare
# nonzero from a simple command would trip set -e before the check.
crc=0
case "$choice" in
1) cc_category_image || crc=$? ;;
2) cc_category_gpu || crc=$? ;;
3) cc_category_devices || crc=$? ;;
4) cc_category_mounts || crc=$? ;;
5) cc_category_ports || crc=$? ;;
6) cc_category_resources || crc=$? ;;
esac
[ "$crc" -eq 77 ] && { cc_die; return 0; } # EOF teardown somewhere below
if [ "$choice" -eq 7 ]; then
rrc=0
cc_review || rrc=$?
case "$rrc" in
77)
cc_die
return 0
;;
0)
menu_self "${CC_ARGS[@]}"
return 0
;;
*) continue ;; # 'n' → hub with edits preserved
esac
fi
done
}
menu_vbox_enter() {
@@ -160,17 +951,68 @@ case "$cmd" in
name="${2:-}"
[ -z "$name" ] && usage
# Parse remaining args: [image] [--dir <path>]
# Parse remaining args: [image] [--dir <path>]… [--device </dev/node>]…
# [--gpu] [--port HOST:CONTAINER]… [--cpus N] [--memory SIZE] [--network MODE]
# The new flags are additive; an invocation that uses none of them
# behaves byte-identically to the pre-categorized verb.
image="ubuntu:22.04"
custom_dir=""
extra_dirs=()
want_gpu=0
devices=()
ports=()
cpus=""
memory=""
network=""
shift 2 || true
while [ $# -gt 0 ]; do
case "$1" in
--dir)
[ -z "${2:-}" ] && { echo "Missing value for --dir"; exit 1; }
custom_dir="$2"
if [ -z "$custom_dir" ]; then
custom_dir="$2"
else
extra_dirs+=("$2")
fi
shift 2
;;
--gpu)
want_gpu=1
shift
;;
--device)
[ -z "${2:-}" ] && { echo "Missing value for --device"; exit 1; }
[[ "$2" == /dev/* ]] || { echo "[!] --device expects a path under /dev/: $2"; exit 1; }
devices+=("$2")
shift 2
;;
--port)
[ -z "${2:-}" ] && { echo "Missing value for --port"; exit 1; }
[[ "$2" =~ ^[0-9]+(:[0-9]+){1,2}$ ]] || { echo "[!] --port expects HOST:CONTAINER (e.g. 8080:80): $2"; exit 1; }
ports+=("$2")
shift 2
;;
--cpus)
[ -z "${2:-}" ] && { echo "Missing value for --cpus"; exit 1; }
[[ "$2" =~ ^[0-9]+(\.[0-9]+)?$ ]] || { echo "[!] --cpus expects a number (e.g. 2 or 1.5): $2"; exit 1; }
cpus="$2"
shift 2
;;
--memory)
[ -z "${2:-}" ] && { echo "Missing value for --memory"; exit 1; }
[[ "$2" =~ ^[0-9]+(b|k|m|g|mb|gb)?$ ]] || { echo "[!] --memory expects a size (e.g. 512m or 2g): $2"; exit 1; }
memory="$2"
shift 2
;;
--network)
[ -z "${2:-}" ] && { echo "Missing value for --network"; exit 1; }
network="$2"
shift 2
;;
-*)
echo "[!] Unknown option: $1 — see 'pos docker vbox --help'"
exit 1
;;
*)
image="$1"
shift
@@ -191,6 +1033,26 @@ case "$cmd" in
mkdir -p "$lab_dir"
echo "[+] Lab directory: $lab_dir"
# Additional bind mounts (repeated --dir): same-path convention like
# the primary lab dir.
mount_flags=()
for d in "${extra_dirs[@]}"; do
mnt_dir="$(cd "$d" 2>/dev/null && pwd)" || { echo "[!] Directory not found: $d"; exit 1; }
mkdir -p "$mnt_dir"
echo "[+] Bind mount: $mnt_dir:$mnt_dir"
mount_flags+=(-v "$mnt_dir:$mnt_dir")
done
# Optional flags in a fixed order: GPU → devices → ports → resources
# → network. Mounts are appended after the primary -v below.
create_flags=()
[ "$want_gpu" -eq 1 ] && create_flags+=(--gpus all)
for d in "${devices[@]}"; do create_flags+=(--device "$d"); done
for p in "${ports[@]}"; do create_flags+=(-p "$p"); done
[ -n "$cpus" ] && create_flags+=(--cpus "$cpus")
[ -n "$memory" ] && create_flags+=(--memory "$memory")
[ -n "$network" ] && create_flags+=(--network "$network")
echo "[+] Pulling image: $image"
docker pull "$image"
@@ -199,7 +1061,9 @@ case "$cmd" in
-it \
--name "$name" \
--label linux_post_install.vbox=true \
${create_flags[@]+"${create_flags[@]}"} \
-v "$lab_dir:$lab_dir" \
${mount_flags[@]+"${mount_flags[@]}"} \
-w "$lab_dir" \
"$image" \
bash >/dev/null