Menu
AkurAI-Build
publicLatest change 7255093e06e11217a8aa00ce4b6f336d3a5fa654 - Add named service identities and akurai_repo_protect by Ólafur Búi Ólafsson
use std::{env, net::SocketAddr, path::PathBuf};
use akurai_build::{
DEFAULT_ADDRESS,
db::{Database, generate_secret, load_secret},
mcp,
server::{self, ServerOptions},
};
use anyhow::{Context, Result};
use clap::{Args, Parser, Subcommand};
use serde::Serialize;
use serde_json::json;
#[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,
/// Serve the full AkurAI Build Model Context Protocol tool set over stdio.
Mcp,
/// Serve the MiniJinja dashboard, API, webhooks, and worker.
Serve(ServeArgs),
/// Operate the AkurAI EC2 platform box: ssh, deploys, TLS, DNS, releases.
Ec2 {
#[command(subcommand)]
command: akurai_build::ec2::Ec2Command,
},
}
#[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_URL")]
public_url: 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 Cli {
data,
key_file,
command,
} = cli;
// Host-ops verbs never touch the encrypted database.
match command {
Action::Ec2 { command } => akurai_build::ec2::execute(command),
command => {
let paths = Paths::new(data, key_file)?;
match command {
Action::Keygen { out } => {
generate_secret(&out)?;
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::Mcp => mcp::serve(open(&paths)?, paths.root.clone(), native_allowed())?,
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 service_tokens = parse_service_tokens(
env::var("AKURAI_SERVICE_TOKENS")
.unwrap_or_default()
.as_str(),
token.as_ref(),
)?;
let public_url = args.public_url.context("AKURAI_PUBLIC_URL is required")?;
database.migrate()?;
server::serve(
database,
ServerOptions {
address: args.address,
token: token.to_string(),
service_tokens,
webhook_secret: webhook_secret.to_string(),
public_url,
data_root: paths.root,
allow_native: args.allow_native,
worker_count: args.worker_count,
},
)
.await?;
}
Action::Ec2 { .. } => unreachable!("handled before database setup"),
}
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 native_allowed() -> bool {
env::var("AKURAI_ALLOW_NATIVE")
.is_ok_and(|value| matches!(value.as_str(), "1" | "true" | "yes"))
}
fn init_logging() {
akurai_log::init("akurai-build");
}
/// `AKURAI_SERVICE_TOKENS="reviewer:<secret>,builder:<secret>"` — named machine
/// identities for headless agents. Each name becomes the actor on everything
/// that token does (PR authorship, reviews, audit), so independent review can
/// be enforced between agents the same way it is between humans.
fn parse_service_tokens(raw: &str, admin_token: &str) -> anyhow::Result<Vec<(String, String)>> {
let mut out: Vec<(String, String)> = Vec::new();
for entry in raw
.split(',')
.map(str::trim)
.filter(|entry| !entry.is_empty())
{
let (name, token) = entry
.split_once(':')
.with_context(|| format!("AKURAI_SERVICE_TOKENS entry {entry:?} must be name:token"))?;
let (name, token) = (name.trim(), token.trim());
anyhow::ensure!(
!name.is_empty()
&& name.len() <= 64
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_'),
"service name {name:?} must be 1..=64 of [a-z0-9_-]"
);
anyhow::ensure!(
!matches!(name, "static-bearer" | "stdio" | "machine"),
"service name {name:?} is reserved"
);
anyhow::ensure!(
token.len() >= 32,
"service token for {name:?} must be at least 32 characters"
);
anyhow::ensure!(
token != admin_token,
"service token for {name:?} must differ from the admin token"
);
anyhow::ensure!(
!out.iter().any(|(n, _)| n == name),
"duplicate service name {name:?}"
);
anyhow::ensure!(
!out.iter().any(|(_, t)| t == token),
"duplicate service token for {name:?}"
);
out.push((name.to_owned(), token.to_owned()));
}
Ok(out)
}
#[cfg(test)]
mod service_token_tests {
use super::parse_service_tokens;
const ADMIN: &str = "adminadminadminadminadminadminadmin";
const T1: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const T2: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
#[test]
fn parses_named_tokens_and_rejects_bad_entries() {
let ok = parse_service_tokens(&format!(" reviewer:{T1}, builder:{T2} ,"), ADMIN)
.expect("valid tokens");
assert_eq!(
ok,
vec![
("reviewer".into(), T1.into()),
("builder".into(), T2.into())
]
);
assert!(
parse_service_tokens("", ADMIN)
.expect("empty is valid")
.is_empty()
);
for bad in [
format!("reviewer={T1}"),
format!("Reviewer:{T1}"),
format!("static-bearer:{T1}"),
"reviewer:short".to_owned(),
format!("reviewer:{ADMIN}"),
format!("reviewer:{T1},reviewer:{T2}"),
format!("reviewer:{T1},builder:{T1}"),
] {
assert!(parse_service_tokens(&bad, ADMIN).is_err(), "{bad}");
}
}
}