Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/dwarves

crates/dwarves-embed/src/explorer.rs

//! **The UI's leg of the 🧭 Explorer** — `dwarf.v1.Explorer` on the forge. Every call is
//! ONE short call the forge answers at once: a question with the fold it keeps or the
//! jera job folding it (`queued` / `running`), an action with the job it queued (or a
//! refusal by name), `Job` with how that job is doing. The pane asks again while it
//! waits. Nothing here reads a checkout or dials nornir.

use std::time::Duration;

use anyhow::{Context, Result};

use crate::pb;

/// One answer of the forge, as the pane reads it.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Reply {
    /// The forge's state (`done` · `queued` · `running` · `failed` · `refused` ·
    /// `unknown`) — or this client's `unreachable` when the call never reached it.
    pub state: String,
    pub job_id: String,
    pub note: String,
    /// The answer (JSON) — the fold, or the job row.
    pub json: String,
    pub waited_ms: u64,
    pub age_ms: u64,
    /// Where it was asked (`Explorer.<Rpc> @ <forge>`).
    pub via: String,
}

impl Reply {
    /// Is the answer on the forge's jera queue (ask again)?
    pub fn is_waiting(&self) -> bool {
        matches!(self.state.as_str(), "queued" | "running")
    }
    /// The answer's JSON (`Null` when there is none).
    pub fn body(&self) -> serde_json::Value {
        serde_json::from_str(&self.json).unwrap_or(serde_json::Value::Null)
    }
}

/// One call of the service.
enum Call {
    Ask(pb::ExplorerAsk),
    BuildWorkspace(pb::ExplorerBuildWorkspace),
    BuildThing(pb::ExplorerBuildThing),
    RunPipeline(pb::ExplorerRunPipeline),
    Job(pb::ExplorerJob),
}

impl Call {
    fn name(&self) -> &'static str {
        match self {
            Call::Ask(_) => "Ask",
            Call::BuildWorkspace(_) => "BuildWorkspace",
            Call::BuildThing(_) => "BuildThing",
            Call::RunPipeline(_) => "RunPipeline",
            Call::Job(_) => "Job",
        }
    }
}

fn rpc(endpoint: &str, token: &str, call: Call) -> Result<pb::ExplorerAnswer> {
    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .context("build tokio runtime for the forge client")?;
    let endpoint = crate::norm_endpoint(endpoint);
    let token = token.to_string();
    rt.block_on(async move {
        let bearer = nornir_service::client::bearer(&token).context("parse bearer token")?;
        let channel = tonic::transport::Channel::from_shared(endpoint.clone())
            .with_context(|| format!("invalid server url `{endpoint}`"))?
            .connect_timeout(Duration::from_secs(3))
            .timeout(crate::READ_TIMEOUT)
            .connect()
            .await
            .with_context(|| format!("connect to dwarves-server at {endpoint}"))?;
        let mut client = pb::explorer_client::ExplorerClient::with_interceptor(
            channel,
            move |mut req: tonic::Request<()>| {
                if let Some(b) = &bearer {
                    req.metadata_mut().insert("authorization", b.clone());
                }
                Ok(req)
            },
        )
        .max_decoding_message_size(64 << 20);
        let name = call.name();
        let out = match call {
            Call::Ask(r) => client.ask(r).await,
            Call::BuildWorkspace(r) => client.build_workspace(r).await,
            Call::BuildThing(r) => client.build_thing(r).await,
            Call::RunPipeline(r) => client.run_pipeline(r).await,
            Call::Job(r) => client.job(r).await,
        };
        anyhow::Ok(
            out.with_context(|| format!("Explorer.{name} RPC"))?
                .into_inner(),
        )
    })
}

fn total(endpoint: &str, token: &str, call: Call) -> Reply {
    let via = format!(
        "Explorer.{} @ {}",
        call.name(),
        crate::norm_endpoint(endpoint)
    );
    match rpc(endpoint, token, call) {
        Ok(a) => Reply {
            state: a.state,
            job_id: a.job_id,
            note: a.note,
            json: a.json,
            waited_ms: a.waited_ms,
            age_ms: a.age_ms,
            via,
        },
        Err(e) => Reply {
            state: "unreachable".into(),
            note: format!("{e:#}"),
            via,
            ..Default::default()
        },
    }
}

/// **Ask** the project tree and the detail of `select` (empty project = the forge's first).
pub fn ask(endpoint: &str, token: &str, project: &str, select: &str, fresh: bool) -> Reply {
    total(
        endpoint,
        token,
        Call::Ask(pb::ExplorerAsk {
            project: project.into(),
            select: select.into(),
            fresh,
        }),
    )
}

/// **Queue the super build of `workspace`** (`only`: `all` · `affected`, which the forge refuses by name).
pub fn build_workspace(
    endpoint: &str,
    token: &str,
    project: &str,
    workspace: &str,
    only: &str,
) -> Reply {
    total(
        endpoint,
        token,
        Call::BuildWorkspace(pb::ExplorerBuildWorkspace {
            project: project.into(),
            workspace: workspace.into(),
            only: only.into(),
        }),
    )
}

/// **Queue a build of the Build Thing `name`** (one `variant`, or its default).
pub fn build_thing(endpoint: &str, token: &str, project: &str, name: &str, variant: &str) -> Reply {
    build_thing_with(endpoint, token, project, name, variant, &[], false, false)
}

/// [`build_thing`] **with the operator's cargo feature choice** — `--features`
/// (one entry per feature), `--no-default-features`, `--all-features` — carried on
/// the queued job to the build.
#[allow(clippy::too_many_arguments)]
pub fn build_thing_with(
    endpoint: &str,
    token: &str,
    project: &str,
    name: &str,
    variant: &str,
    features: &[String],
    no_default_features: bool,
    all_features: bool,
) -> Reply {
    total(
        endpoint,
        token,
        Call::BuildThing(pb::ExplorerBuildThing {
            project: project.into(),
            name: name.into(),
            variant: variant.into(),
            features: features.to_vec(),
            no_default_features,
            all_features,
        }),
    )
}

/// **Queue a run of the pipeline `name`.**
pub fn run_pipeline(endpoint: &str, token: &str, project: &str, name: &str) -> Reply {
    total(
        endpoint,
        token,
        Call::RunPipeline(pb::ExplorerRunPipeline {
            project: project.into(),
            name: name.into(),
        }),
    )
}

/// **How the queued action `job_id` is doing.**
pub fn job(endpoint: &str, token: &str, job_id: &str) -> Reply {
    total(
        endpoint,
        token,
        Call::Job(pb::ExplorerJob {
            job_id: job_id.into(),
        }),
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The forge's file and this client's are ONE contract: byte for byte.
    #[test]
    fn the_proto_is_the_forges_file_byte_for_byte() {
        let here = include_str!("../proto/explorer.proto");
        let forge = include_str!("../../dwarves/proto/explorer.proto");
        assert_eq!(here, forge, "crates/dwarves-embed/proto/explorer.proto must equal crates/dwarves/proto/explorer.proto");
    }

    /// A forge that is not there is an answer, not a panic and not an empty tree.
    #[test]
    fn an_unreachable_forge_is_a_state() {
        let a = ask("http://127.0.0.1:1", "", "nordisk", "", false);
        assert_eq!(a.state, "unreachable", "{a:?}");
        assert!(a.json.is_empty() && !a.note.is_empty() && a.via.contains("Explorer.Ask"));
        assert_eq!(
            build_workspace("http://127.0.0.1:1", "", "", "nordisk", "all").state,
            "unreachable"
        );
    }
}