1use std::net::SocketAddr;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use std::time::Duration;
10
11use anyhow::Context;
12use clap::Parser;
13use prospero_core::bus::{EventBus, InProcessBus};
14use prospero_core::config_store::{ConfigStore, SqliteConfigStore};
15use prospero_core::discovery::{DiscoveryEnv, EnsureConfig};
16use prospero_core::fleet::{FleetConfig, FleetManager};
17use prospero_core::fleet_provider::LocalFleet;
18use prospero_core::ownership::{Ownership, SelfOwnsAll};
19use prospero_core::sqlite_store::SqliteStore;
20use prospero_core::store::Store;
21use prospero_core::{DistributedBus, LeasedOwnership, PostgresConfigStore, PostgresStore};
22use tokio::task::JoinHandle;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
31#[value(rename_all = "lower")]
32enum FleetBackend {
33 Local,
34 K8s,
35}
36
37#[derive(Debug, Parser)]
39#[command(name = "prosperod", version, about)]
40struct Args {
41 #[arg(long, env = "PROSPERO_ADDR", default_value = "127.0.0.1:7878")]
43 addr: SocketAddr,
44
45 #[arg(long, env = "PROSPERO_FLEET", default_value = "local")]
49 fleet_backend: FleetBackend,
50
51 #[arg(long, env = "PROSPERO_K8S_CALIBAND_CA_FILE")]
54 k8s_caliband_ca_file: Option<PathBuf>,
55
56 #[arg(long, env = "PROSPERO_K8S_CALIBAND_TOKEN_FILE")]
59 k8s_caliband_token_file: Option<PathBuf>,
60
61 #[arg(
63 long,
64 env = "PROSPERO_K8S_CALIBAND_SERVER_NAME",
65 default_value = "caliband"
66 )]
67 k8s_caliband_server_name: String,
68
69 #[arg(long, env = "KUBECONFIG")]
72 kubeconfig: Option<PathBuf>,
73
74 #[arg(long, env = "PROSPERO_DATA_DIR")]
76 data_dir: Option<PathBuf>,
77
78 #[arg(long, env = "PROSPERO_HOST", default_value = "local")]
80 host: String,
81
82 #[arg(long, default_value_t = 2000)]
84 poll_interval_ms: u64,
85
86 #[arg(long)]
88 no_autostart: bool,
89
90 #[arg(long, default_value = "caliband")]
92 caliband_bin: String,
93
94 #[arg(long = "default-env", value_parser = parse_key_val)]
96 default_env: Vec<(String, String)>,
97
98 #[arg(long, default_value_t = 0)]
100 retention_days: u64,
101
102 #[arg(long, env = "PROSPERO_DATABASE_URL")]
106 database_url: Option<String>,
107
108 #[arg(long, env = "PROSPERO_REPLICA_ID")]
111 replica_id: Option<String>,
112
113 #[arg(long, default_value_t = 30.0)]
116 lease_ttl_secs: f64,
117
118 #[arg(long)]
121 heartbeat_interval_ms: Option<u64>,
122}
123
124fn parse_key_val(s: &str) -> Result<(String, String), String> {
126 match s.split_once('=') {
127 Some((k, v)) if !k.is_empty() => Ok((k.to_string(), v.to_string())),
128 _ => Err(format!("expected KEY=VALUE, got '{s}'")),
129 }
130}
131
132#[cfg_attr(not(feature = "k8s"), allow(dead_code))]
146fn require_token_tls(token_present: bool, tls_present: bool) -> anyhow::Result<()> {
147 if token_present && !tls_present {
148 anyhow::bail!(
149 "a session-plane token is configured (--k8s-caliband-token-file / \
150 PROSPERO_K8S_CALIBAND_TOKEN_FILE) but TLS is not (--k8s-caliband-ca-file / \
151 PROSPERO_K8S_CALIBAND_CA_FILE); the token would be sent in cleartext. \
152 Configure the CA file to enable TLS, or unset the token."
153 );
154 }
155 Ok(())
156}
157
158#[cfg_attr(not(feature = "k8s"), allow(dead_code))]
159fn read_token_file(path: &Path) -> anyhow::Result<String> {
160 let raw = std::fs::read_to_string(path)
161 .with_context(|| format!("reading session-plane token file {}", path.display()))?;
162 Ok(raw.trim_end().to_string())
163}
164
165#[cfg(feature = "k8s")]
171fn load_session_plane_tls(
172 ca_file: Option<&Path>,
173 server_name: &str,
174) -> anyhow::Result<Option<prospero_core::caliband::transport::TlsClient>> {
175 let Some(ca_file) = ca_file else {
176 return Ok(None);
177 };
178 let ca_pem = std::fs::read(ca_file)
179 .with_context(|| format!("reading session-plane CA file {}", ca_file.display()))?;
180 let client = prospero_core::caliband::transport::tls_client_from_pem(&ca_pem, server_name)
181 .with_context(|| format!("building session-plane TLS from {}", ca_file.display()))?;
182 Ok(Some(client))
183}
184
185#[cfg(feature = "k8s")]
188async fn build_kube_client(kubeconfig: Option<&Path>) -> anyhow::Result<kube::Client> {
189 match kubeconfig {
190 Some(path) => {
191 let kc = kube::config::Kubeconfig::read_from(path)
192 .with_context(|| format!("reading kubeconfig {}", path.display()))?;
193 let cfg = kube::Config::from_custom_kubeconfig(
194 kc,
195 &kube::config::KubeConfigOptions::default(),
196 )
197 .await
198 .with_context(|| format!("loading kubeconfig {}", path.display()))?;
199 kube::Client::try_from(cfg).with_context(|| "building kube client from kubeconfig")
200 }
201 None => kube::Client::try_default()
202 .await
203 .with_context(|| "connecting to the Kubernetes API server"),
204 }
205}
206
207fn resolve_replica_id(explicit: Option<&str>) -> String {
210 explicit
211 .map(str::to_string)
212 .or_else(|| std::env::var("HOSTNAME").ok().filter(|h| !h.is_empty()))
213 .unwrap_or_else(|| "prosperod-local".to_string())
214}
215
216fn heartbeat_interval(explicit_ms: Option<u64>, lease_ttl_secs: f64) -> Duration {
219 match explicit_ms {
220 Some(ms) => Duration::from_millis(ms.max(1)),
221 None => {
222 let secs = (lease_ttl_secs / 3.0).max(1.0);
223 Duration::from_secs_f64(secs)
224 }
225 }
226}
227
228fn default_data_dir() -> PathBuf {
230 if let Some(xdg) = std::env::var_os("XDG_DATA_HOME") {
231 PathBuf::from(xdg).join("prospero")
232 } else if let Some(home) = std::env::var_os("HOME") {
233 PathBuf::from(home).join(".local/share/prospero")
234 } else {
235 PathBuf::from(".prospero")
236 }
237}
238
239#[tokio::main]
240async fn main() -> anyhow::Result<()> {
241 tracing_subscriber::fmt()
242 .with_env_filter(
243 tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
244 )
245 .init();
246
247 let args = Args::parse();
248
249 #[cfg(not(feature = "k8s"))]
257 if args.fleet_backend == FleetBackend::K8s {
258 anyhow::bail!(
259 "PROSPERO_FLEET=k8s requires a prosperod built with the k8s feature \
260 (`cargo build -p prospero-daemon --features k8s`)."
261 );
262 }
263
264 let data_dir = args.data_dir.clone().unwrap_or_else(default_data_dir);
265 std::fs::create_dir_all(&data_dir)
266 .with_context(|| format!("creating data dir {}", data_dir.display()))?;
267
268 let mut config = FleetConfig::new(args.host.clone(), data_dir.clone());
269 config.poll_interval = Duration::from_millis(args.poll_interval_ms);
270 config.discovery_env = DiscoveryEnv::from_process();
271 config.ensure = EnsureConfig {
272 autostart: !args.no_autostart,
273 caliband_bin: args.caliband_bin.clone(),
274 ..EnsureConfig::default()
275 };
276 config.default_env = args.default_env.iter().cloned().collect();
277
278 let (store, bus): (Arc<dyn Store>, Arc<dyn EventBus>) = if let Some(url) =
284 args.database_url.clone()
285 {
286 let store: Arc<dyn Store> = Arc::new(
287 PostgresStore::connect(&url)
288 .await
289 .with_context(|| "connecting clustered event store")?,
290 );
291 let bus: Arc<dyn EventBus> = Arc::new(
292 DistributedBus::connect(&url, store.clone())
293 .await
294 .with_context(|| "connecting clustered event bus")?,
295 );
296 tracing::info!(target: "prosperod", topology = "clustered", "selected clustered topology");
297 (store, bus)
298 } else {
299 let store: Arc<dyn Store> = Arc::new(
300 SqliteStore::open(&data_dir)
301 .await
302 .with_context(|| "opening event store")?,
303 );
304 let bus: Arc<dyn EventBus> = Arc::new(InProcessBus::new(config.event_buffer));
305 tracing::info!(target: "prosperod", topology = "standalone", "selected standalone topology");
306 (store, bus)
307 };
308
309 #[allow(clippy::type_complexity)]
320 let (fleet, admin, poll_handle, heartbeat_handle, manager_for_shutdown): (
321 Arc<dyn prospero_core::FleetProvider>,
322 Option<Arc<dyn prospero_core::FleetAdmin>>,
323 Option<JoinHandle<()>>,
324 Option<JoinHandle<()>>,
325 Option<FleetManager>,
326 ) = match args.fleet_backend {
327 FleetBackend::Local => {
328 let (config_store, ownership, heartbeat_handle): (
332 Arc<dyn ConfigStore>,
333 Arc<dyn Ownership>,
334 Option<JoinHandle<()>>,
335 ) = if let Some(url) = args.database_url.clone() {
336 let replica_id = resolve_replica_id(args.replica_id.as_deref());
337 let config_store: Arc<dyn ConfigStore> = Arc::new(
338 PostgresConfigStore::connect(&url)
339 .await
340 .with_context(|| "connecting clustered config store")?,
341 );
342 let ownership = Arc::new(
343 LeasedOwnership::connect(&url, replica_id.clone(), args.lease_ttl_secs)
344 .await
345 .with_context(|| "connecting clustered ownership")?,
346 );
347
348 let interval = heartbeat_interval(args.heartbeat_interval_ms, args.lease_ttl_secs);
351 let hb = ownership.clone();
352 let heartbeat_handle = tokio::spawn(async move {
353 let mut tick = tokio::time::interval(interval);
354 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
355 loop {
356 tick.tick().await;
357 hb.heartbeat().await;
358 }
359 });
360 tracing::info!(
361 target: "prosperod", %replica_id,
362 lease_ttl_secs = args.lease_ttl_secs,
363 heartbeat_ms = interval.as_millis() as u64,
364 "clustered ownership + heartbeat active"
365 );
366 (
367 config_store,
368 ownership as Arc<dyn Ownership>,
369 Some(heartbeat_handle),
370 )
371 } else {
372 let config_store: Arc<dyn ConfigStore> = Arc::new(
373 SqliteConfigStore::open(&data_dir)
374 .await
375 .with_context(|| "opening config store")?,
376 );
377 (
378 config_store,
379 Arc::new(SelfOwnsAll) as Arc<dyn Ownership>,
380 None,
381 )
382 };
383
384 let manager = FleetManager::with_seams(
385 config,
386 store.clone(),
387 config_store,
388 bus.clone(),
389 ownership,
390 )
391 .await
392 .with_context(|| "building fleet manager")?;
393
394 let local = LocalFleet::new(manager.clone());
395 let poll_handle = tokio::spawn(manager.clone().run());
396 tracing::info!(target: "prosperod", backend = "local", "serving via LocalFleet");
397 (
398 Arc::new(local.clone()) as Arc<dyn prospero_core::FleetProvider>,
399 Some(Arc::new(local) as Arc<dyn prospero_core::FleetAdmin>),
400 Some(poll_handle),
401 heartbeat_handle,
402 Some(manager),
403 )
404 }
405 #[cfg(feature = "k8s")]
406 FleetBackend::K8s => {
407 let client = build_kube_client(args.kubeconfig.as_deref()).await?;
408 let ns =
409 std::env::var("PROSPERO_K8S_NAMESPACE").unwrap_or_else(|_| "default".to_string());
410 let api = prospero_core::KubeTaskApi::new(client.clone(), &ns);
411
412 let workspace_api = Arc::new(prospero_core::KubeWorkspaceApi::new(client, &ns));
422 let workspace_admin =
423 Arc::new(prospero_core::K8sWorkspaceAdmin::new(workspace_api.clone()));
424
425 let tls = load_session_plane_tls(
430 args.k8s_caliband_ca_file.as_deref(),
431 &args.k8s_caliband_server_name,
432 )?;
433 let token = args
434 .k8s_caliband_token_file
435 .as_deref()
436 .map(read_token_file)
437 .transpose()?;
438
439 require_token_tls(token.is_some(), tls.is_some())?;
441
442 let (ownership, heartbeat_handle): (Arc<dyn Ownership>, Option<JoinHandle<()>>) =
449 if let Some(url) = args.database_url.clone() {
450 let replica_id = resolve_replica_id(args.replica_id.as_deref());
451 let ownership = Arc::new(
452 LeasedOwnership::connect(&url, replica_id.clone(), args.lease_ttl_secs)
453 .await
454 .with_context(|| "connecting clustered ownership (k8s)")?,
455 );
456 let interval =
457 heartbeat_interval(args.heartbeat_interval_ms, args.lease_ttl_secs);
458 let hb = ownership.clone();
459 let heartbeat_handle = tokio::spawn(async move {
460 let mut tick = tokio::time::interval(interval);
461 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
462 loop {
463 tick.tick().await;
464 hb.heartbeat().await;
465 }
466 });
467 tracing::info!(
468 target: "prosperod", backend = "k8s", %replica_id,
469 lease_ttl_secs = args.lease_ttl_secs,
470 heartbeat_ms = interval.as_millis() as u64,
471 "clustered k8s ownership + heartbeat active"
472 );
473 (ownership as Arc<dyn Ownership>, Some(heartbeat_handle))
474 } else {
475 (Arc::new(SelfOwnsAll) as Arc<dyn Ownership>, None)
476 };
477
478 let k8s = prospero_core::K8sFleet::new(api, bus.clone(), store.clone())
482 .with_network(tls.clone(), token.clone())
483 .with_ownership(ownership)
484 .with_workspaces(workspace_api);
485 tracing::info!(
486 target: "prosperod", backend = "k8s", namespace = %ns,
487 session_tls = tls.is_some(), session_token = token.is_some(),
488 "serving via K8sFleet (no FleetManager)"
489 );
490 (
491 Arc::new(k8s) as Arc<dyn prospero_core::FleetProvider>,
492 Some(workspace_admin as Arc<dyn prospero_core::FleetAdmin>),
493 None,
494 heartbeat_handle,
495 None,
496 )
497 }
498 #[cfg(not(feature = "k8s"))]
499 FleetBackend::K8s => anyhow::bail!(
500 "PROSPERO_FLEET=k8s requires a prosperod built with the k8s feature \
501 (`cargo build -p prospero-daemon --features k8s`)."
502 ),
503 };
504
505 if args.retention_days > 0 {
508 let s = store.clone();
509 let max_age = Duration::from_secs(args.retention_days * 24 * 3600);
510 tokio::spawn(async move {
511 let mut tick = tokio::time::interval(Duration::from_secs(3600));
512 loop {
513 tick.tick().await;
514 match prospero_core::store::prune_store_older_than(s.as_ref(), max_age).await {
515 Ok(n) if n > 0 => {
516 tracing::info!(target: "prosperod", pruned = n, "retention swept old events")
517 }
518 Ok(_) => {}
519 Err(e) => {
520 tracing::warn!(target: "prosperod", error = %e, "retention prune failed")
521 }
522 }
523 }
524 });
525 }
526
527 let app = prospero_api::router(fleet, admin, store.clone(), bus.clone());
528 let listener = tokio::net::TcpListener::bind(args.addr)
529 .await
530 .with_context(|| format!("binding {}", args.addr))?;
531
532 tracing::info!(
533 addr = %args.addr,
534 data_dir = %data_dir.display(),
535 "prosperod listening"
536 );
537
538 axum::serve(listener, app)
539 .with_graceful_shutdown(shutdown_signal())
540 .await
541 .with_context(|| "serving HTTP")?;
542
543 if let Some(manager) = &manager_for_shutdown {
547 manager.begin_shutdown();
548 }
549 if let Some(poll_handle) = poll_handle
550 && let Err(e) = poll_handle.await
551 {
552 tracing::warn!(error = %e, "poll loop did not drain cleanly");
553 }
554 if let Some(hb) = heartbeat_handle {
555 hb.abort();
556 }
557
558 tracing::info!("prosperod shut down");
559 Ok(())
560}
561
562async fn shutdown_signal() {
564 let ctrl_c = async {
565 let _ = tokio::signal::ctrl_c().await;
566 };
567
568 #[cfg(unix)]
569 let terminate = async {
570 if let Ok(mut sig) =
571 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
572 {
573 sig.recv().await;
574 }
575 };
576 #[cfg(not(unix))]
577 let terminate = std::future::pending::<()>();
578
579 tokio::select! {
580 _ = ctrl_c => {},
581 _ = terminate => {},
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use super::parse_key_val;
588 use super::read_token_file;
589 use super::require_token_tls;
590 use super::{heartbeat_interval, resolve_replica_id};
591 use std::time::Duration;
592
593 #[test]
594 fn read_token_file_trims_trailing_newline() {
595 let dir = tempfile::tempdir().unwrap();
596 let p = dir.path().join("token");
597 std::fs::write(&p, "s3cr3t\n").unwrap();
598 assert_eq!(read_token_file(&p).unwrap(), "s3cr3t");
599 }
600
601 #[test]
602 fn read_token_file_missing_is_err() {
603 let dir = tempfile::tempdir().unwrap();
604 assert!(read_token_file(&dir.path().join("nope")).is_err());
605 }
606
607 #[test]
608 fn require_token_tls_rejects_token_without_tls() {
609 assert!(require_token_tls(true, false).is_err());
611 assert!(require_token_tls(true, true).is_ok());
614 assert!(require_token_tls(false, false).is_ok());
615 assert!(require_token_tls(false, true).is_ok());
616 }
617
618 #[cfg(feature = "k8s")]
619 mod k8s_tls {
620 use super::super::load_session_plane_tls;
621
622 fn write_ca(dir: &std::path::Path) -> std::path::PathBuf {
623 let cert = rcgen::generate_simple_self_signed(vec!["caliband".into()]).unwrap();
625 let p = dir.join("ca.crt");
626 std::fs::write(&p, cert.cert.pem()).unwrap();
627 p
628 }
629
630 #[test]
631 fn none_ca_means_tls_off() {
632 assert!(load_session_plane_tls(None, "caliband").unwrap().is_none());
633 }
634
635 #[test]
636 fn good_ca_builds_a_client() {
637 let dir = tempfile::tempdir().unwrap();
638 let ca = write_ca(dir.path());
639 assert!(
640 load_session_plane_tls(Some(&ca), "caliband")
641 .unwrap()
642 .is_some()
643 );
644 }
645
646 #[test]
647 fn unparseable_pem_is_err() {
648 let dir = tempfile::tempdir().unwrap();
649 let p = dir.path().join("bad.crt");
650 std::fs::write(&p, "not a pem").unwrap();
651 assert!(load_session_plane_tls(Some(&p), "caliband").is_err());
652 }
653
654 #[test]
655 fn missing_ca_file_is_err() {
656 let dir = tempfile::tempdir().unwrap();
657 assert!(load_session_plane_tls(Some(&dir.path().join("nope")), "caliband").is_err());
658 }
659 }
660
661 #[test]
662 fn replica_id_prefers_explicit_then_falls_back() {
663 assert_eq!(resolve_replica_id(Some("r7")), "r7");
664 assert!(!resolve_replica_id(None).is_empty());
667 }
668
669 #[test]
670 fn heartbeat_defaults_to_a_third_of_ttl_and_is_clamped() {
671 assert_eq!(
672 heartbeat_interval(Some(500), 30.0),
673 Duration::from_millis(500)
674 );
675 assert_eq!(heartbeat_interval(None, 30.0), Duration::from_secs(10));
676 assert_eq!(heartbeat_interval(None, 0.6), Duration::from_secs(1));
678 assert_eq!(heartbeat_interval(Some(0), 30.0), Duration::from_millis(1));
679 }
680
681 #[test]
682 fn parses_key_value() {
683 assert_eq!(
684 parse_key_val("A=b").unwrap(),
685 ("A".to_string(), "b".to_string())
686 );
687 assert_eq!(
689 parse_key_val("URL=http://h:1?x=1").unwrap(),
690 ("URL".to_string(), "http://h:1?x=1".to_string())
691 );
692 assert!(parse_key_val("noequals").is_err());
693 assert!(parse_key_val("=val").is_err()); }
695}