crates/monetize-ui-server/src/self_box.rs
//! **"Update this box"** (owner 2026-10-18) — the ONE box's own update, from its console.
//! Thin: the box (monetize-admin, PID 1) decides and does it — what runs, what the release
//! bucket offers next, whether it may be taken live, the stage, the restart, the trial and its
//! confirmation; the console only shows it and passes the operator's word on.
//!
//! ```text
//! GET /api/self → {app, slot, base, live_update, next_app, next_build, plan, why, asked, last}
//! POST /api/self/update → the same, the update asked (an admin's: POST is admin-only)
//! GET /api/self/certs → {certificates: [{host, hosts, valid_until, next_attempt, last_reason, lines, try_now, alert, …}]}
//! POST /api/self/certs/try → {words} — {host}: the box's "try now" (its rule, its refusal)
//! ```
//!
//! The box's half is a [`SelfBox`] the binary that serves the console installs at its start
//! ([`install`]); a console with none answers that this box cannot update itself from here.
use std::sync::{Arc, Mutex};
use axum::extract::State;
use axum::Json;
use serde::{Deserialize, Serialize};
use crate::api::ApiError;
use crate::app::AppState;
use crate::grpc::Fail;
/// **What this box runs and what it would take** — the box's own words.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SelfLook {
/// The running app's pin.
pub app: String,
/// The slot setting (`a`, `b`, `b trial`).
pub slot: String,
pub base: String,
pub live_update: bool,
/// The release bucket's next app pin and the monetize commit it was built from.
pub next_app: String,
pub next_build: String,
/// `nothing` (it runs the next already), `live` (it may be taken now), `wait` (the last
/// update is on trial) or `base` (only the kit's keep upgrade takes it).
pub plan: String,
/// Why not, for `wait` / `base`.
pub why: String,
/// An update was asked and the box has not taken it yet.
pub asked: bool,
/// The box's last word on an update it took (staged …, or REFUSED …).
pub last: String,
}
/// **The box's half**: what it runs, and the operator's word.
pub trait SelfBox: Send + Sync {
fn look(&self) -> Result<SelfLook, String>;
/// Ask the update; answers at once (the box takes it within seconds and restarts).
fn ask_update(&self, by: &str) -> Result<(), String>;
/// **This box's certificates** (owner 2026-10-25): edda estate-front's status report,
/// `{"certificates": [{host, hosts, valid_until, next_attempt, last_reason, lines, try_now, alert, …}]}`.
fn certs(&self) -> Result<serde_json::Value, String> {
Err("this box does not say what its certificates are".into())
}
/// An admin's "try now" for `host`: the box decides whether the wait may be skipped.
fn try_cert(&self, _host: &str, _by: &str) -> Result<String, String> {
Err("this box does not take a try now".into())
}
}
static BOX: Mutex<Option<Arc<dyn SelfBox>>> = Mutex::new(None);
/// Install this box's half (the console's start; a test's fake).
pub fn install(b: Arc<dyn SelfBox>) {
*BOX.lock().unwrap_or_else(|p| p.into_inner()) = Some(b);
}
fn the_box() -> Result<Arc<dyn SelfBox>, ApiError> {
BOX.lock().unwrap_or_else(|p| p.into_inner()).clone().ok_or_else(|| ApiError(Fail { code: "unimplemented".into(), message: "this console does not run on a box that updates itself".into() }))
}
fn refused(message: String) -> ApiError {
ApiError(Fail { code: "failedprecondition".into(), message })
}
/// `GET /api/self`.
pub async fn look(State(_): State<AppState>) -> Result<Json<SelfLook>, ApiError> {
let b = the_box()?;
tokio::task::spawn_blocking(move || b.look()).await.map_err(|e| refused(e.to_string()))?.map(Json).map_err(refused)
}
/// `POST /api/self/update` — the signed-in admin's word.
pub async fn update(State(_): State<AppState>, caller: Option<axum::Extension<crate::auth::Caller>>) -> Result<Json<SelfLook>, ApiError> {
let by = caller.map(|axum::Extension(c)| format!("console:{}", c.name())).unwrap_or_else(|| "console".to_owned());
let b = the_box()?;
let who = by.clone();
let looked = tokio::task::spawn_blocking(move || b.ask_update(&who).and_then(|()| b.look())).await.map_err(|e| refused(e.to_string()))?.map_err(refused)?;
tracing::info!(target: crate::auth::AUDIT_TARGET, event = "self.update", user = %by, next = %looked.next_build, "the operator asked this box to update itself");
Ok(Json(looked))
}
/// `GET /api/self/certs` — the box's certificate report, as the box says it.
pub async fn certs(State(_): State<AppState>) -> Result<Json<serde_json::Value>, ApiError> {
let b = the_box()?;
tokio::task::spawn_blocking(move || b.certs()).await.map_err(|e| refused(e.to_string()))?.map(Json).map_err(refused)
}
/// The body of `POST /api/self/certs/try`.
#[derive(Debug, Deserialize)]
pub struct TryCert {
pub host: String,
}
/// `POST /api/self/certs/try` — an admin's "try now" for one certificate; the box decides.
pub async fn try_cert(State(_): State<AppState>, caller: Option<axum::Extension<crate::auth::Caller>>, Json(t): Json<TryCert>) -> Result<Json<serde_json::Value>, ApiError> {
let by = caller.map(|axum::Extension(c)| format!("console:{}", c.name())).unwrap_or_else(|| "console".to_owned());
let b = the_box()?;
let (host, who) = (t.host.trim().to_ascii_lowercase(), by.clone());
let h = host.clone();
let words = tokio::task::spawn_blocking(move || b.try_cert(&h, &who)).await.map_err(|e| refused(e.to_string()))?.map_err(refused)?;
tracing::info!(target: crate::auth::AUDIT_TARGET, event = "self.certs.try", user = %by, host = %host, "the operator asked a certificate to be tried now");
Ok(Json(serde_json::json!({ "words": words })))
}