use super::{node::NodeInfo, objects::CarrierPayload};
use anyhow::{anyhow, Result};
use nanoid::nanoid;
use serde::Serialize;
use std::{
collections::BTreeMap,
io::{BufRead, BufReader, Read},
path::{Path, PathBuf},
process::{Child, ChildStdin, Command, Stdio},
thread,
time::{Duration, Instant},
};
use tracing::warn;
const HARNESS: &str = include_str!("harness.mjs");
const HARNESS_FILE_NAME: &str = "harness.mjs";
const HEALTH_TIMEOUT: Duration = Duration::from_secs(10);
const HEALTH_POLL: Duration = Duration::from_millis(20);
#[derive(Debug, Clone, Serialize)]
struct State<'a> {
carriers: &'a BTreeMap<String, PathBuf>,
#[serde(flatten)]
payload: &'a CarrierPayload,
}
pub(crate) fn harness_dir(site_root: &Path) -> PathBuf {
if let Some(override_dir) = std::env::var_os("ARCHIVAL_CARRIERS_DIR") {
return PathBuf::from(override_dir);
}
let key = seahash::hash(
std::fs::canonicalize(site_root)
.unwrap_or_else(|_| site_root.to_path_buf())
.to_string_lossy()
.as_bytes(),
);
std::env::temp_dir()
.join("archival-carriers")
.join(format!("{:016x}", key))
}
pub(crate) fn write_harness(dir: &Path) -> Result<PathBuf> {
std::fs::create_dir_all(dir)?;
let path = dir.join(HARNESS_FILE_NAME);
std::fs::write(&path, HARNESS)?;
Ok(path)
}
pub(crate) struct Sidecar {
child: Child,
_stdin: ChildStdin,
port: u16,
token: String,
client: reqwest::blocking::Client,
}
impl Sidecar {
pub fn spawn(
node: &NodeInfo,
harness: &Path,
extra_args: &[String],
port: Option<u16>,
) -> Result<Self> {
let token = nanoid!();
let mut args = node.strip_types_flags().unwrap_or_default();
args.extend(extra_args.iter().cloned());
args.push(harness.to_string_lossy().into_owned());
let mut child = Command::new("node")
.args(&args)
.env("ARCHIVAL_CARRIER_TOKEN", &token)
.env("ARCHIVAL_CARRIER_PORT", port.unwrap_or(0).to_string())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| anyhow!("couldn't start the carrier sidecar: {}", e))?;
let stdin = child.stdin.take().expect("stdin is piped");
let stdout = child.stdout.take().expect("stdout is piped");
let stderr = child.stderr.take().expect("stderr is piped");
pump(stderr);
let port = read_port(stdout)?;
Ok(Self {
child,
_stdin: stdin,
port,
token,
client: reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(5))
.no_proxy()
.build()?,
})
}
pub fn port(&self) -> u16 {
self.port
}
pub fn wait_until_healthy(&self) -> Result<()> {
let deadline = Instant::now() + HEALTH_TIMEOUT;
let mut last = None;
while Instant::now() < deadline {
match self.control("health").send() {
Ok(response) if response.status().is_success() => return Ok(()),
Ok(response) => last = Some(response.status().to_string()),
Err(e) => last = Some(e.to_string()),
}
thread::sleep(HEALTH_POLL);
}
Err(anyhow!(
"the carrier sidecar never became healthy{}",
last.map(|l| format!(": {}", l)).unwrap_or_default()
))
}
pub fn push_state(
&self,
carriers: &BTreeMap<String, PathBuf>,
payload: &CarrierPayload,
) -> Result<()> {
let response = self
.control("state")
.json(&State { carriers, payload })
.send()?;
if !response.status().is_success() {
return Err(anyhow!(
"the carrier sidecar rejected the site's objects: {}",
response.status()
));
}
Ok(())
}
fn control(&self, route: &str) -> reqwest::blocking::RequestBuilder {
self.client
.post(format!(
"http://127.0.0.1:{}/__control/{}",
self.port, route
))
.header("x-archival-token", &self.token)
}
pub fn shutdown(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Drop for Sidecar {
fn drop(&mut self) {
self.shutdown();
}
}
fn read_port(stdout: impl Read + Send + 'static) -> Result<u16> {
let mut reader = BufReader::new(stdout);
let mut line = String::new();
reader.read_line(&mut line)?;
let port = serde_json::from_str::<serde_json::Value>(&line)
.ok()
.and_then(|v| v["archivalCarrier"]["port"].as_u64())
.ok_or_else(|| {
anyhow!(
"the carrier sidecar didn't report a port (it said: {})",
line.trim()
)
})?;
pump(reader);
u16::try_from(port).map_err(|_| anyhow!("the carrier sidecar reported port {}", port))
}
fn pump(stream: impl Read + Send + 'static) {
thread::spawn(move || {
for line in BufReader::new(stream).lines() {
match line {
Ok(line) => println!("[carrier] {}", line),
Err(e) => {
warn!("lost the carrier sidecar's output: {}", e);
break;
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn the_harness_is_written_outside_the_site() {
let site = TempDir::new().unwrap();
let dir = harness_dir(site.path());
assert!(
!dir.starts_with(site.path()),
"generated files must not land in the user's repo: {}",
dir.display()
);
}
#[test]
fn the_harness_dir_is_stable_per_site() {
let a = TempDir::new().unwrap();
let b = TempDir::new().unwrap();
assert_eq!(harness_dir(a.path()), harness_dir(a.path()));
assert_ne!(harness_dir(a.path()), harness_dir(b.path()));
}
#[test]
fn the_harness_is_written_verbatim() {
let dir = TempDir::new().unwrap();
let path = write_harness(dir.path()).unwrap();
assert_eq!(std::fs::read_to_string(path).unwrap(), HARNESS);
}
}