//! A program to create and inspect job COBs and the automated
//! processing runs they record, for a repository.
//!
//! Run `radicle-job --help` to see how to use the program.
use std::{collections::BTreeSet, error::Error as _, time::Duration};
use clap::Parser;
use uuid::Uuid;
use radicle::{
cob, crypto,
crypto::signature::Signer,
git::Oid,
node::{
device::Device,
sync::{Announcer, AnnouncerConfig, ReplicationFactor},
Handle, Node,
},
prelude::{Profile, ReadStorage, RepoId},
profile,
storage::git::Repository,
};
use radicle_job::*;
const TIMEOUT: Duration = Duration::from_millis(5000);
fn main() {
if let Err(err) = fallible_main() {
eprintln!("ERROR: {err}");
let mut err = err.source();
while let Some(underlying) = err {
eprintln!("caused by: {underlying}");
err = underlying.source();
}
std::process::exit(1);
}
}
fn fallible_main() -> Result<(), RadJobError> {
let args = Args::parse();
run(args)
}
/// Create, update, and query Radicle job COBs.
///
/// A job COB records results of automated processing of a
/// repository for a given Git commit, by one or more nodes. For
/// example, CI runs would be recorded as job COBs.
///
/// This program is a utility for Radicle developers to experiment
/// with and investigate job COBs. It allows you to see what jobs have
/// jobs and runs have been recorded. Output is JSON, for developer
/// convenience.
#[derive(Parser)]
#[clap(version)]
struct Args {
/// Use this repository. Default is the current working directory.
#[clap(short, long)]
repository: Option<RepoId>,
/// Use this to not sync with the network after any modifications.
///
/// Note that if `--no-sync` was used, you can use `rad sync -a` to announce
/// at a later point.
#[clap(long)]
no_sync: bool,
#[clap(subcommand)]
command: command::Command,
}
impl Args {
fn repository(&self, profile: &Profile) -> Result<Repository, error::Repository> {
let repo_id = if let Some(repo_id) = self.repository {
repo_id
} else {
let (_repo, repo_id) = radicle::rad::cwd().map_err(error::Repository::Cwd)?;
repo_id
};
profile
.storage
.repository(repo_id)
.map_err(|err| error::Repository::Open { rid: repo_id, err })
}
}
fn profile() -> Result<Profile, error::Profile> {
Profile::load().map_err(error::Profile)
}
fn jobs(repo: &Repository) -> Result<Jobs<'_, Repository>, error::Jobs> {
Jobs::open(repo).map_err(|err| error::Jobs { rid: repo.id, err })
}
fn announce(profile: &Profile, repo_id: RepoId) -> Result<(), error::Announce> {
let mut node = Node::new(profile.home.socket());
let (synced, unsynced) = node
.seeds_for(repo_id, [*profile.id()])
.map_err(error::Announce::Seeds)?
.iter()
.fold(
(BTreeSet::new(), BTreeSet::new()),
|(mut synced, mut unsynced), seed| {
if seed.is_synced() {
synced.insert(seed.nid);
} else {
unsynced.insert(seed.nid);
}
(synced, unsynced)
},
);
let announcer = Announcer::new(AnnouncerConfig::public(
*profile.id(),
ReplicationFactor::MustReach(1),
BTreeSet::new(),
synced,
unsynced,
))
.map_err(error::Announce::Announcer)?;
node.announce(repo_id, [*profile.id()], TIMEOUT, announcer, |_, _| ())
.map_err(error::Announce::Announcement)?;
Ok(())
}
fn run(args: Args) -> Result<(), RadJobError> {
use command::*;
let profile = profile()?;
let repo = args.repository(&profile)?;
let mut jobs = jobs(&repo)?;
match args.command {
Command::New(command) => {
let signer = profile.signer().map_err(error::Signer)?;
new_job(command, &mut jobs, &signer)?;
if !args.no_sync {
announce(&profile, repo.id)?
}
}
Command::List(command) => list_jobs(command, &jobs)?,
Command::Run(command) => {
let signer = profile.signer().map_err(error::Signer)?;
run_job(command, &mut jobs, &signer)?;
if !args.no_sync {
announce(&profile, repo.id)?
}
}
Command::Show(command) => show_job(command, &jobs)?,
Command::Failed(command) => {
let signer = profile.signer().map_err(error::Signer)?;
failed_job(command, &mut jobs, &signer)?;
if !args.no_sync {
announce(&profile, repo.id)?
}
}
Command::Succeeded(command) => {
let signer = profile.signer().map_err(error::Signer)?;
succeeded_job(command, &mut jobs, &signer)?;
if !args.no_sync {
announce(&profile, repo.id)?
}
}
}
Ok(())
}
fn run_job<G>(
command::Run { oid, url }: command::Run,
jobs: &mut Jobs<'_, Repository>,
signer: &Device<G>,
) -> Result<(), error::Run>
where
G: Signer<crypto::Signature>,
{
let uuid = Uuid::new_v4();
let (id, job) = find_by_commit(oid, jobs)?;
let mut job = JobMut::new(id, job, jobs);
job.run(uuid, url, signer)
.map_err(|err| error::Run::Store { id, err })?;
println!("{uuid}");
Ok(())
}
fn succeeded_job<G>(
command::Succeeded { oid, run }: command::Succeeded,
jobs: &mut Jobs<'_, Repository>,
signer: &Device<G>,
) -> Result<(), error::Succeeded>
where
G: Signer<crypto::Signature>,
{
let (id, job) = find_by_commit(oid, jobs)?;
let mut job = JobMut::new(id, job, jobs);
job.finish(run, Reason::Succeeded, signer)
.map_err(|err| error::Succeeded::Store { id, err })?;
Ok(())
}
fn failed_job<G>(
command::Failed { oid, run }: command::Failed,
jobs: &mut Jobs<'_, Repository>,
signer: &Device<G>,
) -> Result<(), error::Failed>
where
G: Signer<crypto::Signature>,
{
let (id, job) = find_by_commit(oid, jobs)?;
let mut job = JobMut::new(id, job, jobs);
job.finish(run, Reason::Failed, signer)
.map_err(|err| error::Failed::Store { id, err })?;
Ok(())
}
fn list_jobs(
command::List { pretty, verbose }: command::List,
jobs: &Jobs<'_, Repository>,
) -> Result<(), error::List> {
let iter = jobs
.all()
.map_err(error::List::All)?
.filter_map(|res| match res {
Ok((id, job)) => Some((JobId::from(id), job)),
Err(err) => {
if verbose {
eprintln!("Failed to retrieve job: {err}");
}
None
}
});
let jobs = display::Jobs::new(iter);
if pretty {
println!("{}", jobs.pretty());
} else {
println!(
"{}",
serde_json::to_string_pretty(&jobs).map_err(error::List::Json)?
)
}
Ok(())
}
fn new_job<G>(
command::New { oid }: command::New,
jobs: &mut Jobs<Repository>,
signer: &Device<G>,
) -> Result<(), error::New>
where
G: Signer<crypto::Signature>,
{
let job = jobs
.create(oid, signer)
.map_err(|err| error::New { oid, err })?;
println!("{}", job.id());
Ok(())
}
fn show_job(
command::Show { pretty, oid }: command::Show,
jobs: &Jobs<Repository>,
) -> Result<(), error::Show> {
let (id, job) = find_by_commit(oid, jobs)?;
let show = radicle_job::display::Job::new(id, &job);
if pretty {
println!("{}", show.pretty());
} else {
println!(
"{}",
serde_json::to_string_pretty(&show).map_err(error::Show::Json)?
);
}
Ok(())
}
fn find_by_commit(oid: Oid, jobs: &Jobs<Repository>) -> Result<(JobId, Job), error::Find> {
jobs.find_by_commit(oid)
.map_err(|err| error::Find::FindCommit { oid, err })?
.next()
.ok_or(error::Find::NoJob(oid))?
.map_err(|err| error::Find::FindCommit { oid, err })
}
#[derive(Debug, thiserror::Error)]
enum RadJobError {
#[error(transparent)]
Profile(#[from] error::Profile),
#[error(transparent)]
Signer(#[from] error::Signer),
#[error(transparent)]
Repository(#[from] error::Repository),
#[error(transparent)]
Jobs(#[from] error::Jobs),
#[error(transparent)]
Announce(#[from] error::Announce),
#[error(transparent)]
Show(#[from] error::Show),
#[error(transparent)]
New(#[from] error::New),
#[error(transparent)]
List(#[from] error::List),
#[error(transparent)]
Run(#[from] error::Run),
#[error(transparent)]
Failed(#[from] error::Failed),
#[error(transparent)]
Succeeded(#[from] error::Succeeded),
}
mod command {
use clap::Parser;
use radicle::git::Oid;
use url::Url;
use uuid::Uuid;
#[derive(Parser)]
pub enum Command {
New(New),
List(List),
Run(Run),
Show(Show),
Failed(Failed),
Succeeded(Succeeded),
}
/// List all job COBs for a repository.
#[derive(Parser)]
pub struct List {
/// Format output in a more human oriented way than JSON.
#[clap(long)]
pub pretty: bool,
/// Output all information of listing jobs, including intermediate errors
#[clap(long, short)]
pub verbose: bool,
}
/// Create a job COB for a specific Git commit.
///
/// Write the job ID to the standard output. It is not usually needed,
/// as job COBs are found using the Git commit ID they refer to.
#[derive(Parser)]
pub struct New {
/// Git object id for the commit.
pub oid: Oid,
}
/// Show the job COB for a Git commit.
#[derive(Parser)]
pub struct Show {
/// Format output in a more human oriented way than JSON.
#[clap(long)]
pub pretty: bool,
/// Git object for the commit.
pub oid: Oid,
}
/// Record a new run for an existing job COB.
///
/// The new run is marked as started. See subcommands "succeeded" and
/// "failed" to change status.
///
/// Write the UUID of the new run to standard output. It is needed to
/// change mark run as finished.
#[derive(Parser)]
pub struct Run {
/// Git commit which the run is processing.
pub oid: Oid,
/// URL to information about the run, such a a run log.
pub url: Url,
}
/// Mark a run as having finished in failure.
#[derive(Parser)]
pub struct Failed {
/// Git commit which the run is processing.
pub oid: Oid,
/// Run identifier, a UUID.
pub run: Uuid,
}
/// Mark a run as having finished successfully.
#[derive(Parser)]
pub struct Succeeded {
/// Git commit which the run is processing.
pub oid: Oid,
/// Run identifier, a UUID.
pub run: Uuid,
}
}
mod error {
use radicle::{
node::{self, sync::AnnouncerError},
rad::CwdError,
storage::RepositoryError,
};
use thiserror::Error;
use super::*;
#[derive(Debug, Error)]
pub enum Show {
#[error(transparent)]
Find(#[from] Find),
#[error("failed to show job, could not serialize to JSON")]
Json(#[source] serde_json::Error),
}
#[derive(Debug, Error)]
#[error("failed to create a new job for the commit {oid}")]
pub struct New {
pub oid: Oid,
#[source]
pub err: cob::store::Error,
}
#[derive(Debug, Error)]
pub enum List {
#[error("failed to list jobs")]
All(#[source] cob::store::Error),
#[error("failed to list jobs, could not serialize to JSON")]
Json(#[source] serde_json::Error),
}
#[derive(Debug, Error)]
pub enum Run {
#[error(transparent)]
Find(#[from] Find),
#[error("failed to add new run for job {id}")]
Store {
id: JobId,
#[source]
err: cob::store::Error,
},
}
#[derive(Debug, Error)]
pub enum Succeeded {
#[error(transparent)]
Find(#[from] Find),
#[error("failed to mark job {id} as succeeded")]
Store {
id: JobId,
#[source]
err: cob::store::Error,
},
}
#[derive(Debug, Error)]
pub enum Failed {
#[error(transparent)]
Find(#[from] Find),
#[error("failed to mark job {id} as failed")]
Store {
id: JobId,
#[source]
err: cob::store::Error,
},
}
#[derive(Debug, Error)]
pub enum Find {
#[error("no job was found for the commit {0}")]
NoJob(Oid),
#[error("failed to find a job for the commit {oid}")]
FindCommit {
oid: Oid,
#[source]
err: cob::store::Error,
},
}
#[derive(Debug, Error)]
pub enum Repository {
#[error("failed to find Radicle repository for current working directory")]
Cwd(#[source] CwdError),
#[error("failed to open Radicle repository {rid}")]
Open {
rid: RepoId,
#[source]
err: RepositoryError,
},
}
#[derive(Debug, Error)]
#[error("failed to load Radicle profile")]
pub struct Profile(#[source] pub profile::Error);
#[derive(Debug, Error)]
#[error("failed to get the signing key of the Radicle profile")]
pub struct Signer(#[source] pub profile::SignerError);
#[derive(Debug, Error)]
pub enum Announce {
#[error("failed to get seeds for announcing changes")]
Seeds(#[source] node::Error),
#[error("failed to announce changes")]
Announcer(AnnouncerError),
#[error("failed to announce changes")]
Announcement(#[source] node::Error),
}
#[derive(Debug, Error)]
#[error("failed to open job store for {rid}")]
pub struct Jobs {
pub rid: RepoId,
#[source]
pub err: RepositoryError,
}
}