| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
use super::{open, ui}; |
| 8 |
use anyhow::{anyhow, bail, Context, Result}; |
| 9 |
use oxy_core::approval::{SignedApproval, Statement}; |
| 10 |
use oxy_core::hash::ObjectRef; |
| 11 |
use serde_json::Value; |
| 12 |
|
| 13 |
|
| 14 |
|
| 15 |
fn run_url(url: &str) -> Result<(String, String, i64, String)> { |
| 16 |
let url = url.trim_end_matches('/'); |
| 17 |
let (repo_url, n) = url |
| 18 |
.rsplit_once("/flow/") |
| 19 |
.ok_or_else(|| anyhow!("{url} is not a run URL (…/OWNER/NAME/flow/N)"))?; |
| 20 |
let number: i64 = n |
| 21 |
.parse() |
| 22 |
.map_err(|_| anyhow!("{url} is not a run URL (…/OWNER/NAME/flow/N)"))?; |
| 23 |
let (rest, name) = repo_url |
| 24 |
.rsplit_once('/') |
| 25 |
.ok_or_else(|| anyhow!("{url} is not a run URL"))?; |
| 26 |
let (base, owner) = rest |
| 27 |
.rsplit_once('/') |
| 28 |
.ok_or_else(|| anyhow!("{url} is not a run URL"))?; |
| 29 |
Ok(( |
| 30 |
base.to_string(), |
| 31 |
format!("{owner}/{name}"), |
| 32 |
number, |
| 33 |
repo_url.to_string(), |
| 34 |
)) |
| 35 |
} |
| 36 |
|
| 37 |
fn to_hex(b: &[u8]) -> String { |
| 38 |
b.iter().map(|x| format!("{x:02x}")).collect() |
| 39 |
} |
| 40 |
|
| 41 |
fn oref(v: &Value, key: &str) -> Result<ObjectRef> { |
| 42 |
v.get(key) |
| 43 |
.and_then(Value::as_str) |
| 44 |
.and_then(ObjectRef::from_hex) |
| 45 |
.ok_or_else(|| anyhow!("the hub's statement has no valid {key}")) |
| 46 |
} |
| 47 |
|
| 48 |
pub fn seal(url: &str, job: &str, reject: bool, as_json: bool) -> Result<i32> { |
| 49 |
let repo = open()?; |
| 50 |
let (base, slug, number, repo_url) = run_url(url)?; |
| 51 |
let options = repo.options_for_url(&repo_url); |
| 52 |
let api = format!("{base}/api/v1/repos/{slug}/flow/runs/{number}/jobs/{job}/approval"); |
| 53 |
let body = oxy_core::protocol::http_get(&api, &options).map_err(|e| anyhow!("{e}"))?; |
| 54 |
let v: Value = serde_json::from_slice(&body).context("the hub did not answer with JSON")?; |
| 55 |
let repo_hex = v.get("repo").and_then(Value::as_str).unwrap_or(""); |
| 56 |
let mine = to_hex(&repo.id()?); |
| 57 |
if repo_hex != mine { |
| 58 |
bail!("run #{number} belongs to repository {repo_hex}, not this checkout's ({mine}): run oxy seal --flow from a checkout of {slug}"); |
| 59 |
} |
| 60 |
let mut repo_id = [0u8; 16]; |
| 61 |
for (i, b) in repo_id.iter_mut().enumerate() { |
| 62 |
*b = u8::from_str_radix(&repo_hex[i * 2..i * 2 + 2], 16)?; |
| 63 |
} |
| 64 |
let inputs = v |
| 65 |
.get("inputs") |
| 66 |
.and_then(Value::as_array) |
| 67 |
.map(|a| { |
| 68 |
a.iter() |
| 69 |
.filter_map(|x| x.as_str().and_then(ObjectRef::from_hex)) |
| 70 |
.collect::<Vec<_>>() |
| 71 |
}) |
| 72 |
.unwrap_or_default(); |
| 73 |
let statement = Statement { |
| 74 |
repo: repo_id, |
| 75 |
run: v |
| 76 |
.get("run") |
| 77 |
.and_then(Value::as_str) |
| 78 |
.unwrap_or(url) |
| 79 |
.to_string(), |
| 80 |
job: job.to_string(), |
| 81 |
job_hash: oref(&v, "job_hash")?, |
| 82 |
state: oref(&v, "state")?, |
| 83 |
inputs, |
| 84 |
environment: v |
| 85 |
.get("environment") |
| 86 |
.and_then(Value::as_str) |
| 87 |
.unwrap_or("") |
| 88 |
.to_string(), |
| 89 |
approve: !reject, |
| 90 |
}; |
| 91 |
let (key, sk) = repo |
| 92 |
.secret_key()? |
| 93 |
.ok_or_else(|| anyhow!("no signing key: create one with `oxy identity new`"))?; |
| 94 |
let me = repo.identity(&key)?; |
| 95 |
let signed = SignedApproval::sign(&statement, &me, &sk); |
| 96 |
let post = format!("{base}/api/v1/repos/{slug}/flow/runs/{number}/jobs/{job}/seal"); |
| 97 |
let payload = serde_json::json!({ "signed": to_hex(&signed.encode()) }).to_string(); |
| 98 |
let (code, answer) = oxy_core::protocol::http_json("POST", &post, Some(&payload), &options) |
| 99 |
.map_err(|e| anyhow!("{e}"))?; |
| 100 |
if !(200..300).contains(&code) { |
| 101 |
let why = serde_json::from_str::<Value>(&answer) |
| 102 |
.ok() |
| 103 |
.and_then(|v| v.get("error").and_then(Value::as_str).map(String::from)); |
| 104 |
bail!( |
| 105 |
"the hub refused the seal (HTTP {code}): {}", |
| 106 |
why.unwrap_or(answer) |
| 107 |
); |
| 108 |
} |
| 109 |
if as_json { |
| 110 |
println!("{answer}"); |
| 111 |
} else { |
| 112 |
let verb = if reject { "Rejected" } else { "Approved" }; |
| 113 |
println!( |
| 114 |
"{} {job} of run #{number} with key {}", |
| 115 |
ui::done(verb), |
| 116 |
&key.to_hex()[..12] |
| 117 |
); |
| 118 |
println!( |
| 119 |
" state {} · job {}", |
| 120 |
&statement.state.to_hex()[..12], |
| 121 |
&statement.job_hash.to_hex()[..12] |
| 122 |
); |
| 123 |
for i in &statement.inputs { |
| 124 |
println!(" takes {}", &i.to_hex()[..12]); |
| 125 |
} |
| 126 |
if !statement.environment.is_empty() { |
| 127 |
println!(" for environment {}", statement.environment); |
| 128 |
} |
| 129 |
} |
| 130 |
Ok(0) |
| 131 |
} |
| 132 |
|
| 133 |
#[cfg(test)] |
| 134 |
mod tests { |
| 135 |
#[test] |
| 136 |
fn run_urls_are_read() { |
| 137 |
let (base, slug, n, repo) = |
| 138 |
super::run_url("https://oxygenhub.io/andymccall/oxygenhub-gcp/flow/8/").unwrap(); |
| 139 |
assert_eq!( |
| 140 |
(base.as_str(), slug.as_str(), n, repo.as_str()), |
| 141 |
( |
| 142 |
"https://oxygenhub.io", |
| 143 |
"andymccall/oxygenhub-gcp", |
| 144 |
8, |
| 145 |
"https://oxygenhub.io/andymccall/oxygenhub-gcp" |
| 146 |
) |
| 147 |
); |
| 148 |
assert!(super::run_url("https://oxygenhub.io/andymccall/x/proposal/3").is_err()); |
| 149 |
assert!(super::run_url("https://oxygenhub.io/andymccall/x/flow/abc").is_err()); |
| 150 |
} |
| 151 |
} |