feat(gui): docker orchestration and fleet supervision (bus daemon)
This commit is contained in:
+6
-3
@@ -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).
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -8,5 +8,7 @@
|
||||
#![deny(missing_docs)]
|
||||
|
||||
pub mod config;
|
||||
pub mod docker;
|
||||
pub mod fleet;
|
||||
pub mod ipc_client;
|
||||
pub mod theme;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user