#!/usr/bin/env bash 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 < [image] [options] pos docker vbox enter pos docker vbox stop pos docker vbox start pos docker vbox rm pos docker vbox ls 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. 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 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 ~/; use "." for the current directory. --gpu Full GPU access (--gpus all) --device 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 pos docker vbox rm lab1 pos docker vbox ls EOF exit 0 } # ── Interactive menu (opt-in front door, Pattern B via lib/menu-lib.sh) ── # Every item re-enters the existing CLI verbs below unchanged — the menu adds # picks/prompts/confirms only (same self-invocation the tool itself uses for # tmux handovers). 'enter' hands the terminal to the container shell and # returns to the loop when the shell exits. menu_self() { # run this tool's own verb as a child process "$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" "$@" } menu_list_vms() { # vbox VM names, one per line (same label filter as 'ls') docker ps -a --filter label=linux_post_install.vbox=true --format '{{.Names}}' | LC_ALL=C sort } menu_pick_vm() { # $1 = prompt → picked VM name on stdout · rc 1 = cancelled / none local -a vms=() mapfile -t vms < <(menu_list_vms 2>/dev/null) if [ ${#vms[@]} -eq 0 ]; then if docker info &>/dev/null; then warn "no vbox VMs found — create one first ('pos docker vbox create ')" else warn "docker daemon not reachable — start Docker first" fi return 1 fi local idx idx="$(menu_pick "$1" "${vms[@]}")" || return 1 printf '%s\n' "${vms[$((idx - 1))]}" } # ── 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() { # → 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() { # 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 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 ~/ (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() { local vm vm="$(menu_pick_vm "Enter which VM?")" || return 0 log "Entering '$vm' — exit its shell to return to the menu" menu_self enter "$vm" } menu_vbox_verb() { # $1 = start|stop — pick a VM, run the verb local verb="$1" vm vm="$(menu_pick_vm "${verb} which VM?")" || return 0 menu_self "$verb" "$vm" } menu_vbox_remove() { local vm vm="$(menu_pick_vm "Remove which VM?")" || return 0 confirm "Permanently remove VM '$vm' (docker rm -f; the host folder is kept)?" n \ || { log "Cancelled — '$vm' kept"; return 0; } menu_self rm "$vm" } run_menu() { menu_guard || exit 1 while true; do local choice choice="$(menu_run "Docker VBox — disposable VMs" \ "List VMs" \ "Create a VM" \ "Enter a VM (interactive shell)" \ "Start a VM" \ "Stop a VM" \ "Remove a VM (permanent)")" || return 0 case "$choice" in 1) menu_self ls ;; 2) menu_vbox_create ;; 3) menu_vbox_enter ;; 4) menu_vbox_verb start ;; 5) menu_vbox_verb stop ;; 6) menu_vbox_remove ;; esac done } # Menu door: explicit verb, or zero args on a terminal. Everything below — # including zero args without a terminal — stays byte-compatible with the # pre-menu CLI. if [ "${1:-}" = "menu" ]; then run_menu exit 0 fi if [ $# -eq 0 ] && [ -t 0 ]; then run_menu exit 0 fi case "${1:-}" in -h|--help) usage ;; esac cmd="${1:-}" [ -z "$cmd" ] && usage container_exists() { docker container inspect "$1" &>/dev/null } container_running() { [[ "$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null)" == "true" ]] } case "$cmd" in create) name="${2:-}" [ -z "$name" ] && usage # Parse remaining args: [image] [--dir ]… [--device ]… # [--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; } 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 ;; esac done if container_exists "$name"; then echo "[!] Container already exists: $name" exit 0 fi if [ -n "$custom_dir" ]; then lab_dir="$(cd "$custom_dir" 2>/dev/null && pwd)" || { echo "[!] Directory not found: $custom_dir"; exit 1; } else lab_dir="$HOME/$name" fi 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" echo "[+] Creating: $name" docker create \ -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 echo "[+] Done" if confirm "Enter now?"; then docker start "$name" >/dev/null exec docker exec -it -w "$lab_dir" "$name" bash fi ;; enter) name="${2:-}" [ -z "$name" ] && usage if ! container_exists "$name"; then echo "[!] Container not found: $name" exit 1 fi if ! container_running "$name"; then docker start "$name" >/dev/null fi # Detect working dir from container mounts lab_dir=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination .Destination}}{{.Source}}{{end}}{{end}}' "$name" 2>/dev/null | head -1) if [ -n "$lab_dir" ] && [ -d "$lab_dir" ]; then exec docker exec -it -w "$lab_dir" "$name" bash else exec docker exec -it "$name" bash fi ;; start) name="${2:-}" [ -z "$name" ] && usage docker start "$name" ;; stop) name="${2:-}" [ -z "$name" ] && usage docker stop "$name" ;; rm) name="${2:-}" [ -z "$name" ] && usage docker rm -f "$name" ;; ls) docker ps -a --filter label=linux_post_install.vbox=true --format "table {{.Names}}\t{{.Image}}\t{{.Status}}" ;; *) usage ;; esac