pih
This commit is contained in:
@@ -192,6 +192,8 @@ Control-plane admin API includes token management endpoints:
|
|||||||
- `DELETE /api/v1/control/enroll-tokens/{token}`
|
- `DELETE /api/v1/control/enroll-tokens/{token}`
|
||||||
- `GET /api/v1/control/audit/events?agent_id=<id>&event_type=<type>&limit=<n>`
|
- `GET /api/v1/control/audit/events?agent_id=<id>&event_type=<type>&limit=<n>`
|
||||||
- `GET /api/v1/control/alerts?lookback_seconds=900`
|
- `GET /api/v1/control/alerts?lookback_seconds=900`
|
||||||
|
- `GET /api/v1/control/alerts/history?since_unix=<ts>&limit=<n>`
|
||||||
|
- `GET /api/v1/control/alerts/ws` (websocket snapshots + recent transitions)
|
||||||
|
|
||||||
If commands still appear silent, verify both processes are running with `-v`
|
If commands still appear silent, verify both processes are running with `-v`
|
||||||
and that `RUST_LOG` is not overriding to a stricter level.
|
and that `RUST_LOG` is not overriding to a stricter level.
|
||||||
@@ -211,6 +213,13 @@ Expected exposure model:
|
|||||||
- Public: `/healthz`, `/api/v1/agents/enroll`, `/api/v1/agent/ws`
|
- Public: `/healthz`, `/api/v1/agents/enroll`, `/api/v1/agent/ws`
|
||||||
- Private (Cloudflare Access): `/ui/*`, `/api/v1/control/*`
|
- Private (Cloudflare Access): `/ui/*`, `/api/v1/control/*`
|
||||||
|
|
||||||
|
Control-plane routing is organized with the same boundary in code:
|
||||||
|
|
||||||
|
- public router: health, enroll, agent websocket
|
||||||
|
- control router: all `/api/v1/control/*` admin endpoints
|
||||||
|
|
||||||
|
This keeps edge policy and app routing aligned as features grow.
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
`wakey` is usable as a local/operator CLI.
|
`wakey` is usable as a local/operator CLI.
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ use std::collections::{BTreeMap, HashSet};
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
|
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use tokio::time::Duration;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::api::json_error;
|
use crate::api::json_error;
|
||||||
@@ -20,14 +22,129 @@ pub struct ActiveAlertsQuery {
|
|||||||
pub enroll_rejected_threshold: Option<u64>,
|
pub enroll_rejected_threshold: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AlertHistoryQuery {
|
||||||
|
pub since_unix: Option<u64>,
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct AlertRuleConfig {
|
||||||
|
pub lookback_seconds: Option<u64>,
|
||||||
|
pub timeout_threshold: Option<u64>,
|
||||||
|
pub auth_rejected_threshold: Option<u64>,
|
||||||
|
pub enroll_rejected_threshold: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn active_alerts(
|
pub async fn active_alerts(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(query): Query<ActiveAlertsQuery>,
|
Query(query): Query<ActiveAlertsQuery>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||||
let lookback_seconds = query.lookback_seconds.unwrap_or(900).clamp(60, 86_400);
|
let alerts = evaluate_alerts(
|
||||||
let timeout_threshold = query.timeout_threshold.unwrap_or(3).max(1);
|
&state,
|
||||||
let auth_rejected_threshold = query.auth_rejected_threshold.unwrap_or(3).max(1);
|
AlertRuleConfig {
|
||||||
let enroll_rejected_threshold = query.enroll_rejected_threshold.unwrap_or(5).max(1);
|
lookback_seconds: query.lookback_seconds,
|
||||||
|
timeout_threshold: query.timeout_threshold,
|
||||||
|
auth_rejected_threshold: query.auth_rejected_threshold,
|
||||||
|
enroll_rejected_threshold: query.enroll_rejected_threshold,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Err(err) = state.store.sync_alert_transitions(&alerts).await {
|
||||||
|
warn!(error = %err, "failed to sync alert transitions");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((StatusCode::OK, Json(alerts)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn alert_history(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<AlertHistoryQuery>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
let limit = query.limit.unwrap_or(100).clamp(1, 500);
|
||||||
|
let history = state
|
||||||
|
.store
|
||||||
|
.list_alert_transitions(query.since_unix, limit)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
warn!(error = %err, "failed reading alert transition history");
|
||||||
|
json_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"alert_history_failed",
|
||||||
|
&err.to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok((StatusCode::OK, Json(history)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn alerts_stream(
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<ActiveAlertsQuery>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let config = AlertRuleConfig {
|
||||||
|
lookback_seconds: query.lookback_seconds,
|
||||||
|
timeout_threshold: query.timeout_threshold,
|
||||||
|
auth_rejected_threshold: query.auth_rejected_threshold,
|
||||||
|
enroll_rejected_threshold: query.enroll_rejected_threshold,
|
||||||
|
};
|
||||||
|
ws.on_upgrade(move |socket| alerts_stream_socket(state, socket, config))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn alerts_stream_socket(state: AppState, mut socket: WebSocket, config: AlertRuleConfig) {
|
||||||
|
let mut tick = tokio::time::interval(Duration::from_secs(5));
|
||||||
|
loop {
|
||||||
|
tick.tick().await;
|
||||||
|
let alerts = match evaluate_alerts(&state, config.clone()).await {
|
||||||
|
Ok(alerts) => alerts,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(code = %err.0, "failed to evaluate alerts for stream");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = state.store.sync_alert_transitions(&alerts).await {
|
||||||
|
warn!(error = %err, "failed to sync alert transitions in stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
let history = match state.store.list_alert_transitions(None, 20).await {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "failed to load alert transition history for stream");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"type": "alerts_snapshot",
|
||||||
|
"ts_unix": now_unix(),
|
||||||
|
"alerts": alerts,
|
||||||
|
"recent_transitions": history,
|
||||||
|
});
|
||||||
|
let encoded = match serde_json::to_string(&payload) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "failed to encode alerts stream payload");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if socket.send(Message::Text(encoded.into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn evaluate_alerts(
|
||||||
|
state: &AppState,
|
||||||
|
config: AlertRuleConfig,
|
||||||
|
) -> Result<Vec<AlertState>, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
let lookback_seconds = config.lookback_seconds.unwrap_or(900).clamp(60, 86_400);
|
||||||
|
let timeout_threshold = config.timeout_threshold.unwrap_or(3).max(1);
|
||||||
|
let auth_rejected_threshold = config.auth_rejected_threshold.unwrap_or(3).max(1);
|
||||||
|
let enroll_rejected_threshold = config.enroll_rejected_threshold.unwrap_or(5).max(1);
|
||||||
|
|
||||||
let now = now_unix();
|
let now = now_unix();
|
||||||
let since_unix = now.saturating_sub(lookback_seconds);
|
let since_unix = now.saturating_sub(lookback_seconds);
|
||||||
@@ -98,7 +215,7 @@ pub async fn active_alerts(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let alerts = build_alerts(
|
Ok(build_alerts(
|
||||||
now,
|
now,
|
||||||
&enrolled_agents,
|
&enrolled_agents,
|
||||||
&connected_agents,
|
&connected_agents,
|
||||||
@@ -108,9 +225,7 @@ pub async fn active_alerts(
|
|||||||
timeout_threshold,
|
timeout_threshold,
|
||||||
auth_rejected_threshold,
|
auth_rejected_threshold,
|
||||||
enroll_rejected_threshold,
|
enroll_rejected_threshold,
|
||||||
);
|
))
|
||||||
|
|
||||||
Ok((StatusCode::OK, Json(alerts)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_alerts(
|
fn build_alerts(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ mod audit;
|
|||||||
mod alerts;
|
mod alerts;
|
||||||
|
|
||||||
pub use commands::{list_agents, run_command};
|
pub use commands::{list_agents, run_command};
|
||||||
pub use alerts::active_alerts;
|
pub use alerts::{active_alerts, alert_history, alerts_stream};
|
||||||
pub use audit::list_audit_events;
|
pub use audit::list_audit_events;
|
||||||
pub use control::{
|
pub use control::{
|
||||||
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeEnrollTokenResponse, StateStatsResponse,
|
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeEnrollTokenResponse, StateStatsResponse,
|
||||||
|
|||||||
@@ -40,6 +40,33 @@ pub enum AgentReply {
|
|||||||
Error(ErrorPayload),
|
Error(ErrorPayload),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn public_api_routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/healthz", get(api::healthz))
|
||||||
|
.route("/api/v1/agents/enroll", post(api::enroll))
|
||||||
|
.route("/api/v1/agent/ws", get(ws::agent_ws))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn control_api_routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/v1/control/enroll-token", post(api::issue_enroll_token))
|
||||||
|
.route("/api/v1/control/enroll-tokens", get(api::list_enroll_tokens))
|
||||||
|
.route(
|
||||||
|
"/api/v1/control/enroll-tokens/{token}",
|
||||||
|
axum::routing::delete(api::revoke_enroll_token),
|
||||||
|
)
|
||||||
|
.route("/api/v1/control/state-stats", get(api::state_stats))
|
||||||
|
.route("/api/v1/control/audit/events", get(api::list_audit_events))
|
||||||
|
.route("/api/v1/control/alerts", get(api::active_alerts))
|
||||||
|
.route("/api/v1/control/alerts/history", get(api::alert_history))
|
||||||
|
.route("/api/v1/control/alerts/ws", get(api::alerts_stream))
|
||||||
|
.route("/api/v1/control/agents", get(api::list_agents))
|
||||||
|
.route(
|
||||||
|
"/api/v1/control/agents/{agent_id}/command",
|
||||||
|
post(api::run_command),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Starts the control-plane HTTP and websocket surfaces and manages daemon lifecycle hooks.
|
/// Starts the control-plane HTTP and websocket surfaces and manages daemon lifecycle hooks.
|
||||||
pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
||||||
write_pid_file(&daemon.pid_file)?;
|
write_pid_file(&daemon.pid_file)?;
|
||||||
@@ -62,24 +89,12 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
|||||||
enroll_token_ttl: daemon.enroll_token_ttl,
|
enroll_token_ttl: daemon.enroll_token_ttl,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Keep route classes explicit so edge policy can map directly:
|
||||||
|
// - public_api_routes: intended internet-facing agent endpoints
|
||||||
|
// - control_api_routes: intended admin-only endpoints behind Access
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/healthz", get(api::healthz))
|
.merge(public_api_routes())
|
||||||
.route("/api/v1/agents/enroll", post(api::enroll))
|
.merge(control_api_routes())
|
||||||
.route("/api/v1/control/enroll-token", post(api::issue_enroll_token))
|
|
||||||
.route("/api/v1/control/enroll-tokens", get(api::list_enroll_tokens))
|
|
||||||
.route(
|
|
||||||
"/api/v1/control/enroll-tokens/{token}",
|
|
||||||
axum::routing::delete(api::revoke_enroll_token),
|
|
||||||
)
|
|
||||||
.route("/api/v1/control/state-stats", get(api::state_stats))
|
|
||||||
.route("/api/v1/control/audit/events", get(api::list_audit_events))
|
|
||||||
.route("/api/v1/control/alerts", get(api::active_alerts))
|
|
||||||
.route("/api/v1/agent/ws", get(ws::agent_ws))
|
|
||||||
.route("/api/v1/control/agents", get(api::list_agents))
|
|
||||||
.route(
|
|
||||||
"/api/v1/control/agents/{agent_id}/command",
|
|
||||||
post(api::run_command),
|
|
||||||
)
|
|
||||||
.with_state(app_state.clone());
|
.with_state(app_state.clone());
|
||||||
|
|
||||||
info!(bind = %daemon.bind, data_dir = %daemon.data_dir.display(), pid_file = %daemon.pid_file.display(), state_file = %daemon.state_file.display(), "starting control-plane server");
|
info!(bind = %daemon.bind, data_dir = %daemon.data_dir.display(), pid_file = %daemon.pid_file.display(), state_file = %daemon.state_file.display(), "starting control-plane server");
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ use tracing::{debug, info, warn};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::state::types::{
|
use crate::state::types::{
|
||||||
AuditEvent, AuditEventFilter, AuditEventInput, EnrollTokenInfo, IssuedAgent, IssuedEnrollToken,
|
AlertState, AlertTransition, AuditEvent, AuditEventFilter, AuditEventInput, EnrollTokenInfo,
|
||||||
StateStats,
|
IssuedAgent, IssuedEnrollToken, StateStats,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct Store {
|
pub struct Store {
|
||||||
@@ -16,6 +16,8 @@ pub struct Store {
|
|||||||
enroll_tokens: sled::Tree,
|
enroll_tokens: sled::Tree,
|
||||||
agents: sled::Tree,
|
agents: sled::Tree,
|
||||||
audit_events: sled::Tree,
|
audit_events: sled::Tree,
|
||||||
|
active_alerts: sled::Tree,
|
||||||
|
alert_transitions: sled::Tree,
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCHEMA_VERSION_KEY: &[u8] = b"schema_version";
|
const SCHEMA_VERSION_KEY: &[u8] = b"schema_version";
|
||||||
@@ -39,6 +41,12 @@ impl Store {
|
|||||||
let audit_events_tree = db
|
let audit_events_tree = db
|
||||||
.open_tree("audit_events")
|
.open_tree("audit_events")
|
||||||
.context("failed to open audit_events tree")?;
|
.context("failed to open audit_events tree")?;
|
||||||
|
let active_alerts_tree = db
|
||||||
|
.open_tree("active_alerts")
|
||||||
|
.context("failed to open active_alerts tree")?;
|
||||||
|
let alert_transitions_tree = db
|
||||||
|
.open_tree("alert_transitions")
|
||||||
|
.context("failed to open alert_transitions tree")?;
|
||||||
|
|
||||||
let store = Self {
|
let store = Self {
|
||||||
db_path,
|
db_path,
|
||||||
@@ -46,6 +54,8 @@ impl Store {
|
|||||||
enroll_tokens: enroll_tree,
|
enroll_tokens: enroll_tree,
|
||||||
agents: agents_tree,
|
agents: agents_tree,
|
||||||
audit_events: audit_events_tree,
|
audit_events: audit_events_tree,
|
||||||
|
active_alerts: active_alerts_tree,
|
||||||
|
alert_transitions: alert_transitions_tree,
|
||||||
};
|
};
|
||||||
|
|
||||||
store.ensure_schema_version()?;
|
store.ensure_schema_version()?;
|
||||||
@@ -275,6 +285,111 @@ impl Store {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn sync_alert_transitions(
|
||||||
|
&self,
|
||||||
|
current: &[AlertState],
|
||||||
|
) -> Result<Vec<AlertTransition>> {
|
||||||
|
let mut previous = std::collections::HashMap::<String, AlertState>::new();
|
||||||
|
for item in self.active_alerts.iter() {
|
||||||
|
let (_, raw) = item.context("failed iterating active_alerts tree")?;
|
||||||
|
let alert: AlertState =
|
||||||
|
serde_json::from_slice(raw.as_ref()).context("failed decoding active alert")?;
|
||||||
|
previous.insert(alert.alert_id.clone(), alert);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut current_map = std::collections::HashMap::<String, AlertState>::new();
|
||||||
|
for alert in current {
|
||||||
|
current_map.insert(alert.alert_id.clone(), alert.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = now_unix();
|
||||||
|
let mut transitions = Vec::new();
|
||||||
|
|
||||||
|
for (alert_id, current_alert) in ¤t_map {
|
||||||
|
if previous.contains_key(alert_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
transitions.push(AlertTransition {
|
||||||
|
transition_id: format!("atr-{}", Uuid::new_v4()),
|
||||||
|
ts_unix: now,
|
||||||
|
alert_id: current_alert.alert_id.clone(),
|
||||||
|
kind: current_alert.kind.clone(),
|
||||||
|
agent_id: current_alert.agent_id.clone(),
|
||||||
|
from_status: None,
|
||||||
|
to_status: "active".into(),
|
||||||
|
message: current_alert.message.clone(),
|
||||||
|
metadata: current_alert.metadata.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (alert_id, previous_alert) in &previous {
|
||||||
|
if current_map.contains_key(alert_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
transitions.push(AlertTransition {
|
||||||
|
transition_id: format!("atr-{}", Uuid::new_v4()),
|
||||||
|
ts_unix: now,
|
||||||
|
alert_id: previous_alert.alert_id.clone(),
|
||||||
|
kind: previous_alert.kind.clone(),
|
||||||
|
agent_id: previous_alert.agent_id.clone(),
|
||||||
|
from_status: Some("active".into()),
|
||||||
|
to_status: "resolved".into(),
|
||||||
|
message: format!("resolved alert {}", previous_alert.alert_id),
|
||||||
|
metadata: previous_alert.metadata.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in self.active_alerts.iter() {
|
||||||
|
let (key, _) = item.context("failed iterating active_alerts keys")?;
|
||||||
|
self.active_alerts
|
||||||
|
.remove(key)
|
||||||
|
.context("failed clearing active alert snapshot")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for alert in current {
|
||||||
|
let key = alert.alert_id.as_bytes();
|
||||||
|
let value = serde_json::to_vec(alert).context("failed encoding active alert")?;
|
||||||
|
self.active_alerts
|
||||||
|
.insert(key, value)
|
||||||
|
.context("failed writing active alert snapshot")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for transition in &transitions {
|
||||||
|
let key = format!("{:020}:{}", transition.ts_unix, transition.transition_id);
|
||||||
|
let value =
|
||||||
|
serde_json::to_vec(transition).context("failed encoding alert transition")?;
|
||||||
|
self.alert_transitions
|
||||||
|
.insert(key.as_bytes(), value)
|
||||||
|
.context("failed persisting alert transition")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.flush()
|
||||||
|
.context("failed flushing state db after alert sync")?;
|
||||||
|
Ok(transitions)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_alert_transitions(
|
||||||
|
&self,
|
||||||
|
since_unix: Option<u64>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<AlertTransition>> {
|
||||||
|
let limit = limit.clamp(1, 500);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for item in self.alert_transitions.iter().rev() {
|
||||||
|
let (_, raw) = item.context("failed iterating alert transition tree")?;
|
||||||
|
let transition: AlertTransition =
|
||||||
|
serde_json::from_slice(raw.as_ref()).context("failed decoding alert transition")?;
|
||||||
|
if let Some(since) = since_unix && transition.ts_unix < since {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(transition);
|
||||||
|
if out.len() >= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
fn flush(&self) -> Result<()> {
|
fn flush(&self) -> Result<()> {
|
||||||
self.enroll_tokens
|
self.enroll_tokens
|
||||||
.flush()
|
.flush()
|
||||||
@@ -285,6 +400,12 @@ impl Store {
|
|||||||
self.audit_events
|
self.audit_events
|
||||||
.flush()
|
.flush()
|
||||||
.context("failed to flush audit event tree")?;
|
.context("failed to flush audit event tree")?;
|
||||||
|
self.active_alerts
|
||||||
|
.flush()
|
||||||
|
.context("failed to flush active alerts tree")?;
|
||||||
|
self.alert_transitions
|
||||||
|
.flush()
|
||||||
|
.context("failed to flush alert transitions tree")?;
|
||||||
debug!(path = %self.db_path.display(), "flushed sled state db");
|
debug!(path = %self.db_path.display(), "flushed sled state db");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -561,4 +682,42 @@ mod tests {
|
|||||||
assert_eq!(filtered[0].request_id.as_deref(), Some("req-1"));
|
assert_eq!(filtered[0].request_id.as_deref(), Some("req-1"));
|
||||||
cleanup_dir(&dir);
|
cleanup_dir(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn alert_transitions_track_open_and_resolve() {
|
||||||
|
let (store, dir) = make_store().await;
|
||||||
|
let alert = crate::state::AlertState {
|
||||||
|
alert_id: "agent_offline:agent-a".into(),
|
||||||
|
kind: "agent_offline".into(),
|
||||||
|
severity: "warning".into(),
|
||||||
|
status: "active".into(),
|
||||||
|
agent_id: Some("agent-a".into()),
|
||||||
|
message: "agent agent-a offline".into(),
|
||||||
|
value: 1,
|
||||||
|
threshold: 1,
|
||||||
|
last_seen_unix: 10,
|
||||||
|
metadata: serde_json::json!({}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let opened = store
|
||||||
|
.sync_alert_transitions(std::slice::from_ref(&alert))
|
||||||
|
.await
|
||||||
|
.expect("open transition should succeed");
|
||||||
|
assert_eq!(opened.len(), 1);
|
||||||
|
assert_eq!(opened[0].to_status, "active");
|
||||||
|
|
||||||
|
let resolved = store
|
||||||
|
.sync_alert_transitions(&[])
|
||||||
|
.await
|
||||||
|
.expect("resolve transition should succeed");
|
||||||
|
assert_eq!(resolved.len(), 1);
|
||||||
|
assert_eq!(resolved[0].to_status, "resolved");
|
||||||
|
|
||||||
|
let history = store
|
||||||
|
.list_alert_transitions(None, 10)
|
||||||
|
.await
|
||||||
|
.expect("history should load");
|
||||||
|
assert!(history.len() >= 2);
|
||||||
|
cleanup_dir(&dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,3 +83,16 @@ pub struct AlertState {
|
|||||||
pub last_seen_unix: u64,
|
pub last_seen_unix: u64,
|
||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AlertTransition {
|
||||||
|
pub transition_id: String,
|
||||||
|
pub ts_unix: u64,
|
||||||
|
pub alert_id: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub agent_id: Option<String>,
|
||||||
|
pub from_status: Option<String>,
|
||||||
|
pub to_status: String,
|
||||||
|
pub message: String,
|
||||||
|
pub metadata: serde_json::Value,
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user