From c9cdd2feca244f18caa251a26b723a4b5a6b2964 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Sun, 7 Jun 2026 00:35:19 -0700 Subject: [PATCH] feat(sbx): install Docker/Multipass on demand from /sbx launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, launching a sandbox on a machine without the backend binary died with a raw "not installed" error and no path forward. Now the missing backend can be installed in-line, gated on explicit consent, so a fresh checkout can go from zero to a running sandbox without leaving the TUI. Folded into the existing `/sbx launch` verb rather than a new command (menu is already dense): `/sbx launch docker|multipass [image] install`. The `install` token opts in; without it the user gets an actionable error naming the exact retry. The token is filtered out of positional image parsing so it never shadows a custom image. The install runs off-thread inside the existing spawn_launch task, before provisioning, so the TUI never blocks and the *launching guard is cleared via BrokerMsg on failure. A fresh Docker install also leaves its daemon up, so launch proceeds straight through. Scripts (detect-first, never silent, --plan dry-run, idempotent if already present): - ensure-multipass.sh (new): Linux→snap (clear failure if snapd absent), macOS→brew cask, Windows→winget. - ensure-docker.sh: new --install mode using Docker's OFFICIAL, GPG-verified apt repo (docker-ce) on Debian/Ubuntu, with dnf (Fedora/RHEL) and pacman (Arch) fallbacks. Deliberately avoids piping get.docker.com into a root shell. Existing daemon-start path intact. sbx.rs: docker_installed()/multipass_installed() detectors and ensure_docker_install()/ensure_multipass_install() wrappers; ENSURE_MULTIPASS const. ui.rs: help text documents the [install] option on both backends. Co-Authored-By: Claude Opus 4.6 --- hh/scripts/ensure-docker.sh | 124 +++++++++++++++++++++++++++++---- hh/scripts/ensure-multipass.sh | 110 +++++++++++++++++++++++++++++ hh/src/app.rs | 70 ++++++++++++++++--- hh/src/sbx.rs | 63 +++++++++++++++++ hh/src/ui.rs | 8 +-- 5 files changed, 349 insertions(+), 26 deletions(-) create mode 100644 hh/scripts/ensure-multipass.sh diff --git a/hh/scripts/ensure-docker.sh b/hh/scripts/ensure-docker.sh index e9301ac..26b80e6 100755 --- a/hh/scripts/ensure-docker.sh +++ b/hh/scripts/ensure-docker.sh @@ -1,29 +1,100 @@ #!/usr/bin/env bash -# ensure-docker.sh — make sure the Docker daemon is up before /sbx launch docker. +# ensure-docker.sh — make sure Docker is installed AND its daemon is up before +# /sbx launch docker. # -# Without this, `docker run` fails with "Cannot connect to the Docker daemon" -# and the sandbox launch dies with a raw error. This script detects a dead -# daemon and — after confirmation — starts it, then waits until it's accepting -# connections. +# Two jobs, detect-first and never silent: +# 1. If the docker binary is missing, optionally INSTALL it (--install) from +# Docker's official, GPG-verified apt repo on Debian/Ubuntu (docker-ce), +# with dnf (Fedora/RHEL) and pacman (Arch) fallbacks. We deliberately do +# NOT pipe get.docker.com into a root shell. +# 2. If the daemon is down, start it (and wait until it accepts connections). +# +# Anything already in place is left untouched (idempotent). # # usage: -# ./ensure-docker.sh # interactive: prompt before starting the daemon -# ./ensure-docker.sh --yes # start without prompting (used by hack-house --start) -# ./ensure-docker.sh --check # test only; exit 0 if up, 1 if down (no changes) +# ./ensure-docker.sh # interactive: prompt before starting the daemon +# ./ensure-docker.sh --yes # start (and install, if --install) without prompting +# ./ensure-docker.sh --install # install Docker if the binary is missing, then start +# ./ensure-docker.sh --check # test only; exit 0 if daemon up, 1 if not (no changes) +# ./ensure-docker.sh --plan # show the install plan; change nothing set -uo pipefail ASSUME_YES=0 CHECK_ONLY=0 +DO_INSTALL=0 +PLAN_ONLY=0 for arg in "$@"; do case "$arg" in - -y|--yes) ASSUME_YES=1 ;; - --check) CHECK_ONLY=1 ;; + -y|--yes) ASSUME_YES=1 ;; + --check) CHECK_ONLY=1 ;; + --install) DO_INSTALL=1 ;; + --plan|--dry-run) PLAN_ONLY=1; DO_INSTALL=1 ;; -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "✖ unknown arg: $arg" >&2; exit 2 ;; esac done -daemon_up() { docker info >/dev/null 2>&1; } +daemon_up() { docker info >/dev/null 2>&1; } +have_docker() { command -v docker >/dev/null 2>&1; } + +# ── Work out how to install Docker on this platform ────────────────────────── +# Emits the ordered list of commands (one per line) to PLAN_LINES; empty if we +# don't know how. Uses Docker's official repo so packages are GPG-verified and +# current (distro packages like docker.io are often stale). +build_install_plan() { + PLAN_LINES="" + [[ "$(uname -s)" == "Linux" ]] || { PLAN_LINES=""; return; } + # shellcheck disable=SC1091 + local id="" id_like="" + if [[ -r /etc/os-release ]]; then + id="$(. /etc/os-release; echo "${ID:-}")" + id_like="$(. /etc/os-release; echo "${ID_LIKE:-}")" + fi + case "$id $id_like" in + *debian*|*ubuntu*) + local repo="ubuntu"; [[ "$id" == "debian" ]] && repo="debian" + PLAN_LINES=$(cat </dev/null +sudo apt-get update +sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +EOF +) + ;; + *fedora*|*rhel*|*centos*) + local repo="fedora"; case " $id $id_like " in *rhel*|*centos*) repo="centos";; esac + PLAN_LINES=$(cat </dev/null)) — nothing to install" >&2 + exit 0 + fi + build_install_plan + if [[ -z "$PLAN_LINES" ]]; then + echo "✖ don't know how to install Docker here — see https://docs.docker.com/engine/install/" >&2 + exit 1 + fi + echo "plan (no changes will be made) — would run:" >&2 + printf ' %s\n' "$PLAN_LINES" >&2 + exit 0 +fi if daemon_up; then [[ $CHECK_ONLY -eq 1 ]] || echo "docker daemon already running" >&2 @@ -31,9 +102,34 @@ if daemon_up; then fi [[ $CHECK_ONLY -eq 1 ]] && exit 1 -if ! command -v docker >/dev/null 2>&1; then - echo "✖ docker is not installed — install Docker first" >&2 - exit 127 +if ! have_docker; then + if [[ $DO_INSTALL -ne 1 ]]; then + echo "✖ docker is not installed — re-run with --install to install it" >&2 + exit 127 + fi + build_install_plan + if [[ -z "$PLAN_LINES" ]]; then + echo "✖ don't know how to install Docker here — see https://docs.docker.com/engine/install/" >&2 + exit 1 + fi + echo "Docker is not installed. The following will run (Docker's official repo):" >&2 + printf ' %s\n' "$PLAN_LINES" >&2 + if [[ $ASSUME_YES -ne 1 ]]; then + printf 'Proceed with install? [y/N] ' >&2 + read -r reply + case "$reply" in + y|Y|yes|YES) ;; + *) echo "✖ aborted — Docker left uninstalled" >&2; exit 1 ;; + esac + fi + while IFS= read -r line; do + [[ -z "$line" ]] && continue + echo "+ $line" >&2 + eval "$line" || { echo "✖ install step failed: $line" >&2; exit 1; } + done <<< "$PLAN_LINES" + have_docker || { echo "✖ install ran but docker is still not callable — check the install log" >&2; exit 1; } + echo "Docker installed ($(docker --version 2>/dev/null))" >&2 + # On Linux a fresh install usually needs the daemon started below; fall through. fi # Work out how to start the daemon on this platform. diff --git a/hh/scripts/ensure-multipass.sh b/hh/scripts/ensure-multipass.sh new file mode 100644 index 0000000..007899e --- /dev/null +++ b/hh/scripts/ensure-multipass.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# ensure-multipass.sh — make sure Multipass is installed before /sbx launch multipass. +# +# Detect-first, never silent: if Multipass is already present this exits 0 and +# changes nothing. If it's missing it prints the EXACT command it would run and +# only installs with explicit consent (--yes). --plan shows a real, no-change +# plan so you can see what would be fetched. +# +# On Linux, Multipass ships ONLY via snap (no apt/dnf package), so snapd must be +# present; if it isn't, this says so rather than guessing a package name. +# +# usage: +# ./ensure-multipass.sh # interactive: prompt before installing +# ./ensure-multipass.sh --yes # install without prompting +# ./ensure-multipass.sh --check # test only; exit 0 if present, 1 if missing +# ./ensure-multipass.sh --plan # show the install plan; change nothing +set -uo pipefail + +ASSUME_YES=0 +CHECK_ONLY=0 +PLAN_ONLY=0 +for arg in "$@"; do + case "$arg" in + -y|--yes) ASSUME_YES=1 ;; + --check) CHECK_ONLY=1 ;; + --plan|--dry-run) PLAN_ONLY=1 ;; + -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "✖ unknown arg: $arg" >&2; exit 2 ;; + esac +done + +installed() { command -v multipass >/dev/null 2>&1 && multipass version >/dev/null 2>&1; } +mp_version() { multipass version 2>/dev/null | head -1; } + +# Already present: report and stop (idempotent). --plan still prints the plan. +if installed; then + if [[ $PLAN_ONLY -ne 1 ]]; then + [[ $CHECK_ONLY -eq 1 ]] || echo "Multipass already installed ($(mp_version)) — nothing to do" >&2 + exit 0 + fi +fi +[[ $CHECK_ONLY -eq 1 ]] && exit 1 + +# Work out how to install on this platform, and the matching no-change plan cmd. +install_cmd="" +plan_cmd="" +need_sudo=0 +manual_note="" +case "$(uname -s)" in + Linux) + if command -v snap >/dev/null 2>&1; then + install_cmd="snap install multipass" + plan_cmd="snap info multipass" # no root needed to inspect + need_sudo=1 + else + echo "✖ Multipass on Linux requires snap (snapd), which isn't installed." >&2 + echo " Install snapd first (e.g. 'sudo apt-get install snapd'), then re-run," >&2 + echo " or see https://multipass.run/install for alternatives." >&2 + exit 1 + fi + ;; + Darwin) + if command -v brew >/dev/null 2>&1; then + install_cmd="brew install --cask multipass" + plan_cmd="brew info --cask multipass" + manual_note="macOS: Multipass installs a system helper; you may be prompted for your password." + fi + ;; + MINGW*|MSYS*|CYGWIN*) + if command -v winget >/dev/null 2>&1; then + install_cmd="winget install -e --id Canonical.Multipass" + plan_cmd="winget show -e --id Canonical.Multipass" + fi + ;; +esac + +if [[ -z "$install_cmd" ]]; then + echo "✖ don't know how to install Multipass here — get it from https://multipass.run/install" >&2 + exit 1 +fi +[[ $need_sudo -eq 1 ]] && install_cmd="sudo $install_cmd" +[[ -n "$manual_note" ]] && echo "ⓘ $manual_note" >&2 + +# --plan: show the real plan and change nothing. +if [[ $PLAN_ONLY -eq 1 ]]; then + echo "plan (no changes will be made): $plan_cmd" >&2 + eval "$plan_cmd" + exit 0 +fi + +# Confirmation (skipped with --yes). +if [[ $ASSUME_YES -ne 1 ]]; then + printf 'Multipass is not installed. Install it with "%s"? [y/N] ' "$install_cmd" >&2 + read -r reply + case "$reply" in + y|Y|yes|YES) ;; + *) echo "✖ aborted — Multipass left uninstalled" >&2; exit 1 ;; + esac +fi + +echo "installing Multipass: $install_cmd" >&2 +eval "$install_cmd" || { echo "✖ install failed (sudo password needed? run it in a terminal)" >&2; exit 1; } + +# Confirm the binary is now callable. +if installed; then + echo "Multipass is ready ($(mp_version))" >&2 + exit 0 +fi +echo "✖ install ran but multipass is still not callable — check the install log" >&2 +exit 1 diff --git a/hh/src/app.rs b/hh/src/app.rs index 4ee8038..5f37be1 100644 --- a/hh/src/app.rs +++ b/hh/src/app.rs @@ -1585,7 +1585,14 @@ fn handle_command( let start_daemon = args .iter() .any(|a| matches!(*a, "--start" | "--start-daemon" | "-y")); - let mut pos = args.iter().copied().filter(|a| !a.starts_with('-')); + // Consent token: if the selected backend's binary is missing, + // `install` opts in to installing it (detect-then-install). It's + // a positional keyword (not the image), so filter it out below. + let want_install = args.iter().any(|a| matches!(*a, "install" | "--install")); + let mut pos = args + .iter() + .copied() + .filter(|a| !a.starts_with('-') && !matches!(*a, "install")); let first = pos.next(); if matches!(first, Some("virtualbox") | Some("vbox")) { // VirtualBox runs locally in its own GUI — host & guest each @@ -1623,18 +1630,47 @@ fn handle_command( .next() .map(str::to_string) .unwrap_or_else(|| backend.default_image().to_string()); - if backend == sbx::Backend::Docker && !start_daemon && !sbx::docker_daemon_up() { + // Is this backend's binary present? Docker/Multipass can be + // installed on consent; Local needs nothing and vbox is + // handled in its own branch above. + let installed = match backend { + sbx::Backend::Docker => sbx::docker_installed(), + sbx::Backend::Multipass => sbx::multipass_installed(), + _ => true, + }; + let token = first.unwrap_or("docker"); + if !installed && !want_install { + app.err(format!( + "{} is not installed — retry with `/sbx launch {token} install` to install it (needs sudo)", + backend.label() + )); + } else if installed + && backend == sbx::Backend::Docker + && !start_daemon + && !sbx::docker_daemon_up() + { app.err("docker daemon is not running — retry with `/sbx launch docker --start` to boot it (sudo), or run ./scripts/ensure-docker.sh in a terminal first"); } else { + // install_first ⇒ binary missing + consent given: install + // it off-thread before provisioning (a fresh Docker + // install also leaves its daemon up). + let install_first = !installed; let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24)); let (rows, cols) = sbx_dims(sz.0, sz.1); *launching = true; let members: Vec = app.users.iter().map(|u| u.username.clone()).collect(); - app.sys(format!( - "summoning {} sandbox… (provisioning unix users; multipass boot ~30s)", - backend.label() - )); + if install_first { + app.sys(format!( + "installing {} (needs sudo)… then summoning the sandbox", + backend.label() + )); + } else { + app.sys(format!( + "summoning {} sandbox… (provisioning unix users; multipass boot ~30s)", + backend.label() + )); + } spawn_launch( backend, image, @@ -1643,6 +1679,7 @@ fn handle_command( rows, cols, start_daemon, + install_first, pty_tx.clone(), broker_tx.clone(), app_tx.clone(), @@ -1751,7 +1788,7 @@ fn handle_command( let _ = atx.send(Net::Sys(format!("loading docker sandbox from {image}…"))); spawn_launch( sbx::Backend::Docker, image, owner, members, rows, - cols, false, pty, btx, atx, + cols, false, false, pty, btx, atx, ); } sbx::SnapKind::Multipass => { @@ -1766,7 +1803,7 @@ fn handle_command( spawn_launch( sbx::Backend::Multipass, sbx::Backend::Multipass.default_image().to_string(), - owner, members, rows, cols, false, pty, btx, atx, + owner, members, rows, cols, false, false, pty, btx, atx, ); } Ok(Err(e)) => { @@ -2402,11 +2439,28 @@ fn spawn_launch( rows: u16, cols: u16, start_daemon: bool, + install_first: bool, pty_tx: UnboundedSender>, broker_tx: UnboundedSender, app_tx: UnboundedSender, ) { tokio::spawn(async move { + // Optional install step: the backend's binary was missing and the user + // opted in. Runs before provisioning; failure aborts the launch (and + // clears the *launching guard via BrokerMsg::Failed). + if install_first { + let res = tokio::task::spawn_blocking(move || match backend { + sbx::Backend::Docker => sbx::ensure_docker_install(), + sbx::Backend::Multipass => sbx::ensure_multipass_install(), + _ => Ok(()), + }) + .await; + if let Err(e) = res.unwrap_or_else(|e| Err(anyhow::anyhow!("join: {e}"))) { + let _ = app_tx.send(Net::Err(format!("install failed: {e}"))); + let _ = broker_tx.send(BrokerMsg::Failed); + return; + } + } let name = SBX_NAME.to_string(); let prep = { let (n, img) = (name.clone(), image.clone()); diff --git a/hh/src/sbx.rs b/hh/src/sbx.rs index 52c822e..bf92b03 100644 --- a/hh/src/sbx.rs +++ b/hh/src/sbx.rs @@ -14,6 +14,8 @@ use std::sync::mpsc; /// Helper that ensures the Docker daemon is running (ships in hh/scripts/). const ENSURE_DOCKER: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/ensure-docker.sh"); +/// Detect-first Multipass installer (ships in hh/scripts/). +const ENSURE_MULTIPASS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/ensure-multipass.sh"); /// Detect-first VirtualBox installer (ships in hh/scripts/). const ENSURE_VBOX: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/ensure-vbox.sh"); /// Baseline dev-toolchain installer run inside Docker sandboxes (hh/scripts/). @@ -23,6 +25,19 @@ const SBX_TOOLS_JSON: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/sandb /// Creates a fresh, cloud-init-provisioned Ubuntu VirtualBox VM (hh/scripts/). const VBOX_NEW: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/vbox-new.sh"); +/// Is the `docker` binary installed? (`docker --version` succeeds.) This is a +/// weaker check than `docker_daemon_up`: the CLI can be present while the daemon +/// is down. We need both before a Docker sandbox can launch. +pub fn docker_installed() -> bool { + Command::new("docker") + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + /// Is the Docker daemon accepting connections? (`docker info` succeeds.) pub fn docker_daemon_up() -> bool { Command::new("docker") @@ -53,6 +68,54 @@ fn start_docker_daemon() -> Result<()> { Ok(()) } +/// Install Docker via `ensure-docker.sh --install --yes` (Docker's official, +/// GPG-verified repo), then leave the daemon started. Consent is the caller's +/// job (they passed `install`); the script is idempotent if Docker is present. +/// Returns the script's last error line on failure (e.g. needs sudo). +pub fn ensure_docker_install() -> Result<()> { + let out = Command::new("bash") + .arg(ENSURE_DOCKER) + .arg("--install") + .arg("--yes") + .output() + .context("running ensure-docker.sh --install")?; + if !out.status.success() { + let err = String::from_utf8_lossy(&out.stderr); + let last = err.lines().last().unwrap_or("could not install Docker"); + anyhow::bail!("{last}"); + } + Ok(()) +} + +/// Is Multipass installed? (`multipass version` succeeds.) +pub fn multipass_installed() -> bool { + Command::new("multipass") + .arg("version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Install Multipass via `ensure-multipass.sh --yes`. On Linux this is a snap +/// install (the only supported channel). Consent is the caller's job; the +/// script is idempotent if Multipass is already present. Returns the script's +/// last error line on failure (e.g. snapd missing, or needs sudo). +pub fn ensure_multipass_install() -> Result<()> { + let out = Command::new("bash") + .arg(ENSURE_MULTIPASS) + .arg("--yes") + .output() + .context("running ensure-multipass.sh")?; + if !out.status.success() { + let err = String::from_utf8_lossy(&out.stderr); + let last = err.lines().last().unwrap_or("could not install Multipass"); + anyhow::bail!("{last}"); + } + Ok(()) +} + // ---- VirtualBox (local GUI VMs) --------------------------------------------- // VirtualBox is integrated as a *local* facility rather than a shared-PTY // backend: a room shares a VM by handing out its appliance, and each member diff --git a/hh/src/ui.rs b/hh/src/ui.rs index 5210352..4842533 100644 --- a/hh/src/ui.rs +++ b/hh/src/ui.rs @@ -154,12 +154,12 @@ fn help_clusters(theme: &Theme) -> Vec { items: vec![ // ── launch (one verb per backend) ── kv( - "/sbx launch docker [image]", - "Linux container — shared shell relayed to the room (default ubuntu:24.04 + auto dev toolchain)", + "/sbx launch docker [image] [install]", + "Linux container — shared shell relayed to the room (default ubuntu:24.04 + auto dev toolchain; append install if Docker is missing)", ), kv( - "/sbx launch multipass [image]", - "full Ubuntu VM — shared shell relayed to the room (same dev toolchain)", + "/sbx launch multipass [image] [install]", + "full Ubuntu VM — shared shell relayed to the room (same dev toolchain; append install if Multipass is missing)", ), kv( "/sbx launch vbox",