Phase 9 F1: write-through aggregator API — fleet tag pin/unpin #103
@@ -716,7 +716,7 @@ pub fn build_app_with_v2(
|
|||||||
.ok();
|
.ok();
|
||||||
let mut v2_router: Router<()> = Router::new();
|
let mut v2_router: Router<()> = Router::new();
|
||||||
if let Some(v2s) = v2_state {
|
if let Some(v2s) = v2_state {
|
||||||
v2_router = v2_router.merge(crate::serve_v2::routes().with_state(v2s));
|
v2_router = v2_router.merge(crate::serve_v2::build(v2s));
|
||||||
}
|
}
|
||||||
if let Some(dir) = v2_static_dir {
|
if let Some(dir) = v2_static_dir {
|
||||||
v2_router = v2_router.nest_service(
|
v2_router = v2_router.nest_service(
|
||||||
|
|||||||
+221
-4
@@ -14,17 +14,20 @@
|
|||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
routing::get,
|
http::StatusCode,
|
||||||
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use tokio::task::JoinSet;
|
||||||
|
|
||||||
use crate::cluster::rpc::{
|
use crate::cluster::rpc::{
|
||||||
call_dashboard_status, call_dashboard_storage, CacheSummary, DashboardStatusReply,
|
call_delete_tag, call_put_tag, call_dashboard_status, call_dashboard_storage, CacheSummary,
|
||||||
DashboardStorageReply, FilesystemUsage, HotTierUsage, MountStatus, TimerStatus,
|
DashboardStatusReply, DashboardStorageReply, FilesystemUsage, HotTierUsage, MountStatus,
|
||||||
|
TimerStatus,
|
||||||
};
|
};
|
||||||
use crate::cluster::transport::{NodeIdentity, QuicClient};
|
use crate::cluster::transport::{NodeIdentity, QuicClient};
|
||||||
use crate::config::{Config, PeerEntry};
|
use crate::config::{Config, PeerEntry};
|
||||||
@@ -45,6 +48,12 @@ pub struct V2State {
|
|||||||
/// (gossip port, not RPC). We assume RPC = gossip + 1 in that
|
/// (gossip port, not RPC). We assume RPC = gossip + 1 in that
|
||||||
/// case unless the operator sets an explicit `rpc_addr`.
|
/// case unless the operator sets an explicit `rpc_addr`.
|
||||||
pub default_rpc_port_offset: u16,
|
pub default_rpc_port_offset: u16,
|
||||||
|
/// Bearer token required on mutating endpoints (POST/DELETE).
|
||||||
|
/// GET stays open — the dashboard loads without credentials.
|
||||||
|
/// Sourced from the aggregator's own `config.toml` `api_token`.
|
||||||
|
/// `None` disables the check (only appropriate on a trusted LAN,
|
||||||
|
/// e.g. Tailscale-only + loopback bind).
|
||||||
|
pub api_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl V2State {
|
impl V2State {
|
||||||
@@ -66,6 +75,7 @@ impl V2State {
|
|||||||
peers: cluster.peers.clone(),
|
peers: cluster.peers.clone(),
|
||||||
client: std::sync::Arc::new(client),
|
client: std::sync::Arc::new(client),
|
||||||
default_rpc_port_offset: 1,
|
default_rpc_port_offset: 1,
|
||||||
|
api_token: cfg.api_token.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,13 +479,214 @@ impl V2State {
|
|||||||
peers: self.peers.clone(),
|
peers: self.peers.clone(),
|
||||||
client: self.client.clone(),
|
client: self.client.clone(),
|
||||||
default_rpc_port_offset: self.default_rpc_port_offset,
|
default_rpc_port_offset: self.default_rpc_port_offset,
|
||||||
|
api_token: self.api_token.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── write-through fan-out (Phase 9 F1) ──────────────────────────
|
||||||
|
//
|
||||||
|
// The aggregator is the single choke-point for cross-fleet writes,
|
||||||
|
// so external callers (clawmates, gitea runners, ops tooling) speak
|
||||||
|
// one URL over ordinary HTTPS instead of minting a fleet-CA leaf
|
||||||
|
// cert per app. The aggregator holds a leaf cert and dials every
|
||||||
|
// peer over the existing mTLS QUIC transport.
|
||||||
|
|
||||||
|
fn decode_blob_id(hex: &str) -> Option<[u8; 32]> {
|
||||||
|
if hex.len() != 64 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = [0u8; 32];
|
||||||
|
for i in 0..32 {
|
||||||
|
let byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
|
||||||
|
out[i] = byte;
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PutTagBody {
|
||||||
|
/// 64-char hex-encoded BlobId (blake3 hash) to bind the tag to.
|
||||||
|
pub blob_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct PeerResult {
|
||||||
|
pub peer: String,
|
||||||
|
pub ok: bool,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct FanoutReply {
|
||||||
|
pub tag: String,
|
||||||
|
pub peers: Vec<PeerResult>,
|
||||||
|
/// Aggregate: `true` iff every peer succeeded.
|
||||||
|
pub all_ok: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v2/tags/:name` — bind a tag to a blob_id on **every**
|
||||||
|
/// peer concurrently. Callers get a per-peer result so partial
|
||||||
|
/// failures are diagnosable (single peer down doesn't hide the
|
||||||
|
/// success of the others).
|
||||||
|
async fn handle_put_tag(
|
||||||
|
Path(name): Path<String>,
|
||||||
|
State(s): State<Arc<V2State>>,
|
||||||
|
Json(body): Json<PutTagBody>,
|
||||||
|
) -> Result<Json<FanoutReply>, (StatusCode, String)> {
|
||||||
|
let value = decode_blob_id(&body.blob_id)
|
||||||
|
.ok_or_else(|| (StatusCode::BAD_REQUEST, "blob_id must be 64-char hex".to_string()))?;
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "tag name cannot be empty".to_string()));
|
||||||
|
}
|
||||||
|
let results = fanout_put_tag(&s, &name, &value).await;
|
||||||
|
let all_ok = results.iter().all(|r| r.ok);
|
||||||
|
Ok(Json(FanoutReply {
|
||||||
|
tag: name,
|
||||||
|
peers: results,
|
||||||
|
all_ok,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE /api/v2/tags/:name` — remove a tag on every peer.
|
||||||
|
/// Per-peer `ok=true` covers both "deleted" and "was already
|
||||||
|
/// absent" (the underlying RPC returns `Ok(false)` for the latter;
|
||||||
|
/// we still surface success — reversing a nonexistent tag is a
|
||||||
|
/// no-op, not an error).
|
||||||
|
async fn handle_delete_tag(
|
||||||
|
Path(name): Path<String>,
|
||||||
|
State(s): State<Arc<V2State>>,
|
||||||
|
) -> Result<Json<FanoutReply>, (StatusCode, String)> {
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "tag name cannot be empty".to_string()));
|
||||||
|
}
|
||||||
|
let results = fanout_delete_tag(&s, &name).await;
|
||||||
|
let all_ok = results.iter().all(|r| r.ok);
|
||||||
|
Ok(Json(FanoutReply {
|
||||||
|
tag: name,
|
||||||
|
peers: results,
|
||||||
|
all_ok,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fanout_put_tag(s: &V2State, name: &str, value: &[u8; 32]) -> Vec<PeerResult> {
|
||||||
|
let mut set = JoinSet::new();
|
||||||
|
for peer in &s.peers {
|
||||||
|
let peer = peer.clone();
|
||||||
|
let name = name.to_string();
|
||||||
|
let value = *value;
|
||||||
|
let s = s.clone_shallow();
|
||||||
|
set.spawn(async move {
|
||||||
|
let peer_name = peer.name.clone();
|
||||||
|
let res = async {
|
||||||
|
let conn = s.dial(&peer).await?;
|
||||||
|
let r = call_put_tag(&conn, &name, &value).await;
|
||||||
|
conn.close(quinn::VarInt::from_u32(0), b"done");
|
||||||
|
r
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
match res {
|
||||||
|
Ok(()) => PeerResult { peer: peer_name, ok: true, error: None },
|
||||||
|
Err(e) => PeerResult { peer: peer_name, ok: false, error: Some(e.to_string()) },
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while let Some(joined) = set.join_next().await {
|
||||||
|
match joined {
|
||||||
|
Ok(r) => out.push(r),
|
||||||
|
Err(e) => out.push(PeerResult {
|
||||||
|
peer: "<join-error>".into(),
|
||||||
|
ok: false,
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Deterministic ordering makes the response diff-friendly and
|
||||||
|
// lets integration tests assert on shape without sorting.
|
||||||
|
out.sort_by(|a, b| a.peer.cmp(&b.peer));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fanout_delete_tag(s: &V2State, name: &str) -> Vec<PeerResult> {
|
||||||
|
let mut set = JoinSet::new();
|
||||||
|
for peer in &s.peers {
|
||||||
|
let peer = peer.clone();
|
||||||
|
let name = name.to_string();
|
||||||
|
let s = s.clone_shallow();
|
||||||
|
set.spawn(async move {
|
||||||
|
let peer_name = peer.name.clone();
|
||||||
|
let res = async {
|
||||||
|
let conn = s.dial(&peer).await?;
|
||||||
|
// Discard the bool — "was already gone" is not an error
|
||||||
|
// at the fan-out layer (reversing an absent tag = no-op).
|
||||||
|
let _ = call_delete_tag(&conn, &name).await?;
|
||||||
|
conn.close(quinn::VarInt::from_u32(0), b"done");
|
||||||
|
Ok::<(), anyhow::Error>(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
match res {
|
||||||
|
Ok(()) => PeerResult { peer: peer_name, ok: true, error: None },
|
||||||
|
Err(e) => PeerResult { peer: peer_name, ok: false, error: Some(e.to_string()) },
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while let Some(joined) = set.join_next().await {
|
||||||
|
match joined {
|
||||||
|
Ok(r) => out.push(r),
|
||||||
|
Err(e) => out.push(PeerResult {
|
||||||
|
peer: "<join-error>".into(),
|
||||||
|
ok: false,
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| a.peer.cmp(&b.peer));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── auth middleware ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// If the aggregator's config sets `api_token`, mutating methods
|
||||||
|
/// (POST/DELETE) must present a matching `Authorization: Bearer …`
|
||||||
|
/// header. GET is always open so the dashboard loads unmodified.
|
||||||
|
async fn v2_auth(
|
||||||
|
State(s): State<Arc<V2State>>,
|
||||||
|
request: axum::extract::Request,
|
||||||
|
next: axum::middleware::Next,
|
||||||
|
) -> axum::response::Response {
|
||||||
|
let method = request.method().clone();
|
||||||
|
let needs_auth =
|
||||||
|
method != axum::http::Method::GET && method != axum::http::Method::HEAD;
|
||||||
|
if needs_auth {
|
||||||
|
if let Some(expected) = &s.api_token {
|
||||||
|
let provided = request
|
||||||
|
.headers()
|
||||||
|
.get(axum::http::header::AUTHORIZATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.strip_prefix("Bearer "));
|
||||||
|
if provided != Some(expected.as_str()) {
|
||||||
|
return axum::response::Response::builder()
|
||||||
|
.status(StatusCode::UNAUTHORIZED)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(axum::body::Body::from(
|
||||||
|
r#"{"error":"unauthorized"}"#,
|
||||||
|
))
|
||||||
|
.unwrap_or_default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next.run(request).await
|
||||||
|
}
|
||||||
|
|
||||||
// ── route registration ──────────────────────────────────────────
|
// ── route registration ──────────────────────────────────────────
|
||||||
|
|
||||||
pub fn routes() -> Router<Arc<V2State>> {
|
/// Assemble the aggregator router with state and middleware baked in.
|
||||||
|
/// The auth middleware needs the concrete `Arc<V2State>` at layer time
|
||||||
|
/// (so it can read `api_token`), which is why this returns a fully-
|
||||||
|
/// stated `Router<()>` instead of a state-generic router.
|
||||||
|
pub fn build(state: Arc<V2State>) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/v2/fleet", get(handle_fleet))
|
.route("/api/v2/fleet", get(handle_fleet))
|
||||||
.route("/api/v2/node/:name/status", get(handle_node_status))
|
.route("/api/v2/node/:name/status", get(handle_node_status))
|
||||||
@@ -485,4 +696,10 @@ pub fn routes() -> Router<Arc<V2State>> {
|
|||||||
.route("/api/v2/storage/snapshots", get(handle_snapshots))
|
.route("/api/v2/storage/snapshots", get(handle_snapshots))
|
||||||
.route("/api/v2/storage/ref-tracking", get(handle_ref_tracking))
|
.route("/api/v2/storage/ref-tracking", get(handle_ref_tracking))
|
||||||
.route("/api/v2/projects", get(handle_projects))
|
.route("/api/v2/projects", get(handle_projects))
|
||||||
|
.route(
|
||||||
|
"/api/v2/tags/:name",
|
||||||
|
post(handle_put_tag).delete(handle_delete_tag),
|
||||||
|
)
|
||||||
|
.route_layer(axum::middleware::from_fn_with_state(state.clone(), v2_auth))
|
||||||
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user