diff --git a/Cargo.toml b/Cargo.toml index 7f34871..a01086c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ hive-sh4re = { path = "hive-sh4re" } rusqlite = { version = "0.37", features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +similar = "2" tokio = { version = "1", features = [ "io-util", "macros", diff --git a/hive-c0re/Cargo.toml b/hive-c0re/Cargo.toml index 553a7d3..f48ad29 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -14,6 +14,7 @@ hive-sh4re.workspace = true rusqlite.workspace = true serde.workspace = true serde_json.workspace = true +similar.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs new file mode 100644 index 0000000..aebc5d7 --- /dev/null +++ b/hive-c0re/src/actions.rs @@ -0,0 +1,45 @@ +//! Operations that are exposed through more than one surface (the host admin +//! socket *and* the dashboard's POST endpoints). Each function takes a +//! `&Coordinator` and the request parameters; callers stitch the response +//! shape they want (HTTP redirect vs JSON). + +use anyhow::Result; + +use crate::coordinator::Coordinator; +use crate::lifecycle; + +/// Approve a pending request: read the agent.nix at the approval's commit from +/// the proposed repo, copy into the applied repo, commit there, and rebuild +/// the agent container. On failure marks the approval failed (with the error +/// note) and returns the error. +pub async fn approve(coord: &Coordinator, id: i64) -> Result<()> { + let approval = coord.approvals.mark_approved(id)?; + tracing::info!(%approval.id, %approval.agent, %approval.commit_ref, "approval: applying + rebuilding"); + + let agent_dir = coord.register_agent(&approval.agent)?; + let proposed_dir = Coordinator::agent_proposed_dir(&approval.agent); + let applied_dir = Coordinator::agent_applied_dir(&approval.agent); + let result: Result<()> = async { + lifecycle::apply_commit(&applied_dir, &proposed_dir, &approval.commit_ref).await?; + lifecycle::rebuild( + &approval.agent, + &coord.hyperhive_flake, + &agent_dir, + &applied_dir, + ) + .await + } + .await; + if let Err(e) = result { + let note = format!("{e:#}"); + let _ = coord.approvals.mark_failed(approval.id, ¬e); + return Err(e); + } + Ok(()) +} + +pub fn deny(coord: &Coordinator, id: i64) -> Result<()> { + coord.approvals.mark_denied(id)?; + tracing::info!(%id, "approval denied"); + Ok(()) +} diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 83ec30c..1178936 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -1,14 +1,24 @@ //! Hyperhive dashboard. Lists managed containers (with deep-links to each -//! container's web UI), pending approvals, and the manager. +//! container's web UI), pending approvals (with unified diff vs the applied +//! repo, plus approve/deny buttons), and the manager. use std::fmt::Write as _; use std::net::SocketAddr; +use std::path::Path; use std::sync::Arc; use anyhow::{Context, Result}; -use axum::{Router, extract::State, http::HeaderMap, response::Html, routing::get}; +use axum::{ + Router, + extract::{Path as AxumPath, State}, + http::{HeaderMap, StatusCode}, + response::{Html, IntoResponse, Redirect, Response}, + routing::{get, post}, +}; use hive_sh4re::Approval; +use tokio::process::Command; +use crate::actions; use crate::coordinator::Coordinator; use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME}; @@ -22,6 +32,8 @@ struct AppState { pub async fn serve(port: u16, coord: Arc) -> Result<()> { let app = Router::new() .route("/", get(index)) + .route("/approve/{id}", post(post_approve)) + .route("/deny/{id}", post(post_deny)) .with_state(AppState { coord }); let addr = SocketAddr::from(([0, 0, 0, 0], port)); let listener = tokio::net::TcpListener::bind(addr) @@ -41,17 +53,42 @@ async fn index(headers: HeaderMap, State(state): State) -> Html\n\n\n\nhyperhive // h1ve-c0re\n{style}\n\n\n{banner}\n{containers}\n{approvals}\n{footer}\n\n\n", - style = STYLE, - banner = BANNER, + "\n\n\n\nhyperhive // h1ve-c0re\n{STYLE}\n\n\n{BANNER}\n{containers}\n{approvals_html}\n{FOOTER}\n\n\n", containers = render_containers(&containers, &hostname), - approvals = render_approvals(&approvals), - footer = FOOTER, )) } +async fn post_approve( + State(state): State, + AxumPath(id): AxumPath, +) -> Response { + match actions::approve(&state.coord, id).await { + Ok(()) => Redirect::to("/").into_response(), + Err(e) => error_response(format!("approve {id} failed: {e:#}")), + } +} + +async fn post_deny(State(state): State, AxumPath(id): AxumPath) -> Response { + match actions::deny(&state.coord, id) { + Ok(()) => Redirect::to("/").into_response(), + Err(e) => error_response(format!("deny {id} failed: {e:#}")), + } +} + +fn error_response(message: String) -> Response { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Html(format!( + "\n\n{STYLE}\n{BANNER}\n

◆ ERR0R ◆

\n
{message}
\n

← back

\n", + message = html_escape(&message), + )), + ) + .into_response() +} + fn render_containers(containers: &[String], hostname: &str) -> String { let mut out = String::from( "

◆ C0NTAINERS ◆

\n
══════════════════════════════════════════════════════════════
\n", @@ -79,7 +116,7 @@ fn render_containers(containers: &[String], hostname: &str) -> String { out } -fn render_approvals(approvals: &[Approval]) -> String { +async fn render_approvals(approvals: &[Approval]) -> String { let mut out = String::from( "

◆ P3NDING APPR0VALS ◆

\n
══════════════════════════════════════════════════════════════
\n", ); @@ -87,20 +124,63 @@ fn render_approvals(approvals: &[Approval]) -> String { out.push_str("

▓ queue empty ▓

\n"); return out; } - out.push_str("
    \n"); + out.push_str("
      \n"); for a in approvals { let sha_short = &a.commit_ref[..a.commit_ref.len().min(12)]; + let diff = approval_diff(&a.agent, &a.commit_ref).await; let _ = writeln!( out, - "
    • #{id} {agent} {sha_short} approve via hive-c0re approve {id}
    • ", + "
    • \n
      #{id} {agent} {sha_short}\n
      \n
      \n
      \n
      diff vs applied
      {diff}
      \n
    • ", id = a.id, agent = a.agent, + diff = html_escape(&diff), ); } out.push_str("
    \n"); out } +async fn approval_diff(agent: &str, commit_ref: &str) -> String { + let applied = Coordinator::agent_applied_dir(agent).join("agent.nix"); + let proposed = Coordinator::agent_proposed_dir(agent); + let applied_text = std::fs::read_to_string(&applied).unwrap_or_default(); + let proposed_text = match git_show(&proposed, commit_ref).await { + Ok(s) => s, + Err(e) => return format!("(error reading proposed agent.nix: {e:#})"), + }; + unified_diff(&applied_text, &proposed_text) +} + +async fn git_show(proposed_dir: &Path, commit_ref: &str) -> Result { + let out = Command::new("git") + .current_dir(proposed_dir) + .args(["show", &format!("{commit_ref}:agent.nix")]) + .output() + .await + .context("git show")?; + if !out.status.success() { + anyhow::bail!( + "git show {commit_ref}:agent.nix failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +fn unified_diff(applied: &str, proposed: &str) -> String { + let diff = similar::TextDiff::from_lines(applied, proposed); + diff.unified_diff() + .context_radius(3) + .header("applied", "proposed") + .to_string() +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + const BANNER: &str = r#""#; @@ -114,6 +194,7 @@ const STYLE: &str = r#"