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"
);
}
}