1use std::collections::BTreeMap;
47use std::net::Ipv4Addr;
48use std::path::{Path, PathBuf};
49use std::sync::Arc;
50use std::time::{Duration, Instant};
51
52use anyhow::{Context, Result};
53use kamaji::native::NativeRuntime;
54use kamaji::{Kamaji, MeshAssignment, MeshIdent};
55use tracing::{info, warn};
56use workload_spec::MeshPort;
57
58use super::native_support::{native_spec, sanitize_ident};
59use crate::capability::Capability;
60use crate::config::{MirrorConfig, Provider, ServiceWithMirrors};
61
62pub const S3_DRIVER_IDENT: &str = "yah-s3-fs";
67
68pub const S3_FS_BIN_ENV: &str = "YAH_S3_FS_BIN";
71
72pub const PORT_NAME_S3: &str = "s3";
75
76const DEV_ENV: &str = "dev";
79
80pub const DEV_ACCESS_KEY: &str = "yahdev";
90
91pub const DEV_SECRET_KEY: &str = "yahdev-local-only";
94
95#[derive(Debug, Clone, Default)]
97pub struct S3DriverOptions {
98 pub binary: Option<PathBuf>,
101 pub ready_timeout: Option<Duration>,
108}
109
110impl S3DriverOptions {
111 fn resolved_binary(&self) -> PathBuf {
112 if let Some(ref p) = self.binary {
113 return p.clone();
114 }
115 if let Some(p) = std::env::var_os(S3_FS_BIN_ENV) {
116 return PathBuf::from(p);
117 }
118 PathBuf::from("yah-s3-fs")
119 }
120
121 fn ready_timeout(&self) -> Duration {
122 self.ready_timeout.unwrap_or(Duration::from_secs(30))
123 }
124}
125
126pub struct RunningS3Driver {
128 pub port: u16,
130 pub endpoint: String,
133 pub buckets: Vec<String>,
135 runtime: Arc<NativeRuntime>,
136 ident: MeshIdent,
137}
138
139impl RunningS3Driver {
140 pub async fn teardown(&self) {
143 self.runtime.teardown_workload(&self.ident).await.ok();
144 }
145}
146
147pub fn camp_needs_s3_driver(services: &BTreeMap<String, ServiceWithMirrors>) -> bool {
153 services
154 .values()
155 .any(|svc| svc.mirrors.contains_key(DEV_ENV))
156}
157
158pub fn declared_s3_buckets(services: &BTreeMap<String, ServiceWithMirrors>) -> Vec<String> {
165 let mut out: Vec<String> = services
166 .iter()
167 .filter(|(_, svc)| svc.mirrors.contains_key(DEV_ENV))
168 .map(|(name, _)| name.clone())
169 .collect();
170 out.sort();
171 out.dedup();
172 out
173}
174
175pub fn binds_local_s3_fs(mirror: &MirrorConfig) -> bool {
181 mirror
182 .driver(Capability::S3)
183 .and_then(|slot| slot.inline_kind())
184 == Some(Provider::LocalS3Fs)
185}
186
187pub fn coords_path(workspace_root: &Path) -> PathBuf {
189 workspace_root.join(".yah/infra/state/dev/s3/coords.json")
190}
191
192pub fn running_endpoint(workspace_root: &Path) -> Option<String> {
204 read_coords(&coords_path(workspace_root)).map(|c| c.endpoint)
205}
206
207pub async fn up_s3_driver(
210 workspace_root: &Path,
211 buckets: Vec<String>,
212 opts: &S3DriverOptions,
213) -> Result<RunningS3Driver> {
214 let binary = opts.resolved_binary();
215 let ident_str = sanitize_ident(S3_DRIVER_IDENT);
216 let ident = MeshIdent(ident_str.clone());
217
218 let mut argv: Vec<String> = vec![
219 binary.display().to_string(),
220 "serve".to_string(),
221 "--workspace".to_string(),
222 workspace_root.display().to_string(),
223 ];
224 for bucket in &buckets {
225 argv.push("--bucket".to_string());
226 argv.push(bucket.clone());
227 }
228
229 let coords = coords_path(workspace_root);
233 let _ = std::fs::remove_file(&coords);
234
235 let mut spec = native_spec(&ident_str, argv, Vec::new());
236 spec.expose.mesh.ports = vec![MeshPort::named(PORT_NAME_S3)];
241
242 let state_dir = workspace_root.join(".yah/jit/native");
243 let runtime = Arc::new(NativeRuntime::new(&state_dir));
244 let mesh = MeshAssignment::inlined(Ipv4Addr::LOCALHOST);
245
246 info!(
247 binary = %binary.display(),
248 buckets = buckets.len(),
249 ident = %ident_str,
250 "spawning yah-s3-fs (kamaji native backend)",
251 );
252
253 runtime
254 .deploy_workload(&spec, &mesh)
255 .await
256 .with_context(|| {
257 format!(
258 "deploying the dev-tier s3 driver via kamaji — install it with \
259 `cargo install --path crates/yah/s3-fs` or point {S3_FS_BIN_ENV} \
260 at the binary ({})",
261 binary.display(),
262 )
263 })?;
264
265 let timeout = opts.ready_timeout();
266 let Some(ready) = wait_for_coords(&coords, timeout).await else {
267 warn!(timeout = ?timeout, "yah-s3-fs did not publish coords; tearing down");
268 runtime.teardown_workload(&ident).await.ok();
269 let (_out, err) = super::native_support::capture_paths(&state_dir, &ident_str);
270 anyhow::bail!(
271 "the dev-tier s3 driver did not become ready within {timeout:?} — \
272 check {} for why",
273 err.display(),
274 );
275 };
276
277 info!(
278 port = ready.port,
279 endpoint = %ready.endpoint,
280 buckets = ready.buckets.len(),
281 "dev-tier s3 driver ready",
282 );
283 Ok(RunningS3Driver {
284 port: ready.port,
285 endpoint: ready.endpoint,
286 buckets: ready.buckets,
287 runtime,
288 ident,
289 })
290}
291
292#[derive(Debug, Clone, PartialEq, Eq)]
297struct ReadyCoords {
298 port: u16,
299 endpoint: String,
300 buckets: Vec<String>,
301}
302
303async fn wait_for_coords(path: &Path, timeout: Duration) -> Option<ReadyCoords> {
306 let deadline = Instant::now() + timeout;
307 loop {
308 if let Some(coords) = read_coords(path) {
309 return Some(coords);
310 }
311 if Instant::now() >= deadline {
312 return None;
313 }
314 tokio::time::sleep(Duration::from_millis(50)).await;
315 }
316}
317
318fn read_coords(path: &Path) -> Option<ReadyCoords> {
321 let bytes = std::fs::read(path).ok()?;
322 let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
323 let port = u16::try_from(v.get("port")?.as_u64()?).ok()?;
324 if port == 0 {
325 return None;
326 }
327 let endpoint = v
328 .get("endpoint")
329 .and_then(|e| e.as_str())
330 .map(str::to_string)
331 .unwrap_or_else(|| format!("http://127.0.0.1:{port}"));
332 let buckets = v
333 .get("buckets")
334 .and_then(|b| b.as_array())
335 .map(|a| {
336 a.iter()
337 .filter_map(|b| b.as_str().map(str::to_string))
338 .collect()
339 })
340 .unwrap_or_default();
341 Some(ReadyCoords {
342 port,
343 endpoint,
344 buckets,
345 })
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use crate::config::ServiceConfig;
352
353 fn service(mirrors: &[(&str, &str)]) -> ServiceWithMirrors {
354 let service: ServiceConfig =
355 toml::from_str("schema_version = 1\nname = \"svc\"\ndomain = \"svc.example\"\n")
356 .expect("parse service");
357 ServiceWithMirrors {
358 service,
359 mirrors: mirrors
360 .iter()
361 .map(|(env, src)| {
362 (
363 (*env).to_string(),
364 toml::from_str::<MirrorConfig>(src).expect("parse mirror"),
365 )
366 })
367 .collect(),
368 component_transform_recipes: BTreeMap::new(),
369 passway_machines: BTreeMap::new(),
370 }
371 }
372
373 const PLAIN: &str = r#"
376schema_version = 1
377shape = "local"
378[providers.static]
379kind = "miniflare-native"
380port = 4321
381"#;
382
383 const BINDS_S3: &str = r#"
384schema_version = 1
385shape = "local"
386[drivers.s3]
387kind = "local-s3-fs"
388"#;
389
390 const CLOUD: &str = r#"
391schema_version = 1
392shape = "single-machine"
393"#;
394
395 fn services(entries: Vec<(&str, ServiceWithMirrors)>) -> BTreeMap<String, ServiceWithMirrors> {
396 entries
397 .into_iter()
398 .map(|(n, s)| (n.to_string(), s))
399 .collect()
400 }
401
402 #[test]
406 fn a_dev_mirror_activates_the_driver_without_declaring_anything() {
407 assert!(camp_needs_s3_driver(&services(vec![(
408 "marketing",
409 service(&[("dev", PLAIN)])
410 )])));
411 }
412
413 #[test]
414 fn an_explicit_binding_also_activates_it() {
415 assert!(camp_needs_s3_driver(&services(vec![(
416 "assets",
417 service(&[("dev", BINDS_S3)])
418 )])));
419 }
420
421 #[test]
423 fn a_camp_with_no_dev_mirror_spawns_nothing() {
424 assert!(!camp_needs_s3_driver(&services(vec![(
425 "prod-only",
426 service(&[("prod", CLOUD)])
427 )])));
428 assert!(!camp_needs_s3_driver(&BTreeMap::new()));
429 }
430
431 #[test]
432 fn buckets_are_the_dev_services_sorted_and_nothing_else() {
433 let svcs = services(vec![
434 ("zeta", service(&[("dev", PLAIN)])),
435 ("alpha", service(&[("dev", BINDS_S3)])),
436 ("prod-only", service(&[("prod", CLOUD)])),
437 ]);
438 assert_eq!(declared_s3_buckets(&svcs), ["alpha", "zeta"]);
439 }
440
441 #[test]
445 fn the_bucket_name_is_the_service_name_verbatim() {
446 let svcs = services(vec![("yah-marketing", service(&[("dev", PLAIN)]))]);
447 assert_eq!(declared_s3_buckets(&svcs), ["yah-marketing"]);
448 }
449
450 #[test]
451 fn the_explicit_binding_is_still_readable_for_tier_aware_callers() {
452 let with = service(&[("dev", BINDS_S3)]);
453 let without = service(&[("dev", PLAIN)]);
454 assert!(binds_local_s3_fs(with.mirrors.get("dev").unwrap()));
455 assert!(!binds_local_s3_fs(without.mirrors.get("dev").unwrap()));
456 }
457
458 #[test]
459 fn the_spec_declares_the_s3_listener_by_name() {
460 let mut spec = native_spec("yah-s3-fs", vec!["yah-s3-fs".to_string()], Vec::new());
461 spec.expose.mesh.ports = vec![MeshPort::named(PORT_NAME_S3)];
462 assert!(spec.expose.mesh.names().contains(&"s3"));
463 assert!(spec.expose.mesh.ports.iter().all(|p| p.number.is_none()));
464 workload_spec::validate::shape(&spec).expect("spec must validate");
465 }
466
467 #[tokio::test]
468 async fn coords_are_incomplete_until_a_real_port_is_published() {
469 let tmp = tempfile::tempdir().unwrap();
470 let path = tmp.path().join("coords.json");
471 let brief = Duration::from_millis(150);
472
473 assert_eq!(wait_for_coords(&path, brief).await, None);
474 std::fs::write(&path, br#"{"port":0}"#).unwrap();
476 assert_eq!(wait_for_coords(&path, brief).await, None);
477 std::fs::write(&path, br#"{"port":51"#).unwrap();
479 assert_eq!(wait_for_coords(&path, brief).await, None);
480 }
481
482 #[tokio::test]
483 async fn a_complete_coords_file_yields_the_endpoint_and_buckets() {
484 let tmp = tempfile::tempdir().unwrap();
485 let path = tmp.path().join("coords.json");
486 std::fs::write(
487 &path,
488 br#"{"port":51234,"endpoint":"http://127.0.0.1:51234",
489 "data_dir":"/tmp/x","buckets":["alpha","zeta"]}"#,
490 )
491 .unwrap();
492 assert_eq!(
493 wait_for_coords(&path, Duration::from_secs(1)).await,
494 Some(ReadyCoords {
495 port: 51234,
496 endpoint: "http://127.0.0.1:51234".to_string(),
497 buckets: vec!["alpha".to_string(), "zeta".to_string()],
498 })
499 );
500 }
501
502 #[test]
505 fn a_missing_endpoint_is_derived_from_the_port() {
506 let tmp = tempfile::tempdir().unwrap();
507 let path = tmp.path().join("coords.json");
508 std::fs::write(&path, br#"{"port":51234}"#).unwrap();
509 assert_eq!(
510 read_coords(&path).map(|c| c.endpoint),
511 Some("http://127.0.0.1:51234".to_string())
512 );
513 }
514}