use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use axum::Router;
use tracing::{info, warn};
use crate::DevStore;
pub const DEV_STATE_DIR: &str = ".mesofact-dev";
pub struct DevServer {
s3: DevStore,
state_dir: PathBuf,
}
impl DevServer {
pub async fn start(root: impl AsRef<Path>) -> Result<Self> {
let root = root.as_ref();
let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
let state_dir = root.join(DEV_STATE_DIR);
tokio::fs::create_dir_all(&state_dir)
.await
.with_context(|| format!("creating dev state dir {}", state_dir.display()))?;
let s3 = DevStore::resolve(&state_dir).await?;
info!(
endpoint = %s3.endpoint,
bucket = %s3.bucket,
provenance = ?s3.provenance,
"mesofact-dev: dev object store resolved (stands in for R2)",
);
let discovery = state_dir.join("s3.json");
if let Err(e) = std::fs::write(
&discovery,
serde_json::json!({ "endpoint": s3.endpoint, "bucket": s3.bucket }).to_string(),
) {
warn!(error = %e, path = %discovery.display(), "dev S3: could not write discovery file");
}
Ok(Self { s3, state_dir })
}
pub fn s3(&self) -> &DevStore {
&self.s3
}
pub fn state_dir(&self) -> &Path {
&self.state_dir
}
pub fn export_env(&self) {
for (key, value) in self.s3.env_vars() {
if let Ok(prior) = std::env::var(&key) {
if prior != value {
warn!(%key, %prior, "mesofact-dev: overriding inherited value with the local store's");
}
}
std::env::set_var(&key, &value);
}
}
pub async fn serve(self, app: Router, addr: SocketAddr) -> Result<()> {
warn!(
addr = %addr,
s3_endpoint = %self.s3.endpoint,
"mesofact-dev: DEV BINARY — dev affordances are linked in; do not ship this target",
);
mesofact::serve_app(app, addr).await
}
}
pub async fn serve_app(app: Router, addr: SocketAddr) -> Result<()> {
let dev = DevServer::start(std::env::current_dir().context("reading current directory")?)
.await?;
dev.export_env();
dev.serve(app, addr).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::ENV_LOCK;
use tempfile::tempdir;
const REQUIRED_ENV: [&str; 4] = [
"S3_ENDPOINT",
"S3_BUCKET",
"S3_ACCESS_KEY_ID",
"S3_SECRET_ACCESS_KEY",
];
fn set_camp_env() {
std::env::set_var("S3_ENDPOINT", "http://127.0.0.1:54321");
std::env::set_var("S3_BUCKET", "dev");
std::env::set_var("S3_ACCESS_KEY_ID", "ak");
std::env::set_var("S3_SECRET_ACCESS_KEY", "sk");
}
fn clear_camp_env() {
for var in REQUIRED_ENV {
std::env::remove_var(var);
}
for var in ["R2_ENDPOINT", "R2_BUCKET", "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY"] {
std::env::remove_var(var);
}
}
#[tokio::test]
async fn start_reads_camp_coordinates_and_writes_discovery_file() {
let _guard = ENV_LOCK.lock().await;
clear_camp_env();
set_camp_env();
let root = tempdir().unwrap();
let dev = DevServer::start(root.path()).await.unwrap();
assert_eq!(dev.state_dir(), dev.state_dir().canonicalize().unwrap());
assert!(dev.state_dir().ends_with(DEV_STATE_DIR));
assert_eq!(dev.s3().endpoint, "http://127.0.0.1:54321");
assert_eq!(dev.s3().bucket, "dev");
let discovery: serde_json::Value = serde_json::from_slice(
&std::fs::read(dev.state_dir().join("s3.json")).expect("discovery file written"),
)
.unwrap();
assert_eq!(discovery["endpoint"], dev.s3().endpoint);
assert_eq!(discovery["bucket"], dev.s3().bucket);
clear_camp_env();
}
#[tokio::test]
async fn start_comes_up_on_an_embedded_store_when_nothing_is_injected() {
let _guard = ENV_LOCK.lock().await;
clear_camp_env();
let root = tempdir().unwrap();
let dev = DevServer::start(root.path()).await.expect("no camp is fine");
assert_eq!(dev.s3().provenance, crate::StoreProvenance::Embedded);
assert_eq!(dev.s3().bucket, crate::EMBEDDED_BUCKET);
assert!(dev.state_dir().join("s3").join(crate::EMBEDDED_BUCKET).is_dir());
let discovery: serde_json::Value = serde_json::from_slice(
&std::fs::read(dev.state_dir().join("s3.json")).expect("discovery file written"),
)
.unwrap();
assert_eq!(discovery["endpoint"], dev.s3().endpoint);
let addr = dev.s3().endpoint.trim_start_matches("http://");
tokio::net::TcpStream::connect(addr)
.await
.expect("the advertised endpoint accepts");
clear_camp_env();
}
#[tokio::test]
async fn start_errors_on_a_half_set_injection() {
let _guard = ENV_LOCK.lock().await;
clear_camp_env();
std::env::set_var("S3_ENDPOINT", "http://127.0.0.1:54321");
let root = tempdir().unwrap();
let err = match DevServer::start(root.path()).await {
Ok(_) => panic!("expected an error on a half-set injection"),
Err(e) => e.to_string(),
};
assert!(err.contains("S3_BUCKET"), "{err}");
assert!(err.contains("half-set"), "{err}");
clear_camp_env();
}
#[tokio::test]
async fn export_env_publishes_r2_coordinates_from_camp_env() {
let _guard = ENV_LOCK.lock().await;
clear_camp_env();
set_camp_env();
let root = tempdir().unwrap();
let dev = DevServer::start(root.path()).await.unwrap();
dev.export_env();
assert_eq!(std::env::var("R2_ENDPOINT").unwrap(), "http://127.0.0.1:54321");
assert_eq!(std::env::var("R2_BUCKET").unwrap(), "dev");
assert_eq!(std::env::var("R2_ACCESS_KEY_ID").unwrap(), "ak");
assert_eq!(std::env::var("R2_SECRET_ACCESS_KEY").unwrap(), "sk");
clear_camp_env();
}
}