#![cfg(unix)]
use std::io::{BufRead as _, BufReader, Write as _};
use std::os::unix::net::UnixStream;
use std::os::unix::process::CommandExt as _;
use std::path::{Path, PathBuf};
use std::process::{Child, Output, Stdio};
use std::time::{Duration, Instant};
use color_eyre::eyre;
use similar_asserts::assert_eq;
mod common;
fn config(proxy_port: u16) -> String {
format!(
r#"version: 1
allocate_ports: [ISOLATED_PORT]
proxy:
port: {proxy_port}
routes:
- host: app
port: ${{ISOLATED_PORT}}
services:
svc:
command: ["sh", "-c", "exec sleep 300"]
cleanup:
command: ["sh", "-c", "echo cleaned"]
"#
)
}
fn run(directory: &Path, args: &[&str]) -> eyre::Result<Output> {
Ok(common::micromux(directory)
.args(args)
.current_dir(directory)
.stdin(Stdio::null())
.output()?)
}
fn free_port() -> eyre::Result<u16> {
Ok(std::net::TcpListener::bind("127.0.0.1:0")?
.local_addr()?
.port())
}
fn ask_proxy(socket: &Path, message: &str) -> eyre::Result<serde_json::Value> {
let mut stream = UnixStream::connect(socket)?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
writeln!(stream, "{message}")?;
let mut line = String::new();
BufReader::new(stream).read_line(&mut line)?;
Ok(serde_json::from_str(&line)?)
}
struct StopProxy(PathBuf);
impl Drop for StopProxy {
fn drop(&mut self) {
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
match ask_proxy(&self.0, r#"{"type":"stop_if_idle"}"#) {
Ok(reply)
if reply.get("type").and_then(serde_json::Value::as_str) == Some("busy") =>
{
std::thread::sleep(Duration::from_millis(50));
}
_ => return,
}
}
}
}
struct Session {
directory: PathBuf,
supervisor: Child,
}
impl Session {
fn stop(&mut self) -> eyre::Result<()> {
let _ = run(&self.directory, &["ctl", "stop"]);
let deadline = Instant::now() + Duration::from_secs(20);
while self.supervisor.try_wait()?.is_none() {
eyre::ensure!(Instant::now() < deadline, "the session never stopped");
std::thread::sleep(Duration::from_millis(50));
}
Ok(())
}
}
impl Drop for Session {
fn drop(&mut self) {
if self.stop().is_err() {
let _ = self.supervisor.kill();
let _ = self.supervisor.wait();
}
}
}
#[test]
fn a_session_keeps_all_its_state_in_the_state_directory() -> eyre::Result<()> {
let project = tempfile::tempdir()?;
let directory = project.path();
let state = common::state_directory(directory);
let proxy_port = free_port()?;
std::fs::write(directory.join("micromux.yaml"), config(proxy_port))?;
let config_path = directory.join("micromux.yaml").canonicalize()?;
let key = micromux::session::config_path_key(&config_path);
let runtime_dir = micromux_control::runtime_dir_in(&state);
let coordination_dir = micromux_control::coordination_dir_in(&state);
let _proxy = StopProxy(coordination_dir.join("proxy.sock"));
let supervisor = common::micromux(directory)
.arg("serve")
.current_dir(directory)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0)
.spawn()?;
let mut session = Session {
directory: directory.to_path_buf(),
supervisor,
};
let deadline = Instant::now() + Duration::from_secs(30);
while !run(directory, &["ctl", "ls"])?.status.success() {
eyre::ensure!(
Instant::now() < deadline,
"the session never answered `ctl ls`"
);
std::thread::sleep(Duration::from_millis(50));
}
let output = run(directory, &["env", "ISOLATED_PORT", "MICROMUX_PROXY_PORT"])?;
eyre::ensure!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let printed = String::from_utf8(output.stdout)?;
let variable = |name: &str| {
printed
.lines()
.find_map(|line| line.strip_prefix(&format!("{name}=")))
.ok_or_else(|| eyre::eyre!("`env` printed no {name}: {printed}"))
};
let port = variable("ISOLATED_PORT")?;
let session_proxy_port = variable("MICROMUX_PROXY_PORT")?;
let record: serde_json::Value = serde_json::from_slice(&std::fs::read(
state.join("ports").join(format!("{key}.json")),
)?)?;
assert_eq!(record["ports"]["ISOLATED_PORT"].to_string(), port);
assert_eq!(
std::fs::read_to_string(state.join("ports").join("registry.lock"))?,
""
);
assert!(runtime_dir.join(format!("{key}.sock")).exists());
assert!(
coordination_dir
.join(format!("{key}.session.lock"))
.exists()
);
let spool_prefix = format!("{}-", session.supervisor.id());
let spools = std::fs::read_dir(state.join("cache").join("session-run-logs"))?
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with(&spool_prefix)
})
.count();
assert_eq!(spools, 1);
assert_eq!(session_proxy_port, proxy_port.to_string());
let info = ask_proxy(&coordination_dir.join("proxy.sock"), r#"{"type":"hello"}"#)?;
assert_eq!(info["port"], proxy_port);
assert_eq!(
info["sessions"][0]["config_path"],
config_path.display().to_string()
);
session.stop()?;
let cleanup = micromux::cleanup_directory_in(&state, &config_path);
let cleanup_log = std::fs::read_to_string(cleanup.join("svc.log"))?;
assert!(cleanup_log.contains("cleaned"), "{cleanup_log}");
let logs = std::fs::read_dir(state.join("cache"))?
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with("micromux.log")
})
.count();
assert!(logs > 0, "the session wrote no log in the state directory");
Ok(())
}
#[test]
fn a_relative_state_directory_is_refused() -> eyre::Result<()> {
let project = tempfile::tempdir()?;
std::fs::write(
project.path().join("micromux.yaml"),
"version: 1\nservices: {}\n",
)?;
let output = common::micromux(project.path())
.env(micromux::STATE_DIR_VARIABLE, "state")
.args(["ctl", "ls"])
.current_dir(project.path())
.stdin(Stdio::null())
.output()?;
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!output.status.success());
assert!(
stderr.contains("MICROMUX_STATE_DIR must be an absolute path"),
"{stderr}"
);
assert!(!project.path().join("state").exists());
Ok(())
}