aegis-tool 0.3.3

Aegis SSH client and managed host agent.
Documentation
use std::path::PathBuf;
use std::time::Duration;

use anyhow::{Context, Result};
use capulus::managed::{
    AgentServiceOptions, JobId, ManagedProduct, ManagedProductOptions, ManagedProgramOptions,
    ManagedRedeployOptions, ManagementClient, ManagementClientOptions, ManagementError,
    ManagementRequest, ManagementResponse, RedeployJob, RedeployOutcome, ResolvedReleaseInfo,
    ServiceHardening, SocketOptions, VersionTarget,
};
use semver::Version;

pub(crate) const MANAGEMENT_SOCKET_PATH: &str = "/run/aegis/capulus.sock";
pub(crate) const MANAGEMENT_SOCKET_NAME: &str = "aegis-capulus.socket";
pub(crate) const APPLICATION_SOCKET_NAME: &str = "aegis-agent.socket";
pub(crate) const ACCESS_GROUP: &str = "aegis";

pub(crate) fn product() -> Result<ManagedProduct> {
    ManagedProductOptions {
        product: "aegis".to_string(),
        package: "aegis-tool".to_string(),
        version: Version::parse(env!("CARGO_PKG_VERSION"))
            .context("aegis-tool package version is not semantic")?,
        program: ManagedProgramOptions {
            cargo_binary: "aegis".to_string(),
            installed_path: PathBuf::from(aegis_dto::layout::SYSTEM_BINARY_PATH),
            command_prefix: vec!["agent".to_string()],
        },
        service: AgentServiceOptions {
            description: "aegis mesh agent".to_string(),
            command: vec![
                "serve".to_string(),
                "--config".to_string(),
                aegis_dto::layout::AGENT_CONFIG_PATH.to_string(),
            ],
            restart_delay: Duration::from_secs(60),
            network_required: true,
            state_directory_mode: 0o755,
            hardening: ServiceHardening::SystemNetworkController,
        },
        application_socket: SocketOptions {
            path: PathBuf::from(crate::config::AEGIS_AGENT_SOCKET_PATH),
            mode: 0o666,
            group: None,
        },
        management_socket: SocketOptions {
            path: PathBuf::from(MANAGEMENT_SOCKET_PATH),
            mode: 0o660,
            group: Some(ACCESS_GROUP.to_string()),
        },
        redeploy: ManagedRedeployOptions {
            build_timeout: Duration::from_secs(40 * 60),
            ..ManagedRedeployOptions::default()
        },
    }
    .validate()
    .context("aegis managed-product declaration is invalid")
}

pub(crate) fn client() -> ManagementClient {
    let mut options = ManagementClientOptions::new(MANAGEMENT_SOCKET_PATH);
    options.timeout = Duration::from_secs(45);
    ManagementClient::new(options)
}

pub(crate) fn schedule(target: VersionTarget) -> Result<RedeployOutcome, ManagementError> {
    match client().request(ManagementRequest::Redeploy { target })? {
        ManagementResponse::Redeploy(outcome) => Ok(outcome),
        _ => Err(unexpected_response("redeploy scheduling")),
    }
}

pub(crate) fn resolve(target: VersionTarget) -> Result<ResolvedReleaseInfo, ManagementError> {
    match client().request(ManagementRequest::Resolve { target })? {
        ManagementResponse::Resolved(release) => Ok(release),
        _ => Err(unexpected_response("release resolution")),
    }
}

pub(crate) fn status(job: JobId) -> Result<RedeployJob, ManagementError> {
    match client().request(ManagementRequest::JobStatus { job })? {
        ManagementResponse::Job(status) => Ok(status),
        _ => Err(unexpected_response("redeploy status")),
    }
}

fn unexpected_response(operation: &str) -> ManagementError {
    ManagementError::Decode(format!(
        "aegis Capulus endpoint returned the wrong response for {operation}"
    ))
}

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

    #[test]
    fn manifest_uses_the_single_program_and_both_systemd_sockets() {
        let product = product().unwrap();
        let manifest = product.installation_manifest();

        assert_eq!(
            product.program().installed_path(),
            std::path::Path::new(aegis_dto::layout::SYSTEM_BINARY_PATH)
        );
        assert!(manifest.files.iter().any(|file| matches!(
            file,
            capulus::managed::ManagedFile::Binary { source_name, .. }
                if source_name == "aegis"
        )));
        assert!(
            manifest
                .enable_units
                .contains(&MANAGEMENT_SOCKET_NAME.to_string())
        );
        assert!(
            manifest
                .enable_units
                .contains(&product.application_socket_name())
        );
        let service = manifest
            .files
            .iter()
            .find_map(|file| match file {
                capulus::managed::ManagedFile::Text {
                    destination,
                    contents,
                    ..
                } if destination
                    == std::path::Path::new("/etc/systemd/system/aegis-agent.service") =>
                {
                    Some(contents)
                }
                _ => None,
            })
            .expect("service unit should be present");
        assert!(
            service.contains("ExecStart=\"/usr/local/bin/aegis\" \"agent\" \"serve\" \"--config\"")
        );
    }
}