~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs-socialweb-computer-requester

socialweb.computer market requester and policy modules for atlibs, with its web pages and lexicons

0
0
119 lines · 6.6 KB Raw History
// cicd-workload: computer.socialweb.cicd.workload.run, a CI job's steps on a machine, through the
// computer.socialweb.cicd.process.* hooks (a composition routes them: process-mcp over the guest agent's
// MCP shell). The checkout is fetched by the machine itself (git over https: knotless serves a repository
// from its owner's PDS); each step is `sh -s` with its variables on stdin, in /work. Output is the run's
// result as it comes: {step, output}; the last result: {steps: [{name, exitCode}], ok}.
//   run  {url, job: {checkout?: {git, commit, merge?: {git, commit}}, env?, steps?: [{name, run, env?}]}}
// (a runner checks out once, then runs one step a call: the machine keeps /work between them)
use abi::{Call, Event, Handle, Run, entries};
use serde_json::{Map, Value, json};

const START: &str = "computer.socialweb.cicd.process.start";
const WRITE: &str = "computer.socialweb.cicd.process.write";
const READ: &str = "computer.socialweb.cicd.process.read";
const WAIT_MS: u64 = 25000;
const MAX_BYTES: u64 = 262144;

fn quoted(s: &str) -> String {
    format!("'{}'", s.replace('\'', "'\\''"))
}

fn text<'a>(v: &'a Value, key: &str, at: &str) -> Result<&'a str, String> {
    v.get(key).and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or(format!("{at}.{key} is required"))
}

fn exports(env: Option<&Value>) -> Result<String, String> {
    let mut out = String::new();
    for (k, v) in env.and_then(Value::as_object).into_iter().flatten() {
        let ok = !k.is_empty() && k.bytes().enumerate().all(|(i, b)| b == b'_' || b.is_ascii_alphabetic() || (i > 0 && b.is_ascii_digit()));
        let Some(v) = v.as_str().filter(|_| ok) else { return Err(format!("env {k}: a NAME and a string value")) };
        out.push_str(&format!("export {k}={}\n", quoted(v)));
    }
    Ok(out)
}

/// The checkout, then the steps: (name, the stdin of `sh -s`).
fn scripts(job: &Value) -> Result<Vec<(String, String)>, String> {
    let mut out = vec![];
    if let Some(checkout) = job.get("checkout") {
        out.push(("checkout".to_string(), checkout_script(checkout)?));
    }
    let shared = exports(job.get("env"))?;
    for (i, step) in job.get("steps").and_then(Value::as_array).into_iter().flatten().enumerate() {
        let at = format!("job.steps[{i}]");
        let name = step.get("name").and_then(Value::as_str).map(String::from).unwrap_or_else(|| format!("step {}", i + 1));
        let script = format!("cd /work\nexport KNOTLESS_RUN_OUTPUTS=/outputs\n{shared}{}exec sh -ec {}\n", exports(step.get("env"))?, quoted(text(step, "run", &at)?));
        out.push((name, script));
    }
    Ok(out)
}

/// Clones the commit into /work (and merges `merge` into it), making /outputs.
fn checkout_script(checkout: &Value) -> Result<String, String> {
    let (git, commit) = (text(checkout, "git", "job.checkout")?, text(checkout, "commit", "job.checkout")?);
    let mut fetch = format!(
        "set -e\ncommand -v git >/dev/null || {{ apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq git >/dev/null; }}\nrm -rf /work /outputs\nmkdir -p /outputs\ngit clone -q {} /work\ncd /work\ngit checkout -q {}\n",
        quoted(git),
        quoted(commit)
    );
    if let Some(merge) = checkout.get("merge") {
        let (git, commit) = (text(merge, "git", "job.checkout.merge")?, text(merge, "commit", "job.checkout.merge")?);
        fetch.push_str(&format!("git fetch -q {} {}\ngit -c user.name=knotless -c user.email=knotless@localhost merge -q --no-edit FETCH_HEAD\n", quoted(git), quoted(commit)));
    }
    Ok(fetch)
}

fn run_handler(run: Run<Map<String, Value>>) -> Result<Handle, String> {
    let url = run.args.get("url").and_then(Value::as_str).ok_or("url is required: the machine's shell")?;
    let job = run.args.get("job").ok_or("job is required")?;
    let steps = scripts(job)?;
    let mut state = if run.state.is_null() { json!({ "step": 0, "codes": [] }) } else { run.state };
    let step = state["step"].as_u64().unwrap_or(0) as usize;
    let start = |state: &Value, i: usize| {
        Handle::new().call(Call::new("start", START).input(json!({ "url": url, "command": "sh -s" }))).state(json!({ "step": i, "codes": state["codes"] }))
    };
    let read = |state: Value| Handle::new().call(Call::new("read", READ).input(json!({ "url": url, "id": state["id"], "cursor": state["cursor"], "waitMs": WAIT_MS, "maxBytes": MAX_BYTES }))).state(state);
    Ok(match run.event {
        Event::Start if steps.is_empty() => Handle::new().result(json!({ "steps": [], "ok": true })).done(),
        Event::Start => start(&state, 0),
        Event::Result { id, result, .. } => {
            let out = result.output().map_err(|e| format!("{}: {e}", steps[step].0))?;
            match id.as_str() {
                "start" => {
                    state["id"] = out["id"].clone();
                    state["cursor"] = json!(0);
                    // the script in the write that closes stdin: a start's own stdin is written as it starts,
                    // so a close right after could come first
                    Handle::new().call(Call::new("write", WRITE).input(json!({ "url": url, "id": out["id"], "data": steps[step].1, "close": true }))).state(state)
                }
                "write" => read(state),
                "read" => {
                    state["cursor"] = out["cursor"].clone();
                    // this read's output, the run's result for now (the host shows each as it comes)
                    let mut said = Map::new();
                    if let Some(chunk) = out["output"].as_str().filter(|s| !s.is_empty()) {
                        said.insert("step".into(), json!(steps[step].0));
                        said.insert("output".into(), json!(chunk));
                    }
                    let next = if out["done"] != true {
                        read(state)
                    } else {
                        let code = out["exitCode"].as_i64().unwrap_or(-1);
                        state["codes"].as_array_mut().expect("a list").push(json!({ "name": steps[step].0, "exitCode": code }));
                        if code != 0 || step + 1 == steps.len() {
                            said.insert("steps".into(), state["codes"].clone());
                            said.insert("ok".into(), json!(code == 0));
                            return Ok(Handle::new().result(Value::Object(said)).done());
                        }
                        start(&state, step + 1)
                    };
                    if said.is_empty() { next } else { next.result(Value::Object(said)) }
                }
                _ => Handle::new(),
            }
        }
        _ => Handle::new(),
    })
}

entries!(run => run_handler);