OXYGENHUB
◆ paths-for-resolutions-and-amend oxygen / src / cli / approve.rs
5.4 KiB · Rust · last changed Sealed approvals: the approval attestation and oxy seal --flow 19 days ago source blame history raw
1
//! `oxy seal --flow RUN_URL --job NAME [--reject]` (oxygenhub#235): sign an
2
//! approval of one job of one run with your key, so the hub can show, and
3
//! anyone can check, who released it. The hub says what the job is (its
4
//! hash with its sandbox, the state, the plan it takes, the environment);
5
//! the signature covers exactly that and nothing else.
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
/// `https://hub/OWNER/NAME/flow/N` to the hub's base, `OWNER/NAME`, N and
14
/// the repository's URL.
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
}