From 2be008e4b000d351f7473bb0b4ae969157954525 Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Sun, 25 Oct 2026 00:08:57 -0700 Subject: [PATCH] feat(gui): docker orchestration and fleet supervision (bus daemon) --- Dockerfile.tines | 9 +- tines-gui/src/docker.rs | 217 ++++++++++++++++++++++++++++++++++++++ tines-gui/src/fleet.rs | 119 +++++++++++++++++++++ tines-gui/src/lib.rs | 2 + tines-gui/tests/docker.rs | 74 +++++++++++++ tines-gui/tests/fleet.rs | 135 ++++++++++++++++++++++++ 6 files changed, 553 insertions(+), 3 deletions(-) create mode 100644 tines-gui/src/docker.rs create mode 100644 tines-gui/src/fleet.rs create mode 100644 tines-gui/tests/docker.rs create mode 100644 tines-gui/tests/fleet.rs diff --git a/Dockerfile.tines b/Dockerfile.tines index 7ac07bd9..c829cfdf 100644 --- a/Dockerfile.tines +++ b/Dockerfile.tines @@ -42,6 +42,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY --from=build /workspace/target/release/tines /usr/local/bin/ COPY --from=build /workspace/target/release/tines-busd /usr/local/bin/ -# The config path comes as the container arg (BT or bus mode, whichever the -# config selects); no default CMD. -ENTRYPOINT ["tines"] +# The container command is the pinned argv from the fleet console: +# `tines /data/config.json` — the binary comes first because the service +# reads the config path from argv[1]. No ENTRYPOINT (it would swallow the +# argv); no default CMD (a bare `docker run` without the command is an +# error, which is what we want). + diff --git a/tines-gui/src/docker.rs b/tines-gui/src/docker.rs new file mode 100644 index 00000000..9c2d7def --- /dev/null +++ b/tines-gui/src/docker.rs @@ -0,0 +1,217 @@ +//! Docker orchestration: build the runtime image and drive one container +//! per node. Every command runs through `std::process::Command` with +//! pinned argv vectors — nothing is ever shell-interpolated. + +use std::io; +use std::path::PathBuf; +use std::process::{Command, Output}; + +use crate::config::fleet_dir; + +/// A docker container id (the value `docker run -d` prints). +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ContainerId(pub String); + +/// Failures the docker module can report. +#[derive(Debug)] +pub enum DockerError { + /// The `docker` binary is not on PATH. + NotInstalled, + /// The docker process could not be spawned. + Spawn(io::Error), + /// Docker ran but exited nonzero. + Exit { + /// The exit code. + code: i32, + /// The process's captured stderr. + stderr: String, + }, +} + +impl std::fmt::Display for DockerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DockerError::NotInstalled => write!(f, "docker is not installed or not on PATH"), + DockerError::Spawn(e) => write!(f, "failed to spawn docker: {e}"), + DockerError::Exit { code, stderr } => { + write!(f, "docker exited {code}: {}", stderr.trim()) + } + } + } +} + +impl std::error::Error for DockerError {} + +/// The docker frontend. A unit struct: docker state lives in the daemon, +/// not here. +pub struct Docker; + +impl Docker { + /// The pinned runtime image tag. + pub fn image() -> &'static str { + "tines-stack:latest" + } + + /// Build the runtime image from the repo root — the crate's parent + /// directory, baked in at compile time (`CARGO_MANIFEST_DIR` points at + /// `tines-gui/`). + pub fn build_image(&self) -> Result<(), DockerError> { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("tines-gui lives one level below the repo root") + .to_path_buf(); + let argv: Vec = vec![ + "docker".into(), + "build".into(), + "-f".into(), + "Dockerfile.tines".into(), + "-t".into(), + Self::image().into(), + ".".into(), + ]; + let mut cmd = spawn(&argv)?; + cmd.current_dir(root); + run(&mut cmd)?; + Ok(()) + } + + /// The exact argv for `start_bt`. Exposed for tests and debugging; + /// [`Self::start_bt`] runs this vector verbatim. + pub fn start_bt_argv(&self, name: &str, fleet: &str) -> Vec { + vec![ + "docker".into(), + "run".into(), + "-d".into(), + "--name".into(), + format!("tines-bt-{name}"), + "--privileged".into(), + "--net=host".into(), + "-v".into(), + "/run/dbus/system_bus_socket:/run/dbus/system_bus_socket".into(), + "-v".into(), + format!("{}:/data", node_dir(fleet, name).display()), + Self::image().into(), + "tines".into(), + "/data/config.json".into(), + ] + } + + /// The exact argv for `start_sim`. Exposed for tests and debugging; + /// [`Self::start_sim`] runs this vector verbatim. + pub fn start_sim_argv(&self, name: &str, fleet: &str) -> Vec { + vec![ + "docker".into(), + "run".into(), + "-d".into(), + "--name".into(), + format!("tines-sim-{name}"), + "-v".into(), + format!("{}:/data", node_dir(fleet, name).display()), + Self::image().into(), + "tines".into(), + "/data/config.json".into(), + ] + } + + /// The exact argv for `stop`. Exposed for tests and debugging. + pub fn stop_argv(&self, id: &ContainerId) -> Vec { + vec!["docker".into(), "stop".into(), id.0.clone()] + } + + /// The exact argv for `remove`. Exposed for tests and debugging. + pub fn remove_argv(&self, id: &ContainerId) -> Vec { + vec!["docker".into(), "rm".into(), id.0.clone()] + } + + /// The exact argv for `logs`. Exposed for tests and debugging. + pub fn logs_argv(&self, id: &ContainerId, tail: u32) -> Vec { + vec![ + "docker".into(), + "logs".into(), + "--tail".into(), + tail.to_string(), + id.0.clone(), + ] + } + + /// Start a bt-mode node container: privileged, host networking, the + /// system D-Bus socket (for the host Bluetooth adapter), and the node's + /// fleet dir mounted at `/data`. Returns the container id. + pub fn start_bt(&self, name: &str, fleet: &str) -> Result { + let out = run_output(&self.start_bt_argv(name, fleet))?; + Ok(ContainerId(out.trim().to_string())) + } + + /// Start a sim-mode node container: same shape, no privileges or host + /// networking. Returns the container id. + pub fn start_sim(&self, name: &str, fleet: &str) -> Result { + let out = run_output(&self.start_sim_argv(name, fleet))?; + Ok(ContainerId(out.trim().to_string())) + } + + /// Stop a container. + pub fn stop(&self, id: &ContainerId) -> Result<(), DockerError> { + run(&mut spawn(&self.stop_argv(id))?) + } + + /// Remove a (stopped) container. + pub fn remove(&self, id: &ContainerId) -> Result<(), DockerError> { + run(&mut spawn(&self.remove_argv(id))?) + } + + /// The container's recent logs (stdout and stderr merged), newest + /// `tail` lines last. + pub fn logs(&self, id: &ContainerId, tail: u32) -> Result { + run_output(&self.logs_argv(id, tail)) + } + + /// Whether `docker inspect` succeeds for the id — i.e. the container + /// exists (the inspect exit status is the pin). + pub fn is_running(&self, id: &ContainerId) -> bool { + match Command::new("docker").arg("inspect").arg(&id.0).output() { + Ok(Output { status, .. }) => status.success(), + Err(_) => false, + } + } +} + +/// `/` — the dir mounted at `/data` inside the container. +fn node_dir(fleet: &str, name: &str) -> PathBuf { + fleet_dir(fleet).join(name) +} + +/// Run a command expecting success, capturing stdout. +fn run_output(argv: &[String]) -> Result { + let out = spawn(argv)?.output().map_err(DockerError::Spawn)?; + if out.status.success() { + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) + } else { + Err(DockerError::Exit { + code: out.status.code().unwrap_or(-1), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + }) + } +} + +/// Run a command expecting success, output inherited. +fn run(cmd: &mut Command) -> Result<(), DockerError> { + let status = cmd.status().map_err(DockerError::Spawn)?; + if status.success() { + Ok(()) + } else { + Err(DockerError::Exit { + code: status.code().unwrap_or(-1), + stderr: String::new(), + }) + } +} + +/// The docker binary or a `NotInstalled` error. +fn spawn(argv: &[String]) -> Result { + if Command::new("docker").arg("--version").output().is_err() { + return Err(DockerError::NotInstalled); + } + let mut cmd = Command::new(&argv[0]); + cmd.args(&argv[1..]); + Ok(cmd) +} diff --git a/tines-gui/src/fleet.rs b/tines-gui/src/fleet.rs new file mode 100644 index 00000000..a125d4cf --- /dev/null +++ b/tines-gui/src/fleet.rs @@ -0,0 +1,119 @@ +//! A fleet: the on-disk layout plus the bus daemon that serves it. +//! +//! [`Fleet::create`] makes the fleet dir (`0700`, via +//! [`crate::config::fleet_dir`]) and spawns `tines-busd +//! /bus.sock` as a supervised child — the daemon is what lets +//! every sim-mode node on the fleet exchange frames over the host IPC bus. +//! Node configs land under `//config.json` and mount into +//! the containers at `/data`, so all container-side paths are `/data/...`. + +use std::io; +use std::path::PathBuf; +use std::process::{Child, Command}; + +use crate::config::{NodeConfig, fleet_dir, generate_node_id}; + +/// How a node connects to its peers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + /// A real Bluetooth radio (privileged container, host adapter). + Bt, + /// The host IPC bus (simulated fleet). + Sim, +} + +/// What `add_node` produced: the node's name and its config dir. +#[derive(Debug, Clone)] +pub struct NodeSpec { + /// The node name (also the dir and container-name stem). + pub name: String, + /// `/` — holds `config.json`. + pub dir: PathBuf, +} + +/// A live fleet with a supervised bus daemon. +pub struct Fleet { + /// The fleet name. + fleet: String, + /// ``. + dir: PathBuf, + /// The daemon child; `None` after `stop_bus`. + busd: Option, +} + +impl Fleet { + /// Create the fleet dir (`0700`) and spawn the bus daemon on + /// `/bus.sock`. `tines-busd` must be on PATH. + pub fn create(fleet: &str) -> io::Result { + let dir = fleet_dir(fleet); + let child = Command::new("tines-busd") + .arg(dir.join("bus.sock")) + .spawn() + .map_err(|e| { + io::Error::new( + e.kind(), + format!("cannot spawn tines-busd (is it on PATH?): {e}"), + ) + })?; + Ok(Fleet { + fleet: fleet.to_string(), + dir, + busd: Some(child), + }) + } + + /// The bus daemon's socket: `/bus.sock`. + pub fn bus_socket(&self) -> PathBuf { + self.dir.join("bus.sock") + } + + /// Write a node's service config and return its spec. A missing + /// `node_id` gets a fresh generated one. Bus mode points `bus_socket` + /// at the in-container `/data/bus.sock` (the fleet dir is mounted at + /// `/data`); log and IPC paths are `/data//log` and + /// `/data//sock`. + pub fn add_node( + &self, + name: &str, + node_id: Option<&str>, + mode: Mode, + peer: Option<&str>, + ) -> io::Result { + let node_id = match node_id { + Some(id) => id.to_string(), + None => generate_node_id(), + }; + let peer = peer.map(str::to_string); + let log_path = format!("/data/{name}/log"); + let socket_path = format!("/data/{name}/sock"); + let config = match mode { + Mode::Bt => NodeConfig::bt(node_id, peer, log_path, socket_path), + Mode::Sim => NodeConfig::bus( + node_id, + peer, + log_path, + socket_path, + "/data/bus.sock".into(), + ), + }; + config.write(&self.fleet, name)?; + Ok(NodeSpec { + name: name.to_string(), + dir: self.dir.join(name), + }) + } + + /// Stop the bus daemon. + pub fn stop_bus(&mut self) { + if let Some(mut child) = self.busd.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +impl Drop for Fleet { + fn drop(&mut self) { + self.stop_bus(); + } +} diff --git a/tines-gui/src/lib.rs b/tines-gui/src/lib.rs index 1c42e8b8..157f2667 100644 --- a/tines-gui/src/lib.rs +++ b/tines-gui/src/lib.rs @@ -8,5 +8,7 @@ #![deny(missing_docs)] pub mod config; +pub mod docker; +pub mod fleet; pub mod ipc_client; pub mod theme; diff --git a/tines-gui/tests/docker.rs b/tines-gui/tests/docker.rs new file mode 100644 index 00000000..87942c52 --- /dev/null +++ b/tines-gui/tests/docker.rs @@ -0,0 +1,74 @@ +//! Command-building tests for the docker module: the exact argv vectors, +//! no real docker involved. + +use tines_gui::config::fleet_dir; +use tines_gui::docker::{ContainerId, Docker}; + +#[test] +fn start_bt_args_match_pinned_flags() { + let docker = Docker; + let name = "scout"; + let fleet = "yard"; + let mounted = fleet_dir(fleet).join(name); + let mounted = mounted.display().to_string(); + + let argv = docker.start_bt_argv(name, fleet); + let expected: Vec = vec![ + "docker".into(), + "run".into(), + "-d".into(), + "--name".into(), + format!("tines-bt-{name}"), + "--privileged".into(), + "--net=host".into(), + "-v".into(), + "/run/dbus/system_bus_socket:/run/dbus/system_bus_socket".into(), + "-v".into(), + format!("{mounted}:/data"), + "tines-stack:latest".into(), + "tines".into(), + "/data/config.json".into(), + ]; + assert_eq!(argv, expected); +} + +#[test] +fn start_sim_args_match_pinned_flags() { + let docker = Docker; + let name = "cargo"; + let fleet = "yard"; + let mounted = fleet_dir(fleet).join(name); + let mounted = mounted.display().to_string(); + + let argv = docker.start_sim_argv(name, fleet); + let expected: Vec = vec![ + "docker".into(), + "run".into(), + "-d".into(), + "--name".into(), + format!("tines-sim-{name}"), + "-v".into(), + format!("{mounted}:/data"), + "tines-stack:latest".into(), + "tines".into(), + "/data/config.json".into(), + ]; + assert_eq!(argv, expected); +} + +#[test] +fn stop_and_remove_args() { + let docker = Docker; + let id = ContainerId("abc123".into()); + assert_eq!(docker.stop_argv(&id), vec!["docker", "stop", "abc123"]); + assert_eq!(docker.remove_argv(&id), vec!["docker", "rm", "abc123"]); + assert_eq!( + docker.logs_argv(&id, 25), + vec!["docker", "logs", "--tail", "25", "abc123"] + ); +} + +#[test] +fn image_name_is_pinned() { + assert_eq!(Docker::image(), "tines-stack:latest"); +} diff --git a/tines-gui/tests/fleet.rs b/tines-gui/tests/fleet.rs new file mode 100644 index 00000000..05d42f84 --- /dev/null +++ b/tines-gui/tests/fleet.rs @@ -0,0 +1,135 @@ +//! Fleet layout and node-config tests. The bus daemon is a fake `tines-busd` +//! seeded onto PATH (the real binary isn't on PATH for unit tests): it +//! records its argv and creates the socket file, so the fleet's spawn +//! protocol is what's under test — the real daemon is covered by tines-bus's +//! own suite and Task 4's e2e. + +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +use tines_gui::fleet::{Fleet, Mode}; + +/// One shared scratch setup for every test in this binary: a temp dir that +/// becomes both XDG_STATE_HOME (fleet dirs) and a PATH dir holding the fake +/// `tines-busd`. Env is process-global, so it is set exactly once before any +/// test touches fleet_dir. +fn setup() -> &'static PathBuf { + static DIR: OnceLock = OnceLock::new(); + DIR.get_or_init(|| { + let dir = std::env::temp_dir().join(format!("tines-gui-fleet-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + // Fake tines-busd: touch the socket named by argv[1], then stay alive. + let fake = dir.join("tines-busd"); + std::fs::write(&fake, "#!/bin/sh\ntouch \"$1\"\nsleep 60\n").unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let path = format!( + "{}:{}", + dir.display(), + std::env::var("PATH").unwrap_or_default() + ); + // SAFETY: set once, before any fleet_dir call; this process is a + // test binary that exits afterwards, so nothing else observes it. + unsafe { + std::env::set_var("XDG_STATE_HOME", &dir); + std::env::set_var("PATH", path); + } + dir + }) +} + +fn fleet_name(tag: &str) -> String { + format!( + "test-{tag}-{}", + std::thread::current().name().unwrap_or("t") + ) +} + +#[test] +fn fleet_dir_layout() { + setup(); + let fleet = Fleet::create(&fleet_name("layout")).expect("fleet creates"); + let dir = tines_gui::config::fleet_dir(&fleet_name("layout")); + let meta = std::fs::metadata(&dir).expect("fleet dir exists"); + assert!(meta.is_dir()); + assert_eq!(meta.permissions().mode() & 0o777, 0o700); + + // The fake daemon creates the bus socket the fleet asked it to bind. + let socket = fleet.bus_socket(); + assert_eq!(socket, dir.join("bus.sock")); + let deadline = Instant::now() + Duration::from_secs(5); + while !socket.exists() { + assert!( + Instant::now() < deadline, + "bus.sock never appeared: {}", + socket.display() + ); + std::thread::sleep(Duration::from_millis(50)); + } +} + +#[test] +fn add_node_bus_config_mounts_data() { + setup(); + let fleet_name = fleet_name("bus"); + let fleet = Fleet::create(&fleet_name).expect("fleet creates"); + let spec = fleet + .add_node("alpha", Some(&"11".repeat(16)), Mode::Sim, None) + .expect("node added"); + + let json = std::fs::read_to_string(spec.dir.join("config.json")).unwrap(); + let parsed: tines::config::Config = + serde_json::from_str(&json).expect("config parses as the service config"); + assert_eq!(parsed.transport, Some(tines::config::TransportKind::Bus)); + assert_eq!(parsed.bus_socket, Some("/data/bus.sock".to_string())); + assert_eq!(parsed.socket_path, "/data/alpha/sock".to_string()); + assert_eq!(parsed.log_path, "/data/alpha/log".to_string()); + assert_eq!(parsed.node_id, "11".repeat(16)); + assert_eq!(parsed.validate(), Ok(())); +} + +#[test] +fn add_node_bt_config_has_peer() { + setup(); + let fleet_name = fleet_name("bt"); + let fleet = Fleet::create(&fleet_name).expect("fleet creates"); + let peer = "22".repeat(16); + let spec = fleet + .add_node("bravo", None, Mode::Bt, Some(&peer)) + .expect("node added"); + + let json = std::fs::read_to_string(spec.dir.join("config.json")).unwrap(); + let parsed: tines::config::Config = + serde_json::from_str(&json).expect("config parses as the service config"); + assert_eq!(parsed.transport, Some(tines::config::TransportKind::Bt)); + assert_eq!(parsed.peer, Some(peer)); + assert_eq!(parsed.bus_socket, None); + assert_eq!(parsed.validate(), Ok(())); +} + +#[test] +fn generated_id_roundtrips() { + setup(); + let fleet_name = fleet_name("gen"); + let fleet = Fleet::create(&fleet_name).expect("fleet creates"); + let spec = fleet + .add_node("charlie", None, Mode::Sim, None) + .expect("node added"); + + let json = std::fs::read_to_string(spec.dir.join("config.json")).unwrap(); + let parsed: tines::config::Config = + serde_json::from_str(&json).expect("config parses as the service config"); + assert_eq!(parsed.node_id.len(), 32); + assert!( + parsed + .node_id + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()), + "generated id must be lowercase hex" + ); +}