4.7 KiB · text History e832e77
//! devendra-agent CLI.
//!
//! - `run` — join the controller document and reconcile this host toward its
//!   published manifest (the long-running daemon mode).
//! - `adopt` — scan an existing quadlet directory and publish its files as an
//!   adoption proposal for the controller to review, then exit.

use std::path::PathBuf;
use std::str::FromStr;

use anyhow::{Context, Result};
use clap::{Args, Parser, Subcommand};
use devendra_agent::adopt::{self, AdoptConfig};
use devendra_agent::reconcile::systemd::Scope;
use devendra_agent::sync::{self, AgentConfig};
use devendra_node::DocTicket;
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};

#[derive(Parser, Debug)]
#[command(name = "devendra-agent", about = "devendra host agent")]
struct Cli {
    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand, Debug)]
enum Cmd {
    /// Join the controller document and reconcile this host continuously.
    Run(RunArgs),
    /// Scan a directory and publish its files as an adoption proposal.
    Adopt(AdoptArgs),
}

/// Flags shared by every subcommand: which host, which document, where to store.
#[derive(Args, Debug)]
struct CommonArgs {
    /// Stable identifier for this host; selects which manifest/proposal to use.
    /// Defaults to the system hostname when not given.
    #[arg(long, env = "DEVENDRA_HOST_ID")]
    host_id: Option<String>,

    /// Controller join ticket (from `devendra-controller ticket`).
    #[arg(long, env = "DEVENDRA_TICKET")]
    ticket: String,

    /// Directory for the agent's iroh blob/doc stores.
    #[arg(
        long,
        env = "DEVENDRA_DATA_DIR",
        default_value = "/var/lib/devendra/agent"
    )]
    data_dir: PathBuf,
}

impl CommonArgs {
    fn ticket(&self) -> Result<DocTicket> {
        DocTicket::from_str(&self.ticket)
            .map_err(|e| anyhow::anyhow!("invalid ticket: {e}"))
            .context("parsing --ticket")
    }

    /// Resolve the host id: the `--host-id` flag/env when set, otherwise the
    /// system hostname. Logs when the hostname fallback is used so it's clear
    /// which manifest the agent selected.
    fn host_id(&self) -> Result<String> {
        if let Some(id) = &self.host_id {
            return Ok(id.clone());
        }
        let host = nix::unistd::gethostname()
            .context("reading system hostname (no --host-id / DEVENDRA_HOST_ID set)")?
            .into_string()
            .map_err(|raw| anyhow::anyhow!("system hostname is not valid UTF-8: {raw:?}"))?;
        if host.is_empty() {
            anyhow::bail!("no --host-id given and the system hostname is empty");
        }
        tracing::info!(host_id = %host, "inferred host id from system hostname");
        Ok(host)
    }
}

#[derive(Args, Debug)]
struct RunArgs {
    #[command(flatten)]
    common: CommonArgs,

    /// Manage per-user systemd units (`systemctl --user`) instead of system units.
    #[arg(long, env = "DEVENDRA_USER_SCOPE")]
    user_scope: bool,

    /// Don't undo files/services that drop out of the desired state. By default
    /// the agent prunes them (e.g. when a fragment is removed from a host).
    #[arg(long, env = "DEVENDRA_NO_PRUNE")]
    no_prune: bool,
}

#[derive(Args, Debug)]
struct AdoptArgs {
    #[command(flatten)]
    common: CommonArgs,

    /// Directory to scan for existing files to adopt.
    #[arg(long, default_value = "/etc/containers/systemd")]
    dir: PathBuf,
}

#[tokio::main]
async fn main() -> Result<()> {
    tracing_subscriber::registry()
        .with(
            EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "devendra_agent=info,devendra_node=info".into()),
        )
        .with(tracing_subscriber::fmt::layer())
        .init();

    match Cli::parse().cmd {
        Cmd::Run(args) => {
            let config = AgentConfig {
                host_id: args.common.host_id()?,
                ticket: args.common.ticket()?,
                data_dir: args.common.data_dir.clone(),
                scope: if args.user_scope {
                    Scope::User
                } else {
                    Scope::System
                },
                prune: !args.no_prune,
            };
            // Ctrl-C ends the loop and flushes the node cleanly.
            sync::run_until(config, async {
                let _ = tokio::signal::ctrl_c().await;
                tracing::info!("received Ctrl-C");
            })
            .await
        }
        Cmd::Adopt(args) => {
            let config = AdoptConfig {
                host_id: args.common.host_id()?,
                ticket: args.common.ticket()?,
                data_dir: args.common.data_dir.clone(),
                scan_dir: args.dir,
            };
            adopt::adopt(config).await
        }
    }
}