cob: Add an experimental "job" COB
This is meant to record in Radicle that automated "jobs" have been run on a repository. The job might be, for example, a CI run that builds the software and runs its tests. The recording is done in a "job COB", which can be in three states: * fresh -- when the job has been triggered, but has not yet started running * running -- when it's running * finished -- when it's finished running The finished state also records whether the job was successful or if it failed. A job COB records the git commit that it runs on, and the "run ID" of the job. The run ID represents an external identifier, such as one assigned by a remote CI system. The COB can optionally store a URL related to the job, such as to the build log from CI. Add the "rad job" subcommand to create and manage job COBs. For production use, the COBs should be created and updated using `heartwood` as a library instead of running "rad job", if at all possible. However, "rad job list" lists the jobs that have been run on the repository. The COB type name is xyz.radicle.beta.job, until we've gained experience with the COB and its implementation and are ready to declare it stable. Signed-off-by: Lars Wirzenius <liw@liw.fi>
This commit is contained in:
parent
eb095c109b
commit
df44cee9ef
|
|
@ -0,0 +1,87 @@
|
|||
The `rad job` command lets you manage job COBs. Let's first checkout the
|
||||
`heartwood` repository:
|
||||
|
||||
```
|
||||
$ rad checkout rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
|
||||
✓ Repository checkout successful under ./heartwood
|
||||
$ cd heartwood
|
||||
```
|
||||
|
||||
Using the `rad job` (or `rad job list`) command we can see that there are
|
||||
currently no jobs listed:
|
||||
|
||||
```
|
||||
$ rad job
|
||||
Nothing to show.
|
||||
```
|
||||
|
||||
Let's create a job to represent a new CI run. We check what the current `HEAD`
|
||||
of the repository is, and use the `rad job trigger` to start a fresh job for
|
||||
that commit:
|
||||
|
||||
```
|
||||
$ git rev-parse HEAD
|
||||
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
|
||||
$ rad job trigger f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
|
||||
╭──────────────────────────────────────────────────╮
|
||||
│ Job fbbda2447c30ebbab9b746498cd41a383ff05225 │
|
||||
│ Commit f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 │
|
||||
│ State fresh │
|
||||
╰──────────────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
Let's check the list again, and we should we see our fresh job there:
|
||||
|
||||
```
|
||||
$ rad job
|
||||
╭───────────────────────────────╮
|
||||
│ ● ID Commit State │
|
||||
├───────────────────────────────┤
|
||||
│ ● fbbda24 f2de534 fresh │
|
||||
╰───────────────────────────────╯
|
||||
```
|
||||
|
||||
From there we can start a new job, assigning an arbitrary identifier `xyzzy`,
|
||||
which would usually from the CI system that is running the job:
|
||||
|
||||
```
|
||||
$ rad job start fbbda2447c30ebbab9b746498cd41a383ff05225 xyzzy
|
||||
```
|
||||
|
||||
Checking the job again, we can now see that the job is `running`:
|
||||
|
||||
```
|
||||
$ rad job
|
||||
╭─────────────────────────────────╮
|
||||
│ ● ID Commit State │
|
||||
├─────────────────────────────────┤
|
||||
│ ● fbbda24 f2de534 running │
|
||||
╰─────────────────────────────────╯
|
||||
$ rad job show fbbda2447c30ebbab9b746498cd41a383ff05225
|
||||
╭──────────────────────────────────────────────────╮
|
||||
│ Job fbbda2447c30ebbab9b746498cd41a383ff05225 │
|
||||
│ Commit f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 │
|
||||
│ State running │
|
||||
│ Run ID xyzzy │
|
||||
╰──────────────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
When a job has finished, we can mark it as done -- either with a `--success` or
|
||||
`--failed` flag -- using the `rad job finish` command:
|
||||
|
||||
```
|
||||
$ rad job finish --success fbbda2447c30ebbab9b746498cd41a383ff05225
|
||||
$ rad job
|
||||
╭───────────────────────────────────╮
|
||||
│ ● ID Commit State │
|
||||
├───────────────────────────────────┤
|
||||
│ ● fbbda24 f2de534 succeeded │
|
||||
╰───────────────────────────────────╯
|
||||
$ rad job show fbbda2447c30ebbab9b746498cd41a383ff05225
|
||||
╭──────────────────────────────────────────────────╮
|
||||
│ Job fbbda2447c30ebbab9b746498cd41a383ff05225 │
|
||||
│ Commit f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 │
|
||||
│ State succeeded │
|
||||
│ Run ID xyzzy │
|
||||
╰──────────────────────────────────────────────────╯
|
||||
```
|
||||
|
|
@ -32,6 +32,8 @@ pub mod rad_init;
|
|||
pub mod rad_inspect;
|
||||
#[path = "commands/issue.rs"]
|
||||
pub mod rad_issue;
|
||||
#[path = "commands/job.rs"]
|
||||
pub mod rad_job;
|
||||
#[path = "commands/ls.rs"]
|
||||
pub mod rad_ls;
|
||||
#[path = "commands/node.rs"]
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const COMMANDS: &[Help] = &[
|
|||
rad_inbox::HELP,
|
||||
rad_inspect::HELP,
|
||||
rad_issue::HELP,
|
||||
rad_job::HELP,
|
||||
rad_ls::HELP,
|
||||
rad_node::HELP,
|
||||
rad_patch::HELP,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,362 @@
|
|||
#![allow(clippy::or_fun_call)]
|
||||
use std::ffi::OsString;
|
||||
|
||||
use anyhow::{anyhow, Context as _};
|
||||
|
||||
use radicle::cob::job::{JobStore, Reason, State};
|
||||
use radicle::crypto::Signer;
|
||||
use radicle::node::Handle;
|
||||
use radicle::storage::{WriteRepository, WriteStorage};
|
||||
use radicle::{cob, Node};
|
||||
|
||||
use crate::git::Rev;
|
||||
use crate::terminal as term;
|
||||
use crate::terminal::args::{Args, Error, Help};
|
||||
use crate::terminal::Element;
|
||||
|
||||
pub const HELP: Help = Help {
|
||||
name: "job",
|
||||
description: "Manage automated jobs on a repository",
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
usage: r#"
|
||||
Usage
|
||||
|
||||
rad job [<option>...]
|
||||
rad job trigger <commit-id>
|
||||
rad job start <job-id> <run-id> [<url>]
|
||||
rad job list
|
||||
rad job show <job-id>
|
||||
rad job finish <job-id> [--success | --failed]
|
||||
rad job delete <job-id>
|
||||
|
||||
Options
|
||||
|
||||
--no-announce Don't announce job records to peers
|
||||
--quiet, -q Don't print anything
|
||||
--help Print help
|
||||
"#,
|
||||
};
|
||||
|
||||
#[derive(Default, Debug, PartialEq, Eq)]
|
||||
pub enum OperationName {
|
||||
Trigger,
|
||||
Start,
|
||||
#[default]
|
||||
List,
|
||||
Show,
|
||||
Finish,
|
||||
Delete,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Operation {
|
||||
Trigger {
|
||||
commit: Rev,
|
||||
},
|
||||
Start {
|
||||
job_id: Rev,
|
||||
run_id: String,
|
||||
info_url: Option<String>,
|
||||
},
|
||||
List,
|
||||
Show {
|
||||
job_id: Rev,
|
||||
},
|
||||
Finish {
|
||||
job_id: Rev,
|
||||
reason: Reason,
|
||||
},
|
||||
Delete {
|
||||
job_id: Rev,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Options {
|
||||
pub op: Operation,
|
||||
pub announce: bool,
|
||||
pub quiet: bool,
|
||||
}
|
||||
|
||||
impl Args for Options {
|
||||
fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
|
||||
use lexopt::prelude::*;
|
||||
|
||||
let mut parser = lexopt::Parser::from_args(args);
|
||||
let mut op: Option<OperationName> = None;
|
||||
let mut commit: Option<Rev> = None;
|
||||
let mut job_id: Option<Rev> = None;
|
||||
let mut run_id: Option<String> = None;
|
||||
let mut info_url: Option<String> = None;
|
||||
let mut announce = true;
|
||||
let mut quiet = false;
|
||||
let mut succeeded = false;
|
||||
let mut failed = false;
|
||||
|
||||
while let Some(arg) = parser.next()? {
|
||||
match arg {
|
||||
Long("help") | Short('h') => {
|
||||
return Err(Error::Help.into());
|
||||
}
|
||||
Long("no-announce") => {
|
||||
announce = false;
|
||||
}
|
||||
Long("quiet") | Short('q') => {
|
||||
quiet = true;
|
||||
}
|
||||
Long("success") | Long("succeeded") | Short('s') => {
|
||||
succeeded = true;
|
||||
}
|
||||
Long("failure") | Long("failed") | Short('f') => {
|
||||
failed = true;
|
||||
}
|
||||
Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
|
||||
"trigger" => op = Some(OperationName::Trigger),
|
||||
"start" => op = Some(OperationName::Start),
|
||||
"list" => op = Some(OperationName::List),
|
||||
"show" => op = Some(OperationName::Show),
|
||||
"finish" => op = Some(OperationName::Finish),
|
||||
"delete" => op = Some(OperationName::Delete),
|
||||
|
||||
unknown => anyhow::bail!("unknown operation '{}'", unknown),
|
||||
},
|
||||
Value(val) if commit.is_none() && op == Some(OperationName::Trigger) => {
|
||||
let val = term::args::oid(&val)?;
|
||||
let val = Rev::from(val.to_string());
|
||||
commit = Some(val);
|
||||
}
|
||||
Value(val)
|
||||
if job_id.is_none()
|
||||
&& op.is_some()
|
||||
&& matches!(
|
||||
op.as_ref().unwrap(),
|
||||
OperationName::Start
|
||||
| OperationName::Show
|
||||
| OperationName::Finish
|
||||
| OperationName::Delete
|
||||
) =>
|
||||
{
|
||||
let val = term::args::oid(&val)?;
|
||||
let val = Rev::from(val.to_string());
|
||||
job_id = Some(val);
|
||||
}
|
||||
Value(val)
|
||||
if job_id.is_some()
|
||||
&& run_id.is_none()
|
||||
&& op.is_some()
|
||||
&& matches!(op.as_ref().unwrap(), OperationName::Start) =>
|
||||
{
|
||||
run_id = Some(val.to_str().unwrap().to_string());
|
||||
}
|
||||
Value(val)
|
||||
if job_id.is_some()
|
||||
&& run_id.is_some()
|
||||
&& op.is_some()
|
||||
&& matches!(op.as_ref().unwrap(), OperationName::Start) =>
|
||||
{
|
||||
info_url = Some(val.to_str().unwrap().to_string());
|
||||
}
|
||||
_ => {
|
||||
return Err(anyhow!(arg.unexpected()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let op = match op.unwrap_or_default() {
|
||||
OperationName::Trigger => Operation::Trigger {
|
||||
commit: commit.ok_or_else(|| anyhow!("a commit id must be provided"))?,
|
||||
},
|
||||
OperationName::Start => Operation::Start {
|
||||
job_id: job_id.ok_or_else(|| anyhow!("a job id must be provided"))?,
|
||||
run_id: run_id.ok_or_else(|| anyhow!("a run id must be provided"))?,
|
||||
info_url,
|
||||
},
|
||||
OperationName::List => Operation::List,
|
||||
OperationName::Show => Operation::Show {
|
||||
job_id: job_id.ok_or_else(|| anyhow!("a job id must be provided"))?,
|
||||
},
|
||||
OperationName::Finish => Operation::Finish {
|
||||
job_id: job_id.ok_or_else(|| anyhow!("a job id must be provided"))?,
|
||||
reason: if !succeeded && !failed {
|
||||
return Err(anyhow!("must give one of --success or --failure"))?;
|
||||
} else if succeeded && failed {
|
||||
return Err(anyhow!("must give one of --success or --failure, not both"))?;
|
||||
} else if succeeded {
|
||||
Reason::Succeeded
|
||||
} else {
|
||||
Reason::Failed
|
||||
},
|
||||
},
|
||||
OperationName::Delete => Operation::Delete {
|
||||
job_id: job_id.ok_or_else(|| anyhow!("a job id to remove must be provided"))?,
|
||||
},
|
||||
};
|
||||
|
||||
Ok((
|
||||
Options {
|
||||
op,
|
||||
announce,
|
||||
quiet,
|
||||
},
|
||||
vec![],
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||
let profile = ctx.profile()?;
|
||||
let signer = term::signer(&profile)?;
|
||||
let (_, rid) = radicle::rad::cwd()?;
|
||||
let repo = profile.storage.repository_mut(rid)?;
|
||||
let announce = options.announce
|
||||
&& matches!(
|
||||
&options.op,
|
||||
Operation::Trigger { .. }
|
||||
| Operation::Start { .. }
|
||||
| Operation::Finish { .. }
|
||||
| Operation::Delete { .. }
|
||||
);
|
||||
|
||||
let mut node = Node::new(profile.socket());
|
||||
let mut ci_store = JobStore::open(&repo)?;
|
||||
|
||||
match options.op {
|
||||
Operation::Trigger { commit } => {
|
||||
trigger(&commit, &mut ci_store, &repo, &signer, options.quiet)?;
|
||||
}
|
||||
Operation::Start {
|
||||
job_id,
|
||||
run_id,
|
||||
info_url,
|
||||
} => {
|
||||
start(&job_id, &run_id, info_url, &mut ci_store, &repo, &signer)?;
|
||||
}
|
||||
Operation::List => {
|
||||
list(&ci_store)?;
|
||||
}
|
||||
Operation::Show { job_id } => {
|
||||
show(&job_id, &ci_store, &repo)?;
|
||||
}
|
||||
Operation::Finish { job_id, reason } => {
|
||||
finish(&job_id, reason, &mut ci_store, &repo, &signer)?;
|
||||
}
|
||||
Operation::Delete { job_id } => {
|
||||
let job_id = job_id.resolve(&repo.backend)?;
|
||||
ci_store.remove(&job_id, &signer)?;
|
||||
}
|
||||
}
|
||||
|
||||
if announce {
|
||||
match node.announce_refs(rid) {
|
||||
Ok(_) => {}
|
||||
Err(e) if e.is_connection_err() => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn trigger<R: WriteRepository + cob::Store, G: Signer>(
|
||||
commit: &Rev,
|
||||
store: &mut JobStore<R>,
|
||||
repo: &radicle::storage::git::Repository,
|
||||
signer: &G,
|
||||
quiet: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
let commit = commit.resolve(&repo.backend)?;
|
||||
let job = store.create(commit, signer)?;
|
||||
if !quiet {
|
||||
term::job::show(&job, job.id())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start<R: WriteRepository + cob::Store, G: Signer>(
|
||||
job_id: &Rev,
|
||||
run_id: &str,
|
||||
info_url: Option<String>,
|
||||
store: &mut JobStore<R>,
|
||||
repo: &radicle::storage::git::Repository,
|
||||
signer: &G,
|
||||
) -> anyhow::Result<()> {
|
||||
let job_id = job_id.resolve(&repo.backend)?;
|
||||
let mut job = store.get_mut(&job_id)?;
|
||||
|
||||
job.start(run_id.to_string(), info_url, signer)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// TODO: This should use the COB cache for performance.
|
||||
fn list<R: WriteRepository + cob::Store>(store: &JobStore<R>) -> anyhow::Result<()> {
|
||||
if store.is_empty()? {
|
||||
term::print(term::format::italic("Nothing to show."));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut table = term::Table::new(term::table::TableOptions::bordered());
|
||||
table.header([
|
||||
term::format::dim(String::from("●")),
|
||||
term::format::bold(String::from("ID")),
|
||||
term::format::bold(String::from("Commit")),
|
||||
term::format::bold(String::from("State")),
|
||||
]);
|
||||
table.divider();
|
||||
|
||||
for result in store.all()? {
|
||||
let Ok((id, ci)) = result else {
|
||||
// Skip COBs that failed to load.
|
||||
continue;
|
||||
};
|
||||
table.push([
|
||||
match ci.state() {
|
||||
State::Fresh => term::format::positive("●").into(),
|
||||
State::Running => term::format::positive("●").into(),
|
||||
State::Finished(Reason::Succeeded) => term::format::positive("●").into(),
|
||||
State::Finished(Reason::Failed) => term::format::negative("●").into(),
|
||||
},
|
||||
term::format::tertiary(term::format::cob(&id).to_string()),
|
||||
term::format::tertiary(term::format::oid(ci.commit()).to_string()),
|
||||
term::format::tertiary(term::format::job_state(ci.state()).to_string()),
|
||||
]);
|
||||
}
|
||||
|
||||
if table.is_empty() {
|
||||
term::print(term::format::dim("No jobs to show."));
|
||||
} else {
|
||||
table.print();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn show<R: WriteRepository + cob::Store>(
|
||||
job_id: &Rev,
|
||||
store: &JobStore<R>,
|
||||
repo: &radicle::storage::git::Repository,
|
||||
) -> anyhow::Result<()> {
|
||||
let job_id = job_id.resolve(&repo.backend)?;
|
||||
let job = store
|
||||
.get(&job_id)?
|
||||
.context("No job with the given ID exists")?;
|
||||
|
||||
term::job::show(&job, &job_id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish<R: WriteRepository + cob::Store, G: Signer>(
|
||||
job_id: &Rev,
|
||||
reason: Reason,
|
||||
store: &mut JobStore<R>,
|
||||
repo: &radicle::storage::git::Repository,
|
||||
signer: &G,
|
||||
) -> anyhow::Result<()> {
|
||||
let job_id = job_id.resolve(&repo.backend)?;
|
||||
let mut job = store.get_mut(&job_id)?;
|
||||
|
||||
job.finish(reason, signer)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -232,6 +232,13 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>>
|
|||
args.to_vec(),
|
||||
);
|
||||
}
|
||||
"job" => {
|
||||
term::run_command_args::<rad_job::Options, _>(
|
||||
rad_job::HELP,
|
||||
rad_job::run,
|
||||
args.to_vec(),
|
||||
);
|
||||
}
|
||||
"ls" => {
|
||||
term::run_command_args::<rad_ls::Options, _>(rad_ls::HELP, rad_ls::run, args.to_vec());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ pub mod args;
|
|||
pub use args::{Args, Error, Help};
|
||||
pub mod format;
|
||||
pub mod io;
|
||||
pub mod job;
|
||||
pub use io::signer;
|
||||
pub mod comment;
|
||||
pub mod highlight;
|
||||
|
|
|
|||
|
|
@ -30,6 +30,11 @@ pub fn oid(oid: impl Into<radicle::git::Oid>) -> Paint<String> {
|
|||
Paint::new(format!("{:.7}", oid.into()))
|
||||
}
|
||||
|
||||
/// Format a job COB state.
|
||||
pub fn job_state(state: radicle::cob::job::State) -> Paint<String> {
|
||||
Paint::new(format!("{}", state))
|
||||
}
|
||||
|
||||
/// Wrap parenthesis around styled input, eg. `"input"` -> `"(input)"`.
|
||||
pub fn parens<D: fmt::Display>(input: Paint<D>) -> Paint<String> {
|
||||
Paint::new(format!("({})", input.item)).with_style(input.style)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
use radicle::cob;
|
||||
use radicle::cob::job;
|
||||
use radicle_term::table::TableOptions;
|
||||
use radicle_term::{Table, VStack};
|
||||
|
||||
use crate::terminal as term;
|
||||
use crate::terminal::Element;
|
||||
|
||||
pub fn show(job: &job::Job, id: &cob::ObjectId) -> anyhow::Result<()> {
|
||||
let mut attrs = Table::<2, term::Line>::new(TableOptions {
|
||||
spacing: 2,
|
||||
..TableOptions::default()
|
||||
});
|
||||
|
||||
attrs.push([
|
||||
term::format::tertiary("Job".to_owned()).into(),
|
||||
term::format::bold(id.to_string()).into(),
|
||||
]);
|
||||
|
||||
attrs.push([
|
||||
term::format::tertiary("Commit".to_owned()).into(),
|
||||
term::format::bold(job.commit().to_owned()).into(),
|
||||
]);
|
||||
|
||||
attrs.push([
|
||||
term::format::tertiary("State".to_owned()).into(),
|
||||
term::format::bold(job.state().to_string()).into(),
|
||||
]);
|
||||
|
||||
if let Some(run_id) = job.run_id() {
|
||||
attrs.push([
|
||||
term::format::tertiary("Run ID".to_owned()).into(),
|
||||
term::format::bold(run_id.to_string()).into(),
|
||||
]);
|
||||
}
|
||||
|
||||
if let Some(info_url) = job.info_url() {
|
||||
attrs.push([
|
||||
term::format::tertiary("Info URL".to_owned()).into(),
|
||||
term::format::bold(info_url.to_string()).into(),
|
||||
]);
|
||||
}
|
||||
|
||||
let widget = VStack::default()
|
||||
.border(Some(term::colors::FAINT))
|
||||
.child(attrs);
|
||||
|
||||
widget.print();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -3020,3 +3020,17 @@ fn rad_workflow() {
|
|||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rad_job() {
|
||||
let mut environment = Environment::new();
|
||||
let profile = environment.profile(config::profile("alice"));
|
||||
let home = &profile.home;
|
||||
let working = environment.tmp().join("working");
|
||||
|
||||
// Setup a test repository.
|
||||
fixtures::repository(&working);
|
||||
|
||||
test("examples/rad-init.md", &working, Some(home), []).unwrap();
|
||||
test("examples/rad-job.md", &working, Some(home), []).unwrap();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ pub mod cache;
|
|||
pub mod common;
|
||||
pub mod identity;
|
||||
pub mod issue;
|
||||
pub mod job;
|
||||
pub mod op;
|
||||
pub mod patch;
|
||||
pub mod store;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,441 @@
|
|||
//! Track "jobs" related to a repository.
|
||||
//!
|
||||
//! The purpose of this COB is to allow users of Radicle to have a way
|
||||
//! of keeping track of what automated processing of changes to a
|
||||
//! repository have been done. A "job" might be a continuous
|
||||
//! integration automation building the software in a repository and
|
||||
//! running its automated tests. A delegate for the repository could
|
||||
//! track COBs emitted by trusted nodes to help with deciding when a
|
||||
//! patch is ready for them to merge.
|
||||
|
||||
use std::{ops::Deref, str::FromStr};
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cob;
|
||||
use crate::cob::change::store::Entry;
|
||||
use crate::cob::store;
|
||||
use crate::cob::store::{Cob, CobAction, Store, Transaction};
|
||||
use crate::cob::{EntryId, ObjectId, TypeName};
|
||||
use crate::crypto::ssh::ExtendedSignature;
|
||||
use crate::crypto::Signer;
|
||||
use crate::git;
|
||||
use crate::prelude::ReadRepository;
|
||||
use crate::storage::{Oid, WriteRepository};
|
||||
|
||||
/// The name of this COB type. Note that this is a "beta" COB, which
|
||||
/// means it's not meant for others to rely on yet, and we may change
|
||||
/// it without warning.
|
||||
pub static TYPENAME: Lazy<TypeName> =
|
||||
Lazy::new(|| FromStr::from_str("xyz.radicle.beta.job").expect("type name is valid"));
|
||||
|
||||
/// An identifier for the job.
|
||||
pub type JobId = ObjectId;
|
||||
|
||||
/// All the possible errors from this type of COB.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("initialization failed: {0}")]
|
||||
Init(&'static str),
|
||||
#[error("op decoding failed: {0}")]
|
||||
Op(#[from] cob::op::OpEncodingError),
|
||||
#[error("store: {0}")]
|
||||
Store(#[from] store::Error),
|
||||
#[error("can't trigger a job which is not fresh")]
|
||||
TriggerWhenNotFresh,
|
||||
#[error("can't start a job which is not triggered")]
|
||||
StartWhenNotFresh,
|
||||
#[error("can't finish a job which is not running")]
|
||||
FinishWhenNotRunning,
|
||||
}
|
||||
|
||||
/// All the possible states of this COB.
|
||||
///
|
||||
/// This is primarily modeled for CI, for now. A COB is created by a
|
||||
/// node when it triggers a CI run, and then updated when the CI
|
||||
/// system actually starts executing the run, and finishes. The CI
|
||||
/// system assigns an identifier to the run, and may have a URL for
|
||||
/// the log. These can also be stored in the COB.
|
||||
///
|
||||
/// This COB is essentially a state machine that tracks the state of
|
||||
/// an automated run Ci. When the COB is created, in state `Fresh`, it
|
||||
/// just records the git commit the run uses. This can't be changed.
|
||||
/// The COB may be created before the run actually starts. Once the
|
||||
/// run starts, the COB state changes to `Running`. When the run
|
||||
/// finished, the state changes to `Finished`, and the state records
|
||||
/// why the run finished: `Succeeded` or `Failed`.
|
||||
///
|
||||
/// No other state changes are allowed for the COB.
|
||||
///
|
||||
/// Note that if CI runs again for the same commit, a new COB is
|
||||
/// created. The two runs may result in different outcomes, even if
|
||||
/// nothing in the source code has changed. For example, the CI system
|
||||
/// may run out of disk space, or use different versions of the
|
||||
/// software used in the run.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialOrd, Ord, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "status")]
|
||||
pub enum State {
|
||||
/// COB has been created, job has not yet started running.
|
||||
#[default]
|
||||
Fresh,
|
||||
/// Job has started running.
|
||||
Running,
|
||||
/// Job has finished.
|
||||
Finished(Reason),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for State {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Fresh => write!(f, "fresh"),
|
||||
Self::Running => write!(f, "running"),
|
||||
Self::Finished(Reason::Succeeded) => write!(f, "succeeded"),
|
||||
Self::Finished(Reason::Failed) => write!(f, "failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why did build finish?
|
||||
#[derive(Debug, Copy, Clone, PartialOrd, Ord, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Reason {
|
||||
/// Build was successful.
|
||||
Succeeded,
|
||||
/// Build failed for some reason.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Actions to update this COB.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum Action {
|
||||
/// Initialize the COB for a new job.
|
||||
Trigger { commit: git::Oid },
|
||||
|
||||
/// Start a job.
|
||||
Start {
|
||||
run_id: String,
|
||||
info_url: Option<String>,
|
||||
},
|
||||
|
||||
/// Finish a job.
|
||||
Finish { reason: Reason },
|
||||
}
|
||||
|
||||
impl CobAction for Action {}
|
||||
|
||||
/// Type of COB operation.
|
||||
pub type Op = cob::Op<Action>;
|
||||
|
||||
/// The COB with actions applied.
|
||||
///
|
||||
/// A job is based on a specific commit. This is set when the COB is
|
||||
/// created and can't be changed.
|
||||
///
|
||||
/// A job has a specific [`State`].
|
||||
///
|
||||
/// A job may have a "run id", which is an arbitrary string. It might
|
||||
/// be the identifier for a CI run, set by an external CI system, for
|
||||
/// example. The id is informational and there no guarantees what it
|
||||
/// means, or that it's unique.
|
||||
///
|
||||
/// A job may also store a URL to more information. This might be a
|
||||
/// link to a run log in a CI system, for example.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Job {
|
||||
commit: git::Oid,
|
||||
state: State,
|
||||
run_id: Option<String>,
|
||||
info_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Job {
|
||||
/// Create a new `Job` in the `Fresh` state, using the provided `commit`.
|
||||
fn new(commit: git::Oid) -> Self {
|
||||
Self {
|
||||
commit,
|
||||
state: State::default(),
|
||||
run_id: None,
|
||||
info_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the commit that this `Job` was created with.
|
||||
pub fn commit(&self) -> git::Oid {
|
||||
self.commit
|
||||
}
|
||||
|
||||
/// Get the run identifier, if any, that was associated with this `Job`.
|
||||
pub fn run_id(&self) -> Option<&str> {
|
||||
self.run_id.as_deref()
|
||||
}
|
||||
|
||||
/// Get the info URL, if any, that was associated with this `Job`.
|
||||
pub fn info_url(&self) -> Option<&str> {
|
||||
self.info_url.as_deref()
|
||||
}
|
||||
|
||||
/// Get the `State` of this `Job`.
|
||||
pub fn state(&self) -> State {
|
||||
self.state
|
||||
}
|
||||
|
||||
/// Apply a single action to the job.
|
||||
fn action(&mut self, action: Action) -> Result<(), Error> {
|
||||
match action {
|
||||
Action::Trigger { .. } => {
|
||||
if self.state != State::Fresh {
|
||||
return Err(Error::TriggerWhenNotFresh);
|
||||
}
|
||||
}
|
||||
|
||||
Action::Start { run_id, info_url } => {
|
||||
if self.state != State::Fresh {
|
||||
return Err(Error::StartWhenNotFresh);
|
||||
}
|
||||
self.state = State::Running;
|
||||
self.run_id = Some(run_id);
|
||||
self.info_url = info_url;
|
||||
}
|
||||
|
||||
Action::Finish { reason } => {
|
||||
if self.state != State::Running {
|
||||
return Err(Error::FinishWhenNotRunning);
|
||||
}
|
||||
self.state = State::Finished(reason);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Cob for Job {
|
||||
type Action = Action;
|
||||
type Error = Error;
|
||||
|
||||
fn type_name() -> &'static TypeName {
|
||||
&TYPENAME
|
||||
}
|
||||
|
||||
fn from_root<R: ReadRepository>(op: Op, _repo: &R) -> Result<Self, Self::Error> {
|
||||
let mut actions = op.actions.into_iter();
|
||||
let Some(Action::Trigger { commit }) = actions.next() else {
|
||||
return Err(Error::Init("the first action must be of type `trigger`"));
|
||||
};
|
||||
let mut job = Job::new(commit);
|
||||
|
||||
for action in actions {
|
||||
job.action(action)?;
|
||||
}
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
fn op<'a, R: ReadRepository, I: IntoIterator<Item = &'a cob::Entry>>(
|
||||
&mut self,
|
||||
op: Op,
|
||||
_concurrent: I,
|
||||
_repo: &R,
|
||||
) -> Result<(), Error> {
|
||||
// Some day this needs to check authorization. However, we
|
||||
// don't yet know what the rules should be.
|
||||
for action in op.actions {
|
||||
self.action(action)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: ReadRepository> cob::Evaluate<R> for Job {
|
||||
type Error = Error;
|
||||
|
||||
fn init(entry: &cob::Entry, repo: &R) -> Result<Self, Self::Error> {
|
||||
let op = Op::try_from(entry)?;
|
||||
let job = Job::from_root(op, repo)?;
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
fn apply<'a, I>(
|
||||
&mut self,
|
||||
entry: &cob::Entry,
|
||||
concurrent: I,
|
||||
repo: &R,
|
||||
) -> Result<(), Self::Error>
|
||||
where
|
||||
I: Iterator<Item = (&'a Oid, &'a Entry<Oid, Oid, ExtendedSignature>)>,
|
||||
{
|
||||
let op = Op::try_from(entry)?;
|
||||
self.op(op, concurrent.map(|(_, e)| e), repo)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: ReadRepository> Transaction<Job, R> {
|
||||
/// Push an [`Action::Trigger`] which will create a new `Job` with the
|
||||
/// provided `commit` in the [`State::Fresh`] state.
|
||||
pub fn trigger(&mut self, commit: git::Oid) -> Result<(), store::Error> {
|
||||
self.push(Action::Trigger { commit })
|
||||
}
|
||||
|
||||
/// Push an [`Action::Start`] which will start the `Job` with the provided
|
||||
/// metadata and move the `Job` into the [`State::Running`] state.
|
||||
pub fn start(&mut self, run_id: String, info_url: Option<String>) -> Result<(), store::Error> {
|
||||
self.push(Action::Start { run_id, info_url })
|
||||
}
|
||||
|
||||
/// Push an [`Action::Finish`] which will finish the `Job` with the provided
|
||||
/// reason, moving the `Job` into the [`State::Finished`] state.
|
||||
pub fn finish(&mut self, reason: Reason) -> Result<(), store::Error> {
|
||||
self.push(Action::Finish { reason })
|
||||
}
|
||||
}
|
||||
|
||||
pub struct JobMut<'a, 'g, R> {
|
||||
id: ObjectId,
|
||||
job: Job,
|
||||
store: &'g mut JobStore<'a, R>,
|
||||
}
|
||||
|
||||
impl<'a, 'g, R> From<JobMut<'a, 'g, R>> for (JobId, Job) {
|
||||
fn from(value: JobMut<'a, 'g, R>) -> Self {
|
||||
(value.id, value.job)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, 'g, R> std::fmt::Debug for JobMut<'a, 'g, R> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.debug_struct("JobMut")
|
||||
.field("id", &self.id)
|
||||
.field("job", &self.job)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, 'g, R> JobMut<'a, 'g, R>
|
||||
where
|
||||
R: WriteRepository + cob::Store,
|
||||
{
|
||||
/// Reload the COB from storage.
|
||||
pub fn reload(&mut self) -> Result<(), store::Error> {
|
||||
self.job = self
|
||||
.store
|
||||
.get(&self.id)?
|
||||
.ok_or_else(|| store::Error::NotFound(TYPENAME.clone(), self.id))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &ObjectId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
/// Transition the `Job` into a running state, storing the provided
|
||||
/// metadata.
|
||||
pub fn start<G: Signer>(
|
||||
&mut self,
|
||||
run_id: String,
|
||||
info_url: Option<String>,
|
||||
signer: &G,
|
||||
) -> Result<EntryId, Error> {
|
||||
self.transaction("Start", signer, |tx| {
|
||||
tx.start(run_id, info_url)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Transition the `Job` into a finished state, with the provided `reason`.
|
||||
pub fn finish<G: Signer>(&mut self, reason: Reason, signer: &G) -> Result<EntryId, Error> {
|
||||
self.transaction("Finish", signer, |tx| tx.finish(reason))
|
||||
}
|
||||
|
||||
pub fn transaction<G, F>(
|
||||
&mut self,
|
||||
message: &str,
|
||||
signer: &G,
|
||||
operations: F,
|
||||
) -> Result<EntryId, Error>
|
||||
where
|
||||
G: Signer,
|
||||
F: FnOnce(&mut Transaction<Job, R>) -> Result<(), store::Error>,
|
||||
{
|
||||
let mut tx = Transaction::default();
|
||||
operations(&mut tx)?;
|
||||
|
||||
let (job, id) = tx.commit(message, self.id, &mut self.store.raw, signer)?;
|
||||
self.job = job;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, 'g, R> Deref for JobMut<'a, 'g, R> {
|
||||
type Target = Job;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.job
|
||||
}
|
||||
}
|
||||
|
||||
pub struct JobStore<'a, R> {
|
||||
raw: Store<'a, Job, R>,
|
||||
}
|
||||
|
||||
impl<'a, R> Deref for JobStore<'a, R> {
|
||||
type Target = Store<'a, Job, R>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.raw
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, R> JobStore<'a, R>
|
||||
where
|
||||
R: WriteRepository + ReadRepository + cob::Store,
|
||||
{
|
||||
pub fn open(repository: &'a R) -> Result<Self, store::Error> {
|
||||
let raw = store::Store::open(repository)?;
|
||||
Ok(Self { raw })
|
||||
}
|
||||
|
||||
/// Get the `Job`, if any, identified by `id`.
|
||||
pub fn get(&self, id: &JobId) -> Result<Option<Job>, store::Error> {
|
||||
self.raw.get(id)
|
||||
}
|
||||
|
||||
/// Get the `Job`, identified by `id`, which can be mutated.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This will fail if the `Job` could not be found.
|
||||
pub fn get_mut<'g>(&'g mut self, id: &JobId) -> Result<JobMut<'a, 'g, R>, store::Error> {
|
||||
let job = self
|
||||
.raw
|
||||
.get(id)?
|
||||
.ok_or_else(|| store::Error::NotFound(TYPENAME.clone(), *id))?;
|
||||
Ok(JobMut {
|
||||
id: *id,
|
||||
job,
|
||||
store: self,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a fresh `Job` with the provided `commit_id`.
|
||||
pub fn create<'g, G: Signer>(
|
||||
&'g mut self,
|
||||
commit_id: git::Oid,
|
||||
signer: &G,
|
||||
) -> Result<JobMut<'a, 'g, R>, Error> {
|
||||
let (id, job) = Transaction::initial("Create job", &mut self.raw, signer, |tx, _| {
|
||||
tx.trigger(commit_id)?;
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(JobMut {
|
||||
id,
|
||||
job,
|
||||
store: self,
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete the `Job` identified by `id`.
|
||||
pub fn remove<G: Signer>(&self, id: &JobId, signer: &G) -> Result<(), store::Error> {
|
||||
self.raw.remove(id, signer)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue