even more tests and docs and reorg

This commit is contained in:
lda
2026-04-11 23:18:01 +07:00 Unverified
parent 1acf4f17c9
commit f17111a239
6 changed files with 193 additions and 60 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
linker = "rust-lld" linker = "rust-lld"
[build] [build]
target = "armv7-unknown-linux-musleabihf" # target = "armv7-unknown-linux-musleabihf"
[alias] [alias]
cl = "c --target=armv7-unknown-linux-musleabihf" cl = "c --target=armv7-unknown-linux-musleabihf"
+11 -2
View File
@@ -44,8 +44,10 @@ This checkpoint captures the current state after control-plane migration, loggin
- Control-plane state backend moved from JSON snapshot to embedded `sled` DB. - Control-plane state backend moved from JSON snapshot to embedded `sled` DB.
- Default state path changed to `/var/lib/wakey-control-plane/state.db`. - Default state path changed to `/var/lib/wakey-control-plane/state.db`.
- Legacy JSON migration support exists: - Legacy JSON migration support has been removed; sled is now the only supported
- if a `.json` path is configured and DB is empty, tokens/agents are migrated into DB. state format.
- Enroll tokens include persisted expiry timestamps and are validated on enroll.
- Periodic and explicit garbage collection remove expired tokens.
## Operator Commands ## Operator Commands
@@ -75,6 +77,13 @@ wakey-agent serve --config /etc/wakey-agent/config.toml
- `cargo check --workspace` - `cargo check --workspace`
- `cargo clippy --workspace` - `cargo clippy --workspace`
## Test Coverage Added In This Pass
- Unit tests in `wakey-control-plane/src/state/store.rs` now cover:
- expired-token garbage collection removes persisted stale tokens
- enroll rejects expired tokens and consumes stale entries
- state stats counters for agents and expired token totals
## Known Tradeoffs / Follow-ups ## Known Tradeoffs / Follow-ups
- Reload semantics with `sled` are now mostly no-op for in-memory state (data is durable in DB). - Reload semantics with `sled` are now mostly no-op for in-memory state (data is durable in DB).
+10
View File
@@ -136,6 +136,8 @@ State is persisted in an embedded `sled` database (default
Relative paths in config are resolved under `data_dir`. Relative paths in config are resolved under `data_dir`.
Enroll tokens are now expiring and revocable. Issuance returns `expires_at_unix`. Enroll tokens are now expiring and revocable. Issuance returns `expires_at_unix`.
Expired tokens are rejected on enroll and can be garbage-collected periodically
or on demand.
### Quick start ### Quick start
@@ -270,6 +272,14 @@ cargo test --no-run
cargo clippy --all-targets --all-features -- -D warnings cargo clippy --all-targets --all-features -- -D warnings
``` ```
Focused control-plane state tests:
```sh
cargo test -p wakey-control-plane state::store::tests::gc_removes_expired_tokens
cargo test -p wakey-control-plane state::store::tests::enroll_rejects_expired_token
cargo test -p wakey-control-plane state::store::tests::stats_counts_agents_and_expired_tokens
```
### On-device ### On-device
Some integration tests are intentionally `#[ignore]` because they use real Some integration tests are intentionally `#[ignore]` because they use real
+6 -57
View File
@@ -1,5 +1,4 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -18,10 +17,14 @@ use crate::state;
use crate::ws; use crate::ws;
mod admin; mod admin;
mod process;
pub use admin::{ pub use admin::{
issue_enroll_token, list_enroll_tokens, revoke_enroll_token, state_stats, issue_enroll_token, list_enroll_tokens, revoke_enroll_token, state_stats,
}; };
pub use process::reload_daemon;
use process::{remove_pid_file, write_pid_file};
/// Shared state for HTTP handlers and websocket relay paths.
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub store: Arc<state::Store>, pub store: Arc<state::Store>,
@@ -37,6 +40,7 @@ pub enum AgentReply {
Error(ErrorPayload), Error(ErrorPayload),
} }
/// 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)?;
info!(pid_file = %daemon.pid_file.display(), "wrote control-plane pid file"); info!(pid_file = %daemon.pid_file.display(), "wrote control-plane pid file");
@@ -91,6 +95,7 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
let mut gc_tick = tokio::time::interval(Duration::from_secs(300)); let mut gc_tick = tokio::time::interval(Duration::from_secs(300));
gc_tick.set_missed_tick_behavior(MissedTickBehavior::Skip); gc_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
// Unix daemon loop: shutdown signal, config reload trigger, and periodic maintenance.
loop { loop {
tokio::select! { tokio::select! {
_ = tokio::signal::ctrl_c() => { _ = tokio::signal::ctrl_c() => {
@@ -133,59 +138,3 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
let _ = remove_pid_file(&daemon.pid_file); let _ = remove_pid_file(&daemon.pid_file);
Ok(()) Ok(())
} }
pub fn reload_daemon(pid_file: &Path) -> Result<()> {
let pid = read_pid(pid_file)?;
info!(pid, pid_file = %pid_file.display(), "sending control-plane reload signal");
send_hup(pid)
}
fn write_pid_file(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create pid dir {}", parent.display()))?;
}
std::fs::write(path, format!("{}\n", std::process::id()))
.with_context(|| format!("failed to write pid file {}", path.display()))
}
fn remove_pid_file(path: &Path) -> Result<()> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => {
Err(err).with_context(|| format!("failed to remove pid file {}", path.display()))
}
}
}
fn read_pid(path: &Path) -> Result<i32> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("failed to read pid file {}", path.display()))?;
let pid = raw
.trim()
.parse::<i32>()
.with_context(|| format!("invalid pid in {}", path.display()))?;
if pid <= 0 {
anyhow::bail!("invalid non-positive pid {pid}");
}
Ok(pid)
}
fn send_hup(pid: i32) -> Result<()> {
#[cfg(unix)]
{
use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid;
kill(Pid::from_raw(pid), Signal::SIGHUP)
.with_context(|| format!("failed to send SIGHUP to pid {pid}"))?;
Ok(())
}
#[cfg(not(unix))]
{
let _ = pid;
anyhow::bail!("reload is only supported on Unix (SIGHUP unavailable on this platform)")
}
}
@@ -0,0 +1,60 @@
use std::path::Path;
use anyhow::{Context, Result};
use tracing::info;
pub fn reload_daemon(pid_file: &Path) -> Result<()> {
let pid = read_pid(pid_file)?;
info!(pid, pid_file = %pid_file.display(), "sending control-plane reload signal");
send_hup(pid)
}
pub fn write_pid_file(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create pid dir {}", parent.display()))?;
}
std::fs::write(path, format!("{}\n", std::process::id()))
.with_context(|| format!("failed to write pid file {}", path.display()))
}
pub fn remove_pid_file(path: &Path) -> Result<()> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => {
Err(err).with_context(|| format!("failed to remove pid file {}", path.display()))
}
}
}
fn read_pid(path: &Path) -> Result<i32> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("failed to read pid file {}", path.display()))?;
let pid = raw
.trim()
.parse::<i32>()
.with_context(|| format!("invalid pid in {}", path.display()))?;
if pid <= 0 {
anyhow::bail!("invalid non-positive pid {pid}");
}
Ok(pid)
}
fn send_hup(pid: i32) -> Result<()> {
#[cfg(unix)]
{
use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid;
kill(Pid::from_raw(pid), Signal::SIGHUP)
.with_context(|| format!("failed to send SIGHUP to pid {pid}"))?;
Ok(())
}
#[cfg(not(unix))]
{
let _ = pid;
anyhow::bail!("reload is only supported on Unix (SIGHUP unavailable on this platform)")
}
}
+105
View File
@@ -312,3 +312,108 @@ fn decode_schema(raw: &[u8]) -> Result<u32> {
arr.copy_from_slice(raw); arr.copy_from_slice(raw);
Ok(u32::from_le_bytes(arr)) Ok(u32::from_le_bytes(arr))
} }
#[cfg(test)]
mod tests {
use std::fs;
use std::time::Duration;
use super::Store;
async fn make_store() -> (Store, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("wakey-cp-store-test-{}", uuid::Uuid::new_v4()));
let db_path = dir.join("state.db");
let store = Store::load_or_init(&db_path, Vec::new(), Duration::from_secs(60))
.await
.expect("store should initialize");
(store, dir)
}
fn cleanup_dir(path: &std::path::Path) {
let _ = fs::remove_dir_all(path);
}
#[tokio::test]
async fn gc_removes_expired_tokens() {
let (store, dir) = make_store().await;
let key = b"enr-expired-gc-test";
let expired = 1u64.to_le_bytes();
store
.enroll_tokens
.insert(key, &expired)
.expect("insert should succeed");
let removed = store
.gc_expired_enroll_tokens()
.await
.expect("gc should succeed");
assert_eq!(removed, 1);
assert!(
store
.enroll_tokens
.get(key)
.expect("read should succeed")
.is_none()
);
cleanup_dir(&dir);
}
#[tokio::test]
async fn enroll_rejects_expired_token() {
let (store, dir) = make_store().await;
let key = b"enr-expired-enroll-test";
let expired = 1u64.to_le_bytes();
store
.enroll_tokens
.insert(key, &expired)
.expect("insert should succeed");
let err = store
.enroll("enr-expired-enroll-test")
.await
.expect_err("expired token should be rejected");
assert!(err.to_string().contains("expired"));
assert!(
store
.enroll_tokens
.get(key)
.expect("read should succeed")
.is_none()
);
cleanup_dir(&dir);
}
#[tokio::test]
async fn stats_counts_agents_and_expired_tokens() {
let (store, dir) = make_store().await;
store
.enroll_tokens
.insert(b"enr-valid-test", &(u64::MAX - 10).to_le_bytes())
.expect("insert valid should succeed");
let _issued = store
.issue_enroll_token(Duration::from_secs(60))
.await
.expect("issue should succeed");
store
.enroll_tokens
.insert(b"enr-expired-stats-test", &1u64.to_le_bytes())
.expect("insert expired should succeed");
let issued_agent = store
.enroll("enr-valid-test")
.await
.expect("enroll should succeed for valid token");
assert!(!issued_agent.agent_id.is_empty());
let stats = store.stats().await.expect("stats should succeed");
assert_eq!(stats.agent_count, 1);
assert_eq!(stats.enroll_token_count, 2);
assert_eq!(stats.expired_enroll_token_count, 1);
cleanup_dir(&dir);
}
}