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    /// How often to check whether an automation's schedule has come due, in
87    /// milliseconds (#220). Cron resolves to whole minutes, so this only bounds
88    /// how late a fire can be, not how often one happens.
89    #[arg(long, default_value_t = 30_000)]
90    automation_interval_ms: u64,
91
92    /// Do not auto-start caliband daemons for registered repos.
93    #[arg(long)]
94    no_autostart: bool,
95
96    /// Path/name of the caliban daemon binary used for autostart.
97    #[arg(long, default_value = "caliband")]
98    caliband_bin: String,
99
100    /// Default env var applied under every repo's resolved config (repeatable).
101    #[arg(long = "default-env", value_parser = parse_key_val)]
102    default_env: Vec<(String, String)>,
103
104    /// Delete events older than this many days on an hourly loop. 0 disables.
105    #[arg(long, default_value_t = 0)]
106    retention_days: u64,
107
108    /// Postgres connection URL. When set, prosperod runs in CLUSTERED mode
109    /// (Postgres store/config + LISTEN/NOTIFY bus + leased ownership); when
110    /// unset, it runs STANDALONE (sqlite + in-process bus + self-owns-all).
111    #[arg(long, env = "PROSPERO_DATABASE_URL")]
112    database_url: Option<String>,
113
114    /// Clustered only: this replica's identity for lease ownership. Defaults to
115    /// the HOSTNAME env (the pod name under k8s). MUST be unique per replica.
116    #[arg(long, env = "PROSPERO_REPLICA_ID")]
117    replica_id: Option<String>,
118
119    /// Clustered only: lease time-to-live in seconds. A stream's owner must
120    /// heartbeat within this window or a peer may take the stream over.
121    #[arg(long, default_value_t = 30.0)]
122    lease_ttl_secs: f64,
123
124    /// Clustered only: how often (ms) to renew held leases. Defaults to a third
125    /// of the lease TTL.
126    #[arg(long)]
127    heartbeat_interval_ms: Option<u64>,
128
129    /// Tokens file (`<name> <scope> sha256:<hex>` per line). When set, every
130    /// non-probe request needs a token (#2, ADR-0010).
131    #[arg(long, env = "PROSPERO_API_TOKENS_FILE")]
132    api_tokens_file: Option<PathBuf>,
133
134    /// Session-cookie HMAC key file (≥ 32 bytes). Required when clustered with
135    /// tokens; standalone generates a random key per process.
136    #[arg(long, env = "PROSPERO_SESSION_KEY_FILE")]
137    session_key_file: Option<PathBuf>,
138
139    /// Serve without authentication on a non-loopback address. Logged loudly.
140    ///
141    /// A bare `--insecure-no-auth` flag is still `true` with no value needed;
142    /// the env var also accepts `1`/`0` (not just `true`/`false`) since that's
143    /// what's documented in docs/container.md and the spec.
144    #[arg(
145        long,
146        env = "PROSPERO_INSECURE_NO_AUTH",
147        action = clap::ArgAction::SetTrue,
148        value_parser = clap::builder::BoolishValueParser::new()
149    )]
150    insecure_no_auth: bool,
151
152    /// Always mark the session cookie `Secure` (otherwise only when
153    /// `X-Forwarded-Proto: https`).
154    ///
155    /// Same env-value leniency as `--insecure-no-auth`: `1`/`0` work, not just
156    /// `true`/`false`.
157    #[arg(
158        long,
159        env = "PROSPERO_COOKIE_SECURE",
160        action = clap::ArgAction::SetTrue,
161        value_parser = clap::builder::BoolishValueParser::new()
162    )]
163    cookie_secure: bool,
164}
165
166/// Parse a `KEY=VALUE` pair (value may contain further `=`).
167fn parse_key_val(s: &str) -> Result<(String, String), String> {
168    match s.split_once('=') {
169        Some((k, v)) if !k.is_empty() => Ok((k.to_string(), v.to_string())),
170        _ => Err(format!("expected KEY=VALUE, got '{s}'")),
171    }
172}
173
174/// Read a bearer token from a mounted-Secret file, trimming the trailing
175/// whitespace/newline that Secret files commonly carry. A missing or
176/// unreadable path is fatal — a silently-empty token would defeat auth.
177///
178/// Not feature-gated (its tests run in every build), but only *called* from the
179/// k8s arm — so a bin-only build without `k8s` sees it as dead. Allow that.
180/// Refuse to start the k8s backend with a session-plane token but no TLS: the
181/// token would be written in the clear on every per-agent dial (see the preamble
182/// in `caliband::transport`), defeating its purpose. Fail fast at startup rather
183/// than silently transmit it (#107).
184///
185/// Like [`read_token_file`], only *called* from the k8s arm, so a non-k8s build
186/// sees it as dead — but its test runs in every build.
187#[cfg_attr(not(feature = "k8s"), allow(dead_code))]
188fn require_token_tls(token_present: bool, tls_present: bool) -> anyhow::Result<()> {
189    if token_present && !tls_present {
190        anyhow::bail!(
191            "a session-plane token is configured (--k8s-caliband-token-file / \
192             PROSPERO_K8S_CALIBAND_TOKEN_FILE) but TLS is not (--k8s-caliband-ca-file / \
193             PROSPERO_K8S_CALIBAND_CA_FILE); the token would be sent in cleartext. \
194             Configure the CA file to enable TLS, or unset the token."
195        );
196    }
197    Ok(())
198}
199
200#[cfg_attr(not(feature = "k8s"), allow(dead_code))]
201fn read_token_file(path: &Path) -> anyhow::Result<String> {
202    let raw = std::fs::read_to_string(path)
203        .with_context(|| format!("reading session-plane token file {}", path.display()))?;
204    Ok(raw.trim_end().to_string())
205}
206
207/// Decide the API authentication mode, refusing unsafe combinations before any
208/// side effects (#2, ADR-0010).
209fn resolve_auth(
210    addr: SocketAddr,
211    clustered: bool,
212    tokens_file: Option<&Path>,
213    session_key_file: Option<&Path>,
214    insecure_no_auth: bool,
215    cookie_secure: bool,
216) -> anyhow::Result<prospero_api::auth::AuthState> {
217    use prospero_api::auth::{AuthState, SessionKey};
218
219    let Some(tokens_path) = tokens_file else {
220        if !addr.ip().is_loopback() && !insecure_no_auth {
221            anyhow::bail!(
222                "refusing to bind {addr} without authentication: set --api-tokens-file / \
223                 PROSPERO_API_TOKENS_FILE, or pass --insecure-no-auth to run unauthenticated"
224            );
225        }
226        return Ok(AuthState::disabled());
227    };
228    if insecure_no_auth {
229        anyhow::bail!("--insecure-no-auth cannot be combined with --api-tokens-file");
230    }
231    let tokens = prospero_core::auth::TokenSet::load(tokens_path)?;
232    let session_key = match session_key_file {
233        Some(path) => {
234            let raw = std::fs::read(path)
235                .with_context(|| format!("reading session key file {}", path.display()))?;
236            let trimmed = raw.trim_ascii_end().to_vec();
237            SessionKey::from_bytes(trimmed)
238                .map_err(|e| anyhow::anyhow!("session key file {}: {e}", path.display()))?
239        }
240        None if clustered => anyhow::bail!(
241            "clustered prosperod (PROSPERO_DATABASE_URL) with API tokens needs \
242             --session-key-file / PROSPERO_SESSION_KEY_FILE so every replica signs \
243             sessions with the same key"
244        ),
245        None => SessionKey::random(),
246    };
247    Ok(AuthState::enabled(tokens, session_key, cookie_secure))
248}
249
250/// Build client-side session-plane TLS from a CA file, when one is configured.
251/// `None` ⇒ TLS stays off (plaintext, unchanged). A good PEM ⇒ `Some(client)`
252/// trusting that CA and validating the server presents `server_name`. An
253/// unreadable file or unparseable PEM is fatal (fail fast — no silent plaintext
254/// fall-back).
255#[cfg(feature = "k8s")]
256fn load_session_plane_tls(
257    ca_file: Option<&Path>,
258    server_name: &str,
259) -> anyhow::Result<Option<prospero_core::caliband::transport::TlsClient>> {
260    let Some(ca_file) = ca_file else {
261        return Ok(None);
262    };
263    let ca_pem = std::fs::read(ca_file)
264        .with_context(|| format!("reading session-plane CA file {}", ca_file.display()))?;
265    let client = prospero_core::caliband::transport::tls_client_from_pem(&ca_pem, server_name)
266        .with_context(|| format!("building session-plane TLS from {}", ca_file.display()))?;
267    Ok(Some(client))
268}
269
270/// Build a `kube::Client`: from an explicit kubeconfig file when `kubeconfig`
271/// is set, else `try_default()` (infers in-cluster then ambient kubeconfig).
272#[cfg(feature = "k8s")]
273async fn build_kube_client(kubeconfig: Option<&Path>) -> anyhow::Result<kube::Client> {
274    match kubeconfig {
275        Some(path) => {
276            let kc = kube::config::Kubeconfig::read_from(path)
277                .with_context(|| format!("reading kubeconfig {}", path.display()))?;
278            let cfg = kube::Config::from_custom_kubeconfig(
279                kc,
280                &kube::config::KubeConfigOptions::default(),
281            )
282            .await
283            .with_context(|| format!("loading kubeconfig {}", path.display()))?;
284            kube::Client::try_from(cfg).with_context(|| "building kube client from kubeconfig")
285        }
286        None => kube::Client::try_default()
287            .await
288            .with_context(|| "connecting to the Kubernetes API server"),
289    }
290}
291
292/// This replica's lease identity: the explicit `--replica-id`, else the
293/// `HOSTNAME` env (the pod name in k8s), else a local fallback.
294fn resolve_replica_id(explicit: Option<&str>) -> String {
295    explicit
296        .map(str::to_string)
297        .or_else(|| std::env::var("HOSTNAME").ok().filter(|h| !h.is_empty()))
298        .unwrap_or_else(|| "prosperod-local".to_string())
299}
300
301/// Heartbeat period: explicit `--heartbeat-interval-ms`, else a third of the
302/// lease TTL (clamped to at least 1s so a tiny TTL can't busy-loop).
303fn heartbeat_interval(explicit_ms: Option<u64>, lease_ttl_secs: f64) -> Duration {
304    match explicit_ms {
305        Some(ms) => Duration::from_millis(ms.max(1)),
306        None => {
307            let secs = (lease_ttl_secs / 3.0).max(1.0);
308            Duration::from_secs_f64(secs)
309        }
310    }
311}
312
313/// Default data dir: `$XDG_DATA_HOME/prospero` or `$HOME/.local/share/prospero`.
314fn default_data_dir() -> PathBuf {
315    if let Some(xdg) = std::env::var_os("XDG_DATA_HOME") {
316        PathBuf::from(xdg).join("prospero")
317    } else if let Some(home) = std::env::var_os("HOME") {
318        PathBuf::from(home).join(".local/share/prospero")
319    } else {
320        PathBuf::from(".prospero")
321    }
322}
323
324#[tokio::main]
325async fn main() -> anyhow::Result<()> {
326    tracing_subscriber::fmt()
327        .with_env_filter(
328            tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
329        )
330        .init();
331
332    let args = Args::parse();
333
334    // Reject an unsupported backend BEFORE any side effects (#121). Phase 1
335    // below creates the data dir and opens the sqlite/Postgres store; doing
336    // that first for a `--fleet-backend k8s` invocation on a build without the
337    // k8s feature would leave a data dir behind and open a store only to bail
338    // out at Phase 2. The `K8s` variant exists in every build — only the
339    // Phase-2 match arm is feature-gated — so guard it here, cfg'd out (and
340    // thus a no-op) when the k8s feature *is* present.
341    #[cfg(not(feature = "k8s"))]
342    if args.fleet_backend == FleetBackend::K8s {
343        anyhow::bail!(
344            "PROSPERO_FLEET=k8s requires a prosperod built with the k8s feature \
345             (`cargo build -p prospero-daemon --features k8s`)."
346        );
347    }
348
349    let auth = resolve_auth(
350        args.addr,
351        args.database_url.is_some(),
352        args.api_tokens_file.as_deref(),
353        args.session_key_file.as_deref(),
354        args.insecure_no_auth,
355        args.cookie_secure,
356    )?;
357    if args.insecure_no_auth {
358        let addr = args.addr;
359        let warn = move || {
360            tracing::warn!(
361                target: "prosperod", %addr,
362                "authentication DISABLED (--insecure-no-auth): anyone who can reach this address controls the fleet"
363            )
364        };
365        warn();
366        tokio::spawn(async move {
367            let mut tick = tokio::time::interval(Duration::from_secs(60));
368            tick.tick().await;
369            loop {
370                tick.tick().await;
371                warn();
372            }
373        });
374    }
375    let auth_enabled = auth.is_enabled();
376
377    let data_dir = args.data_dir.clone().unwrap_or_else(default_data_dir);
378    std::fs::create_dir_all(&data_dir)
379        .with_context(|| format!("creating data dir {}", data_dir.display()))?;
380
381    let mut config = FleetConfig::new(args.host.clone(), data_dir.clone());
382    config.poll_interval = Duration::from_millis(args.poll_interval_ms);
383    config.discovery_env = DiscoveryEnv::from_process();
384    config.ensure = EnsureConfig {
385        autostart: !args.no_autostart,
386        caliband_bin: args.caliband_bin.clone(),
387        ..EnsureConfig::default()
388    };
389    config.default_env = args.default_env.iter().cloned().collect();
390
391    // Phase 1 — the shared observability plane (store + bus), composed per
392    // storage topology and handed to whichever backend serves. A Postgres URL
393    // ⇒ clustered (Postgres store + LISTEN/NOTIFY bus); else standalone (sqlite
394    // + in-process bus). This is all the k8s backend needs (#83): it reads
395    // history/SSE from this store/bus and never builds a FleetManager.
396    let (store, bus): (Arc<dyn Store>, Arc<dyn EventBus>) = if let Some(url) =
397        args.database_url.clone()
398    {
399        let store: Arc<dyn Store> = Arc::new(
400            PostgresStore::connect(&url)
401                .await
402                .with_context(|| "connecting clustered event store")?,
403        );
404        let bus: Arc<dyn EventBus> = Arc::new(
405            DistributedBus::connect(&url, store.clone())
406                .await
407                .with_context(|| "connecting clustered event bus")?,
408        );
409        tracing::info!(target: "prosperod", topology = "clustered", "selected clustered topology");
410        (store, bus)
411    } else {
412        let store: Arc<dyn Store> = Arc::new(
413            SqliteStore::open(&data_dir)
414                .await
415                .with_context(|| "opening event store")?,
416        );
417        let bus: Arc<dyn EventBus> = Arc::new(InProcessBus::new(config.event_buffer));
418        tracing::info!(target: "prosperod", topology = "standalone", "selected standalone topology");
419        (store, bus)
420    };
421
422    // Phase 2 — select the serving backend over the shared store/bus. `local`
423    // builds the full FleetManager (registry + ownership + poll loop); `k8s`
424    // serves K8sFleet with NO manager, poll loop, config store, ownership, or
425    // heartbeat — those are local-only machinery, inert under k8s (#83). Both
426    // backends now wire a `FleetAdmin`: local's is the registry-backed manager;
427    // k8s's is a `K8sWorkspaceAdmin` over `Workspace` CRs (#142), so the config
428    // routes work under k8s instead of returning 405.
429    // The match yields the serving fleet/admin plus the background handles this
430    // backend owns (poll loop, heartbeat, manager for graceful shutdown) — all
431    // `None` under k8s, which starts none of them (#83).
432    #[allow(clippy::type_complexity)]
433    let (fleet, admin, poll_handle, heartbeat_handle, manager_for_shutdown, automation_config): (
434        Arc<dyn prospero_core::FleetProvider>,
435        Option<Arc<dyn prospero_core::FleetAdmin>>,
436        Option<JoinHandle<()>>,
437        Option<JoinHandle<()>>,
438        Option<FleetManager>,
439        Option<Arc<dyn ConfigStore>>,
440    ) = match args.fleet_backend {
441        FleetBackend::Local => {
442            // Per-topology registry + ownership seams (clustered adds a lease
443            // heartbeat). Both topologies go through `with_seams`, building the
444            // same manager `FleetManager::new` would for standalone.
445            let (config_store, ownership, heartbeat_handle): (
446                Arc<dyn ConfigStore>,
447                Arc<dyn Ownership>,
448                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 config_store: Arc<dyn ConfigStore> = Arc::new(
452                    PostgresConfigStore::connect(&url)
453                        .await
454                        .with_context(|| "connecting clustered config store")?,
455                );
456                let ownership = Arc::new(
457                    LeasedOwnership::connect(&url, replica_id.clone(), args.lease_ttl_secs)
458                        .await
459                        .with_context(|| "connecting clustered ownership")?,
460                );
461
462                // Heartbeat: renew this replica's held leases so it keeps its
463                // streams. Local-only — k8s builds no ownership (#83).
464                let interval = heartbeat_interval(args.heartbeat_interval_ms, args.lease_ttl_secs);
465                let hb = ownership.clone();
466                let heartbeat_handle = tokio::spawn(async move {
467                    let mut tick = tokio::time::interval(interval);
468                    tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
469                    loop {
470                        tick.tick().await;
471                        hb.heartbeat().await;
472                    }
473                });
474                tracing::info!(
475                    target: "prosperod", %replica_id,
476                    lease_ttl_secs = args.lease_ttl_secs,
477                    heartbeat_ms = interval.as_millis() as u64,
478                    "clustered ownership + heartbeat active"
479                );
480                (
481                    config_store,
482                    ownership as Arc<dyn Ownership>,
483                    Some(heartbeat_handle),
484                )
485            } else {
486                let config_store: Arc<dyn ConfigStore> = Arc::new(
487                    SqliteConfigStore::open(&data_dir)
488                        .await
489                        .with_context(|| "opening config store")?,
490                );
491                (
492                    config_store,
493                    Arc::new(SelfOwnsAll) as Arc<dyn Ownership>,
494                    None,
495                )
496            };
497
498            // Kept for the automation engine, which needs the *same* shared
499            // store: its per-tick claim is what stops replicas double-firing.
500            let automation_config = config_store.clone();
501            let manager = FleetManager::with_seams(
502                config,
503                store.clone(),
504                config_store,
505                bus.clone(),
506                ownership,
507            )
508            .await
509            .with_context(|| "building fleet manager")?;
510
511            let local = LocalFleet::new(manager.clone());
512            let poll_handle = tokio::spawn(manager.clone().run());
513            tracing::info!(target: "prosperod", backend = "local", "serving via LocalFleet");
514            (
515                Arc::new(local.clone()) as Arc<dyn prospero_core::FleetProvider>,
516                Some(Arc::new(local) as Arc<dyn prospero_core::FleetAdmin>),
517                Some(poll_handle),
518                heartbeat_handle,
519                Some(manager),
520                Some(automation_config),
521            )
522        }
523        #[cfg(feature = "k8s")]
524        FleetBackend::K8s => {
525            let client = build_kube_client(args.kubeconfig.as_deref()).await?;
526            let ns =
527                std::env::var("PROSPERO_K8S_NAMESPACE").unwrap_or_else(|_| "default".to_string());
528            let api = prospero_core::KubeTaskApi::new(client.clone(), &ns);
529
530            // The k8s config plane (#142): a `FleetAdmin` over `Workspace` CRs so
531            // the dashboard can create/configure workspaces under k8s. Wiring this
532            // as `admin = Some(..)` is what removes the 405 those routes returned
533            // (and flips `GET /api/capabilities` `admin` to `true` on k8s).
534            //
535            // The same `Workspace` registry is shared with `K8sFleet` below
536            // (#149/#151) so the fleet snapshot (`GET /api/fleet`) surfaces the
537            // registered `Workspace` CRs instead of a synthetic 'k8s' entry —
538            // keeping `/api/fleet` and `/api/workspaces` in agreement.
539            // Automations (#220) need a store shared by every replica, because
540            // the per-tick claim is what arbitrates between them. Under k8s
541            // that means Postgres; without it, automations stay off rather
542            // than silently firing once per pod.
543            let automation_config: Option<Arc<dyn ConfigStore>> =
544                if let Some(url) = args.database_url.clone() {
545                    Some(Arc::new(
546                        PostgresConfigStore::connect(&url)
547                            .await
548                            .with_context(|| "connecting config store for automations (k8s)")?,
549                    ))
550                } else {
551                    None
552                };
553
554            let workspace_api = Arc::new(prospero_core::KubeWorkspaceApi::new(client, &ns));
555            let workspace_admin =
556                Arc::new(prospero_core::K8sWorkspaceAdmin::new(workspace_api.clone()));
557
558            // Session-plane security (ADR 0051): trust caliband's serving cert
559            // via the mounted-Secret CA, and present the shared bearer token.
560            // Both are Option — unset ⇒ with_network(None, None), i.e. today's
561            // plaintext behavior, so existing deployments are unaffected.
562            let tls = load_session_plane_tls(
563                args.k8s_caliband_ca_file.as_deref(),
564                &args.k8s_caliband_server_name,
565            )?;
566            let token = args
567                .k8s_caliband_token_file
568                .as_deref()
569                .map(read_token_file)
570                .transpose()?;
571
572            // Never send the bearer token over plaintext (#107).
573            require_token_tls(token.is_some(), tls.is_some())?;
574
575            // Leader election for the session plane (#108): when clustered
576            // (Postgres present), gate attach/emit on a per-agent `LeasedOwnership`
577            // lease so 2+ replicas don't both stream — and double-emit — the same
578            // agent. Reuses the exact replica-id + lease-ttl + heartbeat machinery
579            // the local clustered arm uses. Standalone keeps `SelfOwnsAll`, so
580            // single-replica k8s behavior is unchanged.
581            let (ownership, heartbeat_handle): (Arc<dyn Ownership>, Option<JoinHandle<()>>) =
582                if let Some(url) = args.database_url.clone() {
583                    let replica_id = resolve_replica_id(args.replica_id.as_deref());
584                    let ownership = Arc::new(
585                        LeasedOwnership::connect(&url, replica_id.clone(), args.lease_ttl_secs)
586                            .await
587                            .with_context(|| "connecting clustered ownership (k8s)")?,
588                    );
589                    let interval =
590                        heartbeat_interval(args.heartbeat_interval_ms, args.lease_ttl_secs);
591                    let hb = ownership.clone();
592                    let heartbeat_handle = tokio::spawn(async move {
593                        let mut tick = tokio::time::interval(interval);
594                        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
595                        loop {
596                            tick.tick().await;
597                            hb.heartbeat().await;
598                        }
599                    });
600                    tracing::info!(
601                        target: "prosperod", backend = "k8s", %replica_id,
602                        lease_ttl_secs = args.lease_ttl_secs,
603                        heartbeat_ms = interval.as_millis() as u64,
604                        "clustered k8s ownership + heartbeat active"
605                    );
606                    (ownership as Arc<dyn Ownership>, Some(heartbeat_handle))
607                } else {
608                    (Arc::new(SelfOwnsAll) as Arc<dyn Ownership>, None)
609                };
610
611            // No FleetManager under k8s: K8sFleet serves directly over the
612            // shared store/bus (#83), with the ownership lease gating the
613            // session plane (#108).
614            // #212: opt-in surfacing of model reasoning in the stream (off by
615            // default for volume/privacy). When on, the dashboard shows a
616            // collapsible Thinking segment — useful for reasoning models whose
617            // reply is mostly thinking.
618            let include_thinking = std::env::var("PROSPERO_INCLUDE_THINKING")
619                .map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
620                .unwrap_or(false);
621            let k8s = prospero_core::K8sFleet::new(api, bus.clone(), store.clone())
622                .with_network(tls.clone(), token.clone())
623                .with_ownership(ownership)
624                .with_workspaces(workspace_api)
625                .with_include_thinking(include_thinking);
626            tracing::info!(
627                target: "prosperod", backend = "k8s", namespace = %ns,
628                session_tls = tls.is_some(), session_token = token.is_some(),
629                include_thinking,
630                "serving via K8sFleet (no FleetManager)"
631            );
632            (
633                Arc::new(k8s) as Arc<dyn prospero_core::FleetProvider>,
634                Some(workspace_admin as Arc<dyn prospero_core::FleetAdmin>),
635                None,
636                heartbeat_handle,
637                None,
638                automation_config,
639            )
640        }
641        #[cfg(not(feature = "k8s"))]
642        FleetBackend::K8s => anyhow::bail!(
643            "PROSPERO_FLEET=k8s requires a prosperod built with the k8s feature \
644             (`cargo build -p prospero-daemon --features k8s`)."
645        ),
646    };
647
648    // Age-based retention (#4) — both arms, off the shared store, with no
649    // dependency on FleetManager (#83).
650    if args.retention_days > 0 {
651        let s = store.clone();
652        let max_age = Duration::from_secs(args.retention_days * 24 * 3600);
653        tokio::spawn(async move {
654            let mut tick = tokio::time::interval(Duration::from_secs(3600));
655            loop {
656                tick.tick().await;
657                match prospero_core::store::prune_store_older_than(s.as_ref(), max_age).await {
658                    Ok(n) if n > 0 => {
659                        tracing::info!(target: "prosperod", pruned = n, "retention swept old events")
660                    }
661                    Ok(_) => {}
662                    Err(e) => {
663                        tracing::warn!(target: "prosperod", error = %e, "retention prune failed")
664                    }
665                }
666            }
667        });
668    }
669
670    // The automation engine (#220): one loop per replica, arbitrated by the
671    // store's per-tick claim rather than by a lease, so a scheduled spawn
672    // still happens when the lease holder is the replica that just died.
673    let (automation_stop, automation_stop_rx) = tokio::sync::watch::channel(false);
674    let (automations, automation_handle) = match automation_config {
675        Some(config_store) => {
676            let engine = Arc::new(prospero_core::automation::AutomationEngine::new(
677                config_store,
678                fleet.clone(),
679            ));
680            let interval = Duration::from_millis(args.automation_interval_ms);
681            let handle = tokio::spawn(engine.clone().run(interval, automation_stop_rx));
682            tracing::info!(
683                target: "prosperod",
684                interval_ms = args.automation_interval_ms,
685                "automation scheduler active"
686            );
687            (Some(engine), Some(handle))
688        }
689        None => {
690            tracing::info!(
691                target: "prosperod",
692                "automations disabled (no shared config store)"
693            );
694            (None, None)
695        }
696    };
697
698    let app =
699        prospero_api::router_with_auth(fleet, admin, store.clone(), bus.clone(), automations, auth);
700    let listener = tokio::net::TcpListener::bind(args.addr)
701        .await
702        .with_context(|| format!("binding {}", args.addr))?;
703
704    tracing::info!(
705        addr = %args.addr,
706        data_dir = %data_dir.display(),
707        auth = auth_enabled,
708        "prosperod listening"
709    );
710
711    axum::serve(listener, app)
712        .with_graceful_shutdown(shutdown_signal())
713        .await
714        .with_context(|| "serving HTTP")?;
715
716    // HTTP has drained; now drain whatever background work this backend started.
717    // Only the local arm builds a poll loop / heartbeat — under k8s there's
718    // nothing to drain (#83).
719    let _ = automation_stop.send(true);
720    if let Some(handle) = automation_handle {
721        let _ = handle.await;
722    }
723    if let Some(manager) = &manager_for_shutdown {
724        manager.begin_shutdown();
725    }
726    if let Some(poll_handle) = poll_handle
727        && let Err(e) = poll_handle.await
728    {
729        tracing::warn!(error = %e, "poll loop did not drain cleanly");
730    }
731    if let Some(hb) = heartbeat_handle {
732        hb.abort();
733    }
734
735    tracing::info!("prosperod shut down");
736    Ok(())
737}
738
739/// Resolve when the process receives Ctrl-C (and SIGTERM on Unix).
740async fn shutdown_signal() {
741    let ctrl_c = async {
742        let _ = tokio::signal::ctrl_c().await;
743    };
744
745    #[cfg(unix)]
746    let terminate = async {
747        if let Ok(mut sig) =
748            tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
749        {
750            sig.recv().await;
751        }
752    };
753    #[cfg(not(unix))]
754    let terminate = std::future::pending::<()>();
755
756    tokio::select! {
757        _ = ctrl_c => {},
758        _ = terminate => {},
759    }
760}
761
762#[cfg(test)]
763mod tests {
764    use super::parse_key_val;
765    use super::read_token_file;
766    use super::require_token_tls;
767    use super::{heartbeat_interval, resolve_replica_id};
768    use std::time::Duration;
769
770    #[test]
771    fn read_token_file_trims_trailing_newline() {
772        let dir = tempfile::tempdir().unwrap();
773        let p = dir.path().join("token");
774        std::fs::write(&p, "s3cr3t\n").unwrap();
775        assert_eq!(read_token_file(&p).unwrap(), "s3cr3t");
776    }
777
778    #[test]
779    fn read_token_file_missing_is_err() {
780        let dir = tempfile::tempdir().unwrap();
781        assert!(read_token_file(&dir.path().join("nope")).is_err());
782    }
783
784    #[test]
785    fn require_token_tls_rejects_token_without_tls() {
786        // The one unsafe combination: a token but no TLS → cleartext token.
787        assert!(require_token_tls(true, false).is_err());
788        // Every other combination is fine (no token, or token protected by TLS,
789        // or TLS with no token).
790        assert!(require_token_tls(true, true).is_ok());
791        assert!(require_token_tls(false, false).is_ok());
792        assert!(require_token_tls(false, true).is_ok());
793    }
794
795    #[cfg(feature = "k8s")]
796    mod k8s_tls {
797        use super::super::load_session_plane_tls;
798
799        fn write_ca(dir: &std::path::Path) -> std::path::PathBuf {
800            // A self-signed cert doubles as its own CA for trust-store loading.
801            let cert = rcgen::generate_simple_self_signed(vec!["caliband".into()]).unwrap();
802            let p = dir.join("ca.crt");
803            std::fs::write(&p, cert.cert.pem()).unwrap();
804            p
805        }
806
807        #[test]
808        fn none_ca_means_tls_off() {
809            assert!(load_session_plane_tls(None, "caliband").unwrap().is_none());
810        }
811
812        #[test]
813        fn good_ca_builds_a_client() {
814            let dir = tempfile::tempdir().unwrap();
815            let ca = write_ca(dir.path());
816            assert!(
817                load_session_plane_tls(Some(&ca), "caliband")
818                    .unwrap()
819                    .is_some()
820            );
821        }
822
823        #[test]
824        fn unparseable_pem_is_err() {
825            let dir = tempfile::tempdir().unwrap();
826            let p = dir.path().join("bad.crt");
827            std::fs::write(&p, "not a pem").unwrap();
828            assert!(load_session_plane_tls(Some(&p), "caliband").is_err());
829        }
830
831        #[test]
832        fn missing_ca_file_is_err() {
833            let dir = tempfile::tempdir().unwrap();
834            assert!(load_session_plane_tls(Some(&dir.path().join("nope")), "caliband").is_err());
835        }
836    }
837
838    #[test]
839    fn replica_id_prefers_explicit_then_falls_back() {
840        assert_eq!(resolve_replica_id(Some("r7")), "r7");
841        // With no explicit id, falls back to HOSTNAME or the local default.
842        // (We don't mutate process env here; just assert it returns non-empty.)
843        assert!(!resolve_replica_id(None).is_empty());
844    }
845
846    #[test]
847    fn heartbeat_defaults_to_a_third_of_ttl_and_is_clamped() {
848        assert_eq!(
849            heartbeat_interval(Some(500), 30.0),
850            Duration::from_millis(500)
851        );
852        assert_eq!(heartbeat_interval(None, 30.0), Duration::from_secs(10));
853        // Tiny TTL clamps to >= 1s; explicit 0 clamps to >= 1ms.
854        assert_eq!(heartbeat_interval(None, 0.6), Duration::from_secs(1));
855        assert_eq!(heartbeat_interval(Some(0), 30.0), Duration::from_millis(1));
856    }
857
858    #[test]
859    fn parses_key_value() {
860        assert_eq!(
861            parse_key_val("A=b").unwrap(),
862            ("A".to_string(), "b".to_string())
863        );
864        // Values may contain '='.
865        assert_eq!(
866            parse_key_val("URL=http://h:1?x=1").unwrap(),
867            ("URL".to_string(), "http://h:1?x=1".to_string())
868        );
869        assert!(parse_key_val("noequals").is_err());
870        assert!(parse_key_val("=val").is_err()); // empty key rejected
871    }
872
873    /// clap's `env` support reads real process env at parse time — there's no
874    /// builder-level way to inject a fake environment into `try_parse_from`,
875    /// so these tests mutate `PROSPERO_INSECURE_NO_AUTH` / `PROSPERO_COOKIE_SECURE`
876    /// on the real process. That's racy against any other test touching the
877    /// same vars (in this crate, nothing else does) and across parallel test
878    /// binaries in general, so every test here holds `ENV_LOCK` for the
879    /// duration and an RAII guard restores whatever value (or absence) was
880    /// there before, even on panic.
881    mod bool_env_args {
882        use super::super::Args;
883        use clap::Parser;
884        use std::sync::Mutex;
885
886        static ENV_LOCK: Mutex<()> = Mutex::new(());
887
888        /// Sets (or clears) an env var for the life of the guard, restoring
889        /// whatever was there before on drop.
890        struct EnvVarGuard {
891            key: &'static str,
892            prev: Option<String>,
893        }
894
895        impl EnvVarGuard {
896            fn set(key: &'static str, value: &str) -> Self {
897                let prev = std::env::var(key).ok();
898                // SAFETY: serialized by `ENV_LOCK`, which every test in this
899                // module holds for its whole body.
900                unsafe { std::env::set_var(key, value) };
901                Self { key, prev }
902            }
903
904            fn unset(key: &'static str) -> Self {
905                let prev = std::env::var(key).ok();
906                // SAFETY: see `set`.
907                unsafe { std::env::remove_var(key) };
908                Self { key, prev }
909            }
910        }
911
912        impl Drop for EnvVarGuard {
913            fn drop(&mut self) {
914                // SAFETY: see `EnvVarGuard::set`.
915                unsafe {
916                    match &self.prev {
917                        Some(v) => std::env::set_var(self.key, v),
918                        None => std::env::remove_var(self.key),
919                    }
920                }
921            }
922        }
923
924        #[test]
925        fn flag_alone_sets_true_with_no_value() {
926            let _lock = ENV_LOCK.lock().unwrap();
927            let _g1 = EnvVarGuard::unset("PROSPERO_INSECURE_NO_AUTH");
928            let _g2 = EnvVarGuard::unset("PROSPERO_COOKIE_SECURE");
929            let args = Args::try_parse_from(["prosperod", "--insecure-no-auth"]).unwrap();
930            assert!(args.insecure_no_auth);
931            assert!(!args.cookie_secure);
932        }
933
934        #[test]
935        fn absent_flag_and_env_defaults_to_false() {
936            let _lock = ENV_LOCK.lock().unwrap();
937            let _g1 = EnvVarGuard::unset("PROSPERO_INSECURE_NO_AUTH");
938            let _g2 = EnvVarGuard::unset("PROSPERO_COOKIE_SECURE");
939            let args = Args::try_parse_from(["prosperod"]).unwrap();
940            assert!(!args.insecure_no_auth);
941            assert!(!args.cookie_secure);
942        }
943
944        #[test]
945        fn env_var_equal_1_is_true() {
946            let _lock = ENV_LOCK.lock().unwrap();
947            let _g1 = EnvVarGuard::set("PROSPERO_INSECURE_NO_AUTH", "1");
948            let _g2 = EnvVarGuard::set("PROSPERO_COOKIE_SECURE", "1");
949            let args = Args::try_parse_from(["prosperod"]).unwrap();
950            assert!(args.insecure_no_auth);
951            assert!(args.cookie_secure);
952        }
953
954        #[test]
955        fn env_var_equal_0_or_false_is_false() {
956            let _lock = ENV_LOCK.lock().unwrap();
957            let _g1 = EnvVarGuard::set("PROSPERO_INSECURE_NO_AUTH", "0");
958            let _g2 = EnvVarGuard::set("PROSPERO_COOKIE_SECURE", "false");
959            let args = Args::try_parse_from(["prosperod"]).unwrap();
960            assert!(!args.insecure_no_auth);
961            assert!(!args.cookie_secure);
962        }
963    }
964
965    mod auth {
966        use super::super::resolve_auth;
967        use prospero_core::Scope;
968        use prospero_core::auth::{generate_token, tokens_file_line};
969        use std::net::SocketAddr;
970        use std::path::PathBuf;
971
972        fn loopback() -> SocketAddr {
973            "127.0.0.1:7878".parse().unwrap()
974        }
975        fn public() -> SocketAddr {
976            "0.0.0.0:7878".parse().unwrap()
977        }
978        fn write(dir: &std::path::Path, name: &str, contents: &str) -> PathBuf {
979            let p = dir.join(name);
980            std::fs::write(&p, contents).unwrap();
981            p
982        }
983        fn tokens(dir: &std::path::Path) -> PathBuf {
984            write(
985                dir,
986                "tokens",
987                &tokens_file_line("root", Scope::Admin, &generate_token()),
988            )
989        }
990        fn msg(e: anyhow::Error) -> String {
991            format!("{e:#}")
992        }
993
994        #[test]
995        fn loopback_without_tokens_runs_with_auth_disabled() {
996            let a = resolve_auth(loopback(), false, None, None, false, false).unwrap();
997            assert!(!a.is_enabled());
998        }
999
1000        #[test]
1001        fn public_bind_without_tokens_is_refused_unless_insecure() {
1002            let e = msg(resolve_auth(public(), false, None, None, false, false).unwrap_err());
1003            assert!(
1004                e.contains("--api-tokens-file") && e.contains("--insecure-no-auth"),
1005                "{e}"
1006            );
1007            let a = resolve_auth(public(), false, None, None, true, false).unwrap();
1008            assert!(!a.is_enabled());
1009        }
1010
1011        #[test]
1012        fn tokens_enable_auth_and_standalone_needs_no_key_file() {
1013            let dir = tempfile::tempdir().unwrap();
1014            let a = resolve_auth(
1015                public(),
1016                false,
1017                Some(&tokens(dir.path())),
1018                None,
1019                false,
1020                false,
1021            )
1022            .unwrap();
1023            assert!(a.is_enabled());
1024        }
1025
1026        #[test]
1027        fn clustered_tokens_require_a_session_key_of_32_bytes() {
1028            let dir = tempfile::tempdir().unwrap();
1029            let t = tokens(dir.path());
1030            let e = msg(resolve_auth(public(), true, Some(&t), None, false, false).unwrap_err());
1031            assert!(e.contains("--session-key-file"), "{e}");
1032            let short = write(dir.path(), "short", "too-short\n");
1033            let e = msg(
1034                resolve_auth(public(), true, Some(&t), Some(&short), false, false).unwrap_err(),
1035            );
1036            assert!(e.contains("at least 32 bytes"), "{e}");
1037            let good = write(dir.path(), "key", &format!("{}\n", "k".repeat(48)));
1038            assert!(
1039                resolve_auth(public(), true, Some(&t), Some(&good), false, false)
1040                    .unwrap()
1041                    .is_enabled()
1042            );
1043        }
1044
1045        #[test]
1046        fn malformed_or_missing_tokens_file_is_fatal() {
1047            let dir = tempfile::tempdir().unwrap();
1048            let bad = write(dir.path(), "bad", "root admin sha256:nothex\n");
1049            let e =
1050                msg(resolve_auth(loopback(), false, Some(&bad), None, false, false).unwrap_err());
1051            assert!(e.contains("line 1"), "{e}");
1052            let missing = dir.path().join("nope");
1053            assert!(resolve_auth(loopback(), false, Some(&missing), None, false, false).is_err());
1054        }
1055
1056        #[test]
1057        fn insecure_flag_conflicts_with_tokens() {
1058            let dir = tempfile::tempdir().unwrap();
1059            let e = msg(resolve_auth(
1060                public(),
1061                false,
1062                Some(&tokens(dir.path())),
1063                None,
1064                true,
1065                false,
1066            )
1067            .unwrap_err());
1068            assert!(e.contains("cannot be combined"), "{e}");
1069        }
1070    }
1071}