feat(gui): docker orchestration and fleet supervision (bus daemon)

This commit is contained in:
2026-10-25 00:08:57 -07:00
parent 01cd416bf0
commit 2be008e4b0
6 changed files with 553 additions and 3 deletions
+6 -3
View File
@@ -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 /usr/local/bin/
COPY --from=build /workspace/target/release/tines-busd /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 # The container command is the pinned argv from the fleet console:
# config selects); no default CMD. # `tines /data/config.json` — the binary comes first because the service
ENTRYPOINT ["tines"] # 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).
+217
View File
@@ -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<String> = 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<String> {
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<String> {
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<String> {
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<String> {
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<String> {
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<ContainerId, DockerError> {
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<ContainerId, DockerError> {
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<String, DockerError> {
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,
}
}
}
/// `<fleet_dir>/<name>` — 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<String, DockerError> {
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<Command, DockerError> {
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)
}
+119
View File
@@ -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
//! <fleet_dir>/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 `<fleet_dir>/<name>/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,
/// `<fleet_dir>/<name>` — holds `config.json`.
pub dir: PathBuf,
}
/// A live fleet with a supervised bus daemon.
pub struct Fleet {
/// The fleet name.
fleet: String,
/// `<fleet_dir>`.
dir: PathBuf,
/// The daemon child; `None` after `stop_bus`.
busd: Option<Child>,
}
impl Fleet {
/// Create the fleet dir (`0700`) and spawn the bus daemon on
/// `<fleet_dir>/bus.sock`. `tines-busd` must be on PATH.
pub fn create(fleet: &str) -> io::Result<Fleet> {
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: `<fleet_dir>/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/<name>/log` and
/// `/data/<name>/sock`.
pub fn add_node(
&self,
name: &str,
node_id: Option<&str>,
mode: Mode,
peer: Option<&str>,
) -> io::Result<NodeSpec> {
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();
}
}
+2
View File
@@ -8,5 +8,7 @@
#![deny(missing_docs)] #![deny(missing_docs)]
pub mod config; pub mod config;
pub mod docker;
pub mod fleet;
pub mod ipc_client; pub mod ipc_client;
pub mod theme; pub mod theme;
+74
View File
@@ -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<String> = 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<String> = 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");
}
+135
View File
@@ -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<PathBuf> = 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"
);
}