Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40403fc6d5 | ||
|
|
6338939657 | ||
|
|
2c7872841a | ||
|
|
fa658567db | ||
|
|
9f26c416c0 | ||
|
|
c32a9367e4 | ||
|
|
786e4610f0 | ||
|
|
127846ef1b | ||
|
|
351341e87c | ||
|
|
fbffccbbb2 | ||
|
|
f80facbbe0 | ||
|
|
030eef0948 | ||
|
|
6caf177416 | ||
|
|
56ab6d26c1 | ||
|
|
d60a0585d6 | ||
|
|
991cd24fc8 |
41 changed files with 1469 additions and 644 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1795,6 +1795,7 @@ dependencies = [
|
|||
"anyhow",
|
||||
"hive-priv-sock",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@
|
|||
system
|
||||
treefmt-eval
|
||||
;
|
||||
inherit (nixpkgs.lib) nixosSystem;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -80,6 +80,19 @@ pub struct RemindArgs {
|
|||
pub file_path: Option<String>,
|
||||
}
|
||||
|
||||
/// MCP tool args for `compact`.
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct CompactArgs {
|
||||
/// Optional wake-up prompt. When set and the compact actually runs
|
||||
/// (gated on context usage — see the tool description), the harness
|
||||
/// drives one synthetic follow-up turn with this string as its body
|
||||
/// as soon as compaction finishes, so you don't have to wait for the
|
||||
/// next external event to continue. Omit for a fire-and-forget compact
|
||||
/// with no follow-up.
|
||||
#[serde(default)]
|
||||
pub wake_prompt: Option<String>,
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Privileged tool arg types (lifecycle, approvals, scheduling, diagnostics)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -25,10 +25,10 @@ mod render;
|
|||
|
||||
pub use args::{
|
||||
AckUntilArgs, AgentGetLooseEndsArgs, AnswerArgs, AskArgs, CancelLooseEndArgs,
|
||||
CancelScheduleArgs, CreateRepoArgs, EditScheduleArgs, FireScheduleNowArgs, GetAgentMetaArgs,
|
||||
GetHostJournalArgs, GetLogsArgs, KillArgs, MarkTodosDoneArgs, RecvArgs, RemindArgs,
|
||||
RequestInitConfigArgs, RequestSchedulePromptArgs, RestartArgs, SendArgs, SetStatusArgs,
|
||||
StartArgs, UpdateArgs, UpdateMetaInputsArgs,
|
||||
CancelScheduleArgs, CompactArgs, CreateRepoArgs, EditScheduleArgs, FireScheduleNowArgs,
|
||||
GetAgentMetaArgs, GetHostJournalArgs, GetLogsArgs, KillArgs, MarkTodosDoneArgs, RecvArgs,
|
||||
RemindArgs, RequestInitConfigArgs, RequestSchedulePromptArgs, RestartArgs, SendArgs,
|
||||
SetStatusArgs, StartArgs, UpdateArgs, UpdateMetaInputsArgs,
|
||||
};
|
||||
pub use render::{annotate_retries, format_ack, format_agent_meta, format_recv};
|
||||
|
||||
|
|
@ -657,11 +657,19 @@ impl AgentServer {
|
|||
that the call is refused with an explanation and has no effect. On a pass, \
|
||||
queues compaction for the end of the current turn (same deferred mechanism \
|
||||
the dashboard button uses, so it never races a live claude process); the \
|
||||
usual pre-compaction notes-checkpoint turn still fires first. No args."
|
||||
usual pre-compaction notes-checkpoint turn still fires first. Pass \
|
||||
`wake_prompt` to have the harness drive one synthetic follow-up turn with \
|
||||
that body as soon as compaction finishes — without it you just go idle \
|
||||
waiting for the next external event, same as ending a turn normally."
|
||||
)]
|
||||
async fn compact(&self) -> String {
|
||||
run_tool_envelope("compact", String::new(), async move {
|
||||
match dial_agent_socket(&hive_agent_sock::Request::Compact).await {
|
||||
async fn compact(&self, Parameters(args): Parameters<CompactArgs>) -> String {
|
||||
let log = format!("{args:?}");
|
||||
run_tool_envelope("compact", log, async move {
|
||||
match dial_agent_socket(&hive_agent_sock::Request::Compact {
|
||||
wake_prompt: args.wake_prompt,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Some(hive_agent_sock::Response::Ok) => {
|
||||
"compact queued — will run at the end of the current turn".to_owned()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,7 +117,12 @@ pub enum Request {
|
|||
/// explains why and takes no action. On a pass, queues the same
|
||||
/// deferred `compact_pending` flag the operator's button sets (consumed
|
||||
/// at the next turn boundary), so it never races a live claude process.
|
||||
Compact,
|
||||
/// `wake_prompt`, when set, is driven as a synthetic follow-up turn once
|
||||
/// the compaction actually finishes — the dashboard button's own
|
||||
/// requests go through `Bus::request_compact` directly with `None`, not
|
||||
/// through this variant, since a human watching the dashboard isn't
|
||||
/// waiting on a wake.
|
||||
Compact { wake_prompt: Option<String> },
|
||||
/// Mirror an outstanding question this agent asked (`ask()` succeeded).
|
||||
/// `target` is who it's waiting on (`"operator"` when asked with
|
||||
/// `to: None`). Part of the questions-mirror increment — see
|
||||
|
|
|
|||
|
|
@ -260,6 +260,14 @@ pub enum TurnState {
|
|||
Compacting,
|
||||
}
|
||||
|
||||
/// One pending `/compact` request (see `Bus::request_compact`).
|
||||
/// `wake_prompt` is what to drive as a synthetic follow-up turn once the
|
||||
/// compaction actually finishes, if anything.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompactRequest {
|
||||
pub wake_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bus {
|
||||
tx: Arc<broadcast::Sender<BusEvent>>,
|
||||
|
|
@ -307,7 +315,23 @@ pub struct Bus {
|
|||
/// One-shot: run `/compact` after the next turn ends. Consumed at the end
|
||||
/// of the current/next turn by `turn::drive_turn`. Deferring to the turn
|
||||
/// boundary keeps compaction from racing a live claude process mid-turn.
|
||||
compact_pending: Arc<AtomicBool>,
|
||||
/// `Some(request)` when a compact is pending; `request.wake_prompt` is
|
||||
/// what to drive as a synthetic follow-up turn once the compaction
|
||||
/// actually completes (`None` = pending but no follow-up wake wanted,
|
||||
/// e.g. the operator dashboard's `/compact` button). `None` = no compact
|
||||
/// pending. Wrapped in [`CompactRequest`] rather than
|
||||
/// `Option<Option<String>>` (clippy pedantic's `option_option` lint,
|
||||
/// and the named field reads clearer at call sites than a bare nested
|
||||
/// `Option`) so "pending" and "what to wake with" can never desync.
|
||||
compact_pending: Arc<Mutex<Option<CompactRequest>>>,
|
||||
/// One-shot, written by `turn::drive_turn`/`turn::run_pending_compact`
|
||||
/// right after a compaction they served finishes, when that compact's
|
||||
/// request carried a `wake_prompt`. Read once by the `hive-agent` serve
|
||||
/// loop after either call site to decide whether to drive a synthetic
|
||||
/// follow-up turn. Separate from `compact_pending`: by the time this is
|
||||
/// set, the compact has already run and that flag has already been
|
||||
/// cleared by `take_compact`.
|
||||
post_compact_wake: Arc<Mutex<Option<String>>>,
|
||||
/// Current fresh-claude-session id (FK to `sessions.id`). Set by the
|
||||
/// bin loop after minting a session row on a fresh start; stamped onto
|
||||
/// every `turn_stats` row until the next fresh session. `None` before
|
||||
|
|
@ -397,7 +421,8 @@ impl Bus {
|
|||
last_cost_usage: Arc::new(Mutex::new(None)),
|
||||
rate_limited: Arc::new(AtomicBool::new(was_rate_limited)),
|
||||
session_reset_pending: Arc::new(AtomicBool::new(false)),
|
||||
compact_pending: Arc::new(AtomicBool::new(false)),
|
||||
compact_pending: Arc::new(Mutex::new(None)),
|
||||
post_compact_wake: Arc::new(Mutex::new(None)),
|
||||
session_id: Arc::new(Mutex::new(None)),
|
||||
fresh_session: Arc::new(AtomicBool::new(false)),
|
||||
tool_calls: Arc::new(Mutex::new(std::collections::HashMap::new())),
|
||||
|
|
@ -438,16 +463,40 @@ impl Bus {
|
|||
}
|
||||
|
||||
/// Request a compaction after the next turn ends (deferred to the turn
|
||||
/// boundary). Idempotent.
|
||||
pub fn request_compact(&self) {
|
||||
self.compact_pending.store(true, Ordering::SeqCst);
|
||||
/// boundary). Idempotent — a second request before the first is
|
||||
/// serviced just overwrites `wake_prompt` with the latest ask. `Some
|
||||
/// (wake_prompt)` schedules a synthetic follow-up turn (driven with
|
||||
/// `wake_prompt` as its body) once the compaction actually completes;
|
||||
/// `None` requests a plain compact with no follow-up wake (the operator
|
||||
/// dashboard's `/compact` button).
|
||||
pub fn request_compact(&self, wake_prompt: Option<String>) {
|
||||
*self.compact_pending.lock().unwrap() = Some(CompactRequest { wake_prompt });
|
||||
}
|
||||
|
||||
/// Take + clear the compact one-shot. Returns true iff `drive_turn` should
|
||||
/// compact at the end of this turn.
|
||||
/// Take + clear the compact one-shot. `Some(request)` means
|
||||
/// `drive_turn`/`run_pending_compact` should compact now —
|
||||
/// `request.wake_prompt` is what to pass to `set_post_compact_wake` once
|
||||
/// that compaction finishes. `None` means no compact is pending.
|
||||
#[must_use]
|
||||
pub fn take_compact(&self) -> bool {
|
||||
self.compact_pending.swap(false, Ordering::SeqCst)
|
||||
pub fn take_compact(&self) -> Option<CompactRequest> {
|
||||
self.compact_pending.lock().unwrap().take()
|
||||
}
|
||||
|
||||
/// Record that a just-finished compaction should drive a synthetic
|
||||
/// follow-up turn with `prompt` as its body. Called by
|
||||
/// `turn::drive_turn`/`turn::run_pending_compact` right after the
|
||||
/// compaction they served (whose `take_compact()` returned a request
|
||||
/// with `wake_prompt: Some(prompt)`) completes.
|
||||
pub fn set_post_compact_wake(&self, prompt: String) {
|
||||
*self.post_compact_wake.lock().unwrap() = Some(prompt);
|
||||
}
|
||||
|
||||
/// Take + clear the post-compact wake one-shot. The serve loop calls
|
||||
/// this after either compact call site to decide whether to
|
||||
/// synthesize a follow-up turn.
|
||||
#[must_use]
|
||||
pub fn take_post_compact_wake(&self) -> Option<String> {
|
||||
self.post_compact_wake.lock().unwrap().take()
|
||||
}
|
||||
|
||||
/// Mark that the current turn started a fresh claude session.
|
||||
|
|
|
|||
|
|
@ -240,6 +240,21 @@ fn synthetic_todo_message(stern: bool) -> hive_sh4re::inbox::DeliveredMessage {
|
|||
}
|
||||
}
|
||||
|
||||
/// Synthetic message that drives the follow-up turn when a `/compact` call
|
||||
/// (self-requested via the `compact` MCP tool's `wake_prompt` arg) finishes
|
||||
/// and asked to be woken. Mirrors `synthetic_todo_message`'s "no broker row"
|
||||
/// sentinel shape (`id = 0`) — the compact tool call itself is the durable
|
||||
/// record that a wake was requested, not a broker message.
|
||||
fn post_compact_wake_message(prompt: String) -> hive_sh4re::inbox::DeliveredMessage {
|
||||
hive_sh4re::inbox::DeliveredMessage {
|
||||
from: "compact".into(),
|
||||
body: prompt,
|
||||
id: 0,
|
||||
redelivered: false,
|
||||
in_reply_to: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Synthetic message that drives the single stop-checkpoint turn when c0re
|
||||
/// signals a graceful stop. The agent gets one final turn to flush durable
|
||||
/// `/state` before the container is stopped; new inbound is already fenced.
|
||||
|
|
@ -772,8 +787,18 @@ async fn serve_loop<S: Surface>(
|
|||
let compacted = turn::run_pending_compact(files, &bus, &session).await;
|
||||
if !compacted {
|
||||
tokio::time::sleep(interval).await;
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
// The compact that just ran may have carried a wake prompt
|
||||
// (the agent's own `compact` tool, not the operator button —
|
||||
// see `Bus::request_compact`). If so, drive it as a synthetic
|
||||
// turn right now instead of looping back to `recv_next` and
|
||||
// waiting for the next external event.
|
||||
let Some(prompt) = bus.take_post_compact_wake() else {
|
||||
continue;
|
||||
};
|
||||
tracing::debug!("post-compact wake queued, driving synthetic follow-up turn");
|
||||
post_compact_wake_message(prompt)
|
||||
}
|
||||
RecvOutcome::TransportError => {
|
||||
// `recv_next` already logged the detail; just retry.
|
||||
|
|
@ -802,32 +827,83 @@ async fn serve_loop<S: Surface>(
|
|||
return Ok(());
|
||||
}
|
||||
};
|
||||
let ctrl = handle_turn::<S>(
|
||||
let turn_ctx = TurnCtx {
|
||||
socket,
|
||||
&bus,
|
||||
stats.as_ref(),
|
||||
bus: &bus,
|
||||
stats: stats.as_ref(),
|
||||
files,
|
||||
&session,
|
||||
session: &session,
|
||||
interrupted: &interrupted,
|
||||
login_state: &login_state,
|
||||
claude_dir: &claude_dir,
|
||||
interval,
|
||||
};
|
||||
drive_turn_and_wake_chain::<S>(&turn_ctx, next, &mut todo_miss_streak).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Loop-invariant turn-driving context for `drive_turn_and_wake_chain`,
|
||||
/// threaded as one bundle instead of double-digit positional args
|
||||
/// (clippy's `too_many_arguments`). Everything here is constant for the
|
||||
/// lifetime of one `serve_loop` call; only the message to drive and the
|
||||
/// todo-miss streak vary per turn and stay as separate params.
|
||||
struct TurnCtx<'a> {
|
||||
socket: &'a Path,
|
||||
bus: &'a Bus,
|
||||
stats: Option<&'a TurnStats>,
|
||||
files: &'a turn::TurnFiles,
|
||||
session: &'a turn::AgentSession,
|
||||
interrupted: &'a Arc<std::sync::atomic::AtomicBool>,
|
||||
login_state: &'a Arc<Mutex<LoginState>>,
|
||||
claude_dir: &'a Path,
|
||||
interval: Duration,
|
||||
}
|
||||
|
||||
/// Drive `next`, then keep driving synthetic follow-up turns for as long as
|
||||
/// a compact that just ran carries a wake prompt
|
||||
/// (`Bus::take_post_compact_wake`) — see `serve_loop`'s comment at the call
|
||||
/// site for why this loops in place instead of returning to the outer
|
||||
/// `select!`/`recv_next`. Ordinarily runs exactly one iteration; only chains
|
||||
/// further if the follow-up turn itself requests another woken compact.
|
||||
/// Split out of `serve_loop` purely to keep that function under clippy's
|
||||
/// line limit.
|
||||
async fn drive_turn_and_wake_chain<S: Surface>(
|
||||
ctx: &TurnCtx<'_>,
|
||||
mut next: hive_sh4re::inbox::DeliveredMessage,
|
||||
todo_miss_streak: &mut u32,
|
||||
) {
|
||||
loop {
|
||||
let ctrl = handle_turn::<S>(
|
||||
ctx.socket,
|
||||
ctx.bus,
|
||||
ctx.stats,
|
||||
ctx.files,
|
||||
ctx.session,
|
||||
next,
|
||||
&interrupted,
|
||||
ctx.interrupted,
|
||||
)
|
||||
.await;
|
||||
apply_todo_wake_checked(ctrl.todo_wake_checked, &mut todo_miss_streak, &bus);
|
||||
apply_todo_wake_checked(ctrl.todo_wake_checked, todo_miss_streak, ctx.bus);
|
||||
if ctrl.auth_failed {
|
||||
*login_state.lock().unwrap() = LoginState::NeedsLogin;
|
||||
*ctx.login_state.lock().unwrap() = LoginState::NeedsLogin;
|
||||
// Baseline the resume check on *this instant*, not on a
|
||||
// directory snapshot taken after `wait_for_login` starts
|
||||
// polling — closes the race where a login lands between the
|
||||
// 401 and the first poll. See `wait_for_login`'s doc comment.
|
||||
login::wait_for_login(
|
||||
&claude_dir,
|
||||
login_state.clone(),
|
||||
&bus,
|
||||
u64::try_from(interval.as_millis()).unwrap_or(2000),
|
||||
ctx.claude_dir,
|
||||
ctx.login_state.clone(),
|
||||
ctx.bus,
|
||||
u64::try_from(ctx.interval.as_millis()).unwrap_or(2000),
|
||||
std::time::SystemTime::now(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let Some(prompt) = ctx.bus.take_post_compact_wake() else {
|
||||
break;
|
||||
};
|
||||
tracing::debug!("post-compact wake queued mid-turn-flow, driving synthetic follow-up turn");
|
||||
next = post_compact_wake_message(prompt);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -242,7 +242,7 @@ fn dispatch(
|
|||
} => record_answering_question(questions, id, &asker, &question),
|
||||
Request::ClearQuestion { id } => clear_question(questions, id),
|
||||
Request::ListQuestions => list_questions(questions),
|
||||
Request::Compact => compact(bus),
|
||||
Request::Compact { wake_prompt } => compact(bus, wake_prompt),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -458,8 +458,10 @@ fn mark_todos_done(store: &Todos, ids: &[i64]) -> Response {
|
|||
/// the agent's own MCP tool instead of the dashboard, and refuses below
|
||||
/// [`COMPACT_MIN_USAGE_FRACTION`] instead of always honouring the request —
|
||||
/// an agent can call this speculatively, a human clicking the dashboard
|
||||
/// button already made the judgment call.
|
||||
fn compact(bus: &Bus) -> Response {
|
||||
/// button already made the judgment call. `wake_prompt`, when set, is
|
||||
/// forwarded to `Bus::request_compact` so the turn loop drives a synthetic
|
||||
/// follow-up turn once the compaction actually finishes.
|
||||
fn compact(bus: &Bus, wake_prompt: Option<String>) -> Response {
|
||||
let Some(usage) = bus.last_ctx_usage() else {
|
||||
return Response::Err {
|
||||
message: "compact refused: no completed turn yet — nothing to compact".to_owned(),
|
||||
|
|
@ -487,9 +489,16 @@ fn compact(bus: &Bus) -> Response {
|
|||
),
|
||||
};
|
||||
}
|
||||
bus.request_compact();
|
||||
let will_wake = wake_prompt.is_some();
|
||||
bus.request_compact(wake_prompt);
|
||||
bus.emit(crate::events::LiveEvent::Note {
|
||||
text: "agent: self-requested /compact — running at the end of the current turn".into(),
|
||||
text: if will_wake {
|
||||
"agent: self-requested /compact (with wake prompt) — running at the end of the \
|
||||
current turn"
|
||||
.into()
|
||||
} else {
|
||||
"agent: self-requested /compact — running at the end of the current turn".into()
|
||||
},
|
||||
});
|
||||
Response::Ok
|
||||
}
|
||||
|
|
|
|||
|
|
@ -374,15 +374,18 @@ pub async fn drive_turn(
|
|||
archive_session(bus);
|
||||
return Err(TurnError::PromptTooLong);
|
||||
}
|
||||
// Operator `/compact` (`POST /api/compact`) deferred to the turn boundary:
|
||||
// run it now that the turn is done, so it works mid-turn rather than only
|
||||
// when the agent is idle. Only on a healthy turn — no point spawning a
|
||||
// compaction after a rate-limited / auth-failed / crashed one.
|
||||
// `is_ok()` first: `take_compact()` clears the flag, so it must only fire
|
||||
// when the compaction will actually run. On an unhealthy turn
|
||||
// (rate-limited / auth-failed / failed) the flag is left set for the next
|
||||
// turn or the idle `run_pending_compact` to service — not silently eaten.
|
||||
if outcome.is_ok() && bus.take_compact() {
|
||||
// Operator `/compact` (`POST /api/compact`) or an agent's own `compact`
|
||||
// MCP tool call, deferred to the turn boundary: run it now that the turn
|
||||
// is done, so it works mid-turn rather than only when the agent is idle.
|
||||
// Only on a healthy turn — no point spawning a compaction after a
|
||||
// rate-limited / auth-failed / crashed one. `is_ok()` first: `take_compact()`
|
||||
// clears the flag, so it must only fire when the compaction will actually
|
||||
// run. On an unhealthy turn (rate-limited / auth-failed / failed) the flag
|
||||
// is left set for the next turn or the idle `run_pending_compact` to
|
||||
// service — not silently eaten.
|
||||
if outcome.is_ok()
|
||||
&& let Some(request) = bus.take_compact()
|
||||
{
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: "operator: /compact — running at turn end".into(),
|
||||
});
|
||||
|
|
@ -390,6 +393,14 @@ pub async fn drive_turn(
|
|||
// does; the serve loop resets to `Idle` once this turn returns.
|
||||
bus.set_state(crate::events::TurnState::Compacting);
|
||||
let _ = session.compact(&config, &sink).await;
|
||||
// If the compact call asked to be woken (the agent's own `compact`
|
||||
// tool with a `wake_prompt`), stash it — the serve loop reads it
|
||||
// back after this turn returns and drives a synthetic follow-up
|
||||
// turn, so a self-requested compact provably doesn't strand the
|
||||
// agent idle waiting for the next external event.
|
||||
if let Some(prompt) = request.wake_prompt {
|
||||
bus.set_post_compact_wake(prompt);
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
outcome
|
||||
|
|
@ -500,11 +511,13 @@ pub fn emit_turn_end(bus: &Bus, outcome: &TurnOutcome) {
|
|||
/// so a queued `/compact` runs even when no turn is driving. (The in-flight
|
||||
/// case is handled at the end of [`drive_turn`].) Resume-only via
|
||||
/// [`InfiniteSession::compact`]: a missing session is a harmless no-op. Returns
|
||||
/// `true` if a compaction ran.
|
||||
/// `true` if a compaction ran; the serve loop follows up with
|
||||
/// `Bus::take_post_compact_wake` to see whether a synthetic follow-up turn
|
||||
/// should run (set below when the compact request carried a `wake_prompt`).
|
||||
pub async fn run_pending_compact(files: &TurnFiles, bus: &Bus, session: &AgentSession) -> bool {
|
||||
if !bus.take_compact() {
|
||||
let Some(request) = bus.take_compact() else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: "operator: /compact — running on idle session".into(),
|
||||
});
|
||||
|
|
@ -520,6 +533,9 @@ pub async fn run_pending_compact(files: &TurnFiles, bus: &Bus, session: &AgentSe
|
|||
}),
|
||||
}
|
||||
bus.set_state(crate::events::TurnState::Idle);
|
||||
if let Some(prompt) = request.wake_prompt {
|
||||
bus.set_post_compact_wake(prompt);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use axum::{
|
|||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{AppState, SigintOutcome, error_response};
|
||||
|
||||
|
|
@ -80,7 +80,10 @@ pub(super) async fn post_cancel_turn(State(state): State<AppState>) -> Response
|
|||
/// claude process) rather than only when the agent is idle. Returns 200
|
||||
/// immediately; the compaction stream lands in the live panel when it runs.
|
||||
pub(super) async fn post_compact(State(state): State<AppState>) -> Response {
|
||||
state.bus.request_compact();
|
||||
// No wake prompt: the operator is watching the dashboard, not waiting on
|
||||
// an inbox message — `request_compact`'s wake-prompt arg exists for the
|
||||
// agent's own `compact` MCP tool (`todo_server.rs::compact`).
|
||||
state.bus.request_compact(None);
|
||||
state.bus.emit(crate::events::LiveEvent::Note {
|
||||
text: "operator: /compact queued — runs at the end of the current turn".into(),
|
||||
});
|
||||
|
|
@ -202,5 +205,10 @@ pub(super) async fn post_mark_todos_done(Form(form): Form<MarkTodosDoneForm>) ->
|
|||
acked += count;
|
||||
}
|
||||
}
|
||||
axum::Json(serde_json::json!({ "acked": acked })).into_response()
|
||||
axum::Json(MarkTodosDoneBody { acked }).into_response()
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MarkTodosDoneBody {
|
||||
acked: u64,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
use axum::extract::State;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::AppState;
|
||||
|
||||
|
|
@ -40,6 +40,11 @@ async fn fetch_reminder_stats(window_secs: u64) -> Option<hive_sh4re::approvals:
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TodosBody {
|
||||
todos: Vec<hive_sh4re::inbox::LooseEnd>,
|
||||
}
|
||||
|
||||
/// `GET /api/todos` — snapshot of this agent's local todos (loose-ends v2).
|
||||
///
|
||||
/// Connects to the in-agent harness socket (`HIVE_AGENT_SOCKET`) and calls
|
||||
|
|
@ -54,5 +59,5 @@ pub(super) async fn api_todos() -> Response {
|
|||
Some(hive_agent_sock::Response::LooseEnds { loose_ends }) => loose_ends,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
axum::Json(serde_json::json!({ "todos": todos })).into_response()
|
||||
axum::Json(TodosBody { todos }).into_response()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,23 @@ use std::convert::Infallible;
|
|||
use axum::Json;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream};
|
||||
|
||||
use super::AppState;
|
||||
|
||||
/// Response body for `GET /api/events/history`. `seq` is omitted from the
|
||||
/// wire entirely on a paginated (non-initial) load — matches the old
|
||||
/// `json!` shape, which only ever set the `"seq"` key when `Some`.
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct EventsHistoryBody {
|
||||
events: Vec<crate::events::StoredEvent>,
|
||||
min_id: Option<i64>,
|
||||
has_more: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
seq: Option<u64>,
|
||||
}
|
||||
|
||||
/// Query params for the paginated history endpoint.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct HistoryParams {
|
||||
|
|
@ -23,7 +35,7 @@ pub(super) struct HistoryParams {
|
|||
pub(super) async fn events_history(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<HistoryParams>,
|
||||
) -> Json<serde_json::Value> {
|
||||
) -> Json<EventsHistoryBody> {
|
||||
use crate::events::HISTORY_CAPACITY;
|
||||
let limit = params.limit.unwrap_or(100).min(HISTORY_CAPACITY);
|
||||
let before = params.before;
|
||||
|
|
@ -51,15 +63,12 @@ pub(super) async fn events_history(
|
|||
se
|
||||
})
|
||||
.collect();
|
||||
let mut resp = serde_json::json!({
|
||||
"events": events,
|
||||
"min_id": min_id,
|
||||
"has_more": has_more,
|
||||
});
|
||||
if let Some(s) = seq {
|
||||
resp["seq"] = serde_json::json!(s);
|
||||
}
|
||||
Json(resp)
|
||||
Json(EventsHistoryBody {
|
||||
events,
|
||||
min_id,
|
||||
has_more,
|
||||
seq,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn events_stream(
|
||||
|
|
|
|||
|
|
@ -899,99 +899,20 @@ pub async fn run_finalize_deploy(coord: &Arc<Coordinator>, approval_id: i64) ->
|
|||
/// imperative infra that `auto_update::ensure_root_agent` recreates on the
|
||||
/// next hive-c0re startup if absent, so destroying it is transient rather
|
||||
/// than something to refuse at the API.
|
||||
pub async fn destroy(coord: &Arc<Coordinator>, name: &str, purge: bool) -> Result<()> {
|
||||
///
|
||||
/// Submits the teardown DAG and returns — it does not wait for the container to
|
||||
/// go away. Same contract as every other lifecycle op (`rebuild`, `kill`,
|
||||
/// `restart`, `start`): the queue owns the work, the caller gets an
|
||||
/// acknowledgement. Progress is visible as real nodes on the dashboard.
|
||||
pub fn destroy(coord: &Arc<Coordinator>, name: &str, purge: bool) {
|
||||
tracing::info!(%name, purge, "destroy");
|
||||
// Guard auto-clears on the success path's final scope exit and on
|
||||
// every early-return / cancellation along the way.
|
||||
// Destroy has no queue node behind it, so nothing in the graph says this
|
||||
// container is going away on purpose — without this the crash watcher
|
||||
// reports every destroy as a crash and the manager tries to recover it.
|
||||
let guard = coord.suppress_crash_watch(name);
|
||||
lifecycle::destroy(name).await?;
|
||||
coord.unregister_agent(name);
|
||||
let runtime = crate::paths::agent_runtime_dir(name);
|
||||
if runtime.exists() {
|
||||
let _ = std::fs::remove_dir_all(&runtime);
|
||||
if let Err(e) = coord.job_queue.insert_job(|b| {
|
||||
crate::job_queue::templates::destroy(b, name, purge);
|
||||
Vec::new()
|
||||
}) {
|
||||
tracing::error!(agent = %name, error = ?e, "destroy: insert failed");
|
||||
}
|
||||
if purge {
|
||||
// The state root may be a btrfs subvolume: a subvolume root
|
||||
// can't be removed with rmdir/`remove_dir_all`, so delete it via
|
||||
// hive-priv (root) first. No-op for plain-dir agents — the loop below
|
||||
// then handles the plain-dir state root plus the applied dir.
|
||||
if let Err(e) = crate::priv_client::delete_agent_subvolume(name).await {
|
||||
tracing::warn!(error = ?e, %name, "purge: delete state subvolume failed");
|
||||
}
|
||||
// A malformed name can't have a persistent state tree (the state dir
|
||||
// is only ever created under a validated Ident), so its removal is a
|
||||
// no-op — skip the state-dir sweep and just clear the applied dir.
|
||||
let state_dir = hive_types::Ident::parse(name)
|
||||
.ok()
|
||||
.map(|id| crate::paths::agent_state_dir(&id));
|
||||
for dir in state_dir
|
||||
.into_iter()
|
||||
.chain([crate::paths::applied_dir(name)])
|
||||
{
|
||||
if dir.exists()
|
||||
&& let Err(e) = std::fs::remove_dir_all(&dir)
|
||||
{
|
||||
tracing::warn!(error = ?e, dir = %dir.display(), "purge: remove failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Meta flake: drop the agent's input + nixosConfiguration so a
|
||||
// future spawn under the same name re-seeds cleanly, and so the
|
||||
// meta lock doesn't reference a vanished applied repo. Log + keep
|
||||
// going on failure — destroy already succeeded at the
|
||||
// nixos-container level, the meta repo is just bookkeeping.
|
||||
if let Err(e) = sync_meta_after_lifecycle(coord).await {
|
||||
tracing::warn!(error = ?e, %name, "meta sync after destroy failed");
|
||||
}
|
||||
let _ = coord.approvals.fail_pending_for_agent(
|
||||
name,
|
||||
if purge {
|
||||
"agent purged"
|
||||
} else {
|
||||
"agent destroyed"
|
||||
},
|
||||
);
|
||||
// Drop the durable power intent — a future agent of the same name
|
||||
// seeds fresh from its observed state.
|
||||
if let Err(e) = coord.power.remove(name) {
|
||||
tracing::warn!(%name, error = ?e, "agent_power: remove on destroy failed");
|
||||
}
|
||||
drop(guard);
|
||||
let _ = coord
|
||||
.push_todo(
|
||||
hive_sh4re::manager::MANAGER_AGENT,
|
||||
"core",
|
||||
Some(format!("destroyed:{name}")),
|
||||
format!("agent '{name}' destroyed"),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
// Container row disappeared — rescan so the dashboard fires
|
||||
// `ContainerRemoved` for the gone row, then emit the
|
||||
// tombstones snapshot (gained one on destroy, lost one on
|
||||
// purge — recompute either way).
|
||||
coord.rescan_containers_and_emit().await;
|
||||
crate::dashboard::emit_tombstones_snapshot(coord).await;
|
||||
// Re-emit the schedules snapshot: the rescan above refreshed the live
|
||||
// roster, so any schedule that still targets the just-destroyed agent
|
||||
// now drops that ghost column live (no page reload needed).
|
||||
coord.emit_schedules_snapshot();
|
||||
// Update tmpfiles.d to remove the destroyed agent's dirs from the
|
||||
// boot-time pre-creation list. Best-effort: failure is logged only.
|
||||
tokio::spawn(lifecycle::sync_tmpfiles());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rerender the meta flake from whatever containers still exist on
|
||||
/// disk. Called after lifecycle ops that change the agent set (today:
|
||||
/// destroy). Idempotent — a no-op when nothing changed.
|
||||
async fn sync_meta_after_lifecycle(coord: &Coordinator) -> Result<()> {
|
||||
let agents = lifecycle::agents_for_meta_listing().await?;
|
||||
crate::meta::sync_agents(&coord.hive_env(), &agents).await
|
||||
coord.emit_rebuild_queue_snapshot();
|
||||
}
|
||||
|
||||
pub fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> {
|
||||
|
|
|
|||
|
|
@ -116,13 +116,6 @@ pub struct Coordinator {
|
|||
/// is never injected into containers.
|
||||
pub model_prices: crate::hive_stats::PriceTable,
|
||||
agents: Mutex<HashMap<String, AgentSocket>>,
|
||||
/// Agents whose lifecycle action (currently just spawn) is in flight.
|
||||
/// Read by the dashboard to render a spinner; cleared when the action
|
||||
/// resolves (success or failure).
|
||||
/// Agents whose container is being taken down by work with **no queue node
|
||||
/// behind it** (destroy, migration), so the crash watcher must not report
|
||||
/// the disappearance as a crash. Not a pill — see [`CrashWatchSuppression`].
|
||||
crash_suppressed: Mutex<HashSet<String>>,
|
||||
/// Tombstone for transients that have JUST been cleared. The
|
||||
/// crash watcher polls every 10s and would race the
|
||||
/// drop-clears-immediately path of `TransientGuard`: an operator
|
||||
|
|
@ -144,9 +137,7 @@ pub struct Coordinator {
|
|||
/// live and both clear. Keyed by agent, the last clear *overwrites* the
|
||||
/// others: a `Prebuild` (`deliberate_stop = false`) landing after a
|
||||
/// `StopForUpdate` (`true`) leaves the tombstone reading `false`, and the
|
||||
/// crash watcher then reports an intentional stop as a **crash**. The
|
||||
/// out-of-band suppression guard, which has no node behind it, uses
|
||||
/// [`NO_NODE_LABEL`].
|
||||
/// crash watcher then reports an intentional stop as a **crash**.
|
||||
recent_transient: Mutex<HashMap<(String, String), (bool, std::time::Instant)>>,
|
||||
/// Timestamps of recent unexpected container crashes, keyed by agent.
|
||||
/// Fed by `crash_watch` each time it classifies a stop as a crash (so
|
||||
|
|
@ -400,57 +391,6 @@ fn fold_tombstones_by_agent<'a>(
|
|||
out
|
||||
}
|
||||
|
||||
/// Tombstone label for work with **no queue node behind it** — the
|
||||
/// out-of-band operations (destroy, migration) that hold a
|
||||
/// [`CrashWatchGuard`] instead of appearing in the derived transient set.
|
||||
///
|
||||
/// [`Coordinator::recent_transient`] is keyed by `(agent, label)` so concurrent
|
||||
/// pills can't overwrite each other's `deliberate_stop`; a guard has no node and
|
||||
/// therefore no node label, so it needs one of its own. Angle-bracketed to keep
|
||||
/// it out of the `NodeKind::as_str` namespace — no node can ever render this.
|
||||
const NO_NODE_LABEL: &str = "<no-node>";
|
||||
|
||||
/// RAII handle returned by [`Coordinator::suppress_crash_watch`]. While held,
|
||||
/// the crash watcher treats this container disappearing as **expected**.
|
||||
///
|
||||
/// This is *not* a dashboard pill. Transients are derived from running queue
|
||||
/// nodes and nothing stores them. But destroy and migration take a container
|
||||
/// down without a node behind them, so nothing in the graph says the
|
||||
/// disappearance was intended — and without that, `crash_watch` fires a
|
||||
/// `ContainerCrash` for every destroy and every migrated agent, and the manager
|
||||
/// tries to "recover" containers that were removed on purpose.
|
||||
///
|
||||
/// It is held rather than stamped once because
|
||||
/// [`crate::workers::crash_watch`]'s grace window is finite and these
|
||||
/// operations are not: a long destroy would outlive a single tombstone. The
|
||||
/// tombstone is stamped on drop, covering the poll that lands just after.
|
||||
///
|
||||
/// Goes away entirely once destroy + migration are real queue nodes.
|
||||
#[must_use = "suppression lasts as long as the guard; bind it for the operation's duration \
|
||||
(`let _guard = coord.suppress_crash_watch(...)`). An unbound call drops it \
|
||||
immediately and the very next poll can report a deliberate stop as a crash."]
|
||||
pub struct CrashWatchSuppression {
|
||||
coord: Arc<Coordinator>,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl Drop for CrashWatchSuppression {
|
||||
fn drop(&mut self) {
|
||||
self.coord
|
||||
.crash_suppressed
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&self.name);
|
||||
// Tombstone the release so the next poll — which may land in the
|
||||
// window between the container going away and this guard dropping —
|
||||
// still reads the stop as deliberate.
|
||||
self.coord.recent_transient.lock().unwrap().insert(
|
||||
(self.name.clone(), NO_NODE_LABEL.to_owned()),
|
||||
(true, std::time::Instant::now()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII guard for the `meta-update` in-progress flag, held for the
|
||||
/// duration of a `run_meta_update` background task. Created by
|
||||
/// `Coordinator::meta_update_guard`. Drop decrements the active-run
|
||||
|
|
@ -586,7 +526,6 @@ impl Coordinator {
|
|||
agent_io_weight,
|
||||
model_prices,
|
||||
agents: Mutex::new(HashMap::new()),
|
||||
crash_suppressed: Mutex::new(HashSet::new()),
|
||||
recent_transient: Mutex::new(HashMap::new()),
|
||||
recent_crashes: Mutex::new(HashMap::new()),
|
||||
graceful_stop_pending: Mutex::new(HashSet::new()),
|
||||
|
|
@ -1300,46 +1239,16 @@ impl Coordinator {
|
|||
map.iter().map(|(k, v)| (k.clone(), v.len())).collect()
|
||||
}
|
||||
|
||||
/// Tell the crash watcher that `name`'s container is going down **on
|
||||
/// purpose**, for the lifetime of the returned guard. See
|
||||
/// [`CrashWatchSuppression`] for why this exists at all.
|
||||
///
|
||||
/// Only for the operations with no queue node behind them. Anything the
|
||||
/// job queue runs answers this from the node itself
|
||||
/// ([`crate::job_queue::NodeKind::takes_container_down`]) and must not come
|
||||
/// through here.
|
||||
///
|
||||
/// The guard's `Drop` runs even on task cancellation, so an aborted HTTP
|
||||
/// request or a panic mid-destroy can't leave a container permanently
|
||||
/// exempt from crash reporting.
|
||||
pub fn suppress_crash_watch(self: &Arc<Self>, name: &str) -> CrashWatchSuppression {
|
||||
self.crash_suppressed
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(name.to_owned());
|
||||
CrashWatchSuppression {
|
||||
coord: self.clone(),
|
||||
name: name.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a no-node operation is currently taking this container down.
|
||||
#[must_use]
|
||||
pub fn crash_watch_suppressed(&self, name: &str) -> bool {
|
||||
self.crash_suppressed.lock().unwrap().contains(name)
|
||||
}
|
||||
|
||||
/// Every live transient, keyed by agent.
|
||||
///
|
||||
/// **Derived on read, stored nowhere.** Straight off the running graph, so
|
||||
/// there is no cached copy to go stale, leak, or disagree with what is
|
||||
/// actually running.
|
||||
///
|
||||
/// Work with no queue node behind it (destroy, migration) therefore shows
|
||||
/// **no pill** — there is nothing in the graph to derive one from. Its
|
||||
/// crash-watch suppression is a separate, narrower thing
|
||||
/// ([`Coordinator::suppress_crash_watch`]); the pill comes back for free
|
||||
/// once those become real nodes.
|
||||
/// Migration is the last operation with no queue node behind it, so it
|
||||
/// shows **no pill** — there is nothing in the graph to derive one from.
|
||||
/// It gets one for free once it becomes real nodes, the way destroy did.
|
||||
///
|
||||
/// ⚠️ **A `Vec` per agent, not one entry.** `running_transients` tests
|
||||
/// status alone, so a lease-exempt `Prebuild` for `a` and a lease-holding
|
||||
/// `StopForUpdate` for `a` are both live pills. Collapsing them to one
|
||||
|
|
@ -1567,8 +1476,13 @@ impl Coordinator {
|
|||
crate::paths::agent_runtime_dir(name).join("mcp.sock")
|
||||
}
|
||||
|
||||
/// Manager-editable proposed config repo. Bind-mounted into the manager
|
||||
/// container as `/agents/<name>/config/`.
|
||||
/// The *proposed* config repo: where a config change lands before it is
|
||||
/// applied, and what an approved deploy promotes into `applied_dir`.
|
||||
///
|
||||
/// **Not bind-mounted into any container.** An agent that edits a config
|
||||
/// clones it from the forge itself; `/agents/<name>/config` shows the
|
||||
/// applied (deployed) tree instead — see `config_bind_source` in
|
||||
/// `lifecycle/host_config.rs`.
|
||||
pub fn agent_proposed_dir(name: &hive_types::Ident) -> PathBuf {
|
||||
crate::paths::agent_state_dir(name).join("config")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,19 +28,20 @@ use utoipa::ToSchema;
|
|||
|
||||
use crate::host_stats::ServerWarning;
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
struct LiveBody {
|
||||
status: &'static str,
|
||||
}
|
||||
|
||||
/// Liveness. Always `200`; no further checks.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/health/live",
|
||||
responses((status = 200, description = "process is up", body = serde_json::Value)),
|
||||
responses((status = 200, description = "process is up", body = LiveBody)),
|
||||
tag = "health"
|
||||
)]
|
||||
pub(super) async fn get_health_live() -> Response {
|
||||
(
|
||||
StatusCode::OK,
|
||||
axum::Json(serde_json::json!({ "status": "ok" })),
|
||||
)
|
||||
.into_response()
|
||||
(StatusCode::OK, axum::Json(LiveBody { status: "ok" })).into_response()
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
|
|
|
|||
|
|
@ -436,10 +436,9 @@ pub(super) struct DestroyForm {
|
|||
params(("name" = String, Path, description = "agent name")),
|
||||
request_body(content = DestroyForm, content_type = "application/x-www-form-urlencoded"),
|
||||
responses(
|
||||
(status = 200, description = "destroyed", body = String),
|
||||
(status = 200, description = "destroy queued", body = String),
|
||||
(status = 400, description = "bad agent name"),
|
||||
(status = 404, description = "no such agent"),
|
||||
(status = 500, description = "destroy failed"),
|
||||
),
|
||||
tag = "lifecycle_ops"
|
||||
)]
|
||||
|
|
@ -453,11 +452,10 @@ pub(super) async fn post_destroy(
|
|||
}
|
||||
// Checkbox semantics: any non-empty value (axum sends "on") = purge.
|
||||
let purge = form.purge.as_deref().is_some_and(|v| !v.is_empty());
|
||||
// `actions::destroy` rescans the container list on success, so the
|
||||
// `ContainerRemoved` event lands before we return 200. The matching
|
||||
// form carries `data-no-refresh`.
|
||||
match actions::destroy(&state.coord, &name, purge).await {
|
||||
Ok(()) => (StatusCode::OK, "ok").into_response(),
|
||||
Err(e) => error_response(&format!("destroy {name} failed: {e:#}")),
|
||||
}
|
||||
// Submit-and-return, like every other lifecycle endpoint here. The
|
||||
// container rescan now runs in the DAG's bookkeeping tail, so
|
||||
// `ContainerRemoved` arrives *after* this 200 rather than before it — the
|
||||
// row disappears when the event lands, same as a rebuild's does.
|
||||
actions::destroy(&state.coord, &name, purge);
|
||||
(StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,26 +8,41 @@ use axum::{
|
|||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use super::{AppState, Ident, error_response, scan_validated_paths};
|
||||
use crate::audit_log::AuditEntry;
|
||||
use crate::container_stats::ContainerResource;
|
||||
use crate::hive_stats::HiveStats;
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(super) struct OperatorInboxItem {
|
||||
id: i64,
|
||||
from: String,
|
||||
body: String,
|
||||
at: chrono::DateTime<chrono::Utc>,
|
||||
in_reply_to: Option<i64>,
|
||||
file_refs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(super) struct OperatorInboxBody {
|
||||
messages: Vec<OperatorInboxItem>,
|
||||
}
|
||||
|
||||
/// Unread operator-directed messages for the dashboard's Y3R C4LL inbox.
|
||||
///
|
||||
/// Returns messages addressed to `"operator"` that haven't been
|
||||
/// acked yet (the operator clears them via the existing
|
||||
/// `POST /api/agent/operator/mark-all-read`). Newest-first; path-shaped
|
||||
/// tokens are validated so the client renders file links like the
|
||||
/// terminal does. Shape: `{ "messages": [{ id, from, body, at,
|
||||
/// in_reply_to, file_refs }] }`.
|
||||
/// terminal does.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/operator-inbox",
|
||||
responses(
|
||||
(status = 200, description = "unread operator-directed messages", body = serde_json::Value),
|
||||
(status = 200, description = "unread operator-directed messages", body = OperatorInboxBody),
|
||||
(status = 500, description = "broker read failed"),
|
||||
),
|
||||
tag = "misc_api"
|
||||
|
|
@ -40,7 +55,7 @@ pub(super) async fn api_operator_inbox(State(state): State<AppState>) -> Respons
|
|||
.unread_for_recipient("operator", INBOX_LIMIT)
|
||||
{
|
||||
Ok(messages) => {
|
||||
let items: Vec<serde_json::Value> = messages
|
||||
let messages: Vec<OperatorInboxItem> = messages
|
||||
.into_iter()
|
||||
.filter_map(|m| {
|
||||
let crate::broker::MessageEvent::Sent {
|
||||
|
|
@ -55,17 +70,17 @@ pub(super) async fn api_operator_inbox(State(state): State<AppState>) -> Respons
|
|||
return None;
|
||||
};
|
||||
let file_refs = scan_validated_paths(&body);
|
||||
Some(serde_json::json!({
|
||||
"id": id,
|
||||
"from": from,
|
||||
"body": body,
|
||||
"at": hive_sh4re::wire_time::from_secs(at),
|
||||
"in_reply_to": in_reply_to,
|
||||
"file_refs": file_refs,
|
||||
}))
|
||||
Some(OperatorInboxItem {
|
||||
id,
|
||||
from,
|
||||
at: hive_sh4re::wire_time::from_secs(at),
|
||||
body,
|
||||
in_reply_to,
|
||||
file_refs,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
axum::Json(serde_json::json!({ "messages": items })).into_response()
|
||||
axum::Json(OperatorInboxBody { messages }).into_response()
|
||||
}
|
||||
Err(e) => error_response(&format!("operator-inbox failed: {e:#}")),
|
||||
}
|
||||
|
|
@ -114,18 +129,23 @@ pub(super) async fn api_container_resources() -> Response {
|
|||
axum::Json(crate::container_stats::gather().await).into_response()
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(super) struct AuditLogBody {
|
||||
entries: Vec<AuditEntry>,
|
||||
total: i64,
|
||||
}
|
||||
|
||||
/// Most-recent agent-initiated privileged-action
|
||||
/// audit entries, newest first (server-clamped to 500).
|
||||
///
|
||||
/// Backs the operator dashboard's audit view. Returns
|
||||
/// `{ "entries": [AuditEntry…], "total": N }` so the UI can show
|
||||
/// Backs the operator dashboard's audit view. `total` lets the UI show
|
||||
/// "latest 500 of N" rather than silently capping. `ts_unix` is in
|
||||
/// **seconds**.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/audit-log",
|
||||
responses(
|
||||
(status = 200, description = "recent audit entries + total count", body = serde_json::Value),
|
||||
(status = 200, description = "recent audit entries + total count", body = AuditLogBody),
|
||||
(status = 500, description = "sqlite read failed"),
|
||||
),
|
||||
tag = "misc_api"
|
||||
|
|
@ -140,7 +160,12 @@ pub(super) async fn api_audit_log(State(state): State<AppState>) -> Response {
|
|||
Ok(n) => n,
|
||||
Err(e) => return error_response(&format!("audit-log count: {e:#}")),
|
||||
};
|
||||
axum::Json(serde_json::json!({ "entries": entries, "total": total })).into_response()
|
||||
axum::Json(AuditLogBody { entries, total }).into_response()
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(super) struct MarkAllReadBody {
|
||||
marked: u64,
|
||||
}
|
||||
|
||||
/// Operator-driven "clear this agent's inbox" — backs the side-panel
|
||||
|
|
@ -148,14 +173,14 @@ pub(super) async fn api_audit_log(State(state): State<AppState>) -> Response {
|
|||
///
|
||||
/// Marks every message addressed to the agent as acked (backfilling
|
||||
/// `delivered_at` for any still-pending rows so vacuum can collect
|
||||
/// them). Returns `{ "marked": N }` so the frontend can show "cleared
|
||||
/// N messages" feedback without an extra fetch.
|
||||
/// them). `marked` lets the frontend show "cleared N messages"
|
||||
/// feedback without an extra fetch.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/agent/{name}/mark-all-read",
|
||||
params(("name" = String, Path, description = "agent name")),
|
||||
responses(
|
||||
(status = 200, description = "count of messages marked read", body = serde_json::Value),
|
||||
(status = 200, description = "count of messages marked read", body = MarkAllReadBody),
|
||||
(status = 400, description = "bad agent name"),
|
||||
(status = 500, description = "broker write failed"),
|
||||
),
|
||||
|
|
@ -172,9 +197,9 @@ pub(super) async fn post_mark_all_read(
|
|||
}
|
||||
};
|
||||
match state.coord.broker.mark_all_read(name.as_str()) {
|
||||
Ok(n) => {
|
||||
tracing::info!(%name, marked = n, "operator marked all messages read");
|
||||
axum::Json(serde_json::json!({ "marked": n })).into_response()
|
||||
Ok(marked) => {
|
||||
tracing::info!(%name, marked, "operator marked all messages read");
|
||||
axum::Json(MarkAllReadBody { marked }).into_response()
|
||||
}
|
||||
Err(e) => error_response(&format!("mark-all-read {name} failed: {e:#}")),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,16 @@ use crate::scheduled_prompts_worker::FireNowReport;
|
|||
|
||||
use super::{AppState, error_problem, error_response};
|
||||
|
||||
#[derive(serde::Serialize, utoipa::ToSchema)]
|
||||
pub(super) struct NewScheduleBody {
|
||||
id: i64,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, utoipa::ToSchema)]
|
||||
pub(super) struct CancelResultBody {
|
||||
cancelled: bool,
|
||||
}
|
||||
|
||||
/// Snapshot of every schedule for the
|
||||
/// scheduled-prompts tab.
|
||||
///
|
||||
|
|
@ -74,7 +84,7 @@ pub(super) async fn api_schedules(State(state): State<AppState>) -> Response {
|
|||
// `api_schedules` above.
|
||||
request_body(content = serde_json::Value, description = "SchedulePromptPayload wire shape"),
|
||||
responses(
|
||||
(status = 200, description = "created; body carries the new row id", body = serde_json::Value),
|
||||
(status = 200, description = "created; body carries the new row id", body = NewScheduleBody),
|
||||
(status = 400, description = "no targets, empty body, or interval_seconds == 0"),
|
||||
(status = 500, description = "submit failed"),
|
||||
),
|
||||
|
|
@ -108,7 +118,7 @@ pub(super) async fn post_schedule_new(
|
|||
match state.coord.scheduled_prompts.submit(&new) {
|
||||
Ok(id) => {
|
||||
state.coord.emit_schedules_snapshot();
|
||||
Ok(axum::Json(serde_json::json!({"id": id})).into_response())
|
||||
Ok(axum::Json(NewScheduleBody { id }).into_response())
|
||||
}
|
||||
Err(e) => Err(error_problem(&format!("schedule submit: {e:#}"))),
|
||||
}
|
||||
|
|
@ -177,7 +187,7 @@ pub(super) async fn post_schedule_fire_now(
|
|||
post,
|
||||
path = "/api/rebuild-queue/{id}/cancel",
|
||||
params(("id" = u64, Path, description = "job-queue node id (a DAG's root cancels the group)")),
|
||||
responses((status = 200, description = "whether the DAG was cancelled", body = serde_json::Value)),
|
||||
responses((status = 200, description = "whether the DAG was cancelled", body = CancelResultBody)),
|
||||
tag = "schedules"
|
||||
)]
|
||||
pub(super) async fn post_rebuild_queue_cancel(
|
||||
|
|
@ -188,9 +198,9 @@ pub(super) async fn post_rebuild_queue_cancel(
|
|||
// Any terminal side effect is the DAG's own spared tail node, which the
|
||||
// scheduler picks up on its next pass — nothing to fire from here.
|
||||
state.coord.emit_rebuild_queue_snapshot();
|
||||
axum::Json(serde_json::json!({"cancelled": true})).into_response()
|
||||
axum::Json(CancelResultBody { cancelled: true }).into_response()
|
||||
} else {
|
||||
axum::Json(serde_json::json!({"cancelled": false})).into_response()
|
||||
axum::Json(CancelResultBody { cancelled: false }).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -726,6 +726,18 @@ pub(super) async fn jobq_rollup(
|
|||
axum::Json(state.coord.job_queue.state_rollup())
|
||||
}
|
||||
|
||||
/// Response body for `/api/dashboard/history`. No `ToSchema` — its
|
||||
/// `events` field wraps [`crate::dashboard_events::DashboardEvent`],
|
||||
/// which doesn't derive `ToSchema` either (a large enum with many
|
||||
/// variants; see that type's doc comment for why annotating it is
|
||||
/// out of scope here). The `responses(...)` doc below spells out the
|
||||
/// shape in prose instead of a `body = ...` reference.
|
||||
#[derive(Serialize)]
|
||||
struct DashboardHistoryBody {
|
||||
seq: u64,
|
||||
events: Vec<crate::dashboard_events::DashboardEvent>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/dashboard/history",
|
||||
|
|
@ -798,7 +810,7 @@ pub(super) async fn dashboard_history(State(state): State<AppState>) -> Response
|
|||
}
|
||||
})
|
||||
.collect();
|
||||
axum::Json(serde_json::json!({ "seq": seq, "events": events })).into_response()
|
||||
axum::Json(DashboardHistoryBody { seq, events }).into_response()
|
||||
}
|
||||
Err(e) => error_response(&format!("dashboard/history failed: {e:#}")),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,15 @@ pub(super) async fn run_node(
|
|||
NodeKind::RebuildBookkeeping { .. } => run_rebuild_bookkeeping(coord, agent).await,
|
||||
NodeKind::Provision { .. } => run_provision(coord, agent).await,
|
||||
NodeKind::Create { .. } => run_create(agent).await,
|
||||
NodeKind::DestroyContainer { .. } => run_destroy_container(coord, agent).await,
|
||||
NodeKind::PurgeState { .. } => {
|
||||
run_purge_state(agent).await;
|
||||
Ok(())
|
||||
}
|
||||
NodeKind::DestroyBookkeeping { purge, .. } => {
|
||||
run_destroy_bookkeeping(coord, agent, *purge).await;
|
||||
Ok(())
|
||||
}
|
||||
NodeKind::MetaLock {
|
||||
sweep,
|
||||
fanout,
|
||||
|
|
@ -163,6 +172,112 @@ async fn run_resolve_approval(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Rerender the meta flake from whatever containers still exist on disk.
|
||||
/// Idempotent — a no-op when nothing changed. Lives here because the destroy
|
||||
/// tail is its only caller; it moved with `destroy` when that became a DAG.
|
||||
async fn sync_meta_after_lifecycle(coord: &Coordinator) -> Result<()> {
|
||||
let agents = crate::lifecycle::agents_for_meta_listing().await?;
|
||||
crate::meta::sync_agents(&coord.hive_env(), &agents).await
|
||||
}
|
||||
|
||||
/// `nixos-container destroy`, then drop the agent from the roster and clear
|
||||
/// its ephemeral runtime dir (the mcp socket, which does not survive a restart
|
||||
/// anyway).
|
||||
///
|
||||
/// The only fallible step is the destroy itself: once the container is gone the
|
||||
/// un-registration cannot meaningfully fail, and returning early would strand
|
||||
/// the roster claiming an agent that no longer exists.
|
||||
async fn run_destroy_container(coord: &Arc<Coordinator>, agent: &str) -> Result<()> {
|
||||
crate::lifecycle::destroy(agent).await?;
|
||||
coord.unregister_agent(agent);
|
||||
let runtime = crate::paths::agent_runtime_dir(agent);
|
||||
if runtime.exists() {
|
||||
let _ = std::fs::remove_dir_all(&runtime);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `purge = true` half: wipe the agent's persistent trees.
|
||||
///
|
||||
/// Every step is best-effort-with-a-warning rather than fatal, and that is
|
||||
/// deliberate — the container is already destroyed by the time this runs, so
|
||||
/// failing the node would leave the operator with a half-purged agent and a red
|
||||
/// DAG, when what they can actually act on is the log line naming the path.
|
||||
async fn run_purge_state(agent: &str) {
|
||||
// The state root may be a btrfs subvolume: a subvolume root can't be
|
||||
// removed with rmdir/`remove_dir_all`, so delete it via hive-priv (root)
|
||||
// first. No-op for plain-dir agents — the loop below then handles the
|
||||
// plain-dir state root plus the applied dir.
|
||||
if let Err(e) = crate::priv_client::delete_agent_subvolume(agent).await {
|
||||
tracing::warn!(error = ?e, %agent, "purge: delete state subvolume failed");
|
||||
}
|
||||
// A malformed name can't have a persistent state tree (the state dir is
|
||||
// only ever created under a validated Ident), so its removal is a no-op —
|
||||
// skip the state-dir sweep and just clear the applied dir.
|
||||
let state_dir = hive_types::Ident::parse(agent)
|
||||
.ok()
|
||||
.map(|id| crate::paths::agent_state_dir(&id));
|
||||
for dir in state_dir
|
||||
.into_iter()
|
||||
.chain([crate::paths::applied_dir(agent)])
|
||||
{
|
||||
if dir.exists()
|
||||
&& let Err(e) = std::fs::remove_dir_all(&dir)
|
||||
{
|
||||
tracing::warn!(error = ?e, dir = %dir.display(), "purge: remove failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Post-destroy bookkeeping. Infallible by construction: every step is
|
||||
/// warn-and-continue, because the destroy it follows has already succeeded and
|
||||
/// none of this is undoable — a failed meta sync or power-store write is a
|
||||
/// bookkeeping drift to log, not a reason to red a DAG whose container is
|
||||
/// already gone.
|
||||
async fn run_destroy_bookkeeping(coord: &Arc<Coordinator>, agent: &str, purge: bool) {
|
||||
// Meta flake: drop the agent's input + nixosConfiguration so a future spawn
|
||||
// under the same name re-seeds cleanly, and so the meta lock doesn't
|
||||
// reference a vanished applied repo.
|
||||
if let Err(e) = sync_meta_after_lifecycle(coord).await {
|
||||
tracing::warn!(error = ?e, %agent, "meta sync after destroy failed");
|
||||
}
|
||||
let _ = coord.approvals.fail_pending_for_agent(
|
||||
agent,
|
||||
if purge {
|
||||
"agent purged"
|
||||
} else {
|
||||
"agent destroyed"
|
||||
},
|
||||
);
|
||||
// Drop the durable power intent — a future agent of the same name seeds
|
||||
// fresh from its observed state.
|
||||
if let Err(e) = coord.power.remove(agent) {
|
||||
tracing::warn!(%agent, error = ?e, "agent_power: remove on destroy failed");
|
||||
}
|
||||
let _ = coord
|
||||
.push_todo(
|
||||
hive_sh4re::manager::MANAGER_AGENT,
|
||||
"core",
|
||||
Some(format!("destroyed:{agent}")),
|
||||
format!("agent '{agent}' destroyed"),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
// Container row disappeared — rescan so the dashboard fires
|
||||
// `ContainerRemoved` for the gone row, then emit the tombstones snapshot
|
||||
// (gained one on destroy, lost one on purge — recompute either way).
|
||||
coord.rescan_containers_and_emit().await;
|
||||
crate::dashboard::emit_tombstones_snapshot(coord).await;
|
||||
// Re-emit the schedules snapshot: the rescan above refreshed the live
|
||||
// roster, so any schedule that still targets the just-destroyed agent now
|
||||
// drops that ghost column live (no page reload needed).
|
||||
coord.emit_schedules_snapshot();
|
||||
// Update tmpfiles.d to remove the destroyed agent's dirs from the boot-time
|
||||
// pre-creation list. Best-effort: failure is logged only.
|
||||
tokio::spawn(crate::lifecycle::sync_tmpfiles());
|
||||
}
|
||||
|
||||
/// Emit this agent's rebuild-complete todo. `ok` is not computed — it is which
|
||||
/// of the tail pair the graph let run. The failure note comes from the DAG's
|
||||
/// first failing node, since the branch knows *that* it failed but not *why*.
|
||||
|
|
|
|||
|
|
@ -78,6 +78,36 @@ pub enum NodeKind {
|
|||
/// First-spawn `nixos-container create` proper. Assumes the
|
||||
/// upstream `Provision` node already registered the agent in meta.
|
||||
Create { agent: String },
|
||||
/// `nixos-container destroy` plus the un-registration that follows it:
|
||||
/// drop the agent from the coordinator's roster and clear its ephemeral
|
||||
/// runtime dir.
|
||||
///
|
||||
/// **Deliberately not in [`NodeKind::takes_container_down`]**, and that
|
||||
/// is the design rather than an oversight. This node runs *downstream of
|
||||
/// a `Stop`*, which already carries the flag honestly, so by the time it
|
||||
/// claims there is nothing left to take down. A container still live here
|
||||
/// is a real bug and must page someone — a `true` would absorb exactly
|
||||
/// that signal, and the flag's whole asymmetry (see that method) is that
|
||||
/// a wrong `true` silently swallows a crash.
|
||||
DestroyContainer { agent: String },
|
||||
/// The `purge = true` half of a destroy: delete the agent's state
|
||||
/// subvolume (via hive-priv, since a subvolume root defeats
|
||||
/// `remove_dir_all`) plus its state and applied dirs. Its own node
|
||||
/// because it is conditional — a plain destroy never inserts it — and
|
||||
/// because it is the irreversible step, so it earns a distinct row in
|
||||
/// the graph rather than hiding inside a bookkeeping tail.
|
||||
PurgeState { agent: String },
|
||||
/// The post-destroy bookkeeping tail: meta sync, fail the agent's pending
|
||||
/// approvals, drop the durable power intent, notify the manager, rescan
|
||||
/// containers, re-emit the tombstone + schedule snapshots, resync
|
||||
/// tmpfiles. Split from [`NodeKind::DestroyContainer`] for the same
|
||||
/// reason [`NodeKind::RebuildBookkeeping`] is split from `Swap`:
|
||||
/// dashboard visibility and retry granularity for work that is pure
|
||||
/// store/meta bookkeeping and touches no container.
|
||||
///
|
||||
/// `purge` only selects the wording of the approval-failure reason and
|
||||
/// the manager notification; the destructive work is `PurgeState`'s.
|
||||
DestroyBookkeeping { agent: String, purge: bool },
|
||||
/// Meta flake lock bump. `sweep = false`: `meta::lock_update`
|
||||
/// (commit fused, under `META_LOCK`) with this node's own `inputs`;
|
||||
/// `sweep = true`: `meta::lock_update_hyperhive`, *non-fatal* (a
|
||||
|
|
@ -341,6 +371,9 @@ impl NodeKind {
|
|||
NodeKind::RebuildBookkeeping { .. } => "rebuild_bookkeeping",
|
||||
NodeKind::Provision { .. } => "provision",
|
||||
NodeKind::Create { .. } => "create",
|
||||
NodeKind::DestroyContainer { .. } => "destroy_container",
|
||||
NodeKind::PurgeState { .. } => "purge_state",
|
||||
NodeKind::DestroyBookkeeping { .. } => "destroy_bookkeeping",
|
||||
NodeKind::MetaLock { .. } => "meta_lock",
|
||||
NodeKind::Reconcile { .. } => "reconcile",
|
||||
NodeKind::Start { .. } => "start",
|
||||
|
|
@ -378,6 +411,9 @@ impl NodeKind {
|
|||
| NodeKind::RebuildBookkeeping { agent }
|
||||
| NodeKind::Provision { agent }
|
||||
| NodeKind::Create { agent }
|
||||
| NodeKind::DestroyContainer { agent }
|
||||
| NodeKind::PurgeState { agent }
|
||||
| NodeKind::DestroyBookkeeping { agent, .. }
|
||||
| NodeKind::Reconcile { agent }
|
||||
| NodeKind::Start { agent }
|
||||
| NodeKind::Stop { agent }
|
||||
|
|
@ -435,6 +471,12 @@ impl NodeKind {
|
|||
// - `Create` / `Start` / `SetWanted{up}` bring a container UP. A
|
||||
// container disappearing *while starting* is a genuine crash and has
|
||||
// to keep reporting as one.
|
||||
// - `DestroyContainer` looks like the most obvious `true` on this list
|
||||
// and is the one that must stay `false`. It is edged downstream of a
|
||||
// `Stop`, so the container is already down when it claims; the stop
|
||||
// that the operator asked for is accounted for by the node that
|
||||
// performs it. A container found alive at destroy time is a genuine
|
||||
// bug, and a `true` here would suppress the alert that says so.
|
||||
// - `Reconcile` is a planner; it fans out `Start` / `Stop`, which carry
|
||||
// their own answer.
|
||||
// - `DeployWindow` brackets a deploy without itself stopping anything.
|
||||
|
|
|
|||
|
|
@ -474,6 +474,59 @@ pub fn spawn(builder: &JobBuilder, agent: &str, approval_id: i64) {
|
|||
resolve_approval_tails(builder, approval_id, provision);
|
||||
}
|
||||
|
||||
/// Teardown: `Stop` → `DestroyContainer` → (`PurgeState`) → `DestroyBookkeeping`.
|
||||
///
|
||||
/// The chain is the point, not a decomposition for its own sake. Destroy used
|
||||
/// to be a straight-line async fn with no queue node behind it, so nothing in
|
||||
/// the graph could answer "is this container going down on purpose?" — which is
|
||||
/// why an imperative crash-watch suppression guard existed at all. Reusing the
|
||||
/// existing [`NodeKind::Stop`] answers it structurally: `Stop` already declares
|
||||
/// `takes_container_down`, so the suppression is derived from the graph like
|
||||
/// every other lifecycle op's.
|
||||
///
|
||||
/// That also makes the precondition an edge rather than an assertion.
|
||||
/// `DestroyContainer` runs only after `Stop` succeeded, so it operates on an
|
||||
/// already-stopped container and carries `takes_container_down = false`
|
||||
/// permanently — a container still alive at that point is a real bug and stays
|
||||
/// loud instead of being absorbed by a flag.
|
||||
///
|
||||
/// `Stop` is idempotent against an already-down container, so the common
|
||||
/// "destroy something that isn't running" path costs nothing extra.
|
||||
///
|
||||
/// `Stop` is the group root and holds the agent lease for the whole teardown;
|
||||
/// the rest are `part_of` children that borrow it, so no other op can interleave
|
||||
/// with a half-destroyed agent. `PurgeState` is inserted only when asked for —
|
||||
/// the graph shows the irreversible step as its own row when it happens, and
|
||||
/// omits it entirely when it doesn't.
|
||||
pub fn destroy(builder: &JobBuilder, agent: &str, purge: bool) {
|
||||
let a = || agent.to_owned();
|
||||
let stop = builder
|
||||
.node(NodeKind::Stop { agent: a() })
|
||||
.needs(Resource::Agent(a()));
|
||||
// `part_of` IS the ordering: a child runs once its parent reaches
|
||||
// `Finishing`, and a node may not also declare a dep on its own parent
|
||||
// (dep-scope validation rejects it — it would deadlock). So the
|
||||
// "container is already stopped" precondition is the group edge itself,
|
||||
// with no explicit `after_ok(stop)` to add.
|
||||
let destroy = builder
|
||||
.node(NodeKind::DestroyContainer { agent: a() })
|
||||
.part_of(stop);
|
||||
// The bookkeeping tail hangs off the purge when there is one, so the
|
||||
// irreversible delete lands before the meta sync that stops referencing it.
|
||||
let last = if purge {
|
||||
builder
|
||||
.node(NodeKind::PurgeState { agent: a() })
|
||||
.part_of(stop)
|
||||
.after_ok(destroy)
|
||||
} else {
|
||||
destroy
|
||||
};
|
||||
let _tail = builder
|
||||
.node(NodeKind::DestroyBookkeeping { agent: a(), purge })
|
||||
.part_of(stop)
|
||||
.after_ok(last);
|
||||
}
|
||||
|
||||
/// Perm change: commit the JSON file(s), then the rebuild subgraph so
|
||||
/// the updated `HIVE_TOOL_GROUPS` / `HIVE_CAPABILITIES` env var takes
|
||||
/// effect in the container. Group-roots are `WritePermFile` plus the rebuild
|
||||
|
|
|
|||
|
|
@ -1714,6 +1714,97 @@ fn spawn_shape_provision_create_dropin_reconcile() {
|
|||
);
|
||||
}
|
||||
|
||||
/// The destroy chain, and the reason it is a chain: `Stop` is reused so the
|
||||
/// crash-watch answer comes from the node that actually stops the container.
|
||||
///
|
||||
/// Asserting the *edges* is the point. `destroy_container` runs `after_ok` a
|
||||
/// `stop`, which is what makes "the container is already down here" a
|
||||
/// structural fact rather than a convention — see the companion test below for
|
||||
/// why that matters.
|
||||
#[test]
|
||||
fn destroy_shape_stop_then_destroy_then_bookkeeping() {
|
||||
let q = JobQueue::new(1);
|
||||
insert(&q, |builder| {
|
||||
templates::destroy(builder, "doomed", false);
|
||||
});
|
||||
assert_eq!(
|
||||
declared_shape(&q),
|
||||
vec![
|
||||
row("stop", None, &[]),
|
||||
// No explicit edge to `stop`: `part_of` already gates the child on
|
||||
// its parent reaching `Finishing`, and declaring a dep on your own
|
||||
// parent is rejected outright (it would deadlock). The precondition
|
||||
// is the group membership.
|
||||
row("destroy_container", Some("stop"), &[]),
|
||||
row(
|
||||
"destroy_bookkeeping",
|
||||
Some("stop"),
|
||||
&[("destroy_container", "done")]
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// `purge` inserts the irreversible delete as its own node, between the destroy
|
||||
/// and the bookkeeping tail — so the meta sync that stops referencing the agent
|
||||
/// runs *after* its trees are actually gone, and a purge is visibly distinct
|
||||
/// from a plain destroy on the graph instead of being a hidden boolean.
|
||||
#[test]
|
||||
fn destroy_shape_purge_inserts_purge_state_before_the_tail() {
|
||||
let q = JobQueue::new(1);
|
||||
insert(&q, |builder| {
|
||||
templates::destroy(builder, "doomed", true);
|
||||
});
|
||||
assert_eq!(
|
||||
declared_shape(&q),
|
||||
vec![
|
||||
row("stop", None, &[]),
|
||||
row("destroy_container", Some("stop"), &[]),
|
||||
row(
|
||||
"purge_state",
|
||||
Some("stop"),
|
||||
&[("destroy_container", "done")]
|
||||
),
|
||||
row(
|
||||
"destroy_bookkeeping",
|
||||
Some("stop"),
|
||||
&[("purge_state", "done")]
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// The counter-case to `rebuild_chain_nodes_suppress_crash_watch`, and the one
|
||||
/// assertion in this file that exists to stop a *plausible* edit rather than a
|
||||
/// wrong one.
|
||||
///
|
||||
/// `destroy_container` is the most obvious candidate for `takes_container_down`
|
||||
/// on the whole list and must stay `false`. It is edged downstream of a `Stop`
|
||||
/// that already carries the flag, so the intentional stop is already accounted
|
||||
/// for; a container still alive when this node claims is a genuine bug. Since a
|
||||
/// wrong `true` **silently swallows a real crash** while a wrong `false` only
|
||||
/// costs a spurious event, this is the asymmetry that has to be pinned.
|
||||
#[test]
|
||||
fn destroy_container_must_not_suppress_crash_watch() {
|
||||
assert!(
|
||||
!NodeKind::DestroyContainer {
|
||||
agent: "a".to_owned()
|
||||
}
|
||||
.takes_container_down(),
|
||||
"destroy_container runs after a Stop that already declared the \
|
||||
container is going down; claiming it again would suppress the alert \
|
||||
for a container found unexpectedly alive"
|
||||
);
|
||||
// The upstream node is where the `true` lives — assert it here too, so the
|
||||
// pair reads as one property and moving the flag breaks this test.
|
||||
assert!(
|
||||
NodeKind::Stop {
|
||||
agent: "a".to_owned()
|
||||
}
|
||||
.takes_container_down()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perm_change_shape_prefixes_rebuild_chain() {
|
||||
let q = JobQueue::new(1);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//! network isolation, forwarded credentials), the systemd resource-limits
|
||||
//! drop-in, and the `write_dropins` verb that re-applies both.
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use hive_priv_sock::{BindMount, CredentialMount};
|
||||
|
|
@ -71,6 +71,19 @@ async fn systemd_daemon_reload() -> Result<()> {
|
|||
/// inside the container.
|
||||
pub const CONTAINER_MANAGER_APPLIED_MOUNT: &str = "/applied";
|
||||
|
||||
/// Host path behind every `/agents/<name>/config` mount: the **applied**
|
||||
/// (deployed) repo, not the working clone at `agents/<name>/config`. That
|
||||
/// clone is where a config change is staged, so it can hold a proposal
|
||||
/// that is still under review or was rejected outright — mounting it shows
|
||||
/// an agent a config which does not govern it. Both mounts (an agent's own
|
||||
/// and a parent's view of a child's) go through here so they cannot drift.
|
||||
///
|
||||
/// Never empty under a live container: `provision_container` runs
|
||||
/// `setup_applied` before `create_only` makes the container at all.
|
||||
fn config_bind_source(name: &str) -> PathBuf {
|
||||
crate::paths::applied_dir(name)
|
||||
}
|
||||
|
||||
/// Append bind flags for `child`'s state and config dirs into `binds`.
|
||||
/// See docs/persistence.md ("Parent access to child state") for what a
|
||||
/// parent may touch and why. Creates missing host-side directories so
|
||||
|
|
@ -104,8 +117,10 @@ fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) {
|
|||
return;
|
||||
};
|
||||
let child_root = crate::paths::agent_state_dir(&child);
|
||||
for (sub, read_only) in [("state", false), ("config", true)] {
|
||||
let host = child_root.join(sub);
|
||||
for (sub, host, read_only) in [
|
||||
("state", child_root.join("state"), false),
|
||||
("config", config_bind_source(child.as_str()), true),
|
||||
] {
|
||||
let _ = std::fs::create_dir_all(&host);
|
||||
binds.push(BindMount {
|
||||
host_path: host.to_string_lossy().into_owned(),
|
||||
|
|
@ -249,9 +264,9 @@ async fn set_nspawn_flags(
|
|||
read_only: false,
|
||||
});
|
||||
}
|
||||
let agent_id = hive_types::Ident::parse(agent_name)
|
||||
hive_types::Ident::parse(agent_name)
|
||||
.map_err(|e| anyhow::anyhow!("invalid agent name {agent_name:?}: {e}"))?;
|
||||
let own_config = crate::paths::agent_state_dir(&agent_id).join("config");
|
||||
let own_config = config_bind_source(agent_name);
|
||||
std::fs::create_dir_all(&own_config)
|
||||
.with_context(|| format!("create {}", own_config.display()))?;
|
||||
binds.push(BindMount {
|
||||
|
|
@ -381,6 +396,27 @@ mod tests {
|
|||
assert_eq!(paths, ["/agents/kiddo/state", "/agents/kiddo/config"]);
|
||||
}
|
||||
|
||||
/// The `config` mount names the **deployed** tree, not the working
|
||||
/// clone the proposal is staged in. Asserted as "outside the child's
|
||||
/// own dir" rather than by equality: the point is that the two are
|
||||
/// different objects, which is what makes the mount unable to show a
|
||||
/// config that was never approved. Equality with `applied_dir` would
|
||||
/// restate the implementation and pass under any future relocation.
|
||||
#[test]
|
||||
fn child_config_mount_is_the_deployed_tree_not_the_working_clone() {
|
||||
let working_clone =
|
||||
crate::paths::agent_state_dir(&hive_types::Ident::parse("kiddo").expect("valid ident"));
|
||||
let config = child_binds()
|
||||
.into_iter()
|
||||
.find(|b| b.container_path.ends_with("/config"))
|
||||
.expect("a config bind");
|
||||
assert!(
|
||||
!std::path::Path::new(&config.host_path).starts_with(&working_clone),
|
||||
"config mount must not come from the child's working clone: {}",
|
||||
config.host_path
|
||||
);
|
||||
}
|
||||
|
||||
/// The regression this exists for. `harness` holds the child's own
|
||||
/// runtime material and was only ever mounted because one loop
|
||||
/// treated all three dirs alike — re-adding it to that loop is a
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
|||
handle_start(&coord, &agents, &infra).await?
|
||||
}
|
||||
HostRequest::Destroy { name, purge } => {
|
||||
actions::destroy(&coord, name.as_str(), *purge).await?;
|
||||
actions::destroy(&coord, name.as_str(), *purge);
|
||||
HostResponse::success()
|
||||
}
|
||||
HostRequest::Rebuild { name } => {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ use anyhow::{Context, Result};
|
|||
use chrono::{DateTime, Utc};
|
||||
use rusqlite::{Connection, params};
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Process-singleton handle, set once at coordinator startup. Mirrors
|
||||
/// `build_logs::GLOBAL` — lets recording sites write without threading an
|
||||
|
|
@ -80,7 +81,7 @@ impl AuditOutcome {
|
|||
}
|
||||
|
||||
/// One audit row as returned to the dashboard.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct AuditEntry {
|
||||
pub id: i64,
|
||||
pub ts_unix: DateTime<Utc>,
|
||||
|
|
|
|||
|
|
@ -88,17 +88,17 @@ fn emit_crash_transitions(coord: &Coordinator, prev: &HashSet<String>, current:
|
|||
// guard between two crash-watch polls.
|
||||
let recent = coord.recent_transient_within(RECENT_TRANSIENT_GRACE);
|
||||
for stopped in prev.difference(current) {
|
||||
// Two sources, because a container can go down on purpose either way:
|
||||
// a running queue node that declared it takes the container down, or a
|
||||
// no-node operation (destroy, migration) holding a suppression guard.
|
||||
// One source: a running queue node that declared it takes the container
|
||||
// down. There used to be a second — an imperative suppression guard for
|
||||
// operations with no node behind them — and destroy becoming a DAG
|
||||
// removed the last caller, so intent now has exactly one home.
|
||||
// `any`, not "the" pill: an agent can have several running nodes at
|
||||
// once (a lease-exempt build alongside a lease-holding stop), and it
|
||||
// only takes one of them expecting the container down for this to be
|
||||
// a deliberate stop rather than a crash.
|
||||
let active = transients
|
||||
.get(stopped)
|
||||
.map(|sts| sts.iter().any(|st| st.takes_container_down))
|
||||
.or_else(|| coord.crash_watch_suppressed(stopped).then_some(true));
|
||||
.map(|sts| sts.iter().any(|st| st.takes_container_down));
|
||||
let recently_cleared = recent.get(stopped).copied();
|
||||
if is_deliberate_stop(active, recently_cleared) {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ workspace = true
|
|||
anyhow.workspace = true
|
||||
hive-priv-sock.workspace = true
|
||||
libc.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ use hive_priv_sock::{
|
|||
NetworkIsolation, PAUSED_MARKER_FILE, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse,
|
||||
PrivStream, PrivStreamLine, SIBLING_CONTAINERS,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use tokio::io::{AsyncWriteExt, BufReader};
|
||||
use tokio::net::unix::OwnedWriteHalf;
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
|
|
@ -468,7 +469,10 @@ async fn exec(
|
|||
// when both `account` and `homeserver` are present; the account
|
||||
// suffix is already validated above.
|
||||
if let (Some(a), Some(hs)) = (account, homeserver) {
|
||||
let meta = serde_json::json!({ "homeserver": hs }).to_string();
|
||||
let meta = serde_json::to_string(&MatrixAccountSidecar {
|
||||
homeserver: hs.as_str(),
|
||||
})
|
||||
.context("serialize matrix account sidecar")?;
|
||||
write_agent_state_file(agent_name, &format!("matrix-account-{a}.json"), &meta)?;
|
||||
}
|
||||
Ok(res)
|
||||
|
|
@ -497,7 +501,10 @@ async fn exec(
|
|||
)?;
|
||||
// Sidecar carries the base URL — there's no host-side nix config
|
||||
// for extra forges, so this is the only place it's persisted.
|
||||
let meta = serde_json::json!({ "base_url": base_url }).to_string();
|
||||
let meta = serde_json::to_string(&ForgeSidecar {
|
||||
base_url: base_url.as_str(),
|
||||
})
|
||||
.context("serialize forge account sidecar")?;
|
||||
write_agent_state_file(agent_name, &format!("forge-{label}.json"), &meta)?;
|
||||
Ok(res)
|
||||
}
|
||||
|
|
@ -957,6 +964,32 @@ fn write_state_file_nofollow(dir: &Path, filename: &str, content: &str) -> Resul
|
|||
Ok(file)
|
||||
}
|
||||
|
||||
/// Sidecar written alongside an extra matrix account's token
|
||||
/// (`matrix-account-<name>.json`) so `hive-matrix-mcp` can auto-discover
|
||||
/// the account's homeserver without a static `matrixAccounts` config
|
||||
/// entry. Read side: `hive-matrix-mcp/src/accounts.rs`'s
|
||||
/// `read_account_homeserver` (deliberately reads via a bare
|
||||
/// `serde_json::Value` rather than this shape — that side treats a
|
||||
/// malformed/missing sidecar as "skip this account" rather than an
|
||||
/// error, so it stays loosely typed; this side is the one place the
|
||||
/// file is written, so it gets the precise shape).
|
||||
#[derive(Serialize)]
|
||||
struct MatrixAccountSidecar<'a> {
|
||||
homeserver: &'a str,
|
||||
}
|
||||
|
||||
/// Sidecar written alongside a dashboard-provisioned extra forge
|
||||
/// account's token (`forge-<label>.json`) so `hive-forge` can resolve
|
||||
/// the account's base URL. Read side: `hive-forge/src/client.rs`'s own
|
||||
/// (separately defined, deserialize-only) `ForgeSidecar` — same field
|
||||
/// name (`base_url`), no shared crate between `hive-priv` and
|
||||
/// `hive-forge` to hang a common type off, so the two structs are
|
||||
/// pinned to the same JSON key by convention, not by the compiler.
|
||||
#[derive(Serialize)]
|
||||
struct ForgeSidecar<'a> {
|
||||
base_url: &'a str,
|
||||
}
|
||||
|
||||
/// Shared helper for `WriteAgentForgeToken` and `WriteAgentMatrixToken`.
|
||||
/// Writes `content` to `AGENT_STATE_ROOT/<agent_name>/state/<filename>`,
|
||||
/// chowns to the agent user (derived from the state dir's existing owner),
|
||||
|
|
@ -2335,6 +2368,35 @@ fn validate_bind_path(path: &str) -> Result<()> {
|
|||
/// write network isolation settings, then append `EXTRA_NSPAWN_FLAGS`.
|
||||
/// When `isolation` is `Some`, writes `PRIVATE_NETWORK=1` + veth wiring;
|
||||
/// when `None`, writes `PRIVATE_NETWORK=0`.
|
||||
/// `--tmpfs=<mount>/.git` for every bound git repo, hiding its metadata
|
||||
/// from inside the container.
|
||||
///
|
||||
/// Two kinds of mount qualify, for one reason: **the agent is given a
|
||||
/// working tree, never a repository.** `/knowledge` is the hive's shared
|
||||
/// docs — whose `.git/config` has held a credential the host-side worker
|
||||
/// embedded — and `/agents/<name>/config` is a config repo, an agent's own
|
||||
/// or a parent's read-only view of a child's. In both cases `.git` carries
|
||||
/// every branch and the full history of a document whose *currently
|
||||
/// deployed* value is the only thing a reader may act on, and an abandoned
|
||||
/// branch is indistinguishable from a live one.
|
||||
///
|
||||
/// An overlay rather than an exported copy: there is no second tree to
|
||||
/// keep in sync, so nothing can go stale, and no code path has to remember
|
||||
/// to refresh it.
|
||||
///
|
||||
/// ⚠️ Ordering matters — these must be appended **after** the `--bind`
|
||||
/// flags so nspawn mounts them on top of the already-mounted trees.
|
||||
/// Config mounts are matched by shape, not by a name list: the set is
|
||||
/// dynamic, growing with each child bound into a parent.
|
||||
fn git_overlay_flags(binds: &[BindMount]) -> Vec<String> {
|
||||
binds
|
||||
.iter()
|
||||
.map(|b| b.container_path.as_str())
|
||||
.filter(|p| *p == "/knowledge" || (p.starts_with("/agents/") && p.ends_with("/config")))
|
||||
.map(|p| format!("--tmpfs={p}/.git"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn write_nspawn_flags(
|
||||
container: &str,
|
||||
binds: &[BindMount],
|
||||
|
|
@ -2395,19 +2457,7 @@ fn write_nspawn_flags(
|
|||
format!("{flag}={}:{}", b.host_path, b.container_path)
|
||||
})
|
||||
.collect();
|
||||
// Defense-in-depth for the knowledge bind-mount: overlay an empty tmpfs
|
||||
// on /knowledge/.git so the repo metadata (including any credentials the
|
||||
// host-side git worker embedded in .git/config) is invisible inside agent
|
||||
// containers. Agents only need the working-tree documents; .git/ has no
|
||||
// legitimate use in-container. The --tmpfs must come after the --bind-ro
|
||||
// so nspawn processes it as an overlay on top of the already-mounted tree.
|
||||
// `crate::knowledge::CONTAINER_MOUNT` is "/knowledge" (hive-c0re const).
|
||||
if binds
|
||||
.iter()
|
||||
.any(|b| b.container_path.as_str() == "/knowledge")
|
||||
{
|
||||
flags.push("--tmpfs=/knowledge/.git".to_owned());
|
||||
}
|
||||
flags.extend(git_overlay_flags(binds));
|
||||
// Credential forwarding: nspawn loads each host secret into the
|
||||
// container's credential store under `<name>`; inner units inherit it
|
||||
// via `LoadCredential=<name>`. Validated (name charset + bind-path
|
||||
|
|
@ -2573,12 +2623,58 @@ async fn sync_agent_tmpfiles(agents: &[AgentTmpfilesEntry]) -> Result<(String, S
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
OwnedFd, PAUSED_MARKER_FILE, PrivRequest, check_fd_agreement, contains_secret_shaped_run,
|
||||
limits_dropin_body, redact_secret_line, remove_marker_in, write_state_file_nofollow,
|
||||
BindMount, OwnedFd, PAUSED_MARKER_FILE, PrivRequest, check_fd_agreement,
|
||||
contains_secret_shaped_run, git_overlay_flags, limits_dropin_body, redact_secret_line,
|
||||
remove_marker_in, write_state_file_nofollow,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
fn bind(container_path: &str) -> BindMount {
|
||||
BindMount {
|
||||
host_path: "/var/lib/hyperhive/whatever".to_owned(),
|
||||
container_path: container_path.to_owned(),
|
||||
read_only: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every bound git repo gets its `.git` overlaid — the knowledge tree
|
||||
/// and *each* config mount, an agent's own plus every child's.
|
||||
///
|
||||
/// The child case is the one worth pinning: that set grows at runtime
|
||||
/// as agents gain children, so a rule written as a list of names would
|
||||
/// silently stop covering new ones.
|
||||
#[test]
|
||||
fn every_bound_git_repo_gets_its_dot_git_hidden() {
|
||||
let flags = git_overlay_flags(&[
|
||||
bind("/knowledge"),
|
||||
bind("/agents/atlas/config"),
|
||||
bind("/agents/kiddo/config"),
|
||||
]);
|
||||
assert_eq!(
|
||||
flags,
|
||||
[
|
||||
"--tmpfs=/knowledge/.git",
|
||||
"--tmpfs=/agents/atlas/config/.git",
|
||||
"--tmpfs=/agents/kiddo/config/.git",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// ...and nothing else does. A blanket "overlay .git on every bind"
|
||||
/// would mask a real `.git` under `state/`, where an agent legitimately
|
||||
/// keeps working clones of its own.
|
||||
#[test]
|
||||
fn non_repo_mounts_are_left_alone() {
|
||||
let flags = git_overlay_flags(&[
|
||||
bind("/agents/atlas/state"),
|
||||
bind("/shared"),
|
||||
bind("/applied"),
|
||||
bind("/agents/atlas/config-notes"),
|
||||
]);
|
||||
assert!(flags.is_empty(), "overlaid a non-repo mount: {flags:?}");
|
||||
}
|
||||
|
||||
/// A request that streams into a caller-supplied descriptor.
|
||||
fn fd_taking_request() -> PrivRequest {
|
||||
PrivRequest::SendAgentSnapshotToFd {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Flake checks: formatting, the clippy gate, the workspace test run,
|
||||
# the nix-options docs eval, and the hivectl CLI-reference freshness
|
||||
# check. Imported per system from flake.nix.
|
||||
# the nix-options docs eval, the hivectl CLI-reference freshness check,
|
||||
# and the module-eval property table. Imported per system from flake.nix.
|
||||
{
|
||||
pkgs,
|
||||
craneLib,
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
self,
|
||||
system,
|
||||
treefmt-eval,
|
||||
nixosSystem,
|
||||
}:
|
||||
let
|
||||
inherit (rust) cleanSrc cargoArtifacts nativeBuildInputs;
|
||||
|
|
@ -15,6 +16,16 @@ in
|
|||
{
|
||||
formatting = treefmt-eval.config.build.check self;
|
||||
|
||||
# The only check here that covers **nix**. Every other one is a Rust
|
||||
# derivation, so a `.nix`-only diff moves no hash and the whole set is
|
||||
# cache hits — green without evaluating what changed. See the file's
|
||||
# header for what belongs in it and what needs something that executes
|
||||
# rather than evaluates.
|
||||
module-eval = import ./module-eval.nix {
|
||||
inherit pkgs self nixosSystem;
|
||||
inherit (pkgs) lib;
|
||||
};
|
||||
|
||||
# Clippy via crane's first-class `cargoClippy` builder. Reuses the
|
||||
# shared `cargoArtifacts` (deps already built) and runs
|
||||
# `cargo clippy --workspace --all-targets` directly.
|
||||
|
|
|
|||
|
|
@ -397,6 +397,39 @@ in
|
|||
};
|
||||
|
||||
config = lib.mkIf config.services.hyperhive.enable {
|
||||
# This service's own gateway surface: the vhost that fronts it and
|
||||
# the name the hive resolver answers for. Declared here rather than
|
||||
# in the gateway so the forge's public face lives with the forge —
|
||||
# the gateway supplies the primitives (`lib.listen`, `lib.tlsFor`,
|
||||
# `lib.securityHeaders`) and never needs to know this service by
|
||||
# name.
|
||||
#
|
||||
# Both halves are gated on `behindGateway`: with it off the operator
|
||||
# fronts forgejo themselves, so this hive must neither claim the
|
||||
# vhost nor answer DNS for it.
|
||||
services.hyperhive.gateway.localNames = lib.optional cfg.behindGateway cfg.domain;
|
||||
|
||||
# `server_name = forge.domain`, proxies all `/` → forgejo. Tuned for
|
||||
# git: `client_max_body_size 1G`, `proxy_read_timeout 1h` (multi-GB
|
||||
# clones). SSH stays direct on `forge.sshPort`. See
|
||||
# `docs/gateway.md`.
|
||||
services.nginx.virtualHosts = lib.optionalAttrs cfg.behindGateway {
|
||||
"${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // {
|
||||
listen = gatewayCfg.lib.listen;
|
||||
extraConfig = gatewayCfg.lib.securityHeaders;
|
||||
locations."/" = {
|
||||
proxyPass = "http://127.0.0.1:${toString cfg.httpPort}/";
|
||||
proxyWebsockets = true;
|
||||
extraConfig = ''
|
||||
proxy_buffering off;
|
||||
client_max_body_size 1G;
|
||||
proxy_read_timeout 1h;
|
||||
proxy_send_timeout 1h;
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
assertions = [
|
||||
{
|
||||
# Fail at EVAL, not at boot. The alternative failure is a login
|
||||
|
|
|
|||
|
|
@ -22,12 +22,15 @@ let
|
|||
# same list rather than each deciding what "a swarm service" means.
|
||||
swarmServiceDomains = config.services.hyperhive.swarm.serviceDomains;
|
||||
matrixCfg = config.services.hyperhive.swarm.matrix;
|
||||
autheliaCfg = config.services.hyperhive.swarm.authelia;
|
||||
uiCfg = config.services.hyperhive.swarm.ui;
|
||||
controllerCfg = config.services.hyperhive.swarm.controller;
|
||||
forgeCfg = config.services.hyperhive.swarm.forge;
|
||||
networkCfg = config.services.hyperhive.network;
|
||||
|
||||
# Every vhost claiming `default_server`, ours and the operator's
|
||||
# alike. Computed once so the assertion below and the message it
|
||||
# prints cannot disagree about what they found.
|
||||
defaultVhosts = lib.filter (n: config.services.nginx.virtualHosts.${n}.default or false) (
|
||||
lib.attrNames config.services.nginx.virtualHosts
|
||||
);
|
||||
|
||||
# Dashboard SPA dist, static-served by nginx.
|
||||
dashboardDist = "${config.services.hyperhive.c0re.servedFrontend}/dashboard";
|
||||
|
||||
|
|
@ -65,11 +68,17 @@ let
|
|||
svcCert = "${tlsDir}/swarm-services.pem";
|
||||
svcKey = "${tlsDir}/swarm-services-key.pem";
|
||||
|
||||
# Styled static error pages. Built once here and reached two ways:
|
||||
# directly by ./vhosts.nix, and via the published kit by any service
|
||||
# module that aims an `error_page` at one.
|
||||
errorPages = import ./error-pages.nix { inherit pkgs; };
|
||||
|
||||
# The vhost construction kit (listen set / per-name TLS attrs /
|
||||
# security headers). Computed here, published as `cfg.lib` below, and
|
||||
# handed to ./vhosts.nix **as the published value** — so the tree the
|
||||
# gateway renders and the kit a service module gets are the same
|
||||
# object by construction, not by two call sites agreeing.
|
||||
# security headers / error pages). Computed here, published as
|
||||
# `cfg.lib` below, and handed to ./vhosts.nix **as the published
|
||||
# value** — so the tree the gateway renders and the kit a service
|
||||
# module gets are the same object by construction, not by two call
|
||||
# sites agreeing.
|
||||
vhostLib = import ./vhost-lib.nix {
|
||||
inherit
|
||||
lib
|
||||
|
|
@ -79,6 +88,7 @@ let
|
|||
svcCert
|
||||
svcKey
|
||||
swarmServiceDomains
|
||||
errorPages
|
||||
;
|
||||
};
|
||||
|
||||
|
|
@ -87,16 +97,12 @@ let
|
|||
inherit
|
||||
lib
|
||||
cfg
|
||||
forgeCfg
|
||||
errorPages
|
||||
matrixCfg
|
||||
autheliaCfg
|
||||
uiCfg
|
||||
controllerCfg
|
||||
hyperhiveDomain
|
||||
dashboardDist
|
||||
swaggerUiTheme
|
||||
;
|
||||
errorPages = import ./error-pages.nix { inherit pkgs; };
|
||||
};
|
||||
in
|
||||
{
|
||||
|
|
@ -125,6 +131,58 @@ in
|
|||
Let's Encrypt needs a contact address for the ACME account.
|
||||
'';
|
||||
}
|
||||
{
|
||||
# Two modules claiming one hostname is a real possibility now
|
||||
# that each service contributes its own name, and dnsmasq would
|
||||
# not complain: duplicate `address=` rules resolve by precedence,
|
||||
# so the loser simply stops being served with no error anywhere.
|
||||
# Fail the build instead — a name is owned by exactly one module.
|
||||
assertion = lib.length (lib.unique cfg.localNames) == lib.length cfg.localNames;
|
||||
message = ''
|
||||
services.hyperhive.gateway.localNames contains a duplicate:
|
||||
${lib.concatStringsSep ", " (
|
||||
lib.unique (lib.filter (n: lib.count (m: m == n) cfg.localNames > 1) cfg.localNames)
|
||||
)}
|
||||
|
||||
Each hostname the hive resolver answers for is contributed by
|
||||
exactly one module. Two modules claiming the same name means
|
||||
two services believe they serve it — resolve which one does
|
||||
rather than letting dnsmasq pick.
|
||||
'';
|
||||
}
|
||||
{
|
||||
# nginx refuses to start with two `default_server`s on one
|
||||
# address ("a duplicate default server for 0.0.0.0:<port>",
|
||||
# exit 1) and nixpkgs asserts nothing — `vhost.default` is a
|
||||
# plain bool rendered straight into the listen line. So without
|
||||
# this, an operator adding their own default vhost gets a
|
||||
# gateway that fails its config test at rebuild time, which
|
||||
# takes the forge, dashboard, matrix and swarm UI with it, and
|
||||
# reports a port rather than a cause.
|
||||
#
|
||||
# Not covered by our `mkDefault`: an operator's own vhost is a
|
||||
# different option path, so nothing merges and nothing
|
||||
# conflicts — priority only helps someone who already knows
|
||||
# ours exists. Fail at eval and name both, so the fix
|
||||
# (`services.nginx.virtualHosts.<ours>.default = false`) is
|
||||
# readable from the error.
|
||||
assertion = lib.length defaultVhosts <= 1;
|
||||
message = ''
|
||||
More than one nginx virtual host is marked `default = true`:
|
||||
${lib.concatStringsSep ", " defaultVhosts}
|
||||
|
||||
nginx allows exactly one default server per listen address
|
||||
and refuses to start otherwise, so this would fail at
|
||||
service start rather than here — taking every site behind
|
||||
the gateway down with it.
|
||||
|
||||
The gateway's own catch-all (`_`, which returns 444) is set
|
||||
with `mkDefault`, so to make yours the default server turn
|
||||
ours off explicitly:
|
||||
|
||||
services.nginx.virtualHosts."_".default = false;
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
# Ensure the gateway state dirs exist at host boot, before anything
|
||||
|
|
@ -323,7 +381,7 @@ in
|
|||
recommendedTlsSettings = true;
|
||||
recommendedGzipSettings = true;
|
||||
recommendedOptimisation = true;
|
||||
inherit (nginxTree) appendHttpConfig virtualHosts;
|
||||
inherit (nginxTree) virtualHosts;
|
||||
};
|
||||
|
||||
# ⚠️ NO `SupplementaryGroups = [ "hive-core" ]` on nginx, and its
|
||||
|
|
@ -353,11 +411,8 @@ in
|
|||
services.dnsmasq = import ./dnsmasq.nix {
|
||||
inherit
|
||||
lib
|
||||
cfg
|
||||
networkCfg
|
||||
forgeCfg
|
||||
matrixCfg
|
||||
autheliaCfg
|
||||
uiCfg
|
||||
hyperhiveDomain
|
||||
;
|
||||
};
|
||||
|
|
@ -371,19 +426,18 @@ in
|
|||
];
|
||||
};
|
||||
|
||||
# `/etc/hosts` entries for local dev — bare hive domain + any
|
||||
# sub-domain modules that are on. `lib.unique` dedupes if any
|
||||
# sub-domain happens to equal another. See `docs/gateway.md`
|
||||
# `/etc/hosts` entries for local dev — the bare hive domain plus
|
||||
# every name a service module contributed. See `docs/gateway.md`
|
||||
# ("Local dev").
|
||||
#
|
||||
# This used to restate the per-service list a THIRD time (after the
|
||||
# vhosts and the dnsmasq records), with its own copy of each
|
||||
# service's guard. It is the same question — "which names does this
|
||||
# host answer for" — so it reads the same answer; a service added
|
||||
# later lands here with no edit, and cannot land here with a
|
||||
# different condition than it used for DNS.
|
||||
networking.hosts = lib.mkIf cfg.localHostsEntry {
|
||||
"127.0.0.1" = lib.unique (
|
||||
[ hyperhiveDomain ]
|
||||
++ lib.optional (config.services.hyperhive.swarm.forge.behindGateway or false
|
||||
) config.services.hyperhive.swarm.forge.domain
|
||||
++ lib.optional (matrixCfg.enable && matrixCfg.gatewayHost != null) matrixCfg.gatewayHost
|
||||
++ lib.optional autheliaCfg.enable autheliaCfg.domain
|
||||
++ lib.optional uiCfg.enable uiCfg.domain
|
||||
);
|
||||
"127.0.0.1" = lib.unique ([ hyperhiveDomain ] ++ cfg.localNames);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,8 @@
|
|||
# are computed by hive-network.
|
||||
{
|
||||
lib,
|
||||
cfg, # services.hyperhive.gateway
|
||||
networkCfg,
|
||||
forgeCfg,
|
||||
matrixCfg,
|
||||
autheliaCfg,
|
||||
uiCfg,
|
||||
hyperhiveDomain,
|
||||
}:
|
||||
{
|
||||
|
|
@ -55,31 +52,19 @@
|
|||
# Hive authoritative records — answer queries for the hive domain
|
||||
# + its sub-domains with the bridge IP, where nginx is reachable
|
||||
# from every container netns.
|
||||
#
|
||||
# The forge / matrix entries are redundant in the common case
|
||||
# where `forge.domain` / `matrix.gatewayHost` are sub-domains of
|
||||
# `hyperhive.domain` — dnsmasq's `/<domain>/` rule already matches
|
||||
# sub-domains. Kept explicit because operators can override either
|
||||
# to a cross-domain hostname (e.g. `forge.domain =
|
||||
# "git.example.com"`); listing them explicitly keeps that case
|
||||
# routed without needing an extra config block.
|
||||
address = [
|
||||
"/${hyperhiveDomain}/${networkCfg.bridgeIp}"
|
||||
]
|
||||
++ lib.optional ((forgeCfg.behindGateway or false)) "/${forgeCfg.domain}/${networkCfg.bridgeIp}"
|
||||
++ lib.optional (
|
||||
matrixCfg.enable && matrixCfg.gatewayHost != null
|
||||
) "/${matrixCfg.gatewayHost}/${networkCfg.bridgeIp}"
|
||||
++ lib.optional autheliaCfg.enable "/${autheliaCfg.domain}/${networkCfg.bridgeIp}"
|
||||
# The swarm UI's name is the swarm APEX by default — a sibling of
|
||||
# the three above, not a child of anything this resolver already
|
||||
# answers for, so the `/<hive domain>/` rule does not cover it.
|
||||
# Names contributed by the modules that own them
|
||||
# (`gateway.localNames`). Same address as everything above — the
|
||||
# bridge IP is the gateway's answer for anything it fronts, and a
|
||||
# contributing module neither knows nor should know it.
|
||||
#
|
||||
# Published to agents deliberately (mara: publishing it is fine).
|
||||
# Reachability is not the access control here: the vhost's
|
||||
# `auth_request` + authelia's `group:operators` rule are, and an
|
||||
# agent that resolves the name still cannot open the page.
|
||||
++ lib.optional uiCfg.enable "/${uiCfg.domain}/${networkCfg.bridgeIp}";
|
||||
# `unique` is not tidiness: two modules claiming one name would
|
||||
# otherwise emit two `address=` rules for it, and dnsmasq resolves
|
||||
# that by precedence rather than by complaining. An assertion in
|
||||
# ./default.nix makes the collision loud instead.
|
||||
++ map (name: "/${name}/${networkCfg.bridgeIp}") (lib.unique cfg.localNames);
|
||||
# DHCP pool covering all usable host addresses on the bridge
|
||||
# subnet — bounds computed by hive-network.nix from
|
||||
# bridgeIp/bridgePrefixLength. All containers (agents and service
|
||||
|
|
|
|||
|
|
@ -91,6 +91,35 @@ in
|
|||
gateway shape. Off by default — operators running with real
|
||||
DNS shouldn't have a stale `/etc/hosts` entry sticking
|
||||
around. Requires `services.hyperhive.domain` to be set.
|
||||
|
||||
`services.hyperhive.enableAllLocalDefaults` turns this on as
|
||||
part of saying "this box is the whole deployment": that mode
|
||||
means there is no real DNS for these names and the operator is
|
||||
browsing them from the host itself. Set it here explicitly to
|
||||
override in either direction.
|
||||
'';
|
||||
};
|
||||
|
||||
localNames = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
internal = true;
|
||||
description = ''
|
||||
Extra hostnames the hive's resolver answers with the bridge IP,
|
||||
contributed by the modules that own those names.
|
||||
|
||||
A service module says **which name**; the gateway decides
|
||||
**where it points** — the same split as `lib.tlsFor`. A service
|
||||
that hardcoded the bridge IP would be one more place to fix when
|
||||
the network layout changes, and it has no business knowing it.
|
||||
|
||||
⚠️ Contribute a name only when THIS host actually serves it. The
|
||||
list is not "names the swarm has" —
|
||||
`services.hyperhive.swarm.serviceDomains` is that, and it is
|
||||
deliberately broader (it drives certificate issuance, so it
|
||||
includes names this hive may only be a client of). Publishing an
|
||||
address record for a service you do not run points every agent
|
||||
on the bridge at a door that isn't there.
|
||||
'';
|
||||
};
|
||||
|
||||
|
|
@ -127,6 +156,22 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
errorPages = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.path;
|
||||
internal = true;
|
||||
readOnly = true;
|
||||
description = ''
|
||||
Read-only: the gateway's styled static error pages, by name
|
||||
(`notFound`, `unreachable`, `unauthorized`, `ssoUnavailable`).
|
||||
|
||||
Published so a service module can aim an `error_page` at one
|
||||
instead of rendering its own — a service that built its own
|
||||
would drift from the rest of the gateway the first time the
|
||||
theme changed, and the operator would meet two different
|
||||
error styles on one hive.
|
||||
'';
|
||||
};
|
||||
|
||||
securityHeaders = lib.mkOption {
|
||||
type = lib.types.lines;
|
||||
internal = true;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
svcCert, # swarm-services leaf, for names the hive CA cannot sign
|
||||
svcKey,
|
||||
swarmServiceDomains, # which names those are (../swarm.nix derives it)
|
||||
errorPages, # ./error-pages.nix: { notFound, unreachable, unauthorized, ssoUnavailable }
|
||||
}:
|
||||
let
|
||||
# nixos `services.nginx.virtualHosts.<name>` ssl attrs for a vhost
|
||||
|
|
@ -98,4 +99,11 @@ in
|
|||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
${lib.optionalString cfg.hsts.enable ''add_header Strict-Transport-Security "${hstsDirectives}" always;''}
|
||||
'';
|
||||
|
||||
# The gateway's styled error pages, re-exported so a service module
|
||||
# can point an `error_page` at one. Republished rather than imported
|
||||
# per module for the same reason as everything else in this kit: these
|
||||
# carry the hive's branding, and a service rendering its own would
|
||||
# drift from the rest of the gateway the first time the theme changes.
|
||||
inherit errorPages;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,15 +3,11 @@
|
|||
# and authelia sub-domain vhosts, and the Accept-header SPA map for the
|
||||
# matrix GUI. Pure function — called from ./default.nix with the
|
||||
# outer-scope config values as arguments; returns
|
||||
# `{ virtualHosts, appendHttpConfig }`.
|
||||
# `{ virtualHosts }`.
|
||||
{
|
||||
lib,
|
||||
cfg, # services.hyperhive.gateway
|
||||
forgeCfg,
|
||||
matrixCfg,
|
||||
autheliaCfg, # services.hyperhive.swarm.authelia
|
||||
uiCfg, # services.hyperhive.swarm.ui
|
||||
controllerCfg, # services.hyperhive.swarm.controller
|
||||
hyperhiveDomain,
|
||||
dashboardDist,
|
||||
swaggerUiTheme, # nix/packages/swagger-ui-theme.nix: has index.html + hyperhive-theme.css
|
||||
|
|
@ -37,239 +33,6 @@ let
|
|||
publicPort = cfg.httpsPort;
|
||||
publicPortSuffix = if publicPort == 443 then "" else ":${toString publicPort}";
|
||||
|
||||
# Forge sub-domain vhost. `server_name = forge.domain`, proxies
|
||||
# all `/` → forgejo. Tuned for git: `client_max_body_size 1G`,
|
||||
# `proxy_read_timeout 1h` (multi-GB clones). SSH stays direct on
|
||||
# `forge.sshPort`. See `docs/gateway.md`. Empty attrset when the
|
||||
# forge isn't behind the gateway.
|
||||
forgeVhost = lib.optionalAttrs (forgeCfg.behindGateway or false) {
|
||||
"${forgeCfg.domain}" = (vhostTlsFor forgeCfg.domain) // {
|
||||
listen = vhostListen;
|
||||
extraConfig = securityHeaders;
|
||||
locations."/" = {
|
||||
proxyPass = "http://127.0.0.1:${toString forgeCfg.httpPort}/";
|
||||
proxyWebsockets = true;
|
||||
extraConfig = ''
|
||||
proxy_buffering off;
|
||||
client_max_body_size 1G;
|
||||
proxy_read_timeout 1h;
|
||||
proxy_send_timeout 1h;
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Authelia sub-domain vhost. `server_name = authelia.domain`, all of
|
||||
# `/` → authelia. Empty attrset unless THIS host runs the container:
|
||||
# every hive knows the swarm's `authelia.url`, but only the one
|
||||
# serving it may claim the name — a client hive declaring this vhost
|
||||
# would answer for a service it does not run.
|
||||
#
|
||||
# ⚠️ The server name must be exactly `autheliaCfg.domain`, not a
|
||||
# near-miss: authelia validates `authelia_url ⊂ session cookie domain`
|
||||
# at STARTUP, so a mismatch is a container that refuses to boot rather
|
||||
# than a login that misbehaves.
|
||||
#
|
||||
# ⚠️ And deliberately NO `dashboardAuth` here. That block is the
|
||||
# gateway's `auth_basic`; applying it to the SSO provider would put
|
||||
# the login page behind the login mechanism it exists to replace.
|
||||
autheliaVhost = lib.optionalAttrs autheliaCfg.enable {
|
||||
"${autheliaCfg.domain}" = (vhostTlsFor autheliaCfg.domain) // {
|
||||
listen = vhostListen;
|
||||
extraConfig = securityHeaders;
|
||||
locations."/" = {
|
||||
proxyPass = "http://127.0.0.1:${toString autheliaCfg.port}/";
|
||||
proxyWebsockets = true;
|
||||
extraConfig = ''
|
||||
proxy_buffering off;
|
||||
# authelia decides by the ORIGINAL request, not by the hop it
|
||||
# sees — the login redirect and the session cookie's domain
|
||||
# both derive from these. Without them every request looks
|
||||
# like it arrived at 127.0.0.1 over plain http.
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Uri $request_uri;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# A dead upstream here means "not bootstrapped" far more often
|
||||
# than "misconfigured proxy", and a bare 502 says the opposite.
|
||||
proxy_intercept_errors on;
|
||||
error_page 502 503 504 = /__hive_sso_unavailable;
|
||||
'';
|
||||
};
|
||||
locations."= /__hive_sso_unavailable" = {
|
||||
extraConfig = ''
|
||||
internal;
|
||||
alias ${errorPages.ssoUnavailable};
|
||||
default_type text/html;
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Swarm UI vhost — the swarm's front page, on the swarm apex, and the
|
||||
# FIRST `auth_request` anywhere in this gateway (everything else is
|
||||
# `auth_basic` + htpasswd).
|
||||
#
|
||||
# ⚠️ `auth_request` answers "is there a session", not "is this an
|
||||
# operator". The operator-only part is authelia's `access_control`
|
||||
# rule (../swarm-authelia.nix) requiring `group:operators` — agents
|
||||
# are getting authelia accounts of their own, and without that rule a
|
||||
# session alone would open this page.
|
||||
#
|
||||
# ⚠️ Failure mode here is LOCKED OUT, not unprotected: a subrequest
|
||||
# that wrongly denies takes the whole UI away. That is the reason the
|
||||
# redirect target and the header set below are copied from a measured
|
||||
# source rather than from an example.
|
||||
# ⚠️ `forceSSL`, not `addSSL` like every other vhost — not a hardening
|
||||
# preference, the only way this page works at all. authelia answers the
|
||||
# auth subrequest for an `http://` target with **400**, and nginx's
|
||||
# `auth_request` only understands 2xx/401/403, so a plain-http visit
|
||||
# dies as "auth request unexpected status: 400" with no hint a login
|
||||
# exists. `vhostListen` binds :80, so without this the door is open on
|
||||
# a port the lock cannot work on. Serving forge or matrix over http is
|
||||
# merely insecure rather than broken, so they keep `addSSL` and the
|
||||
# asymmetry stays local to the vhost whose correctness depends on the
|
||||
# scheme. `removeAttrs` because nixos asserts on a vhost declaring both.
|
||||
# Shared with every swarm-UI-vhost location below (`/`, `/api/`,
|
||||
# `/api/docs/`) — auth_request does not inherit across sibling
|
||||
# locations, so each one that should be operator-gated repeats this
|
||||
# verbatim rather than only the page itself being protected while its
|
||||
# own API and API docs are reachable unauthenticated.
|
||||
swarmAuthRequest = ''
|
||||
auth_request /__hive_authelia;
|
||||
# Captured BEFORE the error_page jump: inside the 401 handler
|
||||
# `$request_uri` is the internal one, so building the return
|
||||
# link there sends the operator back to the auth subrequest
|
||||
# instead of the page they asked for.
|
||||
auth_request_set $target_url $scheme://$http_host$request_uri;
|
||||
error_page 401 =302 https://${autheliaCfg.domain}/?rd=$target_url;
|
||||
'';
|
||||
|
||||
swarmUiVhost = lib.optionalAttrs uiCfg.enable {
|
||||
"${uiCfg.domain}" = (builtins.removeAttrs (vhostTlsFor uiCfg.domain) [ "addSSL" ]) // {
|
||||
forceSSL = true;
|
||||
listen = vhostListen;
|
||||
extraConfig = securityHeaders;
|
||||
locations = {
|
||||
"/" = {
|
||||
root = "${uiCfg.package}";
|
||||
extraConfig = ''
|
||||
${swarmAuthRequest}
|
||||
# SPA: any path the bundle routes client-side is served the
|
||||
# entry document rather than a 404 from the filesystem.
|
||||
try_files $uri /index.html;
|
||||
'';
|
||||
};
|
||||
# swarm-controller's whole HTTP surface, including the live
|
||||
# `/api/openapi.json` spec — proxied untouched (no URI segment
|
||||
# after the socket path, same "pass the request through as-is"
|
||||
# shape as the per-hive dashboard's own `/api/` proxy) so the
|
||||
# path swarm-controller registered a route at is the path
|
||||
# nginx forwards, no prefix-stripping to keep in sync by hand.
|
||||
"/api/" = {
|
||||
proxyPass = "http://unix:${controllerCfg.socketPath}:";
|
||||
extraConfig = swarmAuthRequest;
|
||||
};
|
||||
# Swagger UI: same "nginx hosts the themed dist straight from
|
||||
# the store, only /api/openapi.json is dynamic" shape as the
|
||||
# per-hive gateway's `swaggerUiLocations` — see that block's
|
||||
# comment for why core-equivalent (here, swarm-controller)
|
||||
# does not also mount its own copy.
|
||||
"= /api/docs" = {
|
||||
extraConfig = ''
|
||||
return 301 /api/docs/;
|
||||
'';
|
||||
};
|
||||
"/api/docs/" = {
|
||||
alias = "${swaggerUiTheme}/";
|
||||
extraConfig = ''
|
||||
index index.html;
|
||||
${swarmAuthRequest}
|
||||
'';
|
||||
};
|
||||
# The subrequest itself. `auth-request` is the implementation
|
||||
# name authelia exposes under `/api/authz/`; `/api/verify` is the
|
||||
# LEGACY path every older example shows.
|
||||
#
|
||||
# Header set measured against the pinned binary (4.39.20), not
|
||||
# copied: `X-Original-URL` and `X-Original-Method` are present as
|
||||
# literals and are what this implementation reads —
|
||||
# `X-Forwarded-Uri` does not appear in it at all, so sending it
|
||||
# would look like configuration and be dead weight.
|
||||
"= /__hive_authelia" = {
|
||||
proxyPass = "http://127.0.0.1:${toString autheliaCfg.port}/api/authz/auth-request";
|
||||
extraConfig = ''
|
||||
internal;
|
||||
# A subrequest carries no body, and forwarding one here makes
|
||||
# authelia read a payload it will never use.
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header X-Original-Method $request_method;
|
||||
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Matrix sub-domain vhost. `server_name = matrixCfg.gatewayHost`.
|
||||
# `/_matrix/*` → tuwunel (CORS *, 50M body cap, 1h long-poll
|
||||
# timeout). `/` serves fluffychat or 404 if GUI off. nginx
|
||||
# longer-prefix-wins puts `/_matrix/` ahead of `/`. See
|
||||
# `docs/gateway.md`. Empty attrset when matrix has no gateway host.
|
||||
matrixVhost = lib.optionalAttrs (matrixCfg.enable && matrixCfg.gatewayHost != null) {
|
||||
"${matrixCfg.gatewayHost}" = (vhostTlsFor matrixCfg.gatewayHost) // {
|
||||
listen = vhostListen;
|
||||
extraConfig = securityHeaders;
|
||||
locations = {
|
||||
"/_matrix/" = {
|
||||
proxyPass = "http://127.0.0.1:${toString matrixCfg.httpPort}";
|
||||
proxyWebsockets = true;
|
||||
extraConfig = ''
|
||||
proxy_buffering off;
|
||||
client_max_body_size 50M;
|
||||
proxy_read_timeout 1h;
|
||||
proxy_send_timeout 1h;
|
||||
${securityHeaders}
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
'';
|
||||
};
|
||||
}
|
||||
// lib.optionalAttrs (matrixCfg.gui.enable) (
|
||||
{
|
||||
# fluffychat at sub-domain root, SPA-fallback via
|
||||
# the Accept-header `$matrix_spa_target` map.
|
||||
"/" = {
|
||||
alias = "${matrixCfg.gui.package}/";
|
||||
extraConfig = ''
|
||||
try_files $uri $uri/ $matrix_spa_target =404;
|
||||
'';
|
||||
};
|
||||
}
|
||||
// {
|
||||
# FluffyChat boot-config pre-fill so the client's
|
||||
# `.well-known/matrix/client` lookup hits the
|
||||
# right delegation endpoint. `domain` is required, so
|
||||
# this is always present.
|
||||
"= /config.json" = {
|
||||
extraConfig = ''
|
||||
default_type application/json;
|
||||
return 200 '{"defaultHomeserver":"${hyperhiveDomain}"}';
|
||||
'';
|
||||
};
|
||||
}
|
||||
)
|
||||
// lib.optionalAttrs (!matrixCfg.gui.enable) {
|
||||
"/" = {
|
||||
return = "404";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# `<hive>/matrix/*` → 301 → `matrix.<hive>/$1` (legacy deep-link
|
||||
# shim during the fluffychat sub-domain move). See `docs/gateway.md`.
|
||||
matrixRedirectLocations =
|
||||
|
|
@ -455,23 +218,42 @@ let
|
|||
};
|
||||
in
|
||||
{
|
||||
# Accept-header SPA map for the matrix GUI only (see docs/gateway.md
|
||||
# "SPA fallback"): text/html → index.html, else a sentinel so
|
||||
# try_files falls through to 404. The dashboard doesn't use an
|
||||
# Accept-header map — it routes by path (see dashboardProxyLocation).
|
||||
appendHttpConfig = lib.optionalString (matrixCfg.enable && matrixCfg.gui.enable) ''
|
||||
map $http_accept $matrix_spa_target {
|
||||
default "/__matrix_spa_no_html_fallback";
|
||||
"~*text/html" "/index.html";
|
||||
}
|
||||
'';
|
||||
|
||||
virtualHosts = {
|
||||
# `tlsFor "_"`, not a separate binding: the default server is a
|
||||
# vhost named `_`, and a name that is not a swarm service domain
|
||||
# (`_` never is) resolves to the hive's own leaf — which is what
|
||||
# this vhost has always served.
|
||||
# The catch-all, and now *only* a catch-all: anything whose `Host`
|
||||
# matches no vhost gets an immediate 444 (close without a response)
|
||||
# rather than being served the hive's dashboard.
|
||||
#
|
||||
# `_` is the idiomatic spelling because it is not a legal hostname,
|
||||
# so it can never match a request by name — it serves traffic solely
|
||||
# by being `default_server`.
|
||||
#
|
||||
# ⚠️ It still needs TLS attrs. It listens on the https port, so a
|
||||
# client connecting by IP completes a TLS handshake *before* nginx
|
||||
# can look at `Host` and reject it; with no cert the vhost fails to
|
||||
# load. The certificate will not match what such a client asked for
|
||||
# — that is unavoidable and correct: nothing can present a valid
|
||||
# cert for a name the operator never issued one for.
|
||||
#
|
||||
# `mkDefault` per the operator: an operator with their own
|
||||
# `default = true` vhost must be able to win without fighting
|
||||
# priorities. The assertion in ./default.nix catches the case where
|
||||
# they add one *without* turning this off, which nginx would
|
||||
# otherwise only report at runtime as a failed config test.
|
||||
"_" = (vhostTlsFor "_") // {
|
||||
listen = vhostListen;
|
||||
default = lib.mkDefault true;
|
||||
extraConfig = ''
|
||||
return 444;
|
||||
'';
|
||||
};
|
||||
|
||||
# The hive's own surface, now reachable by NAME. This used to be
|
||||
# served by the `_` vhost above: no vhost was named for the hive
|
||||
# domain, so every dashboard and agent-UI request matched the
|
||||
# default server instead (confirmed against 24h of nginx's access
|
||||
# log — `server: _` on requests whose Host *was* the hive domain).
|
||||
# Naming it is what lets the catch-all start rejecting.
|
||||
${hyperhiveDomain} = (vhostTlsFor hyperhiveDomain) // {
|
||||
listen = vhostListen;
|
||||
locations =
|
||||
matrixRedirectLocations
|
||||
|
|
@ -503,9 +285,5 @@ in
|
|||
include /var/lib/hive-gateway/conf/agents.conf;
|
||||
'';
|
||||
};
|
||||
}
|
||||
// forgeVhost
|
||||
// autheliaVhost
|
||||
// matrixVhost
|
||||
// swarmUiVhost;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ let
|
|||
networkCfg = config.services.hyperhive.network;
|
||||
tlsCfg = config.services.hyperhive.tls;
|
||||
gatewayCfg = config.services.hyperhive.gateway;
|
||||
hyperhiveDomain = config.services.hyperhive.domain;
|
||||
|
||||
# Same runtime→build-time bridge hive-ci and hive-forge already cross:
|
||||
# binds the hive trust bundle (which folds in the swarm root) into the
|
||||
|
|
@ -396,6 +397,85 @@ in
|
|||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
# Matrix's own gateway surface: the sub-domain vhost, the name the
|
||||
# hive resolver answers for, and the Accept-header map that vhost's
|
||||
# SPA fallback reads. All three are matrix knowledge and none of
|
||||
# them is the gateway's business.
|
||||
#
|
||||
# `gatewayHost = null` means matrix is reachable directly rather
|
||||
# than fronted, so there is no name to claim and no vhost to serve —
|
||||
# every clause below carries that guard.
|
||||
services.hyperhive.gateway.localNames = lib.optional (cfg.gatewayHost != null) cfg.gatewayHost;
|
||||
|
||||
# Accept-header SPA map, used only by the `/` location below (see
|
||||
# docs/gateway.md "SPA fallback"): text/html → index.html, else a
|
||||
# sentinel so `try_files` falls through to 404. `appendHttpConfig`
|
||||
# is a `lines` option, so this merges with anything else the host
|
||||
# contributes instead of replacing it.
|
||||
#
|
||||
# The dashboard needs no equivalent — it routes by path.
|
||||
services.nginx.appendHttpConfig = lib.optionalString cfg.gui.enable ''
|
||||
map $http_accept $matrix_spa_target {
|
||||
default "/__matrix_spa_no_html_fallback";
|
||||
"~*text/html" "/index.html";
|
||||
}
|
||||
'';
|
||||
|
||||
# `server_name = gatewayHost`. `/_matrix/*` → tuwunel (CORS `*`, 50M
|
||||
# body cap, 1h long-poll timeout). `/` serves fluffychat, or 404
|
||||
# with the GUI off. nginx's longest-prefix rule puts `/_matrix/`
|
||||
# ahead of `/` with no ordering needed.
|
||||
#
|
||||
# ⚠️ The `.well-known/matrix/*` delegation is deliberately NOT here.
|
||||
# It stays on the hive's own vhost because the spec requires it to
|
||||
# be served at the *server name*, which is the hive domain — it is
|
||||
# the hive answering "where is my homeserver", not the homeserver
|
||||
# answering for itself.
|
||||
services.nginx.virtualHosts = lib.optionalAttrs (cfg.gatewayHost != null) {
|
||||
"${cfg.gatewayHost}" = (gatewayCfg.lib.tlsFor cfg.gatewayHost) // {
|
||||
listen = gatewayCfg.lib.listen;
|
||||
extraConfig = gatewayCfg.lib.securityHeaders;
|
||||
locations = {
|
||||
"/_matrix/" = {
|
||||
proxyPass = "http://127.0.0.1:${toString cfg.httpPort}";
|
||||
proxyWebsockets = true;
|
||||
extraConfig = ''
|
||||
proxy_buffering off;
|
||||
client_max_body_size 50M;
|
||||
proxy_read_timeout 1h;
|
||||
proxy_send_timeout 1h;
|
||||
${gatewayCfg.lib.securityHeaders}
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
'';
|
||||
};
|
||||
}
|
||||
// lib.optionalAttrs cfg.gui.enable {
|
||||
# fluffychat at sub-domain root, SPA-fallback via the
|
||||
# Accept-header `$matrix_spa_target` map above.
|
||||
"/" = {
|
||||
alias = "${cfg.gui.package}/";
|
||||
extraConfig = ''
|
||||
try_files $uri $uri/ $matrix_spa_target =404;
|
||||
'';
|
||||
};
|
||||
# FluffyChat boot-config pre-fill so the client's
|
||||
# `.well-known/matrix/client` lookup hits the right delegation
|
||||
# endpoint. `domain` is required, so this is always present.
|
||||
"= /config.json" = {
|
||||
extraConfig = ''
|
||||
default_type application/json;
|
||||
return 200 '{"defaultHomeserver":"${hyperhiveDomain}"}';
|
||||
'';
|
||||
};
|
||||
}
|
||||
// lib.optionalAttrs (!cfg.gui.enable) {
|
||||
"/" = {
|
||||
return = "404";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# `serverName` is irrevocably embedded in user/room IDs; it derives
|
||||
# from `services.hyperhive.domain` (required, asserted in
|
||||
# hive-network.nix) when not set explicitly, so no separate
|
||||
|
|
|
|||
|
|
@ -29,11 +29,15 @@ in
|
|||
example = true;
|
||||
description = ''
|
||||
Run the whole swarm on this host. Turning this on asserts the
|
||||
swarm-level toggles that an all-on-one-box deployment implies:
|
||||
the swarm's shared services
|
||||
toggles that an all-on-one-box deployment implies: the swarm's
|
||||
shared services
|
||||
(`services.hyperhive.swarm.enableRequiredServices`), the swarm
|
||||
CA (`services.hyperhive.swarm.ca.autoConfigure`), and the swarm
|
||||
controller (`services.hyperhive.swarm.controller.enable`).
|
||||
CA (`services.hyperhive.swarm.ca.autoConfigure`), the swarm
|
||||
controller (`services.hyperhive.swarm.controller.enable`), and the
|
||||
host's `/etc/hosts` entries for the names this hive serves
|
||||
(`services.hyperhive.gateway.localHostsEntry`) — with no real DNS
|
||||
for those names, the operator is browsing them from the same box
|
||||
that answers for them.
|
||||
|
||||
**Off by default, and that is the load-bearing part.** A swarm's
|
||||
services and its hives can live on different hosts, and a host has
|
||||
|
|
@ -52,6 +56,21 @@ in
|
|||
# own `default` (1500) and loses to any explicit definition, which is
|
||||
# exactly the precedence a deployment mode wants: it fills in for an
|
||||
# operator who hasn't spoken, and never argues with one who has.
|
||||
# The gateway's own all-local bit. `localHostsEntry` maps every name
|
||||
# this hive answers for to 127.0.0.1 in the HOST's /etc/hosts, which is
|
||||
# exactly what "this box is the whole deployment" implies: there is no
|
||||
# real DNS for these names, and the operator is browsing them from the
|
||||
# same machine that serves them.
|
||||
#
|
||||
# ⚠️ It does NOT affect what containers resolve. dnsmasq sets
|
||||
# `no-hosts = true` unconditionally (see hive-gateway/dnsmasq.nix), so
|
||||
# agents keep getting the bridge IP from the authoritative `address=`
|
||||
# rules rather than the host's 127.0.0.1 — an entry that would point
|
||||
# every agent at its own netns. That guard already existing is what
|
||||
# makes turning this on by default safe; without it this line would
|
||||
# break every agent's access to the forge.
|
||||
config.services.hyperhive.gateway.localHostsEntry = lib.mkDefault cfg.enableAllLocalDefaults;
|
||||
|
||||
config.services.hyperhive.swarm = {
|
||||
enableRequiredServices = lib.mkDefault cfg.enableAllLocalDefaults;
|
||||
ca.autoConfigure = lib.mkDefault cfg.enableAllLocalDefaults;
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
let
|
||||
cfg = config.services.hyperhive.swarm.authelia;
|
||||
hyperhiveCfg = config.services.hyperhive;
|
||||
gatewayCfg = hyperhiveCfg.gateway;
|
||||
hyperhiveDomain = hyperhiveCfg.domain;
|
||||
swarmDomain = hyperhiveCfg.swarm.domain;
|
||||
uiCfg = hyperhiveCfg.swarm.ui;
|
||||
|
|
@ -402,6 +403,59 @@ in
|
|||
};
|
||||
|
||||
config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) {
|
||||
# Authelia's own gateway surface: the vhost that fronts it and the
|
||||
# name the hive resolver answers for. Both live here rather than in
|
||||
# the gateway, and both are inside `cfg.enable` — that guard is the
|
||||
# load-bearing part.
|
||||
#
|
||||
# ⚠️ Every hive in a swarm knows `authelia.url`, but only the host
|
||||
# that RUNS the container may claim the name. A client hive
|
||||
# declaring this vhost would answer for a service it does not run,
|
||||
# and publishing the DNS record would point every agent on its
|
||||
# bridge at that wrong answer.
|
||||
services.hyperhive.gateway.localNames = [ cfg.domain ];
|
||||
|
||||
# `server_name = authelia.domain`, all of `/` → authelia.
|
||||
#
|
||||
# ⚠️ The server name must be exactly `cfg.domain`, not a near-miss:
|
||||
# authelia validates `authelia_url ⊂ session cookie domain` at
|
||||
# STARTUP, so a mismatch is a container that refuses to boot rather
|
||||
# than a login that misbehaves.
|
||||
#
|
||||
# ⚠️ And deliberately NO `dashboardAuth` here. That block is the
|
||||
# gateway's `auth_basic`; applying it to the SSO provider would put
|
||||
# the login page behind the login mechanism it exists to replace.
|
||||
services.nginx.virtualHosts."${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // {
|
||||
listen = gatewayCfg.lib.listen;
|
||||
extraConfig = gatewayCfg.lib.securityHeaders;
|
||||
locations."/" = {
|
||||
proxyPass = "http://127.0.0.1:${toString cfg.port}/";
|
||||
proxyWebsockets = true;
|
||||
extraConfig = ''
|
||||
proxy_buffering off;
|
||||
# authelia decides by the ORIGINAL request, not by the hop it
|
||||
# sees — the login redirect and the session cookie's domain
|
||||
# both derive from these. Without them every request looks
|
||||
# like it arrived at 127.0.0.1 over plain http.
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Uri $request_uri;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# A dead upstream here means "not bootstrapped" far more often
|
||||
# than "misconfigured proxy", and a bare 502 says the opposite.
|
||||
proxy_intercept_errors on;
|
||||
error_page 502 503 504 = /__hive_sso_unavailable;
|
||||
'';
|
||||
};
|
||||
locations."= /__hive_sso_unavailable" = {
|
||||
extraConfig = ''
|
||||
internal;
|
||||
alias ${gatewayCfg.lib.errorPages.ssoUnavailable};
|
||||
default_type text/html;
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
containers.${cfg.machine} = {
|
||||
autoStart = true;
|
||||
ephemeral = false;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,24 @@
|
|||
}:
|
||||
let
|
||||
cfg = config.services.hyperhive.swarm.ui;
|
||||
gatewayCfg = config.services.hyperhive.gateway;
|
||||
autheliaCfg = config.services.hyperhive.swarm.authelia;
|
||||
controllerCfg = config.services.hyperhive.swarm.controller;
|
||||
|
||||
# Repeated verbatim by every location that should be operator-gated
|
||||
# (`/`, `/api/`, `/api/docs/`) rather than set once on the server:
|
||||
# nginx's `auth_request` does NOT inherit across sibling locations, so
|
||||
# setting it only on the page would leave this UI's own API and API
|
||||
# docs reachable without a session.
|
||||
swarmAuthRequest = ''
|
||||
auth_request /__hive_authelia;
|
||||
# Captured BEFORE the error_page jump: inside the 401 handler
|
||||
# `$request_uri` is the internal one, so building the return
|
||||
# link there sends the operator back to the auth subrequest
|
||||
# instead of the page they asked for.
|
||||
auth_request_set $target_url $scheme://$http_host$request_uri;
|
||||
error_page 401 =302 https://${autheliaCfg.domain}/?rd=$target_url;
|
||||
'';
|
||||
swarmCfg = config.services.hyperhive.swarm;
|
||||
hiveDomain = config.services.hyperhive.domain;
|
||||
in
|
||||
|
|
@ -93,5 +111,110 @@ in
|
|||
'';
|
||||
}
|
||||
];
|
||||
|
||||
# The swarm UI's own gateway surface. Published to agents on the
|
||||
# bridge deliberately: reachability is not the access control here —
|
||||
# the `auth_request` below and authelia's `group:operators` rule
|
||||
# are, and an agent that resolves the name still cannot open the
|
||||
# page.
|
||||
#
|
||||
# The apex is a SIBLING of `forge.<swarm>` / `chat.<swarm>`, not a
|
||||
# child of anything the resolver already answers for, so the
|
||||
# `/<hive domain>/` rule does not cover it and this record is what
|
||||
# makes the name resolve at all.
|
||||
services.hyperhive.gateway.localNames = [ cfg.domain ];
|
||||
|
||||
# The swarm's front page, and the FIRST `auth_request` anywhere in
|
||||
# this gateway (everything else is `auth_basic` + htpasswd).
|
||||
#
|
||||
# ⚠️ `auth_request` answers "is there a session", not "is this an
|
||||
# operator". The operator-only part is authelia's `access_control`
|
||||
# rule (./swarm-authelia.nix) requiring `group:operators` — agents
|
||||
# have authelia accounts of their own, and without that rule a
|
||||
# session alone would open this page.
|
||||
#
|
||||
# ⚠️ Failure mode here is LOCKED OUT, not unprotected: a subrequest
|
||||
# that wrongly denies takes the whole UI away. That is why the
|
||||
# redirect target and the header set below come from a measured
|
||||
# source rather than an example.
|
||||
#
|
||||
# ⚠️ `forceSSL`, not `addSSL` like every other vhost — not a
|
||||
# hardening preference, the only way this page works at all.
|
||||
# authelia answers the auth subrequest for an `http://` target with
|
||||
# **400**, and nginx's `auth_request` only understands 2xx/401/403,
|
||||
# so a plain-http visit dies as "auth request unexpected status:
|
||||
# 400" with no hint a login exists. The shared listen set binds :80,
|
||||
# so without this the door is open on a port the lock cannot work
|
||||
# on. Serving forge or matrix over http is merely insecure rather
|
||||
# than broken, so they keep `addSSL` and the asymmetry stays local
|
||||
# to the vhost whose correctness depends on the scheme.
|
||||
# `removeAttrs` because nixos asserts on a vhost declaring both.
|
||||
services.nginx.virtualHosts."${cfg.domain}" =
|
||||
(builtins.removeAttrs (gatewayCfg.lib.tlsFor cfg.domain) [ "addSSL" ])
|
||||
// {
|
||||
forceSSL = true;
|
||||
listen = gatewayCfg.lib.listen;
|
||||
extraConfig = gatewayCfg.lib.securityHeaders;
|
||||
locations = {
|
||||
"/" = {
|
||||
root = "${cfg.package}";
|
||||
extraConfig = ''
|
||||
${swarmAuthRequest}
|
||||
# SPA: any path the bundle routes client-side is served the
|
||||
# entry document rather than a 404 from the filesystem.
|
||||
try_files $uri /index.html;
|
||||
'';
|
||||
};
|
||||
# swarm-controller's whole HTTP surface, including the live
|
||||
# `/api/openapi.json` spec — proxied untouched (no URI segment
|
||||
# after the socket path, same "pass the request through as-is"
|
||||
# shape as the per-hive dashboard's own `/api/` proxy) so the
|
||||
# path swarm-controller registered a route at is the path
|
||||
# nginx forwards, no prefix-stripping to keep in sync by hand.
|
||||
"/api/" = {
|
||||
proxyPass = "http://unix:${controllerCfg.socketPath}:";
|
||||
extraConfig = swarmAuthRequest;
|
||||
};
|
||||
# Swagger UI: same "nginx hosts the themed dist straight from
|
||||
# the store, only /api/openapi.json is dynamic" shape as the
|
||||
# per-hive gateway's `swaggerUiLocations`.
|
||||
"= /api/docs" = {
|
||||
extraConfig = ''
|
||||
return 301 /api/docs/;
|
||||
'';
|
||||
};
|
||||
"/api/docs/" = {
|
||||
alias = "${gatewayCfg.swaggerUiTheme}/";
|
||||
extraConfig = ''
|
||||
index index.html;
|
||||
${swarmAuthRequest}
|
||||
'';
|
||||
};
|
||||
# The subrequest itself. `auth-request` is the implementation
|
||||
# name authelia exposes under `/api/authz/`; `/api/verify` is
|
||||
# the LEGACY path every older example shows.
|
||||
#
|
||||
# Header set measured against the pinned binary (4.39.20), not
|
||||
# copied: `X-Original-URL` and `X-Original-Method` are present
|
||||
# as literals and are what this implementation reads —
|
||||
# `X-Forwarded-Uri` does not appear in it at all, so sending it
|
||||
# would look like configuration and be dead weight.
|
||||
"= /__hive_authelia" = {
|
||||
proxyPass = "http://127.0.0.1:${toString autheliaCfg.port}/api/authz/auth-request";
|
||||
extraConfig = ''
|
||||
internal;
|
||||
# A subrequest carries no body, and forwarding one here makes
|
||||
# authelia read a payload it will never use.
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header X-Original-Method $request_method;
|
||||
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
119
nix/module-eval.nix
Normal file
119
nix/module-eval.nix
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# `checks.module-eval` — the flake check that covers **nix**.
|
||||
#
|
||||
# Why this exists: every other check in ./checks.nix is a Rust
|
||||
# derivation, so a `.nix`-only diff moves no hash, every check is a
|
||||
# cache hit, and `nix flake check` reports green **without evaluating
|
||||
# what changed**. This one's derivation hash is a function of the
|
||||
# evaluated *results* below, so a nix change that flips a property
|
||||
# rebuilds it and the builder fails naming that property.
|
||||
#
|
||||
# ## What belongs here, and what does not
|
||||
#
|
||||
# Anything expressible as a module `assertion` **should be one instead**:
|
||||
# an assertion fires at deploy time for a real operator, not only in CI.
|
||||
# What cannot be an assertion is the **absence class** — "a hive that
|
||||
# hasn't opted in renders exactly what it did before", "this unit does
|
||||
# not exist unless X". Those are claims about the *rendered config*
|
||||
# rather than about a config being invalid, so they need an evaluator.
|
||||
#
|
||||
# ⚠️ **Cases are named by the PROPERTY they defend, never by the ticket
|
||||
# that prompted them.** A case named after a ticket has the ticket's
|
||||
# lifetime; a case named after a property lives as long as the property.
|
||||
#
|
||||
# ⚠️ **This check evaluates. It does not execute.** Where the artifact is
|
||||
# a command line, an HTTP request or a certificate, a value assertion
|
||||
# cannot stand in — those need something that *runs* them. And a case
|
||||
# that needs a **rendered file** must stub the packages that file drags
|
||||
# in (`swarm.ui.package = pkgs.emptyDirectory`), or it costs a full
|
||||
# frontend build to answer a question about a listen directive.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
self,
|
||||
nixosSystem,
|
||||
}:
|
||||
let
|
||||
# Stub host, same shape ./docs/default.nix already uses: enough for a
|
||||
# `nixosSystem` to evaluate, nothing that pulls a real disk or
|
||||
# bootloader in.
|
||||
hive =
|
||||
extra:
|
||||
(nixosSystem {
|
||||
system = pkgs.stdenv.hostPlatform.system;
|
||||
modules = [
|
||||
self.nixosModules.default
|
||||
{
|
||||
fileSystems."/" = {
|
||||
device = "/dev/null";
|
||||
fsType = "tmpfs";
|
||||
};
|
||||
boot.loader.grub.enable = false;
|
||||
system.stateVersion = "25.11";
|
||||
services.hyperhive = {
|
||||
enable = true;
|
||||
hiveName = "h1";
|
||||
swarm.domain = "t.local";
|
||||
swarm.hives.h1.domain = "h1.t.local";
|
||||
}
|
||||
// extra;
|
||||
}
|
||||
];
|
||||
}).config;
|
||||
|
||||
allLocal = hive { enableAllLocalDefaults = true; };
|
||||
bare = hive { };
|
||||
|
||||
# Each case: a name stating the property, and `ok`.
|
||||
cases = [
|
||||
{
|
||||
name = "a hive that has not opted into all-local runs no swarm controller";
|
||||
ok = !bare.services.hyperhive.swarm.controller.enable;
|
||||
}
|
||||
{
|
||||
name = "the all-local mode turns the swarm controller on";
|
||||
ok = allLocal.services.hyperhive.swarm.controller.enable;
|
||||
}
|
||||
{
|
||||
# The gateway's per-name issuer choice. If this ever collapses to a
|
||||
# constant, every swarm-service vhost serves a certificate its CA
|
||||
# is name-constrained out of — which evaluates cleanly and fails in
|
||||
# a browser.
|
||||
name = "a swarm service name gets the swarm-services leaf and the default server does not";
|
||||
ok =
|
||||
let
|
||||
l = allLocal.services.hyperhive.gateway.lib;
|
||||
in
|
||||
(l.tlsFor "t.local").sslCertificate != (l.tlsFor "_").sslCertificate;
|
||||
}
|
||||
{
|
||||
# nixos asserts when a vhost declares both, so this is also a
|
||||
# statement that the `removeAttrs` upstream of it still happens.
|
||||
name = "the swarm UI vhost forces TLS instead of merely adding it";
|
||||
ok =
|
||||
let
|
||||
v = allLocal.services.nginx.virtualHosts."t.local";
|
||||
in
|
||||
v.forceSSL && !(v.addSSL or false);
|
||||
}
|
||||
{
|
||||
name = "a hive with matrix off serves no matrix discovery endpoint";
|
||||
ok =
|
||||
!(builtins.hasAttr "= /.well-known/matrix/client" bare.services.nginx.virtualHosts."_".locations);
|
||||
}
|
||||
];
|
||||
|
||||
bad = builtins.filter (c: !c.ok) cases;
|
||||
report = lib.concatMapStringsSep "\n" (c: " echo 'FAILED: ${c.name}' >&2") bad;
|
||||
in
|
||||
# The results are embedded in the builder text on purpose: that is what
|
||||
# makes this derivation's hash depend on them, so a nix-only change that
|
||||
# flips a case cannot be answered from cache.
|
||||
pkgs.runCommand "hyperhive-module-eval" { } ''
|
||||
${report}
|
||||
${
|
||||
if bad == [ ] then
|
||||
"echo '${toString (builtins.length cases)} module properties hold' && touch $out"
|
||||
else
|
||||
"echo 'module-eval: ${toString (builtins.length bad)} of ${toString (builtins.length cases)} properties broke' >&2 && exit 1"
|
||||
}
|
||||
''
|
||||
Loading…
Reference in a new issue