liminal-server 0.21.1

Standalone server for the liminal messaging bus
Documentation
//! Installs the services registry once the listener is bound (SRV-028 R5):
//! the registry is read by one scan, and the services last recorded live
//! are started off the start path, so the start line does not grow with
//! them.

use std::path::PathBuf;
use std::sync::Arc;

use crate::ServerError;
use crate::config::ServerConfig;
use crate::services::lifecycle::{Lifecycle, LysPlacer, Placer};
use crate::services::lys_http::{HttpLys, LysHttp};

/// Stops every service process when the server's run ends; their states
/// stay, so a live service starts again with the next start.
#[derive(Debug)]
pub struct Services(Option<Arc<Lifecycle>>);

impl Services {
    /// Gives the registry the address the listener bound and starts every
    /// service last recorded live, off the start path.
    ///
    /// # Errors
    /// The restart thread could not start.
    pub fn listening(&self, address: std::net::SocketAddr) -> Result<(), ServerError> {
        let Some(lifecycle) = self.0.as_ref() else {
            return Ok(());
        };
        lifecycle.listening(address.to_string());
        let refused = |message: String| ServerError::ConfigValidation { message };
        let restarting = Arc::clone(lifecycle);
        std::thread::Builder::new()
            .name("liminal-services-restart".to_owned())
            .spawn(move || {
                let now_ms = std::time::SystemTime::now()
                    .duration_since(std::time::UNIX_EPOCH)
                    .map_or(0, |since| {
                        u64::try_from(since.as_millis()).unwrap_or(u64::MAX)
                    });
                for (service, error) in restarting.restart_live(now_ms) {
                    tracing::warn!(%service, %error, "a live service could not be restarted");
                }
            })
            .map_err(|error| refused(format!("the services restart could not start: {error}")))?;
        Ok(())
    }
}

impl Services {
    /// The server's stop was asked: an exit from here keeps its service's
    /// recorded state (see [`Lifecycle::shutdown_begun`]).
    pub fn shutdown_begun(&self) {
        if let Some(lifecycle) = self.0.as_ref() {
            lifecycle.shutdown_begun();
        }
    }
}

impl Drop for Services {
    fn drop(&mut self) {
        if let Some(lifecycle) = self.0.take() {
            lifecycle.shutdown();
        }
    }
}

/// Opens and installs the registry on the supervisor before any connection
/// is admitted, so no request finds it missing; holds nothing without
/// templates. [`Services::listening`] then gives it the bound address and
/// starts the restart of live services on its own thread.
///
/// # Errors
/// The registry or the app credential cannot be read, or Lys cannot be
/// reached as configured.
pub fn install(
    config: &ServerConfig,
    supervisor: &crate::server::connection::ConnectionSupervisor,
    lys_ask: Option<&tokio::runtime::Runtime>,
) -> Result<Services, ServerError> {
    let Some(lys) = config.auth.as_ref().and_then(|auth| auth.lys.as_ref()) else {
        return Ok(Services(None));
    };
    if lys.templates.is_empty() {
        return Ok(Services(None));
    }
    let refused = |message: String| ServerError::ConfigValidation { message };
    let store = config
        .persistence_path
        .as_ref()
        .ok_or_else(|| refused("auth.lys.templates: persistence_path is required".to_owned()))?;
    let placer: Option<Arc<dyn Placer>> = match (lys.app_credential_path.as_ref(), lys_ask) {
        (Some(path), Some(runtime)) => {
            let credential = std::fs::read_to_string(path).map_err(|error| {
                refused(format!(
                    "auth.lys.app_credential_path '{}' cannot be read: {}",
                    path.display(),
                    error.kind()
                ))
            })?;
            let http: Arc<dyn LysHttp> = Arc::new(
                HttpLys::new(
                    lys.api_url.as_deref().unwrap_or(&lys.issuer),
                    runtime.handle().clone(),
                )
                .map_err(|error| refused(error.to_string()))?,
            );
            Some(Arc::new(LysPlacer::new(http, credential.trim().to_owned())))
        }
        _ => None,
    };
    let lifecycle = Lifecycle::open(
        &lys.templates,
        registry_dir(store),
        lys.network_id.clone(),
        placer,
    )
    .map_err(|error| refused(error.to_string()))?;
    supervisor.install_lifecycle(Arc::clone(&lifecycle))?;
    Ok(Services(Some(lifecycle)))
}

/// The registry sits beside the store, never inside it: the store directory
/// belongs to its engine.
fn registry_dir(store: &std::path::Path) -> PathBuf {
    let name = store.file_name().map_or_else(
        || "liminal".into(),
        |name| name.to_string_lossy().into_owned(),
    );
    store.with_file_name(format!("{name}-services"))
}

#[cfg(test)]
mod tests {
    use super::registry_dir;

    #[test]
    fn the_registry_sits_beside_the_store() {
        assert_eq!(
            registry_dir(std::path::Path::new("/data/liminal/store")),
            std::path::PathBuf::from("/data/liminal/store-services")
        );
    }
}