feat(sbx): install Docker/Multipass on demand from /sbx launch
CI / rust client (hh) (macos-latest) (push) Waiting to run
CI / rust client (hh) (ubuntu-latest) (push) Waiting to run
CI / rust coverage (push) Waiting to run
CI / python server (3.10) (push) Waiting to run
CI / python server (3.11) (push) Waiting to run
CI / python server (3.12) (push) Waiting to run
CI / headless e2e smoke (push) Waiting to run
CI / dependency audit (push) Waiting to run
CI / secret scanning (push) Waiting to run
CI / rust client (hh) (macos-latest) (push) Waiting to run
CI / rust client (hh) (ubuntu-latest) (push) Waiting to run
CI / rust coverage (push) Waiting to run
CI / python server (3.10) (push) Waiting to run
CI / python server (3.11) (push) Waiting to run
CI / python server (3.12) (push) Waiting to run
CI / headless e2e smoke (push) Waiting to run
CI / dependency audit (push) Waiting to run
CI / secret scanning (push) Waiting to run
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 <noreply@anthropic.com>
This commit is contained in:
+110
-14
@@ -1,29 +1,100 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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"
|
# Two jobs, detect-first and never silent:
|
||||||
# and the sandbox launch dies with a raw error. This script detects a dead
|
# 1. If the docker binary is missing, optionally INSTALL it (--install) from
|
||||||
# daemon and — after confirmation — starts it, then waits until it's accepting
|
# Docker's official, GPG-verified apt repo on Debian/Ubuntu (docker-ce),
|
||||||
# connections.
|
# 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:
|
# usage:
|
||||||
# ./ensure-docker.sh # interactive: prompt before starting the daemon
|
# ./ensure-docker.sh # interactive: prompt before starting the daemon
|
||||||
# ./ensure-docker.sh --yes # start without prompting (used by hack-house --start)
|
# ./ensure-docker.sh --yes # start (and install, if --install) without prompting
|
||||||
# ./ensure-docker.sh --check # test only; exit 0 if up, 1 if down (no changes)
|
# ./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
|
set -uo pipefail
|
||||||
|
|
||||||
ASSUME_YES=0
|
ASSUME_YES=0
|
||||||
CHECK_ONLY=0
|
CHECK_ONLY=0
|
||||||
|
DO_INSTALL=0
|
||||||
|
PLAN_ONLY=0
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
-y|--yes) ASSUME_YES=1 ;;
|
-y|--yes) ASSUME_YES=1 ;;
|
||||||
--check) CHECK_ONLY=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 ;;
|
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||||
*) echo "✖ unknown arg: $arg" >&2; exit 2 ;;
|
*) echo "✖ unknown arg: $arg" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
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 <<EOF
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y ca-certificates curl
|
||||||
|
sudo install -m 0755 -d /etc/apt/keyrings
|
||||||
|
sudo curl -fsSL https://download.docker.com/linux/$repo/gpg -o /etc/apt/keyrings/docker.asc
|
||||||
|
sudo chmod a+r /etc/apt/keyrings/docker.asc
|
||||||
|
echo "deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$repo \$(. /etc/os-release && echo \${VERSION_CODENAME}) stable" | sudo tee /etc/apt/sources.list.d/docker.list >/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 <<EOF
|
||||||
|
sudo dnf -y install dnf-plugins-core
|
||||||
|
sudo dnf config-manager --add-repo https://download.docker.com/linux/$repo/docker-ce.repo
|
||||||
|
sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
;;
|
||||||
|
*arch*)
|
||||||
|
PLAN_LINES="sudo pacman -S --noconfirm docker"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── --plan: show the install plan and change nothing ─────────────────────────
|
||||||
|
if [[ $PLAN_ONLY -eq 1 ]]; then
|
||||||
|
if have_docker; then
|
||||||
|
echo "Docker already installed ($(docker --version 2>/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
|
if daemon_up; then
|
||||||
[[ $CHECK_ONLY -eq 1 ]] || echo "docker daemon already running" >&2
|
[[ $CHECK_ONLY -eq 1 ]] || echo "docker daemon already running" >&2
|
||||||
@@ -31,9 +102,34 @@ if daemon_up; then
|
|||||||
fi
|
fi
|
||||||
[[ $CHECK_ONLY -eq 1 ]] && exit 1
|
[[ $CHECK_ONLY -eq 1 ]] && exit 1
|
||||||
|
|
||||||
if ! command -v docker >/dev/null 2>&1; then
|
if ! have_docker; then
|
||||||
echo "✖ docker is not installed — install Docker first" >&2
|
if [[ $DO_INSTALL -ne 1 ]]; then
|
||||||
exit 127
|
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
|
fi
|
||||||
|
|
||||||
# Work out how to start the daemon on this platform.
|
# Work out how to start the daemon on this platform.
|
||||||
|
|||||||
@@ -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
|
||||||
+62
-8
@@ -1585,7 +1585,14 @@ fn handle_command(
|
|||||||
let start_daemon = args
|
let start_daemon = args
|
||||||
.iter()
|
.iter()
|
||||||
.any(|a| matches!(*a, "--start" | "--start-daemon" | "-y"));
|
.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();
|
let first = pos.next();
|
||||||
if matches!(first, Some("virtualbox") | Some("vbox")) {
|
if matches!(first, Some("virtualbox") | Some("vbox")) {
|
||||||
// VirtualBox runs locally in its own GUI — host & guest each
|
// VirtualBox runs locally in its own GUI — host & guest each
|
||||||
@@ -1623,18 +1630,47 @@ fn handle_command(
|
|||||||
.next()
|
.next()
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.unwrap_or_else(|| backend.default_image().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");
|
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 {
|
} 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 sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
|
||||||
let (rows, cols) = sbx_dims(sz.0, sz.1);
|
let (rows, cols) = sbx_dims(sz.0, sz.1);
|
||||||
*launching = true;
|
*launching = true;
|
||||||
let members: Vec<String> =
|
let members: Vec<String> =
|
||||||
app.users.iter().map(|u| u.username.clone()).collect();
|
app.users.iter().map(|u| u.username.clone()).collect();
|
||||||
app.sys(format!(
|
if install_first {
|
||||||
"summoning {} sandbox… (provisioning unix users; multipass boot ~30s)",
|
app.sys(format!(
|
||||||
backend.label()
|
"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(
|
spawn_launch(
|
||||||
backend,
|
backend,
|
||||||
image,
|
image,
|
||||||
@@ -1643,6 +1679,7 @@ fn handle_command(
|
|||||||
rows,
|
rows,
|
||||||
cols,
|
cols,
|
||||||
start_daemon,
|
start_daemon,
|
||||||
|
install_first,
|
||||||
pty_tx.clone(),
|
pty_tx.clone(),
|
||||||
broker_tx.clone(),
|
broker_tx.clone(),
|
||||||
app_tx.clone(),
|
app_tx.clone(),
|
||||||
@@ -1751,7 +1788,7 @@ fn handle_command(
|
|||||||
let _ = atx.send(Net::Sys(format!("loading docker sandbox from {image}…")));
|
let _ = atx.send(Net::Sys(format!("loading docker sandbox from {image}…")));
|
||||||
spawn_launch(
|
spawn_launch(
|
||||||
sbx::Backend::Docker, image, owner, members, rows,
|
sbx::Backend::Docker, image, owner, members, rows,
|
||||||
cols, false, pty, btx, atx,
|
cols, false, false, pty, btx, atx,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
sbx::SnapKind::Multipass => {
|
sbx::SnapKind::Multipass => {
|
||||||
@@ -1766,7 +1803,7 @@ fn handle_command(
|
|||||||
spawn_launch(
|
spawn_launch(
|
||||||
sbx::Backend::Multipass,
|
sbx::Backend::Multipass,
|
||||||
sbx::Backend::Multipass.default_image().to_string(),
|
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)) => {
|
Ok(Err(e)) => {
|
||||||
@@ -2402,11 +2439,28 @@ fn spawn_launch(
|
|||||||
rows: u16,
|
rows: u16,
|
||||||
cols: u16,
|
cols: u16,
|
||||||
start_daemon: bool,
|
start_daemon: bool,
|
||||||
|
install_first: bool,
|
||||||
pty_tx: UnboundedSender<Vec<u8>>,
|
pty_tx: UnboundedSender<Vec<u8>>,
|
||||||
broker_tx: UnboundedSender<BrokerMsg>,
|
broker_tx: UnboundedSender<BrokerMsg>,
|
||||||
app_tx: UnboundedSender<Net>,
|
app_tx: UnboundedSender<Net>,
|
||||||
) {
|
) {
|
||||||
tokio::spawn(async move {
|
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 name = SBX_NAME.to_string();
|
||||||
let prep = {
|
let prep = {
|
||||||
let (n, img) = (name.clone(), image.clone());
|
let (n, img) = (name.clone(), image.clone());
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ use std::sync::mpsc;
|
|||||||
|
|
||||||
/// Helper that ensures the Docker daemon is running (ships in hh/scripts/).
|
/// 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");
|
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/).
|
/// Detect-first VirtualBox installer (ships in hh/scripts/).
|
||||||
const ENSURE_VBOX: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/ensure-vbox.sh");
|
const ENSURE_VBOX: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/ensure-vbox.sh");
|
||||||
/// Baseline dev-toolchain installer run inside Docker sandboxes (hh/scripts/).
|
/// 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/).
|
/// Creates a fresh, cloud-init-provisioned Ubuntu VirtualBox VM (hh/scripts/).
|
||||||
const VBOX_NEW: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/vbox-new.sh");
|
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.)
|
/// Is the Docker daemon accepting connections? (`docker info` succeeds.)
|
||||||
pub fn docker_daemon_up() -> bool {
|
pub fn docker_daemon_up() -> bool {
|
||||||
Command::new("docker")
|
Command::new("docker")
|
||||||
@@ -53,6 +68,54 @@ fn start_docker_daemon() -> Result<()> {
|
|||||||
Ok(())
|
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 (local GUI VMs) ---------------------------------------------
|
||||||
// VirtualBox is integrated as a *local* facility rather than a shared-PTY
|
// 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
|
// backend: a room shares a VM by handing out its appliance, and each member
|
||||||
|
|||||||
+4
-4
@@ -154,12 +154,12 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
|||||||
items: vec![
|
items: vec![
|
||||||
// ── launch (one verb per backend) ──
|
// ── launch (one verb per backend) ──
|
||||||
kv(
|
kv(
|
||||||
"/sbx launch docker [image]",
|
"/sbx launch docker [image] [install]",
|
||||||
"Linux container — shared shell relayed to the room (default ubuntu:24.04 + auto dev toolchain)",
|
"Linux container — shared shell relayed to the room (default ubuntu:24.04 + auto dev toolchain; append install if Docker is missing)",
|
||||||
),
|
),
|
||||||
kv(
|
kv(
|
||||||
"/sbx launch multipass [image]",
|
"/sbx launch multipass [image] [install]",
|
||||||
"full Ubuntu VM — shared shell relayed to the room (same dev toolchain)",
|
"full Ubuntu VM — shared shell relayed to the room (same dev toolchain; append install if Multipass is missing)",
|
||||||
),
|
),
|
||||||
kv(
|
kv(
|
||||||
"/sbx launch vbox",
|
"/sbx launch vbox",
|
||||||
|
|||||||
Reference in New Issue
Block a user