use std::collections::BTreeMap;
use std::net::Ipv4Addr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use kamaji::native::NativeRuntime;
use kamaji::{Kamaji, MeshAssignment, MeshIdent};
use tracing::{info, warn};
use workload_spec::MeshPort;
use super::native_support::{native_spec, sanitize_ident};
use crate::capability::Capability;
use crate::config::{MirrorConfig, Provider, ServiceWithMirrors};
pub const S3_DRIVER_IDENT: &str = "yah-s3-fs";
pub const S3_FS_BIN_ENV: &str = "YAH_S3_FS_BIN";
pub const PORT_NAME_S3: &str = "s3";
const DEV_ENV: &str = "dev";
pub const DEV_ACCESS_KEY: &str = "yahdev";
pub const DEV_SECRET_KEY: &str = "yahdev-local-only";
#[derive(Debug, Clone, Default)]
pub struct S3DriverOptions {
pub binary: Option<PathBuf>,
pub ready_timeout: Option<Duration>,
}
impl S3DriverOptions {
fn resolved_binary(&self) -> PathBuf {
if let Some(ref p) = self.binary {
return p.clone();
}
if let Some(p) = std::env::var_os(S3_FS_BIN_ENV) {
return PathBuf::from(p);
}
PathBuf::from("yah-s3-fs")
}
fn ready_timeout(&self) -> Duration {
self.ready_timeout.unwrap_or(Duration::from_secs(30))
}
}
pub struct RunningS3Driver {
pub port: u16,
pub endpoint: String,
pub buckets: Vec<String>,
runtime: Arc<NativeRuntime>,
ident: MeshIdent,
}
impl RunningS3Driver {
pub async fn teardown(&self) {
self.runtime.teardown_workload(&self.ident).await.ok();
}
}
pub fn camp_needs_s3_driver(services: &BTreeMap<String, ServiceWithMirrors>) -> bool {
services
.values()
.any(|svc| svc.mirrors.contains_key(DEV_ENV))
}
pub fn declared_s3_buckets(services: &BTreeMap<String, ServiceWithMirrors>) -> Vec<String> {
let mut out: Vec<String> = services
.iter()
.filter(|(_, svc)| svc.mirrors.contains_key(DEV_ENV))
.map(|(name, _)| name.clone())
.collect();
out.sort();
out.dedup();
out
}
pub fn binds_local_s3_fs(mirror: &MirrorConfig) -> bool {
mirror
.driver(Capability::S3)
.and_then(|slot| slot.inline_kind())
== Some(Provider::LocalS3Fs)
}
pub fn coords_path(workspace_root: &Path) -> PathBuf {
workspace_root.join(".yah/infra/state/dev/s3/coords.json")
}
pub fn running_endpoint(workspace_root: &Path) -> Option<String> {
read_coords(&coords_path(workspace_root)).map(|c| c.endpoint)
}
pub async fn up_s3_driver(
workspace_root: &Path,
buckets: Vec<String>,
opts: &S3DriverOptions,
) -> Result<RunningS3Driver> {
let binary = opts.resolved_binary();
let ident_str = sanitize_ident(S3_DRIVER_IDENT);
let ident = MeshIdent(ident_str.clone());
let mut argv: Vec<String> = vec![
binary.display().to_string(),
"serve".to_string(),
"--workspace".to_string(),
workspace_root.display().to_string(),
];
for bucket in &buckets {
argv.push("--bucket".to_string());
argv.push(bucket.clone());
}
let coords = coords_path(workspace_root);
let _ = std::fs::remove_file(&coords);
let mut spec = native_spec(&ident_str, argv, Vec::new());
spec.expose.mesh.ports = vec![MeshPort::named(PORT_NAME_S3)];
let state_dir = workspace_root.join(".yah/jit/native");
let runtime = Arc::new(NativeRuntime::new(&state_dir));
let mesh = MeshAssignment::inlined(Ipv4Addr::LOCALHOST);
info!(
binary = %binary.display(),
buckets = buckets.len(),
ident = %ident_str,
"spawning yah-s3-fs (kamaji native backend)",
);
runtime
.deploy_workload(&spec, &mesh)
.await
.with_context(|| {
format!(
"deploying the dev-tier s3 driver via kamaji — install it with \
`cargo install --path crates/yah/s3-fs` or point {S3_FS_BIN_ENV} \
at the binary ({})",
binary.display(),
)
})?;
let timeout = opts.ready_timeout();
let Some(ready) = wait_for_coords(&coords, timeout).await else {
warn!(timeout = ?timeout, "yah-s3-fs did not publish coords; tearing down");
runtime.teardown_workload(&ident).await.ok();
let (_out, err) = super::native_support::capture_paths(&state_dir, &ident_str);
anyhow::bail!(
"the dev-tier s3 driver did not become ready within {timeout:?} — \
check {} for why",
err.display(),
);
};
info!(
port = ready.port,
endpoint = %ready.endpoint,
buckets = ready.buckets.len(),
"dev-tier s3 driver ready",
);
Ok(RunningS3Driver {
port: ready.port,
endpoint: ready.endpoint,
buckets: ready.buckets,
runtime,
ident,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ReadyCoords {
port: u16,
endpoint: String,
buckets: Vec<String>,
}
async fn wait_for_coords(path: &Path, timeout: Duration) -> Option<ReadyCoords> {
let deadline = Instant::now() + timeout;
loop {
if let Some(coords) = read_coords(path) {
return Some(coords);
}
if Instant::now() >= deadline {
return None;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
fn read_coords(path: &Path) -> Option<ReadyCoords> {
let bytes = std::fs::read(path).ok()?;
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
let port = u16::try_from(v.get("port")?.as_u64()?).ok()?;
if port == 0 {
return None;
}
let endpoint = v
.get("endpoint")
.and_then(|e| e.as_str())
.map(str::to_string)
.unwrap_or_else(|| format!("http://127.0.0.1:{port}"));
let buckets = v
.get("buckets")
.and_then(|b| b.as_array())
.map(|a| {
a.iter()
.filter_map(|b| b.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
Some(ReadyCoords {
port,
endpoint,
buckets,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ServiceConfig;
fn service(mirrors: &[(&str, &str)]) -> ServiceWithMirrors {
let service: ServiceConfig =
toml::from_str("schema_version = 1\nname = \"svc\"\n[address]\nkind = \"front-door\"\ndomain = \"svc.example\"\n")
.expect("parse service");
ServiceWithMirrors {
service,
mirrors: mirrors
.iter()
.map(|(env, src)| {
(
(*env).to_string(),
toml::from_str::<MirrorConfig>(src).expect("parse mirror"),
)
})
.collect(),
component_transform_recipes: BTreeMap::new(),
passway_machines: BTreeMap::new(),
}
}
const PLAIN: &str = r#"
schema_version = 1
shape = "local"
[providers.static]
kind = "miniflare-native"
port = 4321
"#;
const BINDS_S3: &str = r#"
schema_version = 1
shape = "local"
[drivers.s3]
kind = "local-s3-fs"
"#;
const CLOUD: &str = r#"
schema_version = 1
shape = "single-machine"
"#;
fn services(entries: Vec<(&str, ServiceWithMirrors)>) -> BTreeMap<String, ServiceWithMirrors> {
entries
.into_iter()
.map(|(n, s)| (n.to_string(), s))
.collect()
}
#[test]
fn a_dev_mirror_activates_the_driver_without_declaring_anything() {
assert!(camp_needs_s3_driver(&services(vec![(
"marketing",
service(&[("dev", PLAIN)])
)])));
}
#[test]
fn an_explicit_binding_also_activates_it() {
assert!(camp_needs_s3_driver(&services(vec![(
"assets",
service(&[("dev", BINDS_S3)])
)])));
}
#[test]
fn a_camp_with_no_dev_mirror_spawns_nothing() {
assert!(!camp_needs_s3_driver(&services(vec![(
"prod-only",
service(&[("prod", CLOUD)])
)])));
assert!(!camp_needs_s3_driver(&BTreeMap::new()));
}
#[test]
fn buckets_are_the_dev_services_sorted_and_nothing_else() {
let svcs = services(vec![
("zeta", service(&[("dev", PLAIN)])),
("alpha", service(&[("dev", BINDS_S3)])),
("prod-only", service(&[("prod", CLOUD)])),
]);
assert_eq!(declared_s3_buckets(&svcs), ["alpha", "zeta"]);
}
#[test]
fn the_bucket_name_is_the_service_name_verbatim() {
let svcs = services(vec![("yah-marketing", service(&[("dev", PLAIN)]))]);
assert_eq!(declared_s3_buckets(&svcs), ["yah-marketing"]);
}
#[test]
fn the_explicit_binding_is_still_readable_for_tier_aware_callers() {
let with = service(&[("dev", BINDS_S3)]);
let without = service(&[("dev", PLAIN)]);
assert!(binds_local_s3_fs(with.mirrors.get("dev").unwrap()));
assert!(!binds_local_s3_fs(without.mirrors.get("dev").unwrap()));
}
#[test]
fn the_spec_declares_the_s3_listener_by_name() {
let mut spec = native_spec("yah-s3-fs", vec!["yah-s3-fs".to_string()], Vec::new());
spec.expose.mesh.ports = vec![MeshPort::named(PORT_NAME_S3)];
assert!(spec.expose.mesh.names().contains(&"s3"));
assert!(spec.expose.mesh.ports.iter().all(|p| p.number.is_none()));
workload_spec::validate::shape(&spec).expect("spec must validate");
}
#[tokio::test]
async fn coords_are_incomplete_until_a_real_port_is_published() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("coords.json");
let brief = Duration::from_millis(150);
assert_eq!(wait_for_coords(&path, brief).await, None);
std::fs::write(&path, br#"{"port":0}"#).unwrap();
assert_eq!(wait_for_coords(&path, brief).await, None);
std::fs::write(&path, br#"{"port":51"#).unwrap();
assert_eq!(wait_for_coords(&path, brief).await, None);
}
#[tokio::test]
async fn a_complete_coords_file_yields_the_endpoint_and_buckets() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("coords.json");
std::fs::write(
&path,
br#"{"port":51234,"endpoint":"http://127.0.0.1:51234",
"data_dir":"/tmp/x","buckets":["alpha","zeta"]}"#,
)
.unwrap();
assert_eq!(
wait_for_coords(&path, Duration::from_secs(1)).await,
Some(ReadyCoords {
port: 51234,
endpoint: "http://127.0.0.1:51234".to_string(),
buckets: vec!["alpha".to_string(), "zeta".to_string()],
})
);
}
#[test]
fn a_missing_endpoint_is_derived_from_the_port() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("coords.json");
std::fs::write(&path, br#"{"port":51234}"#).unwrap();
assert_eq!(
read_coords(&path).map(|c| c.endpoint),
Some("http://127.0.0.1:51234".to_string())
);
}
}