AkurAI Build
Menu

AkurAI-Build

public

Latest change 30e6b8c347048d64c2c048dfd05a8ded219d37f6 - Host authenticated Git repositories over Smart HTTP with repo host/sync CLI by Ólafur Búi Ólafsson

use std::{
    env, fs,
    net::SocketAddr,
    path::PathBuf,
    process::Command,
    thread,
    time::{Duration, Instant},
};

use akurai_build::{
    DEFAULT_ADDRESS, config,
    db::{Database, RepositoryQuery, RunQuery, generate_secret, load_secret},
    hosted_git, mcp,
    runner::Runner,
    server::{self, ServerOptions},
};
use anyhow::{Context, Result, ensure};
use clap::{Args, Parser, Subcommand};
use serde::Serialize;
use serde_json::json;
use tracing_subscriber::EnvFilter;

#[derive(Parser)]
#[command(
    name = "akurai",
    version,
    about = "Lean Git-native CI/CD on the Bunfork foundation"
)]
struct Cli {
    #[arg(long, env = "AKURAI_DATA")]
    data: Option<PathBuf>,
    #[arg(long, env = "AKURAI_KEY_FILE")]
    key_file: Option<PathBuf>,
    #[command(subcommand)]
    command: Action,
}

#[derive(Subcommand)]
enum Action {
    /// Generate a private 256-bit key or token.
    Keygen {
        #[arg(long)]
        out: PathBuf,
    },
    /// Create or update the encrypted database schema.
    Migrate,
    /// Write a minimal .akurai.yml into a repository.
    Init {
        #[arg(long, default_value = ".akurai.yml")]
        out: PathBuf,
        #[arg(long)]
        force: bool,
    },
    /// Register and inspect Git repositories.
    Repo {
        #[command(subcommand)]
        command: RepoAction,
    },
    /// Queue a pipeline, optionally waiting in this process.
    Run(RunArgs),
    /// Query pipeline runs across all registered repositories.
    Runs {
        #[arg(long)]
        repo: Option<String>,
        #[arg(long, help = "comma-separated statuses")]
        status: Option<String>,
        #[arg(long)]
        git_ref: Option<String>,
        #[arg(long, help = "comma-separated triggers")]
        trigger: Option<String>,
        #[arg(long, short = 'q')]
        search: Option<String>,
        #[arg(long, default_value_t = 20)]
        limit: usize,
        #[arg(long, default_value_t = 0)]
        offset: usize,
    },
    /// Show the Titan build workers and their last heartbeat.
    Workers,
    /// Show a run, jobs, artifacts, and deployments.
    Show { id: i64 },
    /// Wait for a persisted terminal or approval-required state.
    Wait {
        id: i64,
        #[arg(long, default_value_t = 3600)]
        timeout: u64,
        #[arg(long, default_value_t = 2)]
        interval: u64,
    },
    /// Return job logs for a run.
    Logs {
        id: i64,
        #[arg(long)]
        failed: bool,
    },
    /// Queue the same immutable revision as a prior run.
    Retry {
        id: i64,
        #[arg(long)]
        wait: bool,
    },
    /// Approve and resume a protected environment.
    Promote {
        id: i64,
        environment: String,
        #[arg(long)]
        wait: bool,
    },
    /// Retrieve build artifacts.
    Artifact {
        #[command(subcommand)]
        command: ArtifactAction,
    },
    /// Serve read-only Model Context Protocol tools over stdio.
    Mcp,
    /// Run controller diagnostics without changing state.
    Doctor,
    /// Serve the MiniJinja dashboard, API, webhooks, and worker.
    Serve(ServeArgs),
}

#[derive(Subcommand)]
enum RepoAction {
    Add {
        name: String,
        url: String,
        #[arg(long, default_value = "main")]
        branch: String,
    },
    /// Create a bare mirror owned by AkurAI Build and register it for CI.
    Host {
        name: String,
        source: PathBuf,
        #[arg(long, default_value = "main")]
        branch: String,
    },
    /// Synchronize an existing AkurAI Build mirror from its trusted Titan checkout.
    Sync {
        name: String,
        source: PathBuf,
    },
    Rename {
        old: String,
        new: String,
    },
    List {
        #[arg(long, short = 'q')]
        search: Option<String>,
        #[arg(long)]
        visibility: Option<String>,
        #[arg(long, default_value_t = 50)]
        limit: usize,
        #[arg(long, default_value_t = 0)]
        offset: usize,
    },
}

#[derive(Args)]
struct RunArgs {
    repository: String,
    #[arg(long)]
    git_ref: Option<String>,
    #[arg(long)]
    commit: Option<String>,
    #[arg(long)]
    wait: bool,
}

#[derive(Subcommand)]
enum ArtifactAction {
    Get {
        id: i64,
        output: PathBuf,
        #[arg(long)]
        force: bool,
    },
}

#[derive(Args)]
struct ServeArgs {
    #[arg(long, env = "AKURAI_ADDRESS", default_value = DEFAULT_ADDRESS)]
    address: SocketAddr,
    #[arg(long, env = "AKURAI_TOKEN_FILE")]
    token_file: Option<PathBuf>,
    #[arg(long, env = "AKURAI_WEBHOOK_SECRET_FILE")]
    webhook_secret_file: Option<PathBuf>,
    #[arg(long, env = "AKURAI_PUBLIC_ORIGIN")]
    public_origin: Option<String>,
    #[arg(long, env = "AKURAI_ALLOW_NATIVE", default_value_t = false)]
    allow_native: bool,
    #[arg(long, env = "AKURAI_WORKERS", default_value_t = 2)]
    worker_count: usize,
}

#[derive(Serialize)]
struct Envelope<T: Serialize> {
    ok: bool,
    data: T,
}

#[tokio::main]
async fn main() {
    init_logging();
    let cli = Cli::parse();
    if let Err(error) = execute(cli).await {
        let body = json!({"ok":false,"error":{"code":"failed","message":format!("{error:#}")}});
        println!(
            "{}",
            serde_json::to_string(&body).unwrap_or_else(|_| "{\"ok\":false}".into())
        );
        std::process::exit(1);
    }
}

async fn execute(cli: Cli) -> Result<()> {
    let paths = Paths::new(cli.data, cli.key_file)?;
    match cli.command {
        Action::Keygen { out } => {
            generate_secret(&out)?;
            print(json!({"path":out,"created":true}))?;
        }
        Action::Init { out, force } => {
            ensure!(
                force || !out.exists(),
                "{} already exists; use --force",
                out.display()
            );
            fs::write(&out, include_str!("../akurai.example.yml"))?;
            print(json!({"path":out,"created":true}))?;
        }
        Action::Migrate => {
            let key = load_secret(&paths.key_file, "AKURAI_DB_KEY")?;
            let database = Database::open(&paths.database, &key, true)?;
            let applied = database.migrate()?;
            print(json!({"database":paths.database,"migrations_applied":applied}))?;
        }
        Action::Repo { command } => {
            let database = open(&paths)?;
            match command {
                RepoAction::Add { name, url, branch } => {
                    config::validate_repository(&name, &url, &branch)?;
                    print(database.add_repository(&name, &url, &branch)?)?;
                }
                RepoAction::Host {
                    name,
                    source,
                    branch,
                } => {
                    let path = hosted_git::host(&paths.root, &name, &source)?;
                    match database.add_repository(
                        &name,
                        path.to_str()
                            .context("hosted repository path is not UTF-8")?,
                        &branch,
                    ) {
                        Ok(repository) => print(json!({
                            "repository": repository,
                            "clone_url": format!("https://akurai-build.olibuijr.com/git/{name}.git")
                        }))?,
                        Err(error) => {
                            let _ = fs::remove_dir_all(path);
                            return Err(error);
                        }
                    }
                }
                RepoAction::Sync { name, source } => {
                    let path = hosted_git::sync(&paths.root, &name, &source)?;
                    print(json!({"name": name, "path": path, "synchronized": true}))?;
                }
                RepoAction::Rename { old, new } => {
                    print(database.rename_repository(&old, &new)?)?;
                }
                RepoAction::List {
                    search,
                    visibility,
                    limit,
                    offset,
                } => print(database.query_repositories(&RepositoryQuery {
                    search,
                    visibility,
                    limit,
                    offset,
                })?)?,
            }
        }
        Action::Run(args) => {
            let database = open(&paths)?;
            let runner = Runner::new(database.clone(), paths.root.clone(), native_allowed())?;
            let id = runner.queue(
                &args.repository,
                args.git_ref.as_deref(),
                args.commit.as_deref(),
                "manual",
            )?;
            if args.wait {
                print(runner.process(id)?)?;
            } else {
                print(json!({"id":id,"status":"queued"}))?;
            }
        }
        Action::Runs {
            repo,
            status,
            git_ref,
            trigger,
            search,
            limit,
            offset,
        } => print(open(&paths)?.query_runs(&RunQuery {
            repository: repo,
            statuses: list_filter(status),
            git_ref,
            triggers: list_filter(trigger),
            search,
            limit,
            offset,
        })?)?,
        Action::Workers => print(open(&paths)?.workers()?)?,
        Action::Show { id } => print(open(&paths)?.detail(id)?)?,
        Action::Wait {
            id,
            timeout,
            interval,
        } => {
            ensure!(
                (1..=14_400).contains(&timeout),
                "timeout must be 1..=14400 seconds"
            );
            ensure!(
                (1..=60).contains(&interval),
                "interval must be 1..=60 seconds"
            );
            let database = open(&paths)?;
            let deadline = Instant::now() + Duration::from_secs(timeout);
            loop {
                let detail = database.detail(id)?;
                if matches!(
                    detail.run.status.as_str(),
                    "waiting" | "succeeded" | "failed" | "canceled" | "interrupted"
                ) {
                    print(detail)?;
                    break;
                }
                ensure!(Instant::now() < deadline, "timed out waiting for run {id}");
                thread::sleep(Duration::from_secs(interval));
            }
        }
        Action::Logs { id, failed } => {
            let mut jobs = open(&paths)?.jobs(id)?;
            if failed {
                jobs.retain(|job| {
                    matches!(job.status.as_str(), "failed" | "interrupted" | "canceled")
                });
            }
            print(jobs.into_iter().map(|job| json!({"id":job.id,"name":job.name,"status":job.status,"logs":job.logs})).collect::<Vec<_>>())?;
        }
        Action::Retry { id, wait } => {
            let database = open(&paths)?;
            let id = database.retry(id)?;
            if wait {
                let runner = Runner::new(database, paths.root.clone(), native_allowed())?;
                print(runner.process(id)?)?;
            } else {
                print(json!({"id":id,"status":"queued"}))?;
            }
        }
        Action::Promote {
            id,
            environment,
            wait,
        } => {
            let database = open(&paths)?;
            let jobs = database.approve_environment(id, &environment)?;
            if wait {
                let runner = Runner::new(database, paths.root.clone(), native_allowed())?;
                print(runner.process(id)?)?;
            } else {
                print(
                    json!({"id":id,"environment":environment,"approved_jobs":jobs,"status":"queued"}),
                )?;
            }
        }
        Action::Artifact { command } => match command {
            ArtifactAction::Get { id, output, force } => {
                ensure!(
                    force || !output.exists(),
                    "{} exists; use --force",
                    output.display()
                );
                let database = open(&paths)?;
                let runner = Runner::new(database.clone(), paths.root.clone(), native_allowed())?;
                let artifact = database.artifact(id)?;
                let source = runner.artifact_path(&artifact)?;
                if let Some(parent) = output.parent().filter(|path| !path.as_os_str().is_empty()) {
                    fs::create_dir_all(parent)?;
                }
                fs::copy(source, &output)?;
                print(
                    json!({"id":id,"output":output,"sha256":artifact.sha256,"bytes":artifact.bytes}),
                )?;
            }
        },
        Action::Mcp => mcp::serve(open(&paths)?)?,
        Action::Doctor => print(doctor(&paths)?)?,
        Action::Serve(args) => {
            let key = load_secret(&paths.key_file, "AKURAI_DB_KEY")?;
            let database = Database::open(&paths.database, &key, false)?;
            let token_file = args
                .token_file
                .unwrap_or_else(|| paths.config.join("api.token"));
            let webhook_file = args
                .webhook_secret_file
                .unwrap_or_else(|| paths.config.join("webhook.token"));
            let token = load_secret(&token_file, "AKURAI_API_TOKEN")?;
            let webhook_secret = load_secret(&webhook_file, "AKURAI_WEBHOOK_SECRET")?;
            let public_origin = args
                .public_origin
                .context("AKURAI_PUBLIC_ORIGIN is required")?;
            server::serve(
                database,
                ServerOptions {
                    address: args.address,
                    token: token.to_string(),
                    webhook_secret: webhook_secret.to_string(),
                    public_origin,
                    data_root: paths.root,
                    allow_native: args.allow_native,
                    worker_count: args.worker_count,
                },
            )
            .await?;
        }
    }
    Ok(())
}

struct Paths {
    root: PathBuf,
    database: PathBuf,
    config: PathBuf,
    key_file: PathBuf,
}

impl Paths {
    fn new(data: Option<PathBuf>, key_file: Option<PathBuf>) -> Result<Self> {
        let home = env::var_os("HOME")
            .map(PathBuf::from)
            .context("HOME is not set")?;
        let root = data.unwrap_or_else(|| home.join(".local/share/akurai-build"));
        let config = home.join(".config/akurai-build");
        let database = root.join("akurai.db");
        let key_file = key_file.unwrap_or_else(|| config.join("database.key"));
        Ok(Self {
            root,
            database,
            config,
            key_file,
        })
    }
}

fn open(paths: &Paths) -> Result<Database> {
    let key = load_secret(&paths.key_file, "AKURAI_DB_KEY")?;
    let database = Database::open(&paths.database, &key, false)?;
    database.validate_schema()?;
    Ok(database)
}

fn print<T: Serialize>(data: T) -> Result<()> {
    println!(
        "{}",
        serde_json::to_string_pretty(&Envelope { ok: true, data })?
    );
    Ok(())
}

fn doctor(paths: &Paths) -> Result<serde_json::Value> {
    let git = version("git", &["--version"]);
    let docker = version("docker", &["info", "--format", "{{.ServerVersion}}"]);
    let database = open(paths)
        .and_then(|database| database.check_ready())
        .map(|()| "ready".to_owned())
        .unwrap_or_else(|error| format!("unavailable: {error}"));
    Ok(json!({"database":database,"git":git,"docker":docker,"data":paths.root}))
}

fn version(program: &str, arguments: &[&str]) -> serde_json::Value {
    match Command::new(program).args(arguments).output() {
        Ok(output) => {
            json!({"ok":output.status.success(),"output":String::from_utf8_lossy(if output.status.success(){&output.stdout}else{&output.stderr}).trim()})
        }
        Err(error) => json!({"ok":false,"output":error.to_string()}),
    }
}

fn list_filter(value: Option<String>) -> Vec<String> {
    value
        .into_iter()
        .flat_map(|value| {
            value
                .split(',')
                .map(str::trim)
                .filter(|item| !item.is_empty())
                .map(str::to_owned)
                .collect::<Vec<_>>()
        })
        .collect()
}

fn native_allowed() -> bool {
    env::var("AKURAI_ALLOW_NATIVE")
        .is_ok_and(|value| matches!(value.as_str(), "1" | "true" | "yes"))
}

fn init_logging() {
    let filter = EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| EnvFilter::new("akurai_build=info,tower_http=info"));
    tracing_subscriber::fmt()
        .with_env_filter(filter)
        .with_writer(std::io::stderr)
        .init();
}