alopex-server 0.5.0

Server component for Alopex DB
Documentation
use std::collections::HashSet;
use std::io::Read;
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

use hyper::{Body, Client, Method, Request, StatusCode};
use tempfile::tempdir;
use tokio::time::sleep;

fn reserve_port() -> u16 {
    let listener = TcpListener::bind("127.0.0.1:0").expect("bind port");
    listener.local_addr().expect("local addr").port()
}

fn reserve_unique_port(used: &mut HashSet<u16>) -> u16 {
    loop {
        let port = reserve_port();
        if used.insert(port) {
            return port;
        }
    }
}

fn toml_path(path: &Path) -> String {
    path.to_string_lossy().replace('\\', "\\\\")
}

fn write_config(dir: &Path, http_port: u16, admin_port: u16, grpc_port: u16) -> PathBuf {
    let config_path = dir.join("alopex.toml");
    let contents = format!(
        "\
http_bind = \"127.0.0.1:{http_port}\"
grpc_bind = \"127.0.0.1:{grpc_port}\"
admin_bind = \"127.0.0.1:{admin_port}\"
data_dir = \"{data_dir}\"
metrics_enabled = false
tracing_enabled = false
audit_log_enabled = false
",
        data_dir = toml_path(dir),
    );
    std::fs::write(&config_path, contents).expect("write config");
    config_path
}

async fn get_status(
    client: &Client<hyper::client::HttpConnector>,
    url: &str,
) -> Option<StatusCode> {
    let request = Request::builder()
        .method(Method::GET)
        .uri(url)
        .body(Body::empty())
        .ok()?;
    let response = client.request(request).await.ok()?;
    Some(response.status())
}

#[tokio::test]
async fn server_binary_starts_and_serves_health() {
    let temp = tempdir().expect("tempdir");
    let mut used = HashSet::new();
    let http_port = reserve_unique_port(&mut used);
    let admin_port = reserve_unique_port(&mut used);
    let grpc_port = reserve_unique_port(&mut used);
    let config_path = write_config(temp.path(), http_port, admin_port, grpc_port);

    let mut child = Command::new(env!("CARGO_BIN_EXE_alopex-server"))
        .arg("--config")
        .arg(&config_path)
        .stdout(Stdio::null())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn server");

    let client = Client::new();
    let deadline = Instant::now() + Duration::from_secs(10);
    let mut admin_ok = false;
    let mut api_ok = false;

    while Instant::now() < deadline {
        if let Ok(Some(status)) = child.try_wait() {
            let mut stderr_output = String::new();
            if let Some(mut stderr) = child.stderr.take() {
                let _ = stderr.read_to_string(&mut stderr_output);
            }
            panic!("alopex-server exited early ({status}). stderr:\n{stderr_output}");
        }

        if !admin_ok {
            if let Some(status) =
                get_status(&client, &format!("http://127.0.0.1:{admin_port}/healthz")).await
            {
                admin_ok = status == StatusCode::OK;
            }
        }
        if !api_ok {
            if let Some(status) = get_status(
                &client,
                &format!("http://127.0.0.1:{http_port}/api/admin/health"),
            )
            .await
            {
                api_ok = status == StatusCode::OK;
            }
        }
        if admin_ok && api_ok {
            break;
        }
        sleep(Duration::from_millis(100)).await;
    }

    if !(admin_ok && api_ok) {
        let mut stderr_output = String::new();
        if let Some(mut stderr) = child.stderr.take() {
            let _ = stderr.read_to_string(&mut stderr_output);
        }
        let _ = child.kill();
        let _ = child.wait();
        panic!(
            "alopex-server failed health checks (admin_ok={admin_ok}, api_ok={api_ok}). stderr:\n{stderr_output}"
        );
    }

    let _ = child.kill();
    let _ = child.wait();
}