liminal-server 0.24.9

Standalone server for the liminal messaging bus
Documentation
//! Lys effects read credentials for the request, never keep their bytes.

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

use super::error::AgentError;
use super::registry::AgentSeats;
use crate::config::types::LysConfig;
use crate::link_pass::MachinePass;
use crate::services::lifecycle::Placer;
use crate::services::lys_http::{HttpLys, LysHttp, segment};

struct Effects {
    http: HttpLys,
    app_credential: Option<PathBuf>,
    pass: Option<MachinePass>,
    machine: Option<String>,
}

impl std::fmt::Debug for Effects {
    fn fmt(&self, out: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        out.debug_struct("AgentLysEffects").finish_non_exhaustive()
    }
}

/// Binds the existing Lys HTTP routes; authority remains with the draft connector.
type Bindings = (Option<Arc<dyn Placer>>, Option<Arc<dyn AgentSeats>>);

pub fn configured(
    lys: &LysConfig,
    runtime: Option<tokio::runtime::Handle>,
) -> Result<Bindings, AgentError> {
    if lys.app_credential_path.is_none() && lys.machine.is_none() {
        return Ok((None, None));
    }
    let handle = runtime.ok_or(AgentError::Invalid(
        "agent Lys effects require the server runtime",
    ))?;
    let http = HttpLys::new(lys.api_url.as_deref().unwrap_or(&lys.issuer), handle)
        .map_err(|_| AgentError::Invalid("agent Lys API origin"))?;
    let pass = lys
        .machine
        .as_ref()
        .map(MachinePass::configured)
        .transpose()
        .map_err(|_| AgentError::Invalid("agent Lys machine pass source"))?;
    let effects = Arc::new(Effects {
        http,
        app_credential: lys.app_credential_path.clone(),
        pass,
        machine: lys
            .services
            .as_ref()
            .map(|services| services.machine.clone()),
    });
    let placer: Option<Arc<dyn Placer>> = lys
        .app_credential_path
        .as_ref()
        .map(|_| effects.clone() as Arc<dyn Placer>);
    let seats: Option<Arc<dyn AgentSeats>> = effects
        .machine
        .as_ref()
        .map(|_| effects.clone() as Arc<dyn AgentSeats>);
    Ok((placer, seats))
}

impl Placer for Effects {
    fn place(
        &self,
        operation: &str,
        child: (&str, &str),
        parent: (&str, &str),
    ) -> Result<(), String> {
        let path = self
            .app_credential
            .as_ref()
            .ok_or("agent_app_credential_absent")?;
        let credential = zeroize::Zeroizing::new(
            std::fs::read_to_string(path).map_err(|_| "agent_app_credential_unreadable")?,
        );
        let answer = self
            .http
            .post(
                credential.trim(),
                "apps/liminal/placements",
                &serde_json::json!({
            "operation":operation, "child":{"kind":child.0,"id":child.1},
            "parent":{"kind":parent.0,"id":parent.1}}),
            )
            .map_err(|_| "lys_could_not_be_asked")?;
        if answer.succeeded() {
            Ok(())
        } else {
            Err(stable_refusal(&answer))
        }
    }
}

impl Effects {
    fn seat(&self, name: &str, operation: &str, start: bool) -> Result<(), AgentError> {
        let pass = self
            .pass
            .as_ref()
            .ok_or(AgentError::Invalid("agent machine pass absent"))?
            .current()
            .map_err(|_| AgentError::Invalid("agent machine pass unavailable"))?;
        let route = if start {
            format!("agents/{}/start-command", segment(name))
        } else {
            format!("seats/{}/stop", segment(name))
        };
        let body = if start {
            serde_json::json!({"operation":operation,
            "machine":self.machine.as_ref().ok_or(AgentError::Invalid("agent machine absent"))?})
        } else {
            serde_json::json!({"operation":operation,"force":false})
        };
        let answer = self
            .http
            .post(&pass, &route, &body)
            .map_err(|_| AgentError::Delivery("lys_could_not_be_asked"))?;
        if answer.succeeded() {
            Ok(())
        } else {
            Err(AgentError::Store(stable_refusal(&answer)))
        }
    }
}

impl AgentSeats for Effects {
    fn start(&self, name: &str, operation: &str) -> Result<(), AgentError> {
        self.seat(name, operation, true)
    }
    fn stop(&self, name: &str, operation: &str) -> Result<(), AgentError> {
        self.seat(name, operation, false)
    }
}

fn stable_refusal(answer: &crate::services::lys_http::LysAnswer) -> String {
    let name = answer.refusal();
    if name
        .bytes()
        .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
        && !name.is_empty()
    {
        name
    } else {
        format!("lys_status_{}", answer.status)
    }
}

#[cfg(test)]
mod tests {
    #[test]
    fn effects_use_the_supplied_runtime_outside_a_tokio_context()
    -> Result<(), Box<dyn std::error::Error>> {
        let runtime = tokio::runtime::Builder::new_multi_thread()
            .enable_all()
            .build()?;
        assert!(tokio::runtime::Handle::try_current().is_err());
        let lys = serde_json::from_value(serde_json::json!({
            "issuer": "https://issuer.test",
            "network_id": "network",
            "keys_path": "keys.json",
            "app_credential_path": "connector"
        }))?;
        let (placer, seats) = super::configured(&lys, Some(runtime.handle().clone()))?;
        assert!(placer.is_some());
        assert!(seats.is_none());
        Ok(())
    }
}