radicle: remove job cob
The Job COB is defined as separate crate: [radicle-job]. So this change removes its definition and use from the `heartwood` repository. [radicle-job]: https://app.radicle.xyz/nodes/seed.radicle.garden/rad:z2UcCU1LgMshWvXj6hXSDDrwB8q8M
This commit is contained in:
parent
f4c8ff7a64
commit
e6ef767f36
|
|
@ -1,87 +0,0 @@
|
|||
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,8 +32,6 @@ 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,7 +25,6 @@ const COMMANDS: &[Help] = &[
|
|||
rad_inbox::HELP,
|
||||
rad_inspect::HELP,
|
||||
rad_issue::HELP,
|
||||
rad_job::HELP,
|
||||
rad_ls::HELP,
|
||||
rad_node::HELP,
|
||||
rad_patch::HELP,
|
||||
|
|
|
|||
|
|
@ -1,380 +0,0 @@
|
|||
#![allow(clippy::or_fun_call)]
|
||||
use std::ffi::OsString;
|
||||
|
||||
use anyhow::{anyhow, Context as _};
|
||||
|
||||
use radicle::cob::job::{JobStore, Reason, State};
|
||||
use radicle::crypto;
|
||||
use radicle::node::device::Device;
|
||||
use radicle::node::{Handle, NodeId};
|
||||
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 (_, 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 } => {
|
||||
let signer = term::signer(&profile)?;
|
||||
trigger(&commit, &mut ci_store, &repo, &signer, options.quiet)?;
|
||||
}
|
||||
Operation::Start {
|
||||
job_id,
|
||||
run_id,
|
||||
info_url,
|
||||
} => {
|
||||
let signer = term::signer(&profile)?;
|
||||
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 } => {
|
||||
let signer = term::signer(&profile)?;
|
||||
finish(&job_id, reason, &mut ci_store, &repo, &signer)?;
|
||||
}
|
||||
Operation::Delete { job_id } => {
|
||||
let signer = term::signer(&profile)?;
|
||||
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, G>(
|
||||
commit: &Rev,
|
||||
store: &mut JobStore<R>,
|
||||
repo: &radicle::storage::git::Repository,
|
||||
signer: &Device<G>,
|
||||
quiet: bool,
|
||||
) -> anyhow::Result<()>
|
||||
where
|
||||
R: WriteRepository + cob::Store<Namespace = NodeId>,
|
||||
G: crypto::signature::Signer<crypto::Signature>,
|
||||
{
|
||||
let commit = commit.resolve(&repo.backend)?;
|
||||
let job = store.create(commit, signer)?;
|
||||
if !quiet {
|
||||
term::job::show(&job, job.id())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start<R, G>(
|
||||
job_id: &Rev,
|
||||
run_id: &str,
|
||||
info_url: Option<String>,
|
||||
store: &mut JobStore<R>,
|
||||
repo: &radicle::storage::git::Repository,
|
||||
signer: &Device<G>,
|
||||
) -> anyhow::Result<()>
|
||||
where
|
||||
R: WriteRepository + cob::Store<Namespace = NodeId>,
|
||||
G: crypto::signature::Signer<crypto::Signature>,
|
||||
{
|
||||
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<Namespace = NodeId>>(
|
||||
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<Namespace = NodeId>>(
|
||||
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, G>(
|
||||
job_id: &Rev,
|
||||
reason: Reason,
|
||||
store: &mut JobStore<R>,
|
||||
repo: &radicle::storage::git::Repository,
|
||||
signer: &Device<G>,
|
||||
) -> anyhow::Result<()>
|
||||
where
|
||||
R: WriteRepository + cob::Store<Namespace = NodeId>,
|
||||
G: crypto::signature::Signer<crypto::Signature>,
|
||||
{
|
||||
let job_id = job_id.resolve(&repo.backend)?;
|
||||
let mut job = store.get_mut(&job_id)?;
|
||||
|
||||
job.finish(reason, signer)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -232,13 +232,6 @@ 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,7 +2,6 @@ pub mod args;
|
|||
pub use args::{Args, Error, Help};
|
||||
pub mod format;
|
||||
pub mod io;
|
||||
pub mod job;
|
||||
pub use io::signer;
|
||||
pub mod cob;
|
||||
pub mod comment;
|
||||
|
|
|
|||
|
|
@ -30,11 +30,6 @@ 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)
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
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(())
|
||||
}
|
||||
|
|
@ -3159,17 +3159,3 @@ 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ pub mod common;
|
|||
pub mod external;
|
||||
pub mod identity;
|
||||
pub mod issue;
|
||||
pub mod job;
|
||||
pub mod op;
|
||||
pub mod patch;
|
||||
pub mod store;
|
||||
|
|
|
|||
|
|
@ -1,458 +0,0 @@
|
|||
//! 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::git;
|
||||
use crate::node::device::Device;
|
||||
use crate::node::NodeId;
|
||||
use crate::prelude::ReadRepository;
|
||||
use crate::storage::{Oid, WriteRepository};
|
||||
|
||||
use super::store::CobWithType;
|
||||
|
||||
/// 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 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 CobWithType for Job {
|
||||
fn type_name() -> &'static TypeName {
|
||||
&TYPENAME
|
||||
}
|
||||
}
|
||||
|
||||
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<R> std::fmt::Debug for JobMut<'_, '_, 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<R> JobMut<'_, '_, R>
|
||||
where
|
||||
R: WriteRepository + cob::Store<Namespace = NodeId>,
|
||||
{
|
||||
/// 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>(
|
||||
&mut self,
|
||||
run_id: String,
|
||||
info_url: Option<String>,
|
||||
signer: &Device<G>,
|
||||
) -> Result<EntryId, Error>
|
||||
where
|
||||
G: crypto::signature::Signer<crypto::Signature>,
|
||||
{
|
||||
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>(&mut self, reason: Reason, signer: &Device<G>) -> Result<EntryId, Error>
|
||||
where
|
||||
G: crypto::signature::Signer<crypto::Signature>,
|
||||
{
|
||||
self.transaction("Finish", signer, |tx| tx.finish(reason))
|
||||
}
|
||||
|
||||
pub fn transaction<G, F>(
|
||||
&mut self,
|
||||
message: &str,
|
||||
signer: &Device<G>,
|
||||
operations: F,
|
||||
) -> Result<EntryId, Error>
|
||||
where
|
||||
G: crypto::signature::Signer<crypto::Signature>,
|
||||
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<R> Deref for JobMut<'_, '_, 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<Namespace = NodeId>,
|
||||
{
|
||||
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>(
|
||||
&'g mut self,
|
||||
commit_id: git::Oid,
|
||||
signer: &Device<G>,
|
||||
) -> Result<JobMut<'a, 'g, R>, Error>
|
||||
where
|
||||
G: crypto::signature::Signer<crypto::Signature>,
|
||||
{
|
||||
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>(&self, id: &JobId, signer: &Device<G>) -> Result<(), store::Error>
|
||||
where
|
||||
G: crypto::signature::Signer<crypto::Signature>,
|
||||
{
|
||||
self.raw.remove(id, signer)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue