Skip to main content

prosperod/
main.rs

1//! `prosperod` — the long-running Prospero control-plane daemon.
2//!
3//! Wires a [`FleetManager`] to the HTTP/SSE API + dashboard, runs the
4//! background poll loop, and serves until interrupted.
5
6use 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/// Fleet control-plane backend, selected by `--fleet-backend`/`PROSPERO_FLEET`.
25///
26/// `local` (default) is the caliband-over-Unix-sockets `LocalFleet`.
27/// `k8s` selects the `K8sFleet` backend (`CalibanTask` CRs + network session
28/// plane, ADR 0008), wired into the request path via the `FleetProvider`/
29/// `FleetAdmin` seams (#76); it needs a build with `--features k8s`.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
31#[value(rename_all = "lower")]
32enum FleetBackend {
33    Local,
34    K8s,
35}
36
37/// Prospero control-plane daemon.
38#[derive(Debug, Parser)]
39#[command(name = "prosperod", version, about)]
40struct Args {
41    /// Address to bind the HTTP API + dashboard on.
42    #[arg(long, env = "PROSPERO_ADDR", default_value = "127.0.0.1:7878")]
43    addr: SocketAddr,
44
45    /// Fleet control-plane backend: `local` (caliband over Unix) or `k8s`
46    /// (CalibanTask CRs; requires a build with `--features k8s`). See
47    /// docs/container.md "Fleet backends".
48    #[arg(long, env = "PROSPERO_FLEET", default_value = "local")]
49    fleet_backend: FleetBackend,
50
51    /// k8s only: PEM CA bundle trusting caliband's session-plane serving cert.
52    /// When set, per-agent dials use TLS; unset ⇒ plaintext (unchanged).
53    #[arg(long, env = "PROSPERO_K8S_CALIBAND_CA_FILE")]
54    k8s_caliband_ca_file: Option<PathBuf>,
55
56    /// k8s only: file holding the session-plane bearer token (contents trimmed).
57    /// When set, per-agent dials present the token; unset ⇒ no token.
58    #[arg(long, env = "PROSPERO_K8S_CALIBAND_TOKEN_FILE")]
59    k8s_caliband_token_file: Option<PathBuf>,
60
61    /// k8s only: SNI / cert-validation name for the session-plane TLS check.
62    #[arg(
63        long,
64        env = "PROSPERO_K8S_CALIBAND_SERVER_NAME",
65        default_value = "caliband"
66    )]
67    k8s_caliband_server_name: String,
68
69    /// k8s only: explicit kubeconfig file. Unset ⇒ infer (in-cluster, then
70    /// ambient kubeconfig).
71    #[arg(long, env = "KUBECONFIG")]
72    kubeconfig: Option<PathBuf>,
73
74    /// Directory for the registry and event store.
75    #[arg(long, env = "PROSPERO_DATA_DIR")]
76    data_dir: Option<PathBuf>,
77
78    /// Host identity reported in fleet snapshots.
79    #[arg(long, env = "PROSPERO_HOST", default_value = "local")]
80    host: String,
81
82    /// Poll interval in milliseconds.
83    #[arg(long, default_value_t = 2000)]
84    poll_interval_ms: u64,
85
86    /// Do not auto-start caliband daemons for registered repos.
87    #[arg(long)]
88    no_autostart: bool,
89
90    /// Path/name of the caliban daemon binary used for autostart.
91    #[arg(long, default_value = "caliband")]
92    caliband_bin: String,
93
94    /// Default env var applied under every repo's resolved config (repeatable).
95    #[arg(long = "default-env", value_parser = parse_key_val)]
96    default_env: Vec<(String, String)>,
97
98    /// Delete events older than this many days on an hourly loop. 0 disables.
99    #[arg(long, default_value_t = 0)]
100    retention_days: u64,
101
102    /// Postgres connection URL. When set, prosperod runs in CLUSTERED mode
103    /// (Postgres store/config + LISTEN/NOTIFY bus + leased ownership); when
104    /// unset, it runs STANDALONE (sqlite + in-process bus + self-owns-all).
105    #[arg(long, env = "PROSPERO_DATABASE_URL")]
106    database_url: Option<String>,
107
108    /// Clustered only: this replica's identity for lease ownership. Defaults to
109    /// the HOSTNAME env (the pod name under k8s). MUST be unique per replica.
110    #[arg(long, env = "PROSPERO_REPLICA_ID")]
111    replica_id: Option<String>,
112
113    /// Clustered only: lease time-to-live in seconds. A stream's owner must
114    /// heartbeat within this window or a peer may take the stream over.
115    #[arg(long, default_value_t = 30.0)]
116    lease_ttl_secs: f64,
117
118    /// Clustered only: how often (ms) to renew held leases. Defaults to a third
119    /// of the lease TTL.
120    #[arg(long)]
121    heartbeat_interval_ms: Option<u64>,
122}
123
124/// Parse a `KEY=VALUE` pair (value may contain further `=`).
125fn 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/// Read a bearer token from a mounted-Secret file, trimming the trailing
133/// whitespace/newline that Secret files commonly carry. A missing or
134/// unreadable path is fatal — a silently-empty token would defeat auth.
135///
136/// Not feature-gated (its tests run in every build), but only *called* from the
137/// k8s arm — so a bin-only build without `k8s` sees it as dead. Allow that.
138/// Refuse to start the k8s backend with a session-plane token but no TLS: the
139/// token would be written in the clear on every per-agent dial (see the preamble
140/// in `caliband::transport`), defeating its purpose. Fail fast at startup rather
141/// than silently transmit it (#107).
142///
143/// Like [`read_token_file`], only *called* from the k8s arm, so a non-k8s build
144/// sees it as dead — but its test runs in every build.
145#[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/// Build client-side session-plane TLS from a CA file, when one is configured.
166/// `None` ⇒ TLS stays off (plaintext, unchanged). A good PEM ⇒ `Some(client)`
167/// trusting that CA and validating the server presents `server_name`. An
168/// unreadable file or unparseable PEM is fatal (fail fast — no silent plaintext
169/// fall-back).
170#[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/// Build a `kube::Client`: from an explicit kubeconfig file when `kubeconfig`
186/// is set, else `try_default()` (infers in-cluster then ambient kubeconfig).
187#[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
207/// This replica's lease identity: the explicit `--replica-id`, else the
208/// `HOSTNAME` env (the pod name in k8s), else a local fallback.
209fn 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
216/// Heartbeat period: explicit `--heartbeat-interval-ms`, else a third of the
217/// lease TTL (clamped to at least 1s so a tiny TTL can't busy-loop).
218fn 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
228/// Default data dir: `$XDG_DATA_HOME/prospero` or `$HOME/.local/share/prospero`.
229fn 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    // Reject an unsupported backend BEFORE any side effects (#121). Phase 1
250    // below creates the data dir and opens the sqlite/Postgres store; doing
251    // that first for a `--fleet-backend k8s` invocation on a build without the
252    // k8s feature would leave a data dir behind and open a store only to bail
253    // out at Phase 2. The `K8s` variant exists in every build — only the
254    // Phase-2 match arm is feature-gated — so guard it here, cfg'd out (and
255    // thus a no-op) when the k8s feature *is* present.
256    #[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    // Phase 1 — the shared observability plane (store + bus), composed per
279    // storage topology and handed to whichever backend serves. A Postgres URL
280    // ⇒ clustered (Postgres store + LISTEN/NOTIFY bus); else standalone (sqlite
281    // + in-process bus). This is all the k8s backend needs (#83): it reads
282    // history/SSE from this store/bus and never builds a FleetManager.
283    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    // Phase 2 — select the serving backend over the shared store/bus. `local`
310    // builds the full FleetManager (registry + ownership + poll loop); `k8s`
311    // serves K8sFleet with NO manager, poll loop, config store, ownership, or
312    // heartbeat — those are local-only machinery, inert under k8s (#83). Both
313    // backends now wire a `FleetAdmin`: local's is the registry-backed manager;
314    // k8s's is a `K8sWorkspaceAdmin` over `Workspace` CRs (#142), so the config
315    // routes work under k8s instead of returning 405.
316    // The match yields the serving fleet/admin plus the background handles this
317    // backend owns (poll loop, heartbeat, manager for graceful shutdown) — all
318    // `None` under k8s, which starts none of them (#83).
319    #[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            // Per-topology registry + ownership seams (clustered adds a lease
329            // heartbeat). Both topologies go through `with_seams`, building the
330            // same manager `FleetManager::new` would for standalone.
331            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                // Heartbeat: renew this replica's held leases so it keeps its
349                // streams. Local-only — k8s builds no ownership (#83).
350                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            // The k8s config plane (#142): a `FleetAdmin` over `Workspace` CRs so
413            // the dashboard can create/configure workspaces under k8s. Wiring this
414            // as `admin = Some(..)` is what removes the 405 those routes returned
415            // (and flips `GET /api/capabilities` `admin` to `true` on k8s).
416            //
417            // The same `Workspace` registry is shared with `K8sFleet` below
418            // (#149/#151) so the fleet snapshot (`GET /api/fleet`) surfaces the
419            // registered `Workspace` CRs instead of a synthetic 'k8s' entry —
420            // keeping `/api/fleet` and `/api/workspaces` in agreement.
421            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            // Session-plane security (ADR 0051): trust caliband's serving cert
426            // via the mounted-Secret CA, and present the shared bearer token.
427            // Both are Option — unset ⇒ with_network(None, None), i.e. today's
428            // plaintext behavior, so existing deployments are unaffected.
429            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            // Never send the bearer token over plaintext (#107).
440            require_token_tls(token.is_some(), tls.is_some())?;
441
442            // Leader election for the session plane (#108): when clustered
443            // (Postgres present), gate attach/emit on a per-agent `LeasedOwnership`
444            // lease so 2+ replicas don't both stream — and double-emit — the same
445            // agent. Reuses the exact replica-id + lease-ttl + heartbeat machinery
446            // the local clustered arm uses. Standalone keeps `SelfOwnsAll`, so
447            // single-replica k8s behavior is unchanged.
448            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            // No FleetManager under k8s: K8sFleet serves directly over the
479            // shared store/bus (#83), with the ownership lease gating the
480            // session plane (#108).
481            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    // Age-based retention (#4) — both arms, off the shared store, with no
506    // dependency on FleetManager (#83).
507    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    // HTTP has drained; now drain whatever background work this backend started.
544    // Only the local arm builds a poll loop / heartbeat — under k8s there's
545    // nothing to drain (#83).
546    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
562/// Resolve when the process receives Ctrl-C (and SIGTERM on Unix).
563async 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        // The one unsafe combination: a token but no TLS → cleartext token.
610        assert!(require_token_tls(true, false).is_err());
611        // Every other combination is fine (no token, or token protected by TLS,
612        // or TLS with no token).
613        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            // A self-signed cert doubles as its own CA for trust-store loading.
624            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        // With no explicit id, falls back to HOSTNAME or the local default.
665        // (We don't mutate process env here; just assert it returns non-empty.)
666        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        // Tiny TTL clamps to >= 1s; explicit 0 clamps to >= 1ms.
677        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        // Values may contain '='.
688        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()); // empty key rejected
694    }
695}