mod config;
mod runtime;
mod system;
use anyhow::{Context, Result};
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
use config::{CodebergTemplate, GiteaTemplate, GithubTemplate, GitlabTemplate, Project};
use std::{
ffi::CString,
io::{self, Write},
os::unix::ffi::OsStrExt,
path::{Path, PathBuf},
};
#[derive(Parser)]
#[command(name = "blip", version, about = "Self-hosted webhook deployment queue")]
struct Cli {
#[arg(short, long, global = true, value_name = "FILE")]
config: Option<PathBuf>,
#[arg(short = 'U', long, conflicts_with = "config")]
upgrade: bool,
#[command(subcommand)]
command: Option<CommandKind>,
}
#[derive(Subcommand)]
enum CommandKind {
Serve,
Config {
#[command(subcommand)]
command: ConfigCommand,
},
Project {
#[command(subcommand)]
command: ProjectCommand,
},
History(HistoryArgs),
Logs(LogsArgs),
Queue,
Service {
#[command(subcommand)]
command: ServiceCommand,
},
}
#[derive(Subcommand)]
enum ConfigCommand {
Path,
Show {
#[arg(long)]
show_secrets: bool,
},
Validate,
Set(ConfigSetArgs),
}
#[derive(Args)]
struct ConfigSetArgs {
#[arg(long, value_name = "ADDRESS")]
bind: Option<String>,
#[arg(long, value_name = "FILE")]
history_file: Option<PathBuf>,
#[arg(long, value_name = "DURATION")]
timeout: Option<String>,
}
#[derive(Subcommand)]
enum ProjectCommand {
List,
#[command(name = "create", visible_alias = "add")]
Create(ProjectAddArgs),
Set(ProjectSetArgs),
Show {
id: String,
},
Remove {
id: String,
#[arg(long)]
yes: bool,
},
}
#[derive(Args)]
struct ProjectSetArgs {
id: String,
#[arg(long, value_name = "TIMEOUT")]
timeout: Option<String>,
}
#[derive(Args)]
struct ProjectAddArgs {
#[arg(long, visible_alias = "key")]
id: String,
#[arg(long)]
name: Option<String>,
#[arg(long, value_name = "FILE")]
script: PathBuf,
#[arg(long, value_enum, default_value_t = ProviderKind::Gitlab)]
provider: ProviderKind,
#[arg(long, conflicts_with = "secret_token")]
signing_token: Option<String>,
#[arg(long, visible_alias = "secret", conflicts_with = "signing_token")]
secret_token: Option<String>,
#[arg(long)]
timestamp_tolerance_seconds: Option<i64>,
#[arg(long)]
replace: bool,
}
#[derive(Clone, Copy, ValueEnum)]
enum ProviderKind {
Gitlab,
Github,
Gitea,
Codeberg,
}
#[derive(Args)]
struct HistoryArgs {
#[arg(long)]
project: Option<String>,
#[arg(long, value_parser = ["success", "failure", "timeout", "cancelled", "rejected"])]
status: Option<String>,
#[arg(long)]
limit: Option<usize>,
}
#[derive(Args)]
struct LogsArgs {
#[arg(short = 'n', long, default_value_t = 100)]
lines: usize,
#[arg(short, long)]
follow: bool,
#[arg(long)]
since: Option<String>,
}
#[derive(Subcommand)]
enum ServiceCommand {
Install {
#[arg(long)]
user: Option<String>,
#[arg(long)]
no_start: bool,
},
Uninstall {
#[arg(long)]
yes: bool,
},
Status,
Start,
Stop,
Restart,
Enable,
Disable,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt::init();
let cli = Cli::parse();
if !cli.upgrade && cli.command.is_none() {
Cli::command().print_help()?;
return Ok(());
}
let config_path = config::resolve_path(cli.config.clone());
if config::migrate_legacy_layout(&config_path)? {
eprintln!(
"migrated legacy configuration into {}",
config_path.display()
);
}
if cli.upgrade {
if cli.command.is_some() {
anyhow::bail!("--upgrade cannot be combined with another command");
}
system::upgrade()?;
return Ok(());
}
let command = cli.command.expect("command checked above");
match command {
CommandKind::Serve => runtime::serve(config::load(&config_path)?).await?,
CommandKind::Config { command } => handle_config(command, &config_path)?,
CommandKind::Project { command } => handle_project(command, &config_path)?,
CommandKind::History(arguments) => {
let config = config::load(&config_path)?;
for line in runtime::read_history(
&config.history_file,
arguments.project.as_deref(),
arguments.status.as_deref(),
arguments.limit,
)
.await?
{
println!("{line}");
}
}
CommandKind::Logs(arguments) => {
system::logs(
arguments.lines,
arguments.follow,
arguments.since.as_deref(),
)?;
}
CommandKind::Queue => {
let config = config::load(&config_path)?;
let lock = runtime::queue_lock_path(&config.history_file);
let deliveries = runtime::delivery_file_path(&config.history_file);
let stats = runtime::delivery_stats(&deliveries).await?;
println!("capacity: {}", runtime::QUEUE_CAPACITY);
println!("lock: {}", lock.display());
println!("deliveries: {}", deliveries.display());
println!("queued: {}", stats.queued);
println!("running: {}", stats.running);
println!("completed: {}", stats.completed);
println!("state: {}", runtime::queue_state(&config.history_file)?);
}
CommandKind::Service { command } => handle_service(command, &config_path)?,
}
Ok(())
}
fn handle_config(command: ConfigCommand, path: &Path) -> Result<()> {
match command {
ConfigCommand::Path => println!("{}", path.display()),
ConfigCommand::Show { show_secrets } => {
let config = config::load(path)?;
print!("{}", config::render(&config, show_secrets));
}
ConfigCommand::Validate => {
let config = config::load(path)?;
println!("configuration valid: {} project(s)", config.projects.len());
}
ConfigCommand::Set(arguments) => {
elevate_for_system_config(path)?;
if arguments.bind.is_none()
&& arguments.history_file.is_none()
&& arguments.timeout.is_none()
{
anyhow::bail!("set at least one of --bind, --history-file, or --timeout");
}
let mut config = config::load_or_default(path)?;
if let Some(bind) = arguments.bind {
config.bind = bind;
}
if let Some(history_file) = arguments.history_file {
config.history_file = history_file;
}
if let Some(timeout) = arguments.timeout {
config::validate_duration(&timeout)
.context("timeout must use a value such as 70s, 21m, 6h, or 2d")?;
config.timeout = timeout;
}
config::save(path, &config)?;
println!("updated {}", path.display());
}
}
Ok(())
}
fn handle_project(command: ProjectCommand, path: &Path) -> Result<()> {
match command {
ProjectCommand::List => {
let config = config::load(path)?;
for (key, project) in config.projects {
println!(
"{key}\t{}\t{}\t{}",
project.name,
project.provider_name(),
project.script.display()
);
}
}
ProjectCommand::Create(mut arguments) => {
elevate_for_system_config(path)?;
let mut config = config::load_or_default(path)?;
if config.projects.contains_key(&arguments.id) && !arguments.replace {
anyhow::bail!(
"project {:?} already exists; pass --replace to update it",
arguments.id
);
}
let project = project_from_arguments(&mut arguments)?;
config.projects.insert(arguments.id.clone(), project);
config::save(path, &config)?;
println!("saved project {}", arguments.id);
}
ProjectCommand::Set(arguments) => {
elevate_for_system_config(path)?;
let mut config = config::load(path)?;
let timeout = arguments
.timeout
.as_deref()
.context("project set requires --timeout TIMEOUT")?;
config::validate_duration(timeout)
.context("timeout must use a value such as 70s, 21m, 6h, or 2d")?;
let project = config
.projects
.get_mut(&arguments.id)
.with_context(|| format!("unknown project: {}", arguments.id))?;
project.timeout = Some(timeout.to_string());
config::save(path, &config)?;
println!("updated project {} timeout to {}", arguments.id, timeout);
}
ProjectCommand::Show { id } => {
let config = config::load(path)?;
let project = config
.projects
.get(&id)
.with_context(|| format!("unknown project: {id}"))?;
let timeout = project.timeout.as_deref().unwrap_or(&config.timeout);
let source = if project.timeout.is_some() {
"project"
} else {
"global"
};
println!(
"id: {id}\nname: {}\nscript: {}\nprovider: {}\ntimeout: {timeout}\ntimeout_source: {source}",
project.name,
project.script.display(),
project.provider_name()
);
}
ProjectCommand::Remove { id, yes } => {
elevate_for_system_config(path)?;
if !yes && !confirm(&format!("remove project {id:?}?"))? {
println!("cancelled");
return Ok(());
}
let mut config = config::load(path)?;
if config.projects.remove(&id).is_none() {
anyhow::bail!("unknown project: {id}");
}
config::save(path, &config)?;
println!("removed project {id}");
}
}
Ok(())
}
fn project_from_arguments(arguments: &mut ProjectAddArgs) -> Result<Project> {
let script = arguments.script.clone();
match arguments.provider {
ProviderKind::Gitlab => {
let (signing_token, secret_token) = gitlab_credentials(arguments)?;
Ok(Project {
name: arguments
.name
.clone()
.unwrap_or_else(|| arguments.id.clone()),
script,
timeout: None,
gitlab: Some(GitlabTemplate {
signing_token,
secret_token,
timestamp_tolerance_seconds: arguments
.timestamp_tolerance_seconds
.unwrap_or_else(config::default_timestamp_tolerance),
}),
github: None,
gitea: None,
codeberg: None,
})
}
provider => {
if arguments.signing_token.is_some() {
anyhow::bail!("--signing-token is available only for the GitLab template");
}
if arguments.timestamp_tolerance_seconds.is_some() {
anyhow::bail!(
"--timestamp-tolerance-seconds is available only for the GitLab template"
);
}
let secret = provider_secret(arguments, provider)?;
Ok(Project {
name: arguments
.name
.clone()
.unwrap_or_else(|| arguments.id.clone()),
script,
timeout: None,
gitlab: None,
github: matches!(provider, ProviderKind::Github).then(|| GithubTemplate {
secret: secret.clone(),
}),
gitea: matches!(provider, ProviderKind::Gitea).then(|| GiteaTemplate {
secret: secret.clone(),
}),
codeberg: matches!(provider, ProviderKind::Codeberg)
.then(|| CodebergTemplate { secret }),
})
}
}
}
fn gitlab_credentials(arguments: &mut ProjectAddArgs) -> Result<(Option<String>, Option<String>)> {
if arguments.signing_token.is_some() || arguments.secret_token.is_some() {
return Ok((
arguments.signing_token.take(),
arguments.secret_token.take(),
));
}
let signing_token =
rpassword::prompt_password("GitLab Signing token (leave empty for Secret token): ")?;
if !signing_token.is_empty() {
return Ok((Some(signing_token), None));
}
let secret_token = rpassword::prompt_password("GitLab Secret token: ")?;
if secret_token.is_empty() {
anyhow::bail!("a GitLab Signing token or Secret token is required");
}
Ok((None, Some(secret_token)))
}
fn provider_secret(arguments: &mut ProjectAddArgs, provider: ProviderKind) -> Result<String> {
if let Some(secret) = arguments.secret_token.take() {
return Ok(secret);
}
let label = match provider {
ProviderKind::Github => "GitHub webhook secret: ",
ProviderKind::Gitea => "Gitea webhook secret: ",
ProviderKind::Codeberg => "Codeberg webhook secret: ",
ProviderKind::Gitlab => unreachable!(),
};
let secret = rpassword::prompt_password(label)?;
if secret.is_empty() {
anyhow::bail!("a webhook secret is required");
}
Ok(secret)
}
fn handle_service(command: ServiceCommand, config_path: &Path) -> Result<()> {
match command {
ServiceCommand::Install { user, no_start } => {
if !system::is_root() {
system::elevate_self()?;
}
config::load(config_path)?;
let user = system::service_user(user)?;
system::install_service(config_path, &user, !no_start)?;
println!("installed blip.service for {user}");
}
ServiceCommand::Uninstall { yes } => {
if !system::is_root() {
system::elevate_self()?;
}
if !yes && !confirm("uninstall blip.service?")? {
println!("cancelled");
return Ok(());
}
system::uninstall_service()?;
println!("uninstalled blip.service; configuration and data were preserved");
}
ServiceCommand::Status => system::service_action("status")?,
ServiceCommand::Start => system::service_action("start")?,
ServiceCommand::Stop => system::service_action("stop")?,
ServiceCommand::Restart => system::service_action("restart")?,
ServiceCommand::Enable => system::service_action("enable")?,
ServiceCommand::Disable => system::service_action("disable")?,
}
Ok(())
}
fn elevate_for_system_config(path: &Path) -> Result<()> {
let target = if path.exists() {
path
} else {
path.parent().unwrap_or_else(|| Path::new("."))
};
let target = CString::new(target.as_os_str().as_bytes())?;
let requires_elevation = unsafe { libc::access(target.as_ptr(), libc::W_OK) } != 0;
if requires_elevation && !system::is_root() {
system::elevate_self()?;
}
Ok(())
}
fn confirm(question: &str) -> Result<bool> {
print!("{question} [y/N] ");
io::stdout().flush()?;
let mut answer = String::new();
io::stdin()
.read_line(&mut answer)
.context("read confirmation")?;
Ok(matches!(
answer.trim().to_ascii_lowercase().as_str(),
"y" | "yes"
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_side_rules_are_rejected_from_configuration() {
let result = toml::from_str::<config::Config>(
r#"
[projects.example-app]
script = "/bin/true"
event = "push"
gitlab.secret_token = "test-secret"
"#,
);
assert!(result.is_err());
}
#[test]
fn upgrade_short_flag_does_not_require_a_subcommand() {
let cli = Cli::try_parse_from(["blip", "-U"]).unwrap();
assert!(cli.upgrade);
assert!(cli.command.is_none());
}
#[test]
fn project_add_accepts_each_provider_template() {
for provider in ["gitlab", "github", "gitea", "codeberg"] {
let cli = Cli::try_parse_from([
"blip",
"project",
"add",
"--id",
"example",
"--script",
"/bin/true",
"--provider",
provider,
"--secret-token",
"test-secret",
]);
assert!(cli.is_ok(), "provider {provider} must parse");
}
}
#[test]
fn project_add_builds_only_the_selected_provider_template() {
for provider in [
ProviderKind::Gitlab,
ProviderKind::Github,
ProviderKind::Gitea,
ProviderKind::Codeberg,
] {
let expected = match provider {
ProviderKind::Gitlab => "gitlab",
ProviderKind::Github => "github",
ProviderKind::Gitea => "gitea",
ProviderKind::Codeberg => "codeberg",
};
let mut arguments = ProjectAddArgs {
id: "example".into(),
name: None,
script: PathBuf::from("/bin/true"),
provider,
signing_token: None,
secret_token: Some("test-secret".into()),
timestamp_tolerance_seconds: None,
replace: false,
};
let project = project_from_arguments(&mut arguments).unwrap();
assert_eq!(project.provider_name(), expected);
}
}
}