refactor: consolidate crate functions #50
328 changed files with 4102 additions and 40297 deletions
2262
Cargo.lock
generated
2262
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
22
Cargo.toml
22
Cargo.toml
|
|
@ -1,23 +1,20 @@
|
|||
[workspace]
|
||||
members = [
|
||||
".",
|
||||
"crates/python",
|
||||
"crates/wasm/runtime",
|
||||
"crates/bin-runner",
|
||||
"crates/bindings/python",
|
||||
"crates/bindings/wasm-runtime",
|
||||
"crates/simulation",
|
||||
"crates/dashboard",
|
||||
"crates/distribution",
|
||||
"crates/std",
|
||||
"crates/process",
|
||||
"crates/datastore",
|
||||
"crates/shared-types",
|
||||
"crates/swactor-node",
|
||||
"crates/streams",
|
||||
"crates/transport",
|
||||
"crates/node",
|
||||
"tests/docker",
|
||||
"crates/ci",
|
||||
"tests/integration",
|
||||
"xtask",
|
||||
]
|
||||
exclude = ["tools/depgraph", "crates/wasm/crypto"]
|
||||
exclude = ["crates/bindings/wasm-crypto"]
|
||||
|
||||
[package]
|
||||
name = "swactor"
|
||||
|
|
@ -33,7 +30,8 @@ strip = false
|
|||
crate-type = ["rlib"]
|
||||
|
||||
[features]
|
||||
default = ["getrandom"]
|
||||
default = ["getrandom", "std"]
|
||||
std = [] # OTP patterns: supervisors, registries, timers, routers
|
||||
getrandom = ["dep:getrandom"]
|
||||
serde = ["dep:serde"]
|
||||
tracing = ["dep:tracing"]
|
||||
|
|
@ -54,7 +52,6 @@ crossbeam-utils = "0.8.21"
|
|||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
proptest = "1"
|
||||
proptest-state-machine = "0.3"
|
||||
swactor-std = { path = "crates/std" }
|
||||
|
||||
[[bench]]
|
||||
name = "runtime_benchmarks"
|
||||
|
|
@ -68,6 +65,3 @@ harness = false
|
|||
name = "hasher_benchmarks"
|
||||
harness = false
|
||||
|
||||
[[example]]
|
||||
name = "tcp_ping_pong"
|
||||
required-features = ["transport"]
|
||||
|
|
|
|||
154
README.md
154
README.md
|
|
@ -1,152 +1,24 @@
|
|||
# swactor
|
||||
|
||||
Minimal actor runtime for Rust. Single-threaded or multi-threaded, with
|
||||
Python and WebAssembly bindings.
|
||||
Minimal actor runtime for Rust. One trait, one message type.
|
||||
Single-threaded (`tick()`) or multi-threaded (`run()`).
|
||||
|
||||
## Quick Start
|
||||
## Description
|
||||
|
||||
```rust
|
||||
use swactor::actor::{ActorAddress, ActorInterface};
|
||||
use swactor::runtime::{Ctx, Runtime, RuntimeConfig};
|
||||
Core runtime is `src/`. Actors implement `ActorInterface` (in `actor.rs`),
|
||||
interact through `Ctx` (in `runtime.rs`), and run on worker threads (`worker.rs`).
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Greet { name: String, reply_to: ActorAddress }
|
||||
`crates/` builds upward: `std` adds OTP patterns (supervision, monitoring, groups),
|
||||
`distribution` adds clustering, everything else composes from there.
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Greeting(String);
|
||||
## Dev commands
|
||||
|
||||
struct Greeter;
|
||||
`cargo xtask --help` for available test groups.
|
||||
|
||||
impl ActorInterface for Greeter {
|
||||
type Incoming = Greet;
|
||||
type Response = Greeting;
|
||||
## Testing
|
||||
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Greet) {
|
||||
let _ = ctx.send(msg.reply_to, Greeting(format!("Hello, {}!", msg.name)));
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let rt = Runtime::new(RuntimeConfig::default());
|
||||
let addr = rt.spawn(Greeter).unwrap();
|
||||
let inbox = rt.new_inbox::<Greeting>().unwrap();
|
||||
|
||||
rt.send_to(addr, Greet { name: "world".into(), reply_to: *inbox.addr() }).unwrap();
|
||||
rt.tick();
|
||||
rt.tick();
|
||||
|
||||
println!("{}", inbox.try_recv().unwrap().0); // "Hello, world!"
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Actor Model
|
||||
|
||||
Actors implement one trait (`ActorInterface`), receive one message type, and
|
||||
hold mutable state. No lifecycle hooks, no supervision trees, no async.
|
||||
|
||||
Every actor gets a 32-byte globally unique `ActorAddress`. The same
|
||||
`ctx.send(addr, msg)` call works whether the target is on the same worker,
|
||||
a different worker thread, an external inbox, or a remote process.
|
||||
|
||||
Single-threaded mode (`rt.tick()`) gives deterministic frame-level control.
|
||||
Multi-threaded mode (`rt.run()`) spawns OS threads with adaptive backoff.
|
||||
|
||||
See [docs/runtime/actor-model.md](docs/runtime/actor-model.md) and
|
||||
[docs/runtime/runtime.md](docs/runtime/runtime.md) for the full model.
|
||||
|
||||
### Transport
|
||||
|
||||
Pluggable cross-process messaging. User-provided codecs handle serialization
|
||||
(gRPC/protobuf, bincode, hand-rolled — no serde bounds imposed) and
|
||||
user-provided transports handle delivery (TCP, in-memory, gRPC channel).
|
||||
|
||||
```bash
|
||||
cargo build --features transport
|
||||
cargo run --example tcp_ping_pong --features transport -- receiver # terminal 1
|
||||
cargo run --example tcp_ping_pong --features transport -- sender # terminal 2
|
||||
cargo check --workspace
|
||||
cargo xtask test <your-feature-crate>
|
||||
cargo xtask test essential
|
||||
```
|
||||
|
||||
See [docs/distribution/transport.md](docs/distribution/transport.md) for the routing chain, codec
|
||||
registry, and address resolution.
|
||||
|
||||
### Runtime Dashboard
|
||||
|
||||
Live web dashboard for monitoring actors, message throughput, and mailbox
|
||||
depths. Supports trace recording and replay at configurable speed.
|
||||
|
||||
Includes hand-authored SVG diagrams (actor lifecycle, message lifecycle,
|
||||
tick cycle, transport routing) and generated diagrams from DOT sources
|
||||
(architecture, dataflow, type erasure).
|
||||
|
||||
See [crates/runtime-dashboard/](crates/runtime-dashboard/README.md).
|
||||
|
||||
### Language Bindings
|
||||
|
||||
**Python** — PyO3 via Maturin. Spawn actors from Python callables, pass
|
||||
dicts as messages, single-threaded or multi-threaded.
|
||||
|
||||
```bash
|
||||
cd crates/swactor-python && maturin develop
|
||||
```
|
||||
|
||||
Examples in `examples/python/` (single-thread, async, Jupyter notebook).
|
||||
|
||||
**WASM** — wasm-bindgen. Runs single-threaded with deterministic addressing
|
||||
(`no_random` feature).
|
||||
|
||||
```bash
|
||||
cd crates/swactor-wasm && wasm-pack build --target nodejs
|
||||
```
|
||||
|
||||
### Connectome Analysis
|
||||
|
||||
Structural analysis of the internal dependency graph.
|
||||
|
||||
- **depgraph** (`tools/depgraph/`) — AST-based extraction of module
|
||||
dependencies, outputs GraphViz DOT
|
||||
- **spectral** (`tools/spectral/`) — Laplacian eigenvalue analysis,
|
||||
Connectome Complexity Index (CCI), coupling heatmaps, interactive HTML
|
||||
dashboard
|
||||
|
||||
```bash
|
||||
cargo run --manifest-path tools/depgraph/Cargo.toml -- --src-dir src/ --output deps
|
||||
python tools/spectral/spectral_analysis.py deps.dot
|
||||
```
|
||||
|
||||
See [docs/connectome/connectome.md](docs/connectome/connectome.md) for metric interpretation.
|
||||
|
||||
## Building & Testing
|
||||
|
||||
```bash
|
||||
cargo test # all tests
|
||||
cargo test --features transport # include transport tests
|
||||
cargo run --example hello # single actor example
|
||||
cargo run --example ring # 500-actor ring topology
|
||||
cargo bench # benchmarks (criterion)
|
||||
```
|
||||
|
||||
## Feature Flags
|
||||
|
||||
| Flag | Default | What it does |
|
||||
|------|---------|--------------|
|
||||
| `getrandom` | yes | System RNG for actor addresses |
|
||||
| `no_random` | no | Deterministic counter (WASM / reproducible tests) |
|
||||
| `transport` | no | Pluggable remote messaging (codec + transport) |
|
||||
| `tracing` | no | `tracing` instrumentation for runtime internals |
|
||||
| `serde` | no | Serde derives for stats types |
|
||||
| `python` | no | PyO3 bindings (cdylib wheel) |
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Covers |
|
||||
|----------|--------|
|
||||
| [Actor Model](docs/runtime/actor-model.md) | Traits, type erasure, addresses |
|
||||
| [Runtime](docs/runtime/runtime.md) | Runtime, Ctx, Inbox, RuntimeHandle, stats |
|
||||
| [Worker Thread](docs/runtime/worker-thread.md) | Tick phases, backoff, routing, full system topology |
|
||||
| [Channels](docs/runtime/channels.md) | HybridChannel, AddressMap, Placement |
|
||||
| [Transport](docs/distribution/transport.md) | Codec, Transport, remote messaging, address resolution |
|
||||
| [Distribution](docs/distribution/distribution.md) | SWIM membership, Kademlia, NodeDriver |
|
||||
| [Connectome](docs/connectome/connectome.md) | CCI metrics, spectral analysis interpretation |
|
||||
| [Dashboard](crates/runtime-dashboard/README.md) | Live web UI, trace recording, diagram index |
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use swactor::{
|
|||
config::RuntimeConfig,
|
||||
runtime::{Ctx, Runtime},
|
||||
};
|
||||
use swactor_std::{RuntimeGroups, RuntimeNaming, StdExtension};
|
||||
use swactor::std::{RuntimeGroups, RuntimeNaming, StdExtension};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
[package]
|
||||
name = "swactor-bin-runner"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
swactor = { path = "../.." }
|
||||
wasmtime = "29"
|
||||
|
||||
[dev-dependencies]
|
||||
swactor = { path = "../..", features = ["getrandom"] }
|
||||
swactor-std = { path = "../std" }
|
||||
wat = "1"
|
||||
proptest = "1"
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let guests_dir = Path::new(manifest_dir).join("tests/guests");
|
||||
|
||||
for guest in &["echo", "double", "silent"] {
|
||||
let guest_dir = guests_dir.join(guest);
|
||||
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
guest_dir.join("src/lib.rs").display()
|
||||
);
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
guest_dir.join("Cargo.toml").display()
|
||||
);
|
||||
|
||||
let status = Command::new("cargo")
|
||||
.args(["build", "--target", "wasm32-unknown-unknown", "--release"])
|
||||
.current_dir(&guest_dir)
|
||||
.status()
|
||||
.unwrap_or_else(|e| panic!("failed to run cargo build for {guest} guest: {e}"));
|
||||
|
||||
assert!(status.success(), "failed to build {guest} guest");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
use swactor::actor::{ActorInterface, Ctx};
|
||||
use wasmtime::{Memory, Store, TypedFunc};
|
||||
|
||||
use crate::ByteMessage;
|
||||
|
||||
/// State accessible to host functions during guest execution.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct HostState {
|
||||
pub outbox: Vec<(swactor::actor::ActorAddress, Vec<u8>)>,
|
||||
}
|
||||
|
||||
/// An actor whose logic is defined by a WebAssembly guest module.
|
||||
///
|
||||
/// Messages arrive as [`ByteMessage`], are copied into Wasm linear memory,
|
||||
/// and processed by the guest's `handle` export. The guest can send messages
|
||||
/// back via the `swactor.send` host import.
|
||||
pub struct WasmActor {
|
||||
pub(crate) store: Store<HostState>,
|
||||
pub(crate) memory: Memory,
|
||||
pub(crate) alloc: TypedFunc<i32, i32>,
|
||||
pub(crate) handle: TypedFunc<(i32, i32), ()>,
|
||||
}
|
||||
|
||||
impl ActorInterface for WasmActor {
|
||||
type Incoming = ByteMessage;
|
||||
type Response = ();
|
||||
|
||||
fn handle(&mut self, ctx: &Ctx, msg: ByteMessage) {
|
||||
let bytes = &msg.0;
|
||||
let len: i32 = match i32::try_from(bytes.len()) {
|
||||
Ok(n) => n,
|
||||
Err(_) => return, // message too large for i32 ABI
|
||||
};
|
||||
|
||||
// 1. Allocate space in guest memory
|
||||
let ptr = match self.alloc.call(&mut self.store, len) {
|
||||
Ok(ptr) if ptr < 0 => return, // invalid pointer
|
||||
Ok(0) if len > 0 => return, // OOM — drop message
|
||||
Ok(ptr) => ptr,
|
||||
Err(_) => return, // alloc trapped — drop message
|
||||
};
|
||||
|
||||
// 2. Write message bytes into guest memory
|
||||
let mem = self.memory.data_mut(&mut self.store);
|
||||
let end = (ptr as usize).saturating_add(bytes.len());
|
||||
if end > mem.len() {
|
||||
return; // alloc returned OOB pointer — drop message
|
||||
}
|
||||
mem[ptr as usize..end].copy_from_slice(bytes);
|
||||
|
||||
// 3. Call guest handle
|
||||
if self.handle.call(&mut self.store, (ptr, len)).is_err() {
|
||||
self.store.data_mut().outbox.clear(); // discard sends from incomplete operation
|
||||
return; // handle trapped — drop message, keep actor alive
|
||||
}
|
||||
|
||||
// 4. Drain outbox → send via ctx
|
||||
let outbox: Vec<_> = self.store.data_mut().outbox.drain(..).collect();
|
||||
for (dest, payload) in outbox {
|
||||
let _ = ctx.send(dest, ByteMessage(payload));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
use swactor::actor::ActorAddress;
|
||||
use wasmtime::{Linker, Module, Store, TypedFunc};
|
||||
|
||||
use crate::actor::{HostState, WasmActor};
|
||||
use crate::engine::SharedEngine;
|
||||
use crate::error::WasmActorError;
|
||||
|
||||
/// Compiles a Wasm module and produces a ready-to-use [`WasmActor`].
|
||||
pub struct WasmActorBuilder {
|
||||
engine: SharedEngine,
|
||||
wasm_bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
impl WasmActorBuilder {
|
||||
pub fn new(engine: SharedEngine, wasm_bytes: impl Into<Vec<u8>>) -> Self {
|
||||
Self {
|
||||
engine,
|
||||
wasm_bytes: wasm_bytes.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compile the module, link host functions, and instantiate.
|
||||
pub fn build(self) -> Result<WasmActor, WasmActorError> {
|
||||
let engine = self.engine.inner();
|
||||
let module = Module::new(engine, &self.wasm_bytes)?;
|
||||
|
||||
let mut linker: Linker<HostState> = Linker::new(engine);
|
||||
Self::link_send(&mut linker)?;
|
||||
|
||||
let mut store = Store::new(engine, HostState::default());
|
||||
let instance = linker.instantiate(&mut store, &module)?;
|
||||
|
||||
// Extract required exports
|
||||
let memory = instance
|
||||
.get_memory(&mut store, "memory")
|
||||
.ok_or(WasmActorError::MissingExport("memory"))?;
|
||||
|
||||
let alloc: TypedFunc<i32, i32> = instance
|
||||
.get_typed_func(&mut store, "alloc")
|
||||
.map_err(|_| WasmActorError::MissingExport("alloc"))?;
|
||||
|
||||
let handle: TypedFunc<(i32, i32), ()> = instance
|
||||
.get_typed_func(&mut store, "handle")
|
||||
.map_err(|_| WasmActorError::MissingExport("handle"))?;
|
||||
|
||||
Ok(WasmActor {
|
||||
store,
|
||||
memory,
|
||||
alloc,
|
||||
handle,
|
||||
})
|
||||
}
|
||||
|
||||
/// Link the `swactor.send` host import.
|
||||
fn link_send(linker: &mut Linker<HostState>) -> Result<(), WasmActorError> {
|
||||
linker.func_wrap(
|
||||
"swactor",
|
||||
"send",
|
||||
|mut caller: wasmtime::Caller<'_, HostState>,
|
||||
dest_ptr: i32,
|
||||
payload_ptr: i32,
|
||||
payload_len: i32|
|
||||
-> Result<(), wasmtime::Error> {
|
||||
let mem = caller
|
||||
.get_export("memory")
|
||||
.and_then(|e| e.into_memory())
|
||||
.ok_or_else(|| wasmtime::Error::msg("guest must export memory"))?;
|
||||
let data = mem.data(&caller);
|
||||
let mem_len = data.len();
|
||||
|
||||
// Validate non-negative arguments
|
||||
if dest_ptr < 0 || payload_ptr < 0 || payload_len < 0 {
|
||||
return Err(wasmtime::Error::msg(
|
||||
"negative argument in swactor.send",
|
||||
));
|
||||
}
|
||||
|
||||
let dest_ptr = dest_ptr as usize;
|
||||
let payload_ptr = payload_ptr as usize;
|
||||
let payload_len = payload_len as usize;
|
||||
|
||||
// Bounds-check with overflow protection
|
||||
let dest_end = dest_ptr
|
||||
.checked_add(32)
|
||||
.ok_or_else(|| wasmtime::Error::msg("dest_ptr overflow"))?;
|
||||
let payload_end = payload_ptr
|
||||
.checked_add(payload_len)
|
||||
.ok_or_else(|| wasmtime::Error::msg("payload range overflow"))?;
|
||||
if dest_end > mem_len || payload_end > mem_len {
|
||||
return Err(wasmtime::Error::msg(
|
||||
"out-of-bounds memory access in swactor.send",
|
||||
));
|
||||
}
|
||||
|
||||
// Read 32-byte destination address
|
||||
let mut addr_bytes = [0u8; 32];
|
||||
addr_bytes.copy_from_slice(&data[dest_ptr..dest_end]);
|
||||
let dest = ActorAddress(addr_bytes);
|
||||
|
||||
// Read payload
|
||||
let payload = data[payload_ptr..payload_end].to_vec();
|
||||
|
||||
caller.data_mut().outbox.push((dest, payload));
|
||||
Ok(())
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use wasmtime::Engine;
|
||||
|
||||
/// A shared, cheaply-cloneable Wasm engine.
|
||||
///
|
||||
/// Created once and reused across multiple [`WasmActor`](crate::WasmActor) instances.
|
||||
/// Configured with maximum sandboxing — no threads, no SIMD, no reference types.
|
||||
#[derive(Clone)]
|
||||
pub struct SharedEngine(Arc<Engine>);
|
||||
|
||||
impl std::fmt::Debug for SharedEngine {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_tuple("SharedEngine").field(&"<Engine>").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SharedEngine {
|
||||
/// Create a new engine with sandboxed defaults.
|
||||
pub fn new() -> Result<Self, wasmtime::Error> {
|
||||
let mut config = wasmtime::Config::new();
|
||||
config.wasm_threads(false);
|
||||
config.wasm_simd(false);
|
||||
config.wasm_relaxed_simd(false);
|
||||
config.wasm_reference_types(false);
|
||||
config.wasm_multi_value(false);
|
||||
config.wasm_bulk_memory(true);
|
||||
let engine = Engine::new(&config)?;
|
||||
Ok(Self(Arc::new(engine)))
|
||||
}
|
||||
|
||||
pub(crate) fn inner(&self) -> &Engine {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
use std::fmt;
|
||||
|
||||
/// Errors that can occur when building or running a WasmActor.
|
||||
#[derive(Debug)]
|
||||
pub enum WasmActorError {
|
||||
/// A required export is missing from the Wasm module.
|
||||
MissingExport(&'static str),
|
||||
/// The Wasm module failed to compile or instantiate.
|
||||
Wasmtime(wasmtime::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for WasmActorError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingExport(name) => write!(f, "missing required export: `{name}`"),
|
||||
Self::Wasmtime(e) => write!(f, "wasmtime error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WasmActorError {}
|
||||
|
||||
impl From<wasmtime::Error> for WasmActorError {
|
||||
fn from(e: wasmtime::Error) -> Self {
|
||||
Self::Wasmtime(e)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
mod actor;
|
||||
mod builder;
|
||||
mod engine;
|
||||
mod error;
|
||||
|
||||
pub use actor::WasmActor;
|
||||
pub use builder::WasmActorBuilder;
|
||||
pub use engine::SharedEngine;
|
||||
pub use error::WasmActorError;
|
||||
|
||||
/// A message carrying raw bytes, suitable for passing to/from Wasm guests.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ByteMessage(pub Vec<u8>);
|
||||
7
crates/bin-runner/tests/guests/double/Cargo.lock
generated
7
crates/bin-runner/tests/guests/double/Cargo.lock
generated
|
|
@ -1,7 +0,0 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "double-guest"
|
||||
version = "0.1.0"
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
[workspace]
|
||||
|
||||
[package]
|
||||
name = "double-guest"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
#![no_std]
|
||||
|
||||
use core::cell::UnsafeCell;
|
||||
use core::panic::PanicInfo;
|
||||
|
||||
// --- bump allocator ---
|
||||
const HEAP_SIZE: usize = 65536;
|
||||
|
||||
struct BumpAlloc {
|
||||
heap: UnsafeCell<[u8; HEAP_SIZE]>,
|
||||
offset: UnsafeCell<usize>,
|
||||
}
|
||||
|
||||
unsafe impl Sync for BumpAlloc {}
|
||||
|
||||
static ALLOC: BumpAlloc = BumpAlloc {
|
||||
heap: UnsafeCell::new([0u8; HEAP_SIZE]),
|
||||
offset: UnsafeCell::new(0),
|
||||
};
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn alloc(size: i32) -> i32 {
|
||||
unsafe {
|
||||
let offset = &mut *ALLOC.offset.get();
|
||||
let heap = &mut *ALLOC.heap.get();
|
||||
let align = 8;
|
||||
let start = (*offset + align - 1) & !(align - 1);
|
||||
let end = start + size as usize;
|
||||
if end > heap.len() {
|
||||
return 0; // OOM
|
||||
}
|
||||
*offset = end;
|
||||
heap.as_ptr().add(start) as i32
|
||||
}
|
||||
}
|
||||
|
||||
// --- host import ---
|
||||
#[link(wasm_import_module = "swactor")]
|
||||
unsafe extern "C" {
|
||||
#[link_name = "send"]
|
||||
fn host_send(dest_ptr: i32, payload_ptr: i32, payload_len: i32);
|
||||
}
|
||||
|
||||
/// Message format: first 32 bytes = destination address, rest = payload.
|
||||
/// Sends the payload back twice to demonstrate multi-send.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn handle(ptr: i32, len: i32) {
|
||||
if len < 32 {
|
||||
return;
|
||||
}
|
||||
let dest_ptr = ptr;
|
||||
let payload_ptr = ptr + 32;
|
||||
let payload_len = len - 32;
|
||||
unsafe {
|
||||
host_send(dest_ptr, payload_ptr, payload_len);
|
||||
host_send(dest_ptr, payload_ptr, payload_len);
|
||||
}
|
||||
}
|
||||
|
||||
#[panic_handler]
|
||||
fn panic(_info: &PanicInfo) -> ! {
|
||||
loop {}
|
||||
}
|
||||
7
crates/bin-runner/tests/guests/echo/Cargo.lock
generated
7
crates/bin-runner/tests/guests/echo/Cargo.lock
generated
|
|
@ -1,7 +0,0 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "echo-guest"
|
||||
version = "0.1.0"
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
[workspace]
|
||||
|
||||
[package]
|
||||
name = "echo-guest"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
#![no_std]
|
||||
|
||||
use core::cell::UnsafeCell;
|
||||
use core::panic::PanicInfo;
|
||||
|
||||
// --- bump allocator ---
|
||||
const HEAP_SIZE: usize = 65536;
|
||||
|
||||
struct BumpAlloc {
|
||||
heap: UnsafeCell<[u8; HEAP_SIZE]>,
|
||||
offset: UnsafeCell<usize>,
|
||||
}
|
||||
|
||||
unsafe impl Sync for BumpAlloc {}
|
||||
|
||||
static ALLOC: BumpAlloc = BumpAlloc {
|
||||
heap: UnsafeCell::new([0u8; HEAP_SIZE]),
|
||||
offset: UnsafeCell::new(0),
|
||||
};
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn alloc(size: i32) -> i32 {
|
||||
unsafe {
|
||||
let offset = &mut *ALLOC.offset.get();
|
||||
let heap = &mut *ALLOC.heap.get();
|
||||
let align = 8;
|
||||
let start = (*offset + align - 1) & !(align - 1);
|
||||
let end = start + size as usize;
|
||||
if end > heap.len() {
|
||||
return 0; // OOM
|
||||
}
|
||||
*offset = end;
|
||||
heap.as_ptr().add(start) as i32
|
||||
}
|
||||
}
|
||||
|
||||
// --- host import ---
|
||||
#[link(wasm_import_module = "swactor")]
|
||||
unsafe extern "C" {
|
||||
#[link_name = "send"]
|
||||
fn host_send(dest_ptr: i32, payload_ptr: i32, payload_len: i32);
|
||||
}
|
||||
|
||||
/// Message format: first 32 bytes = destination address, rest = payload.
|
||||
/// Echo sends the payload portion back to the specified destination.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn handle(ptr: i32, len: i32) {
|
||||
if len < 32 {
|
||||
return;
|
||||
}
|
||||
let dest_ptr = ptr;
|
||||
let payload_ptr = ptr + 32;
|
||||
let payload_len = len - 32;
|
||||
unsafe {
|
||||
host_send(dest_ptr, payload_ptr, payload_len);
|
||||
}
|
||||
}
|
||||
|
||||
#[panic_handler]
|
||||
fn panic(_info: &PanicInfo) -> ! {
|
||||
loop {}
|
||||
}
|
||||
7
crates/bin-runner/tests/guests/silent/Cargo.lock
generated
7
crates/bin-runner/tests/guests/silent/Cargo.lock
generated
|
|
@ -1,7 +0,0 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "silent-guest"
|
||||
version = "0.1.0"
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
[workspace]
|
||||
|
||||
[package]
|
||||
name = "silent-guest"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
#![no_std]
|
||||
|
||||
use core::cell::UnsafeCell;
|
||||
use core::panic::PanicInfo;
|
||||
|
||||
// --- bump allocator ---
|
||||
const HEAP_SIZE: usize = 65536;
|
||||
|
||||
struct BumpAlloc {
|
||||
heap: UnsafeCell<[u8; HEAP_SIZE]>,
|
||||
offset: UnsafeCell<usize>,
|
||||
}
|
||||
|
||||
unsafe impl Sync for BumpAlloc {}
|
||||
|
||||
static ALLOC: BumpAlloc = BumpAlloc {
|
||||
heap: UnsafeCell::new([0u8; HEAP_SIZE]),
|
||||
offset: UnsafeCell::new(0),
|
||||
};
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn alloc(size: i32) -> i32 {
|
||||
unsafe {
|
||||
let offset = &mut *ALLOC.offset.get();
|
||||
let heap = &mut *ALLOC.heap.get();
|
||||
let align = 8;
|
||||
let start = (*offset + align - 1) & !(align - 1);
|
||||
let end = start + size as usize;
|
||||
if end > heap.len() {
|
||||
return 0; // OOM
|
||||
}
|
||||
*offset = end;
|
||||
heap.as_ptr().add(start) as i32
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn handle(_ptr: i32, _len: i32) {
|
||||
// Silent: receive bytes, do nothing
|
||||
}
|
||||
|
||||
#[panic_handler]
|
||||
fn panic(_info: &PanicInfo) -> ! {
|
||||
loop {}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc f8674b306ba8187ad75bcc5a33c4a679e497a0053dc6fdb2234ccaeaae8f85b7 # shrinks to guest_idx = 0, n_msgs = 0, ticks_before = 1, ticks_after = 1
|
||||
cc db98a9c0726c0477ba56ec6098a1f21518faaff4551d90e915790ce3c890dd53 # shrinks to payload = []
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -8,5 +8,5 @@ name = "swactor"
|
|||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
swactor = { path = "../.." }
|
||||
swactor = { path = "../../.." }
|
||||
pyo3 = { version = "0.23", features = ["extension-module"] }
|
||||
|
|
@ -7,6 +7,5 @@ edition = "2024"
|
|||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
swactor = { path = "../../..", default-features = false, features = ["wasm"] }
|
||||
swactor-std = { path = "../../std", default-features = false, features = ["wasm"] }
|
||||
swactor = { path = "../../..", default-features = false, features = ["wasm", "std"] }
|
||||
wasm-bindgen = "0.2"
|
||||
|
|
@ -4,7 +4,7 @@ use wasm_bindgen::prelude::*;
|
|||
|
||||
use swactor::actor::{ActorAddress, ActorExited, ActorInterface};
|
||||
use swactor::runtime::{Ctx, Inbox, Runtime, RuntimeConfig};
|
||||
use swactor_std::{CtxGroups, CtxWatching, RuntimeNaming, RuntimeGroups, StdExtension};
|
||||
use swactor::std::{CtxGroups, CtxWatching, RuntimeNaming, RuntimeGroups, StdExtension};
|
||||
|
||||
// ─── Core JS-facing types ───────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
[package]
|
||||
name = "swactor-ci"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
swactor = { path = "../..", features = ["serde"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
serde_json = "1"
|
||||
|
||||
# Shared optional deps (used by lib local feature and binaries)
|
||||
tiny_http = { version = "0.12", optional = true }
|
||||
ureq = { version = "2", features = ["json"], optional = true }
|
||||
hmac = { version = "0.12", optional = true }
|
||||
sha2 = { version = "0.10", optional = true }
|
||||
hex = { version = "0.4", optional = true }
|
||||
|
||||
# Binary-only optional deps
|
||||
iroh = { version = "0.96", optional = true }
|
||||
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
|
||||
clap = { version = "4", features = ["derive"], optional = true }
|
||||
ctrlc = { version = "3", optional = true }
|
||||
dashboard = { path = "../dashboard", optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
local = [
|
||||
"dep:tiny_http",
|
||||
"dep:ureq",
|
||||
"dep:hmac",
|
||||
"dep:sha2",
|
||||
"dep:hex",
|
||||
]
|
||||
relay = [
|
||||
"local",
|
||||
"dep:iroh",
|
||||
"dep:tokio",
|
||||
"dep:clap",
|
||||
]
|
||||
runner = [
|
||||
"local",
|
||||
"dep:iroh",
|
||||
"dep:tokio",
|
||||
"dep:clap",
|
||||
"dep:ctrlc",
|
||||
"dep:dashboard",
|
||||
]
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
[[bin]]
|
||||
name = "ci-relay"
|
||||
path = "src/bin/ci_relay.rs"
|
||||
required-features = ["relay"]
|
||||
|
||||
[[bin]]
|
||||
name = "local-runner"
|
||||
path = "src/bin/local_runner.rs"
|
||||
required-features = ["runner"]
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
//! ci-relay — webhook relay for VPS side.
|
||||
//!
|
||||
//! Receives Forgejo webhook POSTs over HTTP, then forwards the parsed
|
||||
//! `WebhookEvent` payloads to the Thinkpad local-runner over iroh.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::Parser;
|
||||
use iroh::{Endpoint, RelayMode};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
use swactor_ci::webhook_server::parse_webhook_json;
|
||||
use swactor_ci::{EventType, WebhookEvent};
|
||||
|
||||
/// ALPN protocol identifier for CI relay traffic over iroh.
|
||||
const ALPN: &[u8] = b"swactor/ci/1";
|
||||
|
||||
/// Wire tag for WebhookEvent messages.
|
||||
const WEBHOOK_TAG: &str = "ci::WebhookEvent";
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "ci-relay", about = "Webhook relay: Forgejo → iroh → local-runner")]
|
||||
struct Args {
|
||||
/// HTTP port for receiving Forgejo webhooks.
|
||||
#[arg(long, default_value = "8787")]
|
||||
port: u16,
|
||||
|
||||
/// Webhook secret for HMAC-SHA256 verification (empty to skip).
|
||||
#[arg(long, default_value = "")]
|
||||
secret: String,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = Args::parse();
|
||||
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("failed to build tokio runtime");
|
||||
|
||||
let endpoint = rt.block_on(async {
|
||||
Endpoint::builder()
|
||||
.alpns(vec![ALPN.to_vec()])
|
||||
.relay_mode(RelayMode::Default)
|
||||
.bind()
|
||||
.await
|
||||
.expect("failed to bind iroh endpoint")
|
||||
});
|
||||
|
||||
let node_id = endpoint.id();
|
||||
eprintln!("ci-relay started");
|
||||
eprintln!(" Iroh Node ID: {node_id}");
|
||||
eprintln!(" Webhook HTTP: http://0.0.0.0:{}", args.port);
|
||||
eprintln!();
|
||||
eprintln!("Waiting for runner to connect...");
|
||||
|
||||
// Shared state: the active connection from the Thinkpad runner.
|
||||
let connection: Arc<TokioMutex<Option<iroh::endpoint::Connection>>> =
|
||||
Arc::new(TokioMutex::new(None));
|
||||
|
||||
// Spawn a task that accepts inbound iroh connections from the runner.
|
||||
{
|
||||
let endpoint = endpoint.clone();
|
||||
let connection = Arc::clone(&connection);
|
||||
rt.spawn(async move {
|
||||
loop {
|
||||
match endpoint.accept().await {
|
||||
Some(incoming) => match incoming.await {
|
||||
Ok(conn) => {
|
||||
let remote = conn.remote_id();
|
||||
eprintln!("Runner connected: {remote}");
|
||||
*connection.lock().await = Some(conn);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("iroh accept error: {e}");
|
||||
}
|
||||
},
|
||||
None => {
|
||||
eprintln!("iroh endpoint closed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Run the HTTP webhook listener on a standard thread (blocking).
|
||||
let secret = args.secret.clone();
|
||||
let server = tiny_http::Server::http(format!("0.0.0.0:{}", args.port))
|
||||
.expect("failed to start HTTP server");
|
||||
|
||||
eprintln!("Listening for webhooks...");
|
||||
|
||||
for mut request in server.incoming_requests() {
|
||||
let response = handle_webhook(&mut request, &secret, &connection, &rt);
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle an incoming webhook HTTP request.
|
||||
///
|
||||
/// Parses and verifies the webhook, then forwards the event over iroh.
|
||||
fn handle_webhook(
|
||||
request: &mut tiny_http::Request,
|
||||
secret: &str,
|
||||
connection: &Arc<TokioMutex<Option<iroh::endpoint::Connection>>>,
|
||||
rt: &tokio::runtime::Runtime,
|
||||
) -> tiny_http::Response<std::io::Cursor<Vec<u8>>> {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
if request.method() != &tiny_http::Method::Post {
|
||||
return tiny_http::Response::from_string("method not allowed").with_status_code(405);
|
||||
}
|
||||
|
||||
// Read body.
|
||||
let mut body = String::new();
|
||||
if let Err(e) = std::io::Read::read_to_string(&mut request.as_reader(), &mut body) {
|
||||
eprintln!("webhook: failed to read body: {e}");
|
||||
return tiny_http::Response::from_string("bad request").with_status_code(400);
|
||||
}
|
||||
|
||||
// Verify HMAC-SHA256 signature if secret is non-empty.
|
||||
if !secret.is_empty() {
|
||||
let sig_header = request
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|h| h.field.equiv("X-Forgejo-Signature"))
|
||||
.map(|h| h.value.as_str().to_string());
|
||||
|
||||
match sig_header {
|
||||
Some(sig_hex) => {
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
let mut mac =
|
||||
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC key creation");
|
||||
hmac::Mac::update(&mut mac, body.as_bytes());
|
||||
let expected = hex::encode(mac.finalize().into_bytes());
|
||||
if sig_hex != expected {
|
||||
eprintln!("webhook: signature mismatch");
|
||||
return tiny_http::Response::from_string("unauthorized").with_status_code(401);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
eprintln!("webhook: missing signature header");
|
||||
return tiny_http::Response::from_string("unauthorized").with_status_code(401);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine event type from Forgejo header.
|
||||
let event_header = request
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|h| h.field.equiv("X-Forgejo-Event"))
|
||||
.map(|h| h.value.as_str().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let event_type = match event_header.as_str() {
|
||||
"push" => EventType::Push,
|
||||
"create" => EventType::Tag,
|
||||
"pull_request" => EventType::Merge,
|
||||
other => {
|
||||
eprintln!("webhook: ignoring event type '{other}'");
|
||||
return tiny_http::Response::from_string("ignored").with_status_code(200);
|
||||
}
|
||||
};
|
||||
|
||||
// Parse JSON body.
|
||||
let json: serde_json::Value = match serde_json::from_str(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("webhook: failed to parse JSON: {e}");
|
||||
return tiny_http::Response::from_string("bad json").with_status_code(400);
|
||||
}
|
||||
};
|
||||
|
||||
let webhook_event = match parse_webhook_json(&json, event_type) {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
eprintln!("webhook: could not extract fields from JSON");
|
||||
return tiny_http::Response::from_string("bad payload").with_status_code(400);
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!(
|
||||
"webhook: {} {} on {}/{}",
|
||||
webhook_event.commit_sha.get(..8).unwrap_or(&webhook_event.commit_sha),
|
||||
webhook_event.branch,
|
||||
webhook_event.repo_owner,
|
||||
webhook_event.repo_name,
|
||||
);
|
||||
|
||||
// Forward over iroh.
|
||||
match forward_event(&webhook_event, connection, rt) {
|
||||
Ok(()) => {
|
||||
eprintln!(" → forwarded to runner");
|
||||
tiny_http::Response::from_string("ok").with_status_code(200)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" → forward failed: {e}");
|
||||
tiny_http::Response::from_string("relay error").with_status_code(502)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize and send a WebhookEvent over the iroh connection.
|
||||
fn forward_event(
|
||||
event: &WebhookEvent,
|
||||
connection: &Arc<TokioMutex<Option<iroh::endpoint::Connection>>>,
|
||||
rt: &tokio::runtime::Runtime,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let payload = serde_json::to_vec(event)?;
|
||||
|
||||
rt.block_on(async {
|
||||
let guard = connection.lock().await;
|
||||
let conn = guard.as_ref().ok_or("no runner connected")?;
|
||||
|
||||
let mut send = conn.open_uni().await?;
|
||||
write_tagged_message(&mut send, WEBHOOK_TAG.as_bytes(), &payload).await?;
|
||||
send.finish()?;
|
||||
|
||||
// Wait briefly for the stream to flush.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Write a tagged message to a QUIC send stream.
|
||||
///
|
||||
/// Frame format: `[4B tag_len][tag_bytes][payload_bytes]`
|
||||
async fn write_tagged_message(
|
||||
send: &mut iroh::endpoint::SendStream,
|
||||
tag: &[u8],
|
||||
payload: &[u8],
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let tag_len = (tag.len() as u32).to_be_bytes();
|
||||
send.write_all(&tag_len).await?;
|
||||
send.write_all(tag).await?;
|
||||
send.write_all(payload).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,386 +0,0 @@
|
|||
//! local-runner — single-machine CI runner for the Thinkpad.
|
||||
//!
|
||||
//! Receives Forgejo webhooks, queues pipelines, and executes jobs
|
||||
//! one at a time for benchmark isolation.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use swactor::actor::ActorAddress;
|
||||
use swactor::config::RuntimeConfig;
|
||||
use swactor::runtime::Runtime;
|
||||
|
||||
use dashboard::ci_collector::{
|
||||
CiSnapshot, CiStatsProvider, JobSnapshot, PipelineSnapshot, ProvisionerStatus,
|
||||
};
|
||||
|
||||
use swactor_ci::local_coordinator::{LocalCiSnapshot, LocalCoordinator, LocalCoordinatorMsg};
|
||||
use swactor_ci::pipeline::PipelineExecution;
|
||||
use swactor_ci::status_reporter::StatusReporter;
|
||||
use swactor_ci::webhook_server;
|
||||
use swactor_ci::yaml;
|
||||
use swactor_ci::{CiConfig, LocalCiConfig};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "local-runner", about = "Swactor local CI runner")]
|
||||
struct Args {
|
||||
/// Webhook listen port.
|
||||
#[arg(long, default_value = "8787")]
|
||||
port: u16,
|
||||
|
||||
/// Forgejo instance URL.
|
||||
#[arg(long, default_value = "")]
|
||||
forgejo_url: String,
|
||||
|
||||
/// Forgejo API token.
|
||||
#[arg(long, default_value = "")]
|
||||
forgejo_token: String,
|
||||
|
||||
/// Webhook secret for HMAC verification (empty to skip).
|
||||
#[arg(long, default_value = "")]
|
||||
secret: String,
|
||||
|
||||
/// Path to .ci.yml file.
|
||||
#[arg(long, default_value = ".ci.yml")]
|
||||
yaml: String,
|
||||
|
||||
/// Base directory for git checkouts.
|
||||
#[arg(long, default_value = "./ci-work")]
|
||||
work_dir: String,
|
||||
|
||||
/// Git clone URL for the repository.
|
||||
#[arg(long, default_value = "")]
|
||||
repo_url: String,
|
||||
|
||||
/// Dashboard HTTP port (omit to disable).
|
||||
#[arg(long)]
|
||||
dashboard_port: Option<u16>,
|
||||
|
||||
/// Iroh Node ID of the ci-relay on the VPS (hex).
|
||||
/// When set, webhooks arrive via iroh instead of HTTP.
|
||||
#[arg(long)]
|
||||
relay_node_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Bridge from LocalCiSnapshot to CiSnapshot for the dashboard.
|
||||
struct LocalCiSnapshotProvider {
|
||||
snapshot: Arc<Mutex<LocalCiSnapshot>>,
|
||||
}
|
||||
|
||||
impl CiStatsProvider for LocalCiSnapshotProvider {
|
||||
fn snapshot(&self) -> CiSnapshot {
|
||||
let local = self.snapshot.lock().unwrap().clone();
|
||||
CiSnapshot {
|
||||
active_pipelines: local
|
||||
.active_pipelines
|
||||
.iter()
|
||||
.map(pipeline_to_dashboard)
|
||||
.collect(),
|
||||
recent_pipelines: local
|
||||
.recent_pipelines
|
||||
.iter()
|
||||
.map(pipeline_to_dashboard)
|
||||
.collect(),
|
||||
provisioner_status: ProvisionerStatus::Online,
|
||||
active_instances: if local.has_running_job { 1 } else { 0 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pipeline_to_dashboard(p: &PipelineExecution) -> PipelineSnapshot {
|
||||
// Convert between swactor_ci types and dashboard::ci_collector types
|
||||
// (structurally identical but separate to avoid a dependency cycle).
|
||||
let status: dashboard::ci_collector::PipelineStatus =
|
||||
serde_json::from_value(serde_json::to_value(&p.status).unwrap()).unwrap();
|
||||
|
||||
PipelineSnapshot {
|
||||
pipeline_id: dashboard::ci_collector::PipelineId(p.pipeline_id.0),
|
||||
pipeline_name: p.pipeline_name.clone(),
|
||||
repo_owner: p.repo_owner.clone(),
|
||||
repo_name: p.repo_name.clone(),
|
||||
commit_sha: p.commit_sha.clone(),
|
||||
branch: p.branch.clone(),
|
||||
status,
|
||||
jobs: p
|
||||
.jobs
|
||||
.values()
|
||||
.map(|j| {
|
||||
let job_status: dashboard::ci_collector::JobStatus =
|
||||
serde_json::from_value(serde_json::to_value(&j.status).unwrap()).unwrap();
|
||||
JobSnapshot {
|
||||
job_id: dashboard::ci_collector::JobId {
|
||||
pipeline_id: dashboard::ci_collector::PipelineId(j.job_id.pipeline_id.0),
|
||||
job_name: j.job_id.job_name.clone(),
|
||||
},
|
||||
job_name: j.definition.name.clone(),
|
||||
status: job_status,
|
||||
output_line_count: j.output_lines.len(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = Args::parse();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
|
||||
// Signal handler.
|
||||
{
|
||||
let stop = Arc::clone(&stop);
|
||||
ctrlc::set_handler(move || {
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
})
|
||||
.expect("failed to set signal handler");
|
||||
}
|
||||
|
||||
// Optionally start dashboard.
|
||||
let dash = args.dashboard_port.map(|port| {
|
||||
let d = dashboard::start_dashboard(dashboard::DashboardConfig {
|
||||
port,
|
||||
..Default::default()
|
||||
});
|
||||
d.install_tracing();
|
||||
d
|
||||
});
|
||||
|
||||
// Create 2-thread runtime.
|
||||
let num_threads = 2;
|
||||
let collector = dashboard::collector::StatsCollector::new(num_threads);
|
||||
let mut rt = Runtime::new(RuntimeConfig {
|
||||
num_threads,
|
||||
max_actors: 256,
|
||||
channel_buffer_size: 2000,
|
||||
..Default::default()
|
||||
});
|
||||
rt.set_stats_hook(collector.clone());
|
||||
|
||||
// Build config.
|
||||
let ci_config = CiConfig {
|
||||
webhook_port: args.port,
|
||||
webhook_secret: args.secret.clone(),
|
||||
forgejo_url: args.forgejo_url.clone(),
|
||||
forgejo_token: args.forgejo_token.clone(),
|
||||
data_dir: args.work_dir.clone(),
|
||||
};
|
||||
let local_config = LocalCiConfig {
|
||||
ci: ci_config,
|
||||
repo_url: args.repo_url.clone(),
|
||||
work_dir: args.work_dir.clone(),
|
||||
ci_yaml_path: args.yaml.clone(),
|
||||
};
|
||||
|
||||
// Spawn StatusReporter.
|
||||
let reporter_addr = rt
|
||||
.spawn(StatusReporter::new())
|
||||
.expect("failed to spawn StatusReporter");
|
||||
|
||||
// Spawn LocalCoordinator.
|
||||
let coordinator = LocalCoordinator::new(local_config).with_status_reporter(reporter_addr);
|
||||
let ci_snapshot = coordinator.ci_snapshot();
|
||||
let coordinator_addr = rt
|
||||
.spawn(coordinator)
|
||||
.expect("failed to spawn LocalCoordinator");
|
||||
|
||||
// Load CI YAML from disk.
|
||||
let yaml_content = std::fs::read_to_string(&args.yaml)
|
||||
.unwrap_or_else(|e| panic!("failed to read {}: {e}", args.yaml));
|
||||
let ci_yaml = yaml::parse_ci_yaml(&yaml_content)
|
||||
.unwrap_or_else(|e| panic!("failed to parse CI YAML: {e}"));
|
||||
|
||||
// Start runtime.
|
||||
let handle = rt.run().expect("failed to start runtime");
|
||||
|
||||
// Send CiYaml to coordinator.
|
||||
let _ = handle
|
||||
.runtime
|
||||
.send_to(coordinator_addr, LocalCoordinatorMsg::SetCiYaml(ci_yaml));
|
||||
|
||||
// Wire dashboard.
|
||||
if let Some(ref d) = dash {
|
||||
d.set_runtime(Arc::clone(&handle.runtime), collector);
|
||||
let provider = Arc::new(LocalCiSnapshotProvider {
|
||||
snapshot: ci_snapshot,
|
||||
});
|
||||
d.set_ci(provider);
|
||||
}
|
||||
|
||||
// Start webhook source: iroh relay or HTTP listener.
|
||||
if let Some(ref relay_id_hex) = args.relay_node_id {
|
||||
start_iroh_receiver(
|
||||
relay_id_hex,
|
||||
Arc::clone(&handle.runtime),
|
||||
coordinator_addr,
|
||||
Arc::clone(&stop),
|
||||
);
|
||||
} else {
|
||||
let _webhook_handle = webhook_server::start_webhook_listener(
|
||||
args.port,
|
||||
args.secret,
|
||||
Arc::clone(&handle.runtime),
|
||||
coordinator_addr,
|
||||
);
|
||||
}
|
||||
|
||||
eprintln!("Local CI runner started");
|
||||
if args.relay_node_id.is_some() {
|
||||
eprintln!(" Webhook: via iroh relay");
|
||||
} else {
|
||||
eprintln!(" Webhook: http://0.0.0.0:{}", args.port);
|
||||
}
|
||||
eprintln!(" YAML: {}", args.yaml);
|
||||
eprintln!(" Workdir: {}", args.work_dir);
|
||||
if let Some(port) = args.dashboard_port {
|
||||
eprintln!(" Dashboard: http://0.0.0.0:{port}");
|
||||
}
|
||||
|
||||
// Main loop — just wait for ctrlc.
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
eprintln!("\nShutting down...");
|
||||
handle.shutdown();
|
||||
if let Some(d) = dash {
|
||||
d.shutdown();
|
||||
}
|
||||
handle.join();
|
||||
}
|
||||
|
||||
// ─── Iroh Webhook Receiver ──────────────────────────────────────────────────
|
||||
|
||||
/// ALPN protocol identifier — must match ci-relay.
|
||||
const CI_ALPN: &[u8] = b"swactor/ci/1";
|
||||
|
||||
/// Connect to the VPS ci-relay via iroh and receive WebhookEvents.
|
||||
///
|
||||
/// Runs in a background thread with its own tokio runtime.
|
||||
fn start_iroh_receiver(
|
||||
relay_id_hex: &str,
|
||||
swactor_rt: Arc<Runtime>,
|
||||
coordinator_addr: ActorAddress,
|
||||
stop: Arc<AtomicBool>,
|
||||
) {
|
||||
let relay_key: iroh::PublicKey = relay_id_hex
|
||||
.parse()
|
||||
.unwrap_or_else(|e| panic!("invalid relay node ID '{relay_id_hex}': {e}"));
|
||||
|
||||
thread::Builder::new()
|
||||
.name("iroh-receiver".into())
|
||||
.spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("failed to build tokio runtime for iroh receiver");
|
||||
|
||||
rt.block_on(async move {
|
||||
let endpoint = iroh::Endpoint::builder()
|
||||
.alpns(vec![CI_ALPN.to_vec()])
|
||||
.relay_mode(iroh::RelayMode::Default)
|
||||
.bind()
|
||||
.await
|
||||
.expect("failed to bind iroh endpoint");
|
||||
|
||||
eprintln!(" Iroh local ID: {}", endpoint.id());
|
||||
|
||||
// Outer reconnection loop: reconnect when the connection drops.
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
eprintln!(" Connecting to relay {relay_key}...");
|
||||
|
||||
let conn = match endpoint.connect(relay_key, CI_ALPN).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("iroh: connect failed: {e}, retrying in 5s...");
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!(" Connected to relay!");
|
||||
|
||||
// Receive loop: the relay opens uni streams to send us events.
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
match tokio::time::timeout(Duration::from_secs(1), conn.accept_uni()).await
|
||||
{
|
||||
Ok(Ok(mut recv)) => {
|
||||
match read_tagged_message(&mut recv).await {
|
||||
Ok((tag, payload)) => {
|
||||
if tag == "ci::WebhookEvent" {
|
||||
match serde_json::from_slice::<
|
||||
swactor_ci::WebhookEvent,
|
||||
>(
|
||||
&payload
|
||||
) {
|
||||
Ok(event) => {
|
||||
eprintln!(
|
||||
"iroh: received webhook {} on {}",
|
||||
event
|
||||
.commit_sha
|
||||
.get(..8)
|
||||
.unwrap_or(&event.commit_sha),
|
||||
event.branch,
|
||||
);
|
||||
let _ = swactor_rt.send_to(
|
||||
coordinator_addr,
|
||||
LocalCoordinatorMsg::Webhook(event),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"iroh: failed to deserialize event: {e}"
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("iroh: unknown tag '{tag}', ignoring");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("iroh: read error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("iroh: connection lost: {e}, reconnecting...");
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
// 1s poll timeout — just loop and check stop flag.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
endpoint.close().await;
|
||||
});
|
||||
})
|
||||
.expect("failed to spawn iroh-receiver thread");
|
||||
}
|
||||
|
||||
/// Read a tagged message from a QUIC recv stream.
|
||||
///
|
||||
/// Frame format: `[4B tag_len][tag_bytes][payload_bytes]`
|
||||
async fn read_tagged_message(
|
||||
recv: &mut iroh::endpoint::RecvStream,
|
||||
) -> Result<(String, Vec<u8>), Box<dyn std::error::Error>> {
|
||||
let mut tag_len_buf = [0u8; 4];
|
||||
recv.read_exact(&mut tag_len_buf).await?;
|
||||
let tag_len = u32::from_be_bytes(tag_len_buf) as usize;
|
||||
|
||||
if tag_len > 1024 {
|
||||
return Err("tag too large".into());
|
||||
}
|
||||
|
||||
let mut tag_buf = vec![0u8; tag_len];
|
||||
recv.read_exact(&mut tag_buf).await?;
|
||||
let tag = String::from_utf8(tag_buf)?;
|
||||
|
||||
let payload = recv.read_to_end(64 * 1024).await?;
|
||||
|
||||
Ok((tag, payload))
|
||||
}
|
||||
|
|
@ -1,430 +0,0 @@
|
|||
//! Coordinator actor: central brain of the CI system.
|
||||
//!
|
||||
//! Receives webhook events, manages pipeline lifecycles, dispatches jobs
|
||||
//! to the Provisioner and RunnerSupervisor actors.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||
|
||||
use crate::pipeline::PipelineExecution;
|
||||
use crate::yaml::{self, CiYaml};
|
||||
use crate::{
|
||||
CiConfig, JobComplete, JobId, JobProgress, JobStatus, PipelineId, ProvisionRequest,
|
||||
ProvisionResponse, StatusUpdate, TerminateRequest, WebhookEvent,
|
||||
};
|
||||
|
||||
/// Messages the Coordinator can receive.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CoordinatorMsg {
|
||||
/// A webhook event from Forgejo.
|
||||
Webhook(WebhookEvent),
|
||||
/// The CI YAML config to use (loaded externally or from the repo).
|
||||
SetCiYaml(CiYaml),
|
||||
/// Response from the Provisioner.
|
||||
ProvisionResponse(ProvisionResponse),
|
||||
/// Streamed output from a RunnerSupervisor.
|
||||
JobProgress(JobProgress),
|
||||
/// Final result from a RunnerSupervisor.
|
||||
JobComplete(JobComplete),
|
||||
/// Notification that the provisioner is offline (detected via SWIM).
|
||||
ProvisionerOffline,
|
||||
/// Notification that the provisioner is back online.
|
||||
ProvisionerOnline,
|
||||
}
|
||||
|
||||
/// The Coordinator actor state.
|
||||
pub struct Coordinator {
|
||||
config: CiConfig,
|
||||
ci_yaml: Option<CiYaml>,
|
||||
pipelines: HashMap<PipelineId, PipelineExecution>,
|
||||
next_pipeline_id: u64,
|
||||
provisioner_addr: Option<ActorAddress>,
|
||||
provisioner_online: bool,
|
||||
/// Maps job_id → runner supervisor address.
|
||||
runner_addrs: HashMap<JobId, ActorAddress>,
|
||||
/// Captured status updates (for testing/simulation).
|
||||
status_updates: Vec<StatusUpdate>,
|
||||
/// Jobs waiting for the provisioner to come online.
|
||||
queued_provisions: Vec<ProvisionRequest>,
|
||||
}
|
||||
|
||||
impl Coordinator {
|
||||
pub fn new(config: CiConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
ci_yaml: None,
|
||||
pipelines: HashMap::new(),
|
||||
next_pipeline_id: 1,
|
||||
provisioner_addr: None,
|
||||
provisioner_online: false,
|
||||
runner_addrs: HashMap::new(),
|
||||
status_updates: Vec::new(),
|
||||
queued_provisions: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_provisioner(mut self, addr: ActorAddress) -> Self {
|
||||
self.provisioner_addr = Some(addr);
|
||||
self.provisioner_online = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_ci_yaml(mut self, yaml: CiYaml) -> Self {
|
||||
self.ci_yaml = Some(yaml);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn pipelines(&self) -> &HashMap<PipelineId, PipelineExecution> {
|
||||
&self.pipelines
|
||||
}
|
||||
|
||||
pub fn status_updates(&self) -> &[StatusUpdate] {
|
||||
&self.status_updates
|
||||
}
|
||||
|
||||
fn handle_webhook(&mut self, ctx: &Ctx, event: WebhookEvent) {
|
||||
let ci = match &self.ci_yaml {
|
||||
Some(ci) => ci.clone(),
|
||||
None => return,
|
||||
};
|
||||
|
||||
let matched = yaml::matching_pipelines(&ci, &event);
|
||||
for pipeline_name in matched {
|
||||
let pipeline_def = &ci.pipelines[&pipeline_name];
|
||||
let pipeline_id = PipelineId(self.next_pipeline_id);
|
||||
self.next_pipeline_id += 1;
|
||||
|
||||
// Build job definitions.
|
||||
let job_defs: Vec<_> = pipeline_def
|
||||
.jobs
|
||||
.iter()
|
||||
.map(|(name, def)| yaml::to_job_definition(name, def))
|
||||
.collect();
|
||||
|
||||
let pipeline = PipelineExecution::new(
|
||||
pipeline_id,
|
||||
pipeline_name.clone(),
|
||||
event.repo_owner.clone(),
|
||||
event.repo_name.clone(),
|
||||
event.commit_sha.clone(),
|
||||
event.branch.clone(),
|
||||
job_defs,
|
||||
);
|
||||
|
||||
// Set pending status on Forgejo.
|
||||
self.emit_status_update(StatusUpdate {
|
||||
repo_owner: event.repo_owner.clone(),
|
||||
repo_name: event.repo_name.clone(),
|
||||
commit_sha: event.commit_sha.clone(),
|
||||
state: "pending".into(),
|
||||
context: format!("ci/{pipeline_name}"),
|
||||
description: format!("Pipeline '{pipeline_name}' is pending"),
|
||||
target_url: None,
|
||||
});
|
||||
|
||||
self.pipelines.insert(pipeline_id, pipeline);
|
||||
|
||||
// Start eligible jobs.
|
||||
self.advance_pipeline(ctx, pipeline_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn advance_pipeline(&mut self, ctx: &Ctx, pipeline_id: PipelineId) {
|
||||
let pipeline = match self.pipelines.get(&pipeline_id) {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// If pipeline is already terminal, emit final status.
|
||||
if pipeline.status.is_terminal() {
|
||||
let update = StatusUpdate {
|
||||
repo_owner: pipeline.repo_owner.clone(),
|
||||
repo_name: pipeline.repo_name.clone(),
|
||||
commit_sha: pipeline.commit_sha.clone(),
|
||||
state: pipeline.status.forgejo_state().into(),
|
||||
context: format!("ci/{}", pipeline.pipeline_name),
|
||||
description: format!(
|
||||
"Pipeline '{}' {}",
|
||||
pipeline.pipeline_name,
|
||||
pipeline.status.forgejo_state()
|
||||
),
|
||||
target_url: None,
|
||||
};
|
||||
self.emit_status_update(update);
|
||||
return;
|
||||
}
|
||||
|
||||
let eligible = pipeline.eligible_jobs();
|
||||
let repo_owner = pipeline.repo_owner.clone();
|
||||
let repo_name = pipeline.repo_name.clone();
|
||||
let commit_sha = pipeline.commit_sha.clone();
|
||||
|
||||
for job_name in eligible {
|
||||
let job_id = JobId {
|
||||
pipeline_id,
|
||||
job_name: job_name.clone(),
|
||||
};
|
||||
|
||||
let spec = {
|
||||
let pipeline = self.pipelines.get(&pipeline_id).unwrap();
|
||||
let job = &pipeline.jobs[&job_name];
|
||||
crate::InstanceSpec {
|
||||
docker_required: job.definition.docker,
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
|
||||
if self.provisioner_online {
|
||||
// Request provisioning.
|
||||
if let Some(prov_addr) = self.provisioner_addr {
|
||||
let request = ProvisionRequest {
|
||||
job_id: job_id.clone(),
|
||||
instance_spec: spec,
|
||||
};
|
||||
let _ = ctx.send(prov_addr, crate::provisioner::ProvisionerMsg::Provision(request));
|
||||
}
|
||||
if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) {
|
||||
if let Some(job) = pipeline.jobs.get_mut(&job_name) {
|
||||
job.status = JobStatus::Provisioning;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Queue for later.
|
||||
let request = ProvisionRequest {
|
||||
job_id: job_id.clone(),
|
||||
instance_spec: spec,
|
||||
};
|
||||
self.queued_provisions.push(request);
|
||||
if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) {
|
||||
if let Some(job) = pipeline.jobs.get_mut(&job_name) {
|
||||
job.status = JobStatus::WaitingForProvisioner;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit per-job status.
|
||||
self.emit_status_update(StatusUpdate {
|
||||
repo_owner: repo_owner.clone(),
|
||||
repo_name: repo_name.clone(),
|
||||
commit_sha: commit_sha.clone(),
|
||||
state: "pending".into(),
|
||||
context: format!("ci/{job_name}"),
|
||||
description: format!("Job '{job_name}' is provisioning"),
|
||||
target_url: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_provision_response(&mut self, ctx: &Ctx, response: ProvisionResponse) {
|
||||
let pipeline_id = response.job_id.pipeline_id;
|
||||
let job_name = response.job_id.job_name.clone();
|
||||
|
||||
match response.result {
|
||||
Ok(instance) => {
|
||||
// Store instance_id for cleanup.
|
||||
if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) {
|
||||
if let Some(job) = pipeline.jobs.get_mut(&job_name) {
|
||||
job.instance_id = Some(instance.instance_id.clone());
|
||||
job.status = JobStatus::Running;
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn a RunnerSupervisor for this job.
|
||||
let pipeline = &self.pipelines[&pipeline_id];
|
||||
let job = &pipeline.jobs[&job_name];
|
||||
|
||||
let start_job = crate::StartJob {
|
||||
job_id: response.job_id.clone(),
|
||||
instance: instance.clone(),
|
||||
repo_url: format!(
|
||||
"{}/{}/{}",
|
||||
self.config.forgejo_url, pipeline.repo_owner, pipeline.repo_name
|
||||
),
|
||||
commit_sha: pipeline.commit_sha.clone(),
|
||||
job_def: job.definition.clone(),
|
||||
};
|
||||
|
||||
let runner = crate::runner::RunnerSupervisor::new(
|
||||
ctx.self_addr(),
|
||||
start_job,
|
||||
);
|
||||
|
||||
match ctx.spawn(runner) {
|
||||
Ok(runner_addr) => {
|
||||
self.runner_addrs.insert(response.job_id, runner_addr);
|
||||
}
|
||||
Err(_) => {
|
||||
// Failed to spawn runner — mark job as failed.
|
||||
if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) {
|
||||
pipeline.set_job_status(
|
||||
&job_name,
|
||||
JobStatus::Failed {
|
||||
reason: "failed to spawn runner".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
self.advance_pipeline(ctx, pipeline_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) {
|
||||
pipeline.set_job_status(
|
||||
&job_name,
|
||||
JobStatus::Failed {
|
||||
reason: err.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
self.advance_pipeline(ctx, pipeline_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_job_complete(&mut self, ctx: &Ctx, complete: JobComplete) {
|
||||
let pipeline_id = complete.job_id.pipeline_id;
|
||||
let job_name = complete.job_id.job_name.clone();
|
||||
|
||||
// Send terminate request for the instance.
|
||||
if let Some(pipeline) = self.pipelines.get(&pipeline_id) {
|
||||
if let Some(job) = pipeline.jobs.get(&job_name) {
|
||||
if let Some(ref instance_id) = job.instance_id {
|
||||
let terminate = TerminateRequest {
|
||||
job_id: complete.job_id.clone(),
|
||||
instance_id: instance_id.clone(),
|
||||
};
|
||||
if let Some(prov_addr) = self.provisioner_addr {
|
||||
let _ = ctx.send(
|
||||
prov_addr,
|
||||
crate::provisioner::ProvisionerMsg::Terminate(terminate),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update job status.
|
||||
let status = match complete.result {
|
||||
Ok(_) => JobStatus::Passed,
|
||||
Err(ref failure) => JobStatus::Failed {
|
||||
reason: failure.to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let (repo_owner, repo_name, commit_sha) = {
|
||||
let pipeline = match self.pipelines.get(&pipeline_id) {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
(
|
||||
pipeline.repo_owner.clone(),
|
||||
pipeline.repo_name.clone(),
|
||||
pipeline.commit_sha.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
// Emit per-job final status.
|
||||
self.emit_status_update(StatusUpdate {
|
||||
repo_owner,
|
||||
repo_name,
|
||||
commit_sha,
|
||||
state: match &status {
|
||||
JobStatus::Passed => "success".into(),
|
||||
_ => "failure".into(),
|
||||
},
|
||||
context: format!("ci/{job_name}"),
|
||||
description: format!("Job '{job_name}' completed"),
|
||||
target_url: None,
|
||||
});
|
||||
|
||||
if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) {
|
||||
pipeline.set_job_status(&job_name, status);
|
||||
}
|
||||
|
||||
// Remove runner address.
|
||||
self.runner_addrs.remove(&complete.job_id);
|
||||
|
||||
// Advance pipeline to schedule downstream jobs.
|
||||
self.advance_pipeline(ctx, pipeline_id);
|
||||
}
|
||||
|
||||
fn handle_provisioner_offline(&mut self) {
|
||||
self.provisioner_online = false;
|
||||
|
||||
// Mark all provisioning jobs as waiting.
|
||||
for pipeline in self.pipelines.values_mut() {
|
||||
for job in pipeline.jobs.values_mut() {
|
||||
if job.status == JobStatus::Provisioning {
|
||||
job.status = JobStatus::WaitingForProvisioner;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_provisioner_online(&mut self, ctx: &Ctx) {
|
||||
self.provisioner_online = true;
|
||||
|
||||
// Flush queued provision requests.
|
||||
let queued = std::mem::take(&mut self.queued_provisions);
|
||||
for request in queued {
|
||||
if let Some(prov_addr) = self.provisioner_addr {
|
||||
let _ = ctx.send(prov_addr, crate::provisioner::ProvisionerMsg::Provision(request));
|
||||
}
|
||||
}
|
||||
|
||||
// Re-advance pipelines that have waiting jobs.
|
||||
let pipeline_ids: Vec<PipelineId> = self.pipelines.keys().copied().collect();
|
||||
for pid in pipeline_ids {
|
||||
// Move waiting jobs back to provisioning.
|
||||
if let Some(pipeline) = self.pipelines.get_mut(&pid) {
|
||||
let waiting_jobs: Vec<String> = pipeline
|
||||
.jobs
|
||||
.iter()
|
||||
.filter(|(_, j)| j.status == JobStatus::WaitingForProvisioner)
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect();
|
||||
|
||||
for job_name in waiting_jobs {
|
||||
if let Some(job) = pipeline.jobs.get_mut(&job_name) {
|
||||
job.status = JobStatus::Pending;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.advance_pipeline(ctx, pid);
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_status_update(&mut self, update: StatusUpdate) {
|
||||
self.status_updates.push(update);
|
||||
}
|
||||
}
|
||||
|
||||
impl ActorInterface for Coordinator {
|
||||
type Incoming = CoordinatorMsg;
|
||||
type Response = ();
|
||||
|
||||
fn handle(&mut self, ctx: &Ctx, msg: CoordinatorMsg) {
|
||||
match msg {
|
||||
CoordinatorMsg::Webhook(event) => self.handle_webhook(ctx, event),
|
||||
CoordinatorMsg::SetCiYaml(yaml) => {
|
||||
self.ci_yaml = Some(yaml);
|
||||
}
|
||||
CoordinatorMsg::ProvisionResponse(resp) => {
|
||||
self.handle_provision_response(ctx, resp);
|
||||
}
|
||||
CoordinatorMsg::JobProgress(progress) => {
|
||||
if let Some(pipeline) = self.pipelines.get_mut(&progress.job_id.pipeline_id) {
|
||||
if let Some(job) = pipeline.jobs.get_mut(&progress.job_id.job_name) {
|
||||
job.output_lines.push(progress.output_line);
|
||||
}
|
||||
}
|
||||
}
|
||||
CoordinatorMsg::JobComplete(complete) => {
|
||||
self.handle_job_complete(ctx, complete);
|
||||
}
|
||||
CoordinatorMsg::ProvisionerOffline => self.handle_provisioner_offline(),
|
||||
CoordinatorMsg::ProvisionerOnline => self.handle_provisioner_online(ctx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,315 +0,0 @@
|
|||
pub mod coordinator;
|
||||
pub mod local_coordinator;
|
||||
pub mod local_runner;
|
||||
pub mod pipeline;
|
||||
pub mod provisioner;
|
||||
pub mod runner;
|
||||
pub mod status_reporter;
|
||||
pub mod webhook_server;
|
||||
pub mod yaml;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::net::IpAddr;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ─── Core Identifiers ───────────────────────────────────────────────────────
|
||||
|
||||
/// Unique identifier for a pipeline execution.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct PipelineId(pub u64);
|
||||
|
||||
impl fmt::Display for PipelineId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "pipeline-{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unique identifier for a job within a pipeline.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct JobId {
|
||||
pub pipeline_id: PipelineId,
|
||||
pub job_name: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for JobId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}/{}", self.pipeline_id, self.job_name)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Job Status ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum JobStatus {
|
||||
Pending,
|
||||
WaitingForProvisioner,
|
||||
Provisioning,
|
||||
Running,
|
||||
Passed,
|
||||
Failed { reason: String },
|
||||
Skipped,
|
||||
Interrupted,
|
||||
}
|
||||
|
||||
impl JobStatus {
|
||||
pub fn is_terminal(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
JobStatus::Passed | JobStatus::Failed { .. } | JobStatus::Skipped | JobStatus::Interrupted
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pipeline Status ────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PipelineStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Passed,
|
||||
Failed,
|
||||
Error { reason: String },
|
||||
}
|
||||
|
||||
impl PipelineStatus {
|
||||
pub fn is_terminal(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
PipelineStatus::Passed | PipelineStatus::Failed | PipelineStatus::Error { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// Convert to Forgejo commit status string.
|
||||
pub fn forgejo_state(&self) -> &'static str {
|
||||
match self {
|
||||
PipelineStatus::Pending => "pending",
|
||||
PipelineStatus::Running => "pending",
|
||||
PipelineStatus::Passed => "success",
|
||||
PipelineStatus::Failed => "failure",
|
||||
PipelineStatus::Error { .. } => "error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Instance Types ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Specification for a spot instance.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InstanceSpec {
|
||||
pub min_cpus: u32,
|
||||
pub min_ram_mb: u32,
|
||||
pub min_disk_gb: u32,
|
||||
pub docker_required: bool,
|
||||
pub region_preferences: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for InstanceSpec {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_cpus: 2,
|
||||
min_ram_mb: 2048,
|
||||
min_disk_gb: 20,
|
||||
docker_required: false,
|
||||
region_preferences: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connection details for a provisioned spot instance.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InstanceReady {
|
||||
pub instance_id: String,
|
||||
pub ip: IpAddr,
|
||||
pub ssh_port: u16,
|
||||
pub ssh_host_key: String,
|
||||
}
|
||||
|
||||
// ─── Provisioner ↔ Coordinator Messages ─────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProvisionRequest {
|
||||
pub job_id: JobId,
|
||||
pub instance_spec: InstanceSpec,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProvisionResponse {
|
||||
pub job_id: JobId,
|
||||
pub result: Result<InstanceReady, ProvisionError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ProvisionError {
|
||||
NoCapacity,
|
||||
ProviderError(String),
|
||||
Timeout,
|
||||
ProvisionerOffline,
|
||||
}
|
||||
|
||||
impl fmt::Display for ProvisionError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ProvisionError::NoCapacity => write!(f, "no capacity available"),
|
||||
ProvisionError::ProviderError(msg) => write!(f, "provider error: {msg}"),
|
||||
ProvisionError::Timeout => write!(f, "provisioning timed out"),
|
||||
ProvisionError::ProvisionerOffline => write!(f, "provisioner is offline"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TerminateRequest {
|
||||
pub job_id: JobId,
|
||||
pub instance_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TerminateAck {
|
||||
pub job_id: JobId,
|
||||
}
|
||||
|
||||
// ─── Coordinator ↔ RunnerSupervisor Messages ────────────────────────────────
|
||||
|
||||
/// Sent from Coordinator to RunnerSupervisor to begin a job.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StartJob {
|
||||
pub job_id: JobId,
|
||||
pub instance: InstanceReady,
|
||||
pub repo_url: String,
|
||||
pub commit_sha: String,
|
||||
pub job_def: JobDefinition,
|
||||
}
|
||||
|
||||
/// Streamed output from RunnerSupervisor back to Coordinator.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JobProgress {
|
||||
pub job_id: JobId,
|
||||
pub output_line: String,
|
||||
}
|
||||
|
||||
/// Final result from RunnerSupervisor.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JobComplete {
|
||||
pub job_id: JobId,
|
||||
pub result: Result<JobSuccess, JobFailure>,
|
||||
pub artifacts: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JobSuccess;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum JobFailure {
|
||||
CommandFailed { exit_code: i32, last_lines: Vec<String> },
|
||||
SshError(String),
|
||||
ExecError(String),
|
||||
Timeout,
|
||||
Interrupted,
|
||||
}
|
||||
|
||||
impl fmt::Display for JobFailure {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
JobFailure::CommandFailed { exit_code, .. } => {
|
||||
write!(f, "command exited with code {exit_code}")
|
||||
}
|
||||
JobFailure::SshError(msg) => write!(f, "SSH error: {msg}"),
|
||||
JobFailure::ExecError(msg) => write!(f, "exec error: {msg}"),
|
||||
JobFailure::Timeout => write!(f, "job timed out"),
|
||||
JobFailure::Interrupted => write!(f, "spot instance interrupted"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Job Definition ─────────────────────────────────────────────────────────
|
||||
|
||||
/// A parsed job from the .ci.yml file.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JobDefinition {
|
||||
pub name: String,
|
||||
pub run: Vec<String>,
|
||||
pub needs: Vec<String>,
|
||||
pub timeout_secs: u64,
|
||||
pub docker: bool,
|
||||
pub artifacts: Vec<String>,
|
||||
pub env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
// ─── Webhook Types ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Parsed webhook event from Forgejo.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WebhookEvent {
|
||||
pub event_type: EventType,
|
||||
pub repo_owner: String,
|
||||
pub repo_name: String,
|
||||
pub branch: String,
|
||||
pub commit_sha: String,
|
||||
pub tag: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum EventType {
|
||||
Push,
|
||||
Tag,
|
||||
Merge,
|
||||
}
|
||||
|
||||
// ─── Forgejo Status Updates ─────────────────────────────────────────────────
|
||||
|
||||
/// A commit status update to send to Forgejo's API.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StatusUpdate {
|
||||
pub repo_owner: String,
|
||||
pub repo_name: String,
|
||||
pub commit_sha: String,
|
||||
pub state: String,
|
||||
pub context: String,
|
||||
pub description: String,
|
||||
pub target_url: Option<String>,
|
||||
}
|
||||
|
||||
// ─── Coordinator Config ─────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CiConfig {
|
||||
pub webhook_port: u16,
|
||||
pub webhook_secret: String,
|
||||
pub forgejo_url: String,
|
||||
pub forgejo_token: String,
|
||||
pub data_dir: String,
|
||||
}
|
||||
|
||||
impl Default for CiConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
webhook_port: 8787,
|
||||
webhook_secret: String::new(),
|
||||
forgejo_url: String::new(),
|
||||
forgejo_token: String::new(),
|
||||
data_dir: "./ci-data".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Local CI Types ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Job execution request for local runner (no InstanceReady needed).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LocalStartJob {
|
||||
pub job_id: JobId,
|
||||
pub work_dir: String,
|
||||
pub job_def: JobDefinition,
|
||||
pub env_overrides: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Configuration for the local CI runner.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LocalCiConfig {
|
||||
pub ci: CiConfig,
|
||||
pub repo_url: String,
|
||||
pub work_dir: String,
|
||||
pub ci_yaml_path: String,
|
||||
}
|
||||
|
|
@ -1,565 +0,0 @@
|
|||
//! LocalCoordinator actor: single-machine CI brain.
|
||||
//!
|
||||
//! Receives webhook events, queues pipelines, executes jobs one at a time
|
||||
//! directly on the host. Supports branch-level supersede for queued pipelines.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::process::Command;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||
|
||||
use crate::pipeline::PipelineExecution;
|
||||
use crate::status_reporter::{JobOutput, StatusReporterMsg};
|
||||
use crate::yaml::{self, CiYaml};
|
||||
use crate::{
|
||||
JobComplete, JobId, JobProgress, JobStatus, LocalCiConfig, LocalStartJob, PipelineId,
|
||||
PipelineStatus, StatusUpdate, WebhookEvent,
|
||||
};
|
||||
|
||||
/// Messages the LocalCoordinator can receive.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LocalCoordinatorMsg {
|
||||
Webhook(WebhookEvent),
|
||||
SetCiYaml(CiYaml),
|
||||
JobProgress(JobProgress),
|
||||
JobComplete(JobComplete),
|
||||
GitReady {
|
||||
pipeline_id: PipelineId,
|
||||
job_name: String,
|
||||
work_dir: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Lightweight snapshot of coordinator state (no dashboard dependency).
|
||||
/// The binary layer converts this to `CiSnapshot` for the dashboard.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LocalCiSnapshot {
|
||||
pub active_pipelines: Vec<PipelineExecution>,
|
||||
pub recent_pipelines: Vec<PipelineExecution>,
|
||||
pub has_running_job: bool,
|
||||
}
|
||||
|
||||
/// The LocalCoordinator actor state.
|
||||
pub struct LocalCoordinator {
|
||||
config: LocalCiConfig,
|
||||
ci_yaml: Option<CiYaml>,
|
||||
pipelines: HashMap<PipelineId, PipelineExecution>,
|
||||
next_pipeline_id: u64,
|
||||
/// FIFO queue of pipeline IDs awaiting execution.
|
||||
queue: VecDeque<PipelineId>,
|
||||
/// The pipeline currently being executed.
|
||||
active_pipeline: Option<PipelineId>,
|
||||
/// At most one running job (job_id, runner actor address).
|
||||
running_job: Option<(JobId, ActorAddress)>,
|
||||
/// Status reporter actor address.
|
||||
status_reporter_addr: Option<ActorAddress>,
|
||||
/// Bounded ring of finished pipelines.
|
||||
completed: VecDeque<PipelineExecution>,
|
||||
/// Shared snapshot for external consumers (e.g. dashboard binary).
|
||||
ci_snapshot: Arc<Mutex<LocalCiSnapshot>>,
|
||||
}
|
||||
|
||||
impl LocalCoordinator {
|
||||
pub fn new(config: LocalCiConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
ci_yaml: None,
|
||||
pipelines: HashMap::new(),
|
||||
next_pipeline_id: 1,
|
||||
queue: VecDeque::new(),
|
||||
active_pipeline: None,
|
||||
running_job: None,
|
||||
status_reporter_addr: None,
|
||||
completed: VecDeque::new(),
|
||||
ci_snapshot: Arc::new(Mutex::new(LocalCiSnapshot::default())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_status_reporter(mut self, addr: ActorAddress) -> Self {
|
||||
self.status_reporter_addr = Some(addr);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_ci_yaml(mut self, yaml: CiYaml) -> Self {
|
||||
self.ci_yaml = Some(yaml);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn ci_snapshot(&self) -> Arc<Mutex<LocalCiSnapshot>> {
|
||||
Arc::clone(&self.ci_snapshot)
|
||||
}
|
||||
|
||||
pub fn pipelines(&self) -> &HashMap<PipelineId, PipelineExecution> {
|
||||
&self.pipelines
|
||||
}
|
||||
|
||||
pub fn completed(&self) -> &VecDeque<PipelineExecution> {
|
||||
&self.completed
|
||||
}
|
||||
|
||||
pub fn queue(&self) -> &VecDeque<PipelineId> {
|
||||
&self.queue
|
||||
}
|
||||
|
||||
pub fn active_pipeline(&self) -> Option<PipelineId> {
|
||||
self.active_pipeline
|
||||
}
|
||||
|
||||
pub fn running_job(&self) -> Option<&(JobId, ActorAddress)> {
|
||||
self.running_job.as_ref()
|
||||
}
|
||||
|
||||
fn handle_webhook(&mut self, ctx: &Ctx, event: WebhookEvent) {
|
||||
let ci = match &self.ci_yaml {
|
||||
Some(ci) => ci.clone(),
|
||||
None => return,
|
||||
};
|
||||
|
||||
let matched = yaml::matching_pipelines(&ci, &event);
|
||||
for pipeline_name in matched {
|
||||
let pipeline_def = &ci.pipelines[&pipeline_name];
|
||||
let pipeline_id = PipelineId(self.next_pipeline_id);
|
||||
self.next_pipeline_id += 1;
|
||||
|
||||
let job_defs: Vec<_> = pipeline_def
|
||||
.jobs
|
||||
.iter()
|
||||
.map(|(name, def)| yaml::to_job_definition(name, def))
|
||||
.collect();
|
||||
|
||||
let pipeline = PipelineExecution::new(
|
||||
pipeline_id,
|
||||
pipeline_name.clone(),
|
||||
event.repo_owner.clone(),
|
||||
event.repo_name.clone(),
|
||||
event.commit_sha.clone(),
|
||||
event.branch.clone(),
|
||||
job_defs,
|
||||
);
|
||||
|
||||
self.emit_status(
|
||||
ctx,
|
||||
StatusUpdate {
|
||||
repo_owner: event.repo_owner.clone(),
|
||||
repo_name: event.repo_name.clone(),
|
||||
commit_sha: event.commit_sha.clone(),
|
||||
state: "pending".into(),
|
||||
context: format!("ci/{pipeline_name}"),
|
||||
description: format!("Pipeline '{pipeline_name}' is pending"),
|
||||
target_url: None,
|
||||
},
|
||||
);
|
||||
|
||||
self.pipelines.insert(pipeline_id, pipeline);
|
||||
self.enqueue_pipeline(pipeline_id, &event.branch);
|
||||
}
|
||||
|
||||
self.try_schedule_next(ctx);
|
||||
}
|
||||
|
||||
/// Enqueue a pipeline, superseding any queued pipeline for the same branch.
|
||||
fn enqueue_pipeline(&mut self, pipeline_id: PipelineId, branch: &str) {
|
||||
// Scan queue for entry with same branch (not the active pipeline).
|
||||
let supersede_idx = self.queue.iter().position(|&qid| {
|
||||
self.pipelines
|
||||
.get(&qid)
|
||||
.map(|p| p.branch == branch)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
if let Some(idx) = supersede_idx {
|
||||
let old_id = self.queue[idx];
|
||||
// Mark old pipeline as superseded.
|
||||
if let Some(old_pipeline) = self.pipelines.get_mut(&old_id) {
|
||||
old_pipeline.status = PipelineStatus::Error {
|
||||
reason: "superseded".into(),
|
||||
};
|
||||
// Mark all pending jobs as skipped.
|
||||
let job_names: Vec<String> = old_pipeline.jobs.keys().cloned().collect();
|
||||
for name in job_names {
|
||||
if old_pipeline.jobs[&name].status == JobStatus::Pending {
|
||||
old_pipeline.set_job_status(&name, JobStatus::Skipped);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Archive the superseded pipeline.
|
||||
if let Some(old_pipeline) = self.pipelines.remove(&old_id) {
|
||||
self.archive_pipeline(old_pipeline);
|
||||
}
|
||||
// Replace queue entry.
|
||||
self.queue[idx] = pipeline_id;
|
||||
} else {
|
||||
self.queue.push_back(pipeline_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Core scheduling: one job at a time.
|
||||
fn try_schedule_next(&mut self, ctx: &Ctx) {
|
||||
// If a job is already running, nothing to do.
|
||||
if self.running_job.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have an active pipeline, try to find eligible jobs.
|
||||
if let Some(active_id) = self.active_pipeline {
|
||||
if let Some(pipeline) = self.pipelines.get(&active_id) {
|
||||
let eligible = pipeline.eligible_jobs();
|
||||
if !eligible.is_empty() {
|
||||
let job_name = eligible[0].clone();
|
||||
self.start_job(ctx, active_id, &job_name);
|
||||
return;
|
||||
}
|
||||
|
||||
// No eligible jobs — check if pipeline is terminal.
|
||||
if pipeline.status.is_terminal() {
|
||||
let pipeline = self.pipelines.remove(&active_id).unwrap();
|
||||
self.emit_status(
|
||||
ctx,
|
||||
StatusUpdate {
|
||||
repo_owner: pipeline.repo_owner.clone(),
|
||||
repo_name: pipeline.repo_name.clone(),
|
||||
commit_sha: pipeline.commit_sha.clone(),
|
||||
state: pipeline.status.forgejo_state().into(),
|
||||
context: format!("ci/{}", pipeline.pipeline_name),
|
||||
description: format!(
|
||||
"Pipeline '{}' {}",
|
||||
pipeline.pipeline_name,
|
||||
pipeline.status.forgejo_state()
|
||||
),
|
||||
target_url: None,
|
||||
},
|
||||
);
|
||||
self.emit_pipeline_comment(ctx, &pipeline);
|
||||
self.archive_pipeline(pipeline);
|
||||
self.active_pipeline = None;
|
||||
// Recurse to pick next from queue.
|
||||
self.try_schedule_next(ctx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Pipeline exists but no eligible jobs and not terminal — waiting for running job.
|
||||
return;
|
||||
}
|
||||
|
||||
// No active pipeline — pop from queue.
|
||||
if let Some(next_id) = self.queue.pop_front() {
|
||||
self.active_pipeline = Some(next_id);
|
||||
self.try_schedule_next(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn start_job(&mut self, ctx: &Ctx, pipeline_id: PipelineId, job_name: &str) {
|
||||
let pipeline = match self.pipelines.get_mut(&pipeline_id) {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
let job = match pipeline.jobs.get_mut(job_name) {
|
||||
Some(j) => j,
|
||||
None => return,
|
||||
};
|
||||
|
||||
job.status = JobStatus::Running;
|
||||
|
||||
let work_dir = format!(
|
||||
"{}/pipeline-{}",
|
||||
self.config.work_dir, pipeline_id.0
|
||||
);
|
||||
|
||||
// Build CI env overrides.
|
||||
let mut env_overrides = HashMap::new();
|
||||
env_overrides.insert("CI".into(), "true".into());
|
||||
env_overrides.insert("CI_COMMIT_SHA".into(), pipeline.commit_sha.clone());
|
||||
env_overrides.insert("CI_BRANCH".into(), pipeline.branch.clone());
|
||||
env_overrides.insert("CI_PIPELINE_ID".into(), pipeline_id.0.to_string());
|
||||
env_overrides.insert("CI_JOB_NAME".into(), job_name.to_string());
|
||||
|
||||
let start_job = LocalStartJob {
|
||||
job_id: job.job_id.clone(),
|
||||
work_dir: work_dir.clone(),
|
||||
job_def: job.definition.clone(),
|
||||
env_overrides,
|
||||
};
|
||||
|
||||
// Perform git checkout inline (blocks this worker, acceptable for local runner).
|
||||
let sha = pipeline.commit_sha.clone();
|
||||
let repo_url = self.config.repo_url.clone();
|
||||
let git_ok = self.git_checkout(&repo_url, &sha, &work_dir);
|
||||
|
||||
if !git_ok {
|
||||
if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) {
|
||||
pipeline.set_job_status(
|
||||
job_name,
|
||||
JobStatus::Failed {
|
||||
reason: "git checkout failed".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
self.try_schedule_next(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Emit per-job running status.
|
||||
if let Some(pipeline) = self.pipelines.get(&pipeline_id) {
|
||||
self.emit_status(
|
||||
ctx,
|
||||
StatusUpdate {
|
||||
repo_owner: pipeline.repo_owner.clone(),
|
||||
repo_name: pipeline.repo_name.clone(),
|
||||
commit_sha: pipeline.commit_sha.clone(),
|
||||
state: "pending".into(),
|
||||
context: format!("ci/{job_name}"),
|
||||
description: format!("Job '{job_name}' is running"),
|
||||
target_url: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Spawn LocalRunner actor.
|
||||
let runner =
|
||||
crate::local_runner::LocalRunner::new(ctx.self_addr(), start_job);
|
||||
|
||||
match ctx.spawn(runner) {
|
||||
Ok(runner_addr) => {
|
||||
let job_id = JobId {
|
||||
pipeline_id,
|
||||
job_name: job_name.to_string(),
|
||||
};
|
||||
self.running_job = Some((job_id, runner_addr));
|
||||
}
|
||||
Err(_) => {
|
||||
if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) {
|
||||
pipeline.set_job_status(
|
||||
job_name,
|
||||
JobStatus::Failed {
|
||||
reason: "failed to spawn runner".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
self.try_schedule_next(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn git_checkout(&self, repo_url: &str, sha: &str, work_dir: &str) -> bool {
|
||||
let path = std::path::Path::new(work_dir);
|
||||
if path.join(".git").exists() {
|
||||
// Already cloned — fetch and checkout.
|
||||
let fetch = Command::new("git")
|
||||
.args(["fetch", "origin"])
|
||||
.current_dir(work_dir)
|
||||
.output();
|
||||
if fetch.is_err() || !fetch.unwrap().status.success() {
|
||||
return false;
|
||||
}
|
||||
let checkout = Command::new("git")
|
||||
.args(["checkout", sha])
|
||||
.current_dir(work_dir)
|
||||
.output();
|
||||
checkout.map(|o| o.status.success()).unwrap_or(false)
|
||||
} else {
|
||||
// Fresh clone.
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let clone = Command::new("git")
|
||||
.args(["clone", repo_url, work_dir])
|
||||
.output();
|
||||
if clone.is_err() || !clone.as_ref().unwrap().status.success() {
|
||||
return false;
|
||||
}
|
||||
let checkout = Command::new("git")
|
||||
.args(["checkout", sha])
|
||||
.current_dir(work_dir)
|
||||
.output();
|
||||
checkout.map(|o| o.status.success()).unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_job_complete(&mut self, ctx: &Ctx, complete: JobComplete) {
|
||||
let pipeline_id = complete.job_id.pipeline_id;
|
||||
let job_name = complete.job_id.job_name.clone();
|
||||
|
||||
let status = match complete.result {
|
||||
Ok(_) => JobStatus::Passed,
|
||||
Err(ref failure) => JobStatus::Failed {
|
||||
reason: failure.to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
// Build a description with output tail for failures.
|
||||
let description = match &status {
|
||||
JobStatus::Passed => format!("Job '{job_name}' passed"),
|
||||
JobStatus::Failed { reason } => {
|
||||
let output_tail = self
|
||||
.pipelines
|
||||
.get(&pipeline_id)
|
||||
.and_then(|p| p.jobs.get(&job_name))
|
||||
.map(|j| {
|
||||
let lines: Vec<&str> = j
|
||||
.output_lines
|
||||
.iter()
|
||||
.rev()
|
||||
.take(10)
|
||||
.map(|s| s.as_str())
|
||||
.collect();
|
||||
lines.into_iter().rev().collect::<Vec<_>>().join("\n")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut desc = format!("Job '{job_name}' failed: {reason}");
|
||||
if !output_tail.is_empty() {
|
||||
desc.push_str("\n");
|
||||
desc.push_str(&output_tail);
|
||||
}
|
||||
// Cap at ~250 chars for the status description field.
|
||||
if desc.len() > 250 {
|
||||
desc.truncate(247);
|
||||
desc.push_str("...");
|
||||
}
|
||||
desc
|
||||
}
|
||||
_ => format!("Job '{job_name}' completed"),
|
||||
};
|
||||
|
||||
// Emit per-job final status.
|
||||
if let Some(pipeline) = self.pipelines.get(&pipeline_id) {
|
||||
self.emit_status(
|
||||
ctx,
|
||||
StatusUpdate {
|
||||
repo_owner: pipeline.repo_owner.clone(),
|
||||
repo_name: pipeline.repo_name.clone(),
|
||||
commit_sha: pipeline.commit_sha.clone(),
|
||||
state: match &status {
|
||||
JobStatus::Passed => "success".into(),
|
||||
_ => "failure".into(),
|
||||
},
|
||||
context: format!("ci/{job_name}"),
|
||||
description,
|
||||
target_url: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) {
|
||||
pipeline.set_job_status(&job_name, status);
|
||||
}
|
||||
|
||||
// Clear running job.
|
||||
self.running_job = None;
|
||||
|
||||
// Schedule next.
|
||||
self.try_schedule_next(ctx);
|
||||
}
|
||||
|
||||
fn emit_status(&self, ctx: &Ctx, update: StatusUpdate) {
|
||||
if let Some(reporter_addr) = self.status_reporter_addr {
|
||||
let _ = ctx.send(
|
||||
reporter_addr,
|
||||
StatusReporterMsg::Report {
|
||||
update,
|
||||
forgejo_url: self.config.ci.forgejo_url.clone(),
|
||||
forgejo_token: self.config.ci.forgejo_token.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_pipeline_comment(&self, ctx: &Ctx, pipeline: &crate::pipeline::PipelineExecution) {
|
||||
let reporter_addr = match self.status_reporter_addr {
|
||||
Some(addr) => addr,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let job_outputs: Vec<JobOutput> = pipeline
|
||||
.jobs
|
||||
.values()
|
||||
.map(|job| {
|
||||
let passed = job.status == JobStatus::Passed;
|
||||
let failure_reason = match &job.status {
|
||||
JobStatus::Failed { reason } => Some(reason.clone()),
|
||||
_ => None,
|
||||
};
|
||||
JobOutput {
|
||||
job_name: job.definition.name.clone(),
|
||||
passed,
|
||||
failure_reason,
|
||||
output_lines: job.output_lines.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let _ = ctx.send(
|
||||
reporter_addr,
|
||||
StatusReporterMsg::PostPipelineComment {
|
||||
repo_owner: pipeline.repo_owner.clone(),
|
||||
repo_name: pipeline.repo_name.clone(),
|
||||
commit_sha: pipeline.commit_sha.clone(),
|
||||
branch: pipeline.branch.clone(),
|
||||
pipeline_name: pipeline.pipeline_name.clone(),
|
||||
pipeline_state: pipeline.status.forgejo_state().to_string(),
|
||||
job_outputs,
|
||||
forgejo_url: self.config.ci.forgejo_url.clone(),
|
||||
forgejo_token: self.config.ci.forgejo_token.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn archive_pipeline(&mut self, pipeline: PipelineExecution) {
|
||||
self.completed.push_back(pipeline);
|
||||
if self.completed.len() > 50 {
|
||||
self.completed.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
fn update_snapshot(&self) {
|
||||
let active: Vec<PipelineExecution> = self.pipelines.values().cloned().collect();
|
||||
let recent: Vec<PipelineExecution> = self
|
||||
.completed
|
||||
.iter()
|
||||
.rev()
|
||||
.take(20)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if let Ok(mut snap) = self.ci_snapshot.lock() {
|
||||
snap.active_pipelines = active;
|
||||
snap.recent_pipelines = recent;
|
||||
snap.has_running_job = self.running_job.is_some();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ActorInterface for LocalCoordinator {
|
||||
type Incoming = LocalCoordinatorMsg;
|
||||
type Response = ();
|
||||
|
||||
fn handle(&mut self, ctx: &Ctx, msg: LocalCoordinatorMsg) {
|
||||
match msg {
|
||||
LocalCoordinatorMsg::Webhook(event) => self.handle_webhook(ctx, event),
|
||||
LocalCoordinatorMsg::SetCiYaml(yaml) => {
|
||||
self.ci_yaml = Some(yaml);
|
||||
}
|
||||
LocalCoordinatorMsg::JobProgress(progress) => {
|
||||
if let Some(pipeline) = self.pipelines.get_mut(&progress.job_id.pipeline_id) {
|
||||
if let Some(job) = pipeline.jobs.get_mut(&progress.job_id.job_name) {
|
||||
job.output_lines.push(progress.output_line);
|
||||
}
|
||||
}
|
||||
}
|
||||
LocalCoordinatorMsg::JobComplete(complete) => {
|
||||
self.handle_job_complete(ctx, complete);
|
||||
}
|
||||
LocalCoordinatorMsg::GitReady {
|
||||
pipeline_id,
|
||||
job_name,
|
||||
work_dir,
|
||||
} => {
|
||||
// Git ready is used in the async variant; for now handled inline in start_job.
|
||||
let _ = (pipeline_id, job_name, work_dir);
|
||||
}
|
||||
}
|
||||
|
||||
self.update_snapshot();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,304 +0,0 @@
|
|||
//! LocalRunner actor: executes job commands directly on the host via shell.
|
||||
//!
|
||||
//! Short-lived actor, one per job. Spawned by LocalCoordinator when a job
|
||||
//! is ready to execute.
|
||||
|
||||
use std::io::BufRead;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||
|
||||
use crate::local_coordinator::LocalCoordinatorMsg;
|
||||
use crate::{JobComplete, JobFailure, JobProgress, JobSuccess, LocalStartJob};
|
||||
|
||||
/// Messages the LocalRunner can receive.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LocalRunnerMsg {
|
||||
/// Begin executing the job (sent to self in on_start).
|
||||
Execute,
|
||||
/// Simulated: job completed (for testing without real shell).
|
||||
SimComplete(Result<(), String>),
|
||||
}
|
||||
|
||||
/// LocalRunner actor state.
|
||||
pub struct LocalRunner {
|
||||
coordinator_addr: ActorAddress,
|
||||
start_job: LocalStartJob,
|
||||
}
|
||||
|
||||
impl LocalRunner {
|
||||
pub fn new(coordinator_addr: ActorAddress, start_job: LocalStartJob) -> Self {
|
||||
Self {
|
||||
coordinator_addr,
|
||||
start_job,
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute all commands in the job definition, streaming output back.
|
||||
fn execute(&self, ctx: &Ctx) {
|
||||
let job_id = &self.start_job.job_id;
|
||||
let work_dir = &self.start_job.work_dir;
|
||||
let timeout_secs = self.start_job.job_def.timeout_secs;
|
||||
|
||||
eprintln!(
|
||||
"[runner] job {}/{} starting ({} commands, timeout {}s, workdir {})",
|
||||
job_id.pipeline_id.0,
|
||||
job_id.job_name,
|
||||
self.start_job.job_def.run.len(),
|
||||
timeout_secs,
|
||||
work_dir,
|
||||
);
|
||||
|
||||
for cmd_str in &self.start_job.job_def.run {
|
||||
eprintln!("[runner] exec: {cmd_str}");
|
||||
|
||||
// Send progress: command being run.
|
||||
let _ = ctx.send(
|
||||
self.coordinator_addr,
|
||||
LocalCoordinatorMsg::JobProgress(JobProgress {
|
||||
job_id: job_id.clone(),
|
||||
output_line: format!("$ {cmd_str}"),
|
||||
}),
|
||||
);
|
||||
|
||||
let child_result = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(cmd_str)
|
||||
.current_dir(work_dir)
|
||||
.envs(&self.start_job.env_overrides)
|
||||
.envs(&self.start_job.job_def.env)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn();
|
||||
|
||||
let mut child = match child_result {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("[runner] spawn failed: {e}");
|
||||
let _ = ctx.send(
|
||||
self.coordinator_addr,
|
||||
LocalCoordinatorMsg::JobComplete(JobComplete {
|
||||
job_id: job_id.clone(),
|
||||
result: Err(JobFailure::ExecError(e.to_string())),
|
||||
artifacts: Vec::new(),
|
||||
}),
|
||||
);
|
||||
ctx.stop_self();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Timeout: spawn a thread that kills the child after timeout_secs.
|
||||
let child_id = child.id();
|
||||
let kill_flag = Arc::new(Mutex::new(false));
|
||||
let kill_flag_clone = Arc::clone(&kill_flag);
|
||||
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
|
||||
let timeout_handle = std::thread::spawn(move || {
|
||||
if done_rx
|
||||
.recv_timeout(std::time::Duration::from_secs(timeout_secs))
|
||||
.is_err()
|
||||
{
|
||||
*kill_flag_clone.lock().unwrap() = true;
|
||||
// Actually kill the child process so the pipe readers unblock.
|
||||
let _ = std::process::Command::new("kill")
|
||||
.args(["-9", &child_id.to_string()])
|
||||
.status();
|
||||
}
|
||||
});
|
||||
|
||||
// Read stdout and stderr concurrently to avoid pipe-buffer deadlock.
|
||||
let (last_lines, timed_out) =
|
||||
drain_child_output(&mut child, job_id, ctx, self.coordinator_addr, &kill_flag);
|
||||
|
||||
let status = child.wait();
|
||||
|
||||
// Signal timeout thread that we're done.
|
||||
let _ = done_tx.send(());
|
||||
let _ = timeout_handle.join();
|
||||
|
||||
if timed_out || *kill_flag.lock().unwrap() {
|
||||
eprintln!("[runner] command timed out after {timeout_secs}s");
|
||||
let _ = ctx.send(
|
||||
self.coordinator_addr,
|
||||
LocalCoordinatorMsg::JobComplete(JobComplete {
|
||||
job_id: job_id.clone(),
|
||||
result: Err(JobFailure::Timeout),
|
||||
artifacts: Vec::new(),
|
||||
}),
|
||||
);
|
||||
ctx.stop_self();
|
||||
return;
|
||||
}
|
||||
|
||||
match status {
|
||||
Ok(exit) if exit.success() => {
|
||||
eprintln!("[runner] command succeeded");
|
||||
}
|
||||
Ok(exit) => {
|
||||
let exit_code = exit.code().unwrap_or(-1);
|
||||
eprintln!("[runner] command failed (exit {exit_code})");
|
||||
for line in &last_lines {
|
||||
eprintln!("[runner] {line}");
|
||||
}
|
||||
let _ = ctx.send(
|
||||
self.coordinator_addr,
|
||||
LocalCoordinatorMsg::JobComplete(JobComplete {
|
||||
job_id: job_id.clone(),
|
||||
result: Err(JobFailure::CommandFailed {
|
||||
exit_code,
|
||||
last_lines,
|
||||
}),
|
||||
artifacts: Vec::new(),
|
||||
}),
|
||||
);
|
||||
ctx.stop_self();
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[runner] wait failed: {e}");
|
||||
let _ = ctx.send(
|
||||
self.coordinator_addr,
|
||||
LocalCoordinatorMsg::JobComplete(JobComplete {
|
||||
job_id: job_id.clone(),
|
||||
result: Err(JobFailure::ExecError(e.to_string())),
|
||||
artifacts: Vec::new(),
|
||||
}),
|
||||
);
|
||||
ctx.stop_self();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"[runner] job {}/{} passed",
|
||||
job_id.pipeline_id.0, job_id.job_name
|
||||
);
|
||||
let _ = ctx.send(
|
||||
self.coordinator_addr,
|
||||
LocalCoordinatorMsg::JobComplete(JobComplete {
|
||||
job_id: job_id.clone(),
|
||||
result: Ok(JobSuccess),
|
||||
artifacts: Vec::new(),
|
||||
}),
|
||||
);
|
||||
ctx.stop_self();
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain stdout and stderr from a child process concurrently.
|
||||
///
|
||||
/// Spawns a background thread for stderr so that both pipes are consumed
|
||||
/// in parallel, preventing the classic pipe-buffer deadlock where the child
|
||||
/// blocks writing to a full stderr while the parent blocks reading stdout.
|
||||
///
|
||||
/// Returns (last_lines, timed_out).
|
||||
fn drain_child_output(
|
||||
child: &mut Child,
|
||||
job_id: &crate::JobId,
|
||||
ctx: &Ctx,
|
||||
coordinator_addr: ActorAddress,
|
||||
kill_flag: &Arc<Mutex<bool>>,
|
||||
) -> (Vec<String>, bool) {
|
||||
let stdout = child.stdout.take();
|
||||
let stderr = child.stderr.take();
|
||||
|
||||
// Collect stderr on a background thread.
|
||||
let stderr_job_id = job_id.clone();
|
||||
let stderr_kill = Arc::clone(kill_flag);
|
||||
let stderr_handle = std::thread::spawn(move || {
|
||||
let mut lines = Vec::new();
|
||||
if let Some(stderr) = stderr {
|
||||
let reader = std::io::BufReader::new(stderr);
|
||||
for line in reader.lines() {
|
||||
if *stderr_kill.lock().unwrap() {
|
||||
break;
|
||||
}
|
||||
if let Ok(line) = line {
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
lines
|
||||
});
|
||||
|
||||
// Read stdout on the current thread, streaming progress.
|
||||
let mut last_lines: Vec<String> = Vec::new();
|
||||
if let Some(stdout) = stdout {
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
for line in reader.lines() {
|
||||
if let Ok(line) = line {
|
||||
let _ = ctx.send(
|
||||
coordinator_addr,
|
||||
LocalCoordinatorMsg::JobProgress(JobProgress {
|
||||
job_id: job_id.clone(),
|
||||
output_line: line.clone(),
|
||||
}),
|
||||
);
|
||||
last_lines.push(line);
|
||||
if last_lines.len() > 50 {
|
||||
last_lines.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Join stderr thread and stream its lines as progress.
|
||||
let timed_out = *kill_flag.lock().unwrap();
|
||||
let stderr_lines = stderr_handle.join().unwrap_or_default();
|
||||
for line in &stderr_lines {
|
||||
let _ = ctx.send(
|
||||
coordinator_addr,
|
||||
LocalCoordinatorMsg::JobProgress(JobProgress {
|
||||
job_id: stderr_job_id.clone(),
|
||||
output_line: format!("[stderr] {line}"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Merge stderr into last_lines tail.
|
||||
for line in stderr_lines {
|
||||
last_lines.push(line);
|
||||
if last_lines.len() > 50 {
|
||||
last_lines.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
(last_lines, timed_out)
|
||||
}
|
||||
|
||||
impl ActorInterface for LocalRunner {
|
||||
type Incoming = LocalRunnerMsg;
|
||||
type Response = ();
|
||||
|
||||
fn on_start(&mut self, ctx: &Ctx) {
|
||||
let _ = ctx.send(ctx.self_addr(), LocalRunnerMsg::Execute);
|
||||
}
|
||||
|
||||
fn handle(&mut self, ctx: &Ctx, msg: LocalRunnerMsg) {
|
||||
match msg {
|
||||
LocalRunnerMsg::Execute => {
|
||||
self.execute(ctx);
|
||||
}
|
||||
LocalRunnerMsg::SimComplete(result) => {
|
||||
let complete = JobComplete {
|
||||
job_id: self.start_job.job_id.clone(),
|
||||
result: match result {
|
||||
Ok(()) => Ok(JobSuccess),
|
||||
Err(msg) => Err(JobFailure::CommandFailed {
|
||||
exit_code: 1,
|
||||
last_lines: vec![msg],
|
||||
}),
|
||||
},
|
||||
artifacts: Vec::new(),
|
||||
};
|
||||
let _ = ctx.send(
|
||||
self.coordinator_addr,
|
||||
LocalCoordinatorMsg::JobComplete(complete),
|
||||
);
|
||||
ctx.stop_self();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
//! Provisioner actor: manages spot instance lifecycle via pluggable provider scripts.
|
||||
//!
|
||||
//! In real deployment, runs on the developer's laptop and calls cloud provider APIs.
|
||||
//! In simulation, provisions are driven by the simulation harness.
|
||||
|
||||
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||
|
||||
use crate::coordinator::CoordinatorMsg;
|
||||
use crate::{
|
||||
InstanceReady, ProvisionError, ProvisionRequest, ProvisionResponse, TerminateRequest,
|
||||
};
|
||||
|
||||
/// Messages the Provisioner can receive.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ProvisionerMsg {
|
||||
/// Request to provision a new spot instance.
|
||||
Provision(ProvisionRequest),
|
||||
/// Request to terminate a spot instance.
|
||||
Terminate(TerminateRequest),
|
||||
/// Simulated: provisioning result delivered asynchronously.
|
||||
SimProvisionResult {
|
||||
request: ProvisionRequest,
|
||||
result: Result<InstanceReady, ProvisionError>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Provisioner actor state.
|
||||
///
|
||||
/// In real deployment, this would invoke provider scripts.
|
||||
/// In simulation, the sim harness controls provision outcomes.
|
||||
pub struct Provisioner {
|
||||
coordinator_addr: ActorAddress,
|
||||
/// Active instances tracked for cleanup.
|
||||
active_instances: Vec<String>,
|
||||
}
|
||||
|
||||
impl Provisioner {
|
||||
pub fn new(coordinator_addr: ActorAddress) -> Self {
|
||||
Self {
|
||||
coordinator_addr,
|
||||
active_instances: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_instances(&self) -> &[String] {
|
||||
&self.active_instances
|
||||
}
|
||||
}
|
||||
|
||||
impl ActorInterface for Provisioner {
|
||||
type Incoming = ProvisionerMsg;
|
||||
type Response = ();
|
||||
|
||||
fn handle(&mut self, ctx: &Ctx, msg: ProvisionerMsg) {
|
||||
match msg {
|
||||
ProvisionerMsg::Provision(_request) => {
|
||||
// In real deployment: invoke provider script, await result.
|
||||
// In simulation: the sim harness sends SimProvisionResult.
|
||||
}
|
||||
ProvisionerMsg::Terminate(request) => {
|
||||
self.active_instances.retain(|id| id != &request.instance_id);
|
||||
// In real deployment: invoke provider destroy script.
|
||||
// In simulation: just track the termination.
|
||||
let _ = ctx.send(
|
||||
self.coordinator_addr,
|
||||
CoordinatorMsg::ProvisionResponse(ProvisionResponse {
|
||||
job_id: request.job_id.clone(),
|
||||
result: Err(ProvisionError::NoCapacity), // placeholder, terminate doesn't need response
|
||||
}),
|
||||
);
|
||||
}
|
||||
ProvisionerMsg::SimProvisionResult { request, result } => {
|
||||
if let Ok(ref instance) = result {
|
||||
self.active_instances.push(instance.instance_id.clone());
|
||||
}
|
||||
let _ = ctx.send(
|
||||
self.coordinator_addr,
|
||||
CoordinatorMsg::ProvisionResponse(ProvisionResponse {
|
||||
job_id: request.job_id,
|
||||
result,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
//! RunnerSupervisor actor: manages SSH session and job execution on a spot instance.
|
||||
//!
|
||||
//! Spawned per-job by the Coordinator. Owns the connection to the spot instance.
|
||||
|
||||
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||
|
||||
use crate::coordinator::CoordinatorMsg;
|
||||
use crate::{JobComplete, JobFailure, JobProgress, JobSuccess, StartJob};
|
||||
|
||||
/// Messages the RunnerSupervisor can receive.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RunnerMsg {
|
||||
/// Begin executing the job (sent immediately after spawn via on_start).
|
||||
Execute,
|
||||
/// Simulated: job command output line.
|
||||
OutputLine(String),
|
||||
/// Simulated: job completed successfully.
|
||||
SimComplete(Result<(), String>),
|
||||
}
|
||||
|
||||
/// RunnerSupervisor actor state.
|
||||
///
|
||||
/// In real deployment, this would manage an SSH connection.
|
||||
/// In simulation, job execution is driven by external messages.
|
||||
pub struct RunnerSupervisor {
|
||||
coordinator_addr: ActorAddress,
|
||||
start_job: StartJob,
|
||||
}
|
||||
|
||||
impl RunnerSupervisor {
|
||||
pub fn new(coordinator_addr: ActorAddress, start_job: StartJob) -> Self {
|
||||
Self {
|
||||
coordinator_addr,
|
||||
start_job,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ActorInterface for RunnerSupervisor {
|
||||
type Incoming = RunnerMsg;
|
||||
type Response = ();
|
||||
|
||||
fn on_start(&mut self, ctx: &Ctx) {
|
||||
// In simulation, the sim harness will send SimComplete messages.
|
||||
// In real deployment, this would initiate SSH connection + command execution.
|
||||
let _ = ctx.send(ctx.self_addr(), RunnerMsg::Execute);
|
||||
}
|
||||
|
||||
fn handle(&mut self, ctx: &Ctx, msg: RunnerMsg) {
|
||||
match msg {
|
||||
RunnerMsg::Execute => {
|
||||
// In real mode, we'd SSH into the instance and run commands.
|
||||
// In simulation, this is a no-op; SimComplete drives completion.
|
||||
}
|
||||
RunnerMsg::OutputLine(line) => {
|
||||
let _ = ctx.send(
|
||||
self.coordinator_addr,
|
||||
CoordinatorMsg::JobProgress(JobProgress {
|
||||
job_id: self.start_job.job_id.clone(),
|
||||
output_line: line,
|
||||
}),
|
||||
);
|
||||
}
|
||||
RunnerMsg::SimComplete(result) => {
|
||||
let complete = JobComplete {
|
||||
job_id: self.start_job.job_id.clone(),
|
||||
result: match result {
|
||||
Ok(()) => Ok(JobSuccess),
|
||||
Err(msg) => Err(JobFailure::CommandFailed {
|
||||
exit_code: 1,
|
||||
last_lines: vec![msg],
|
||||
}),
|
||||
},
|
||||
artifacts: Vec::new(),
|
||||
};
|
||||
let _ = ctx.send(
|
||||
self.coordinator_addr,
|
||||
CoordinatorMsg::JobComplete(complete),
|
||||
);
|
||||
ctx.stop_self();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,327 +0,0 @@
|
|||
//! StatusReporter actor: fire-and-forget Forgejo commit status updates.
|
||||
//!
|
||||
//! Receives status update messages and POSTs them to the Forgejo API.
|
||||
//! Can also post pipeline summary comments to PRs.
|
||||
|
||||
use swactor::actor::{ActorInterface, Ctx};
|
||||
|
||||
use crate::StatusUpdate;
|
||||
|
||||
/// Captured output for a single job, used to build PR comments.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JobOutput {
|
||||
pub job_name: String,
|
||||
pub passed: bool,
|
||||
pub failure_reason: Option<String>,
|
||||
pub output_lines: Vec<String>,
|
||||
}
|
||||
|
||||
/// Messages the StatusReporter can receive.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum StatusReporterMsg {
|
||||
Report {
|
||||
update: StatusUpdate,
|
||||
forgejo_url: String,
|
||||
forgejo_token: String,
|
||||
},
|
||||
PostPipelineComment {
|
||||
repo_owner: String,
|
||||
repo_name: String,
|
||||
commit_sha: String,
|
||||
branch: String,
|
||||
pipeline_name: String,
|
||||
pipeline_state: String,
|
||||
job_outputs: Vec<JobOutput>,
|
||||
forgejo_url: String,
|
||||
forgejo_token: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// StatusReporter actor state.
|
||||
pub struct StatusReporter;
|
||||
|
||||
impl StatusReporter {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
#[cfg(feature = "local")]
|
||||
fn post_status(update: &StatusUpdate, forgejo_url: &str, forgejo_token: &str) {
|
||||
let url = format!(
|
||||
"{}/api/v1/repos/{}/{}/statuses/{}",
|
||||
forgejo_url.trim_end_matches('/'),
|
||||
update.repo_owner,
|
||||
update.repo_name,
|
||||
update.commit_sha,
|
||||
);
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
"state": update.state,
|
||||
"context": update.context,
|
||||
"description": update.description,
|
||||
});
|
||||
|
||||
if let Some(ref target_url) = update.target_url {
|
||||
body["target_url"] = serde_json::Value::String(target_url.clone());
|
||||
}
|
||||
|
||||
let result = ureq::post(&url)
|
||||
.set("Authorization", &format!("token {forgejo_token}"))
|
||||
.set("Content-Type", "application/json")
|
||||
.send_string(&body.to_string());
|
||||
|
||||
if let Err(e) = result {
|
||||
eprintln!("StatusReporter: failed to post status to {url}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "local")]
|
||||
fn find_pr_for_branch(
|
||||
forgejo_url: &str,
|
||||
forgejo_token: &str,
|
||||
repo_owner: &str,
|
||||
repo_name: &str,
|
||||
branch: &str,
|
||||
) -> Option<u64> {
|
||||
let url = format!(
|
||||
"{}/api/v1/repos/{}/{}/pulls?state=open&limit=50",
|
||||
forgejo_url.trim_end_matches('/'),
|
||||
repo_owner,
|
||||
repo_name,
|
||||
);
|
||||
|
||||
let response = ureq::get(&url)
|
||||
.set("Authorization", &format!("token {forgejo_token}"))
|
||||
.call();
|
||||
|
||||
let response = match response {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("StatusReporter: failed to list PRs: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let body: serde_json::Value = match response.into_json() {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("StatusReporter: failed to parse PR list: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let prs = body.as_array()?;
|
||||
for pr in prs {
|
||||
let head_ref = pr.get("head")?.get("ref")?.as_str()?;
|
||||
if head_ref == branch {
|
||||
return pr.get("number")?.as_u64();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(feature = "local")]
|
||||
fn post_pr_comment(
|
||||
forgejo_url: &str,
|
||||
forgejo_token: &str,
|
||||
repo_owner: &str,
|
||||
repo_name: &str,
|
||||
pr_number: u64,
|
||||
body_text: &str,
|
||||
) -> Option<String> {
|
||||
let url = format!(
|
||||
"{}/api/v1/repos/{}/{}/issues/{}/comments",
|
||||
forgejo_url.trim_end_matches('/'),
|
||||
repo_owner,
|
||||
repo_name,
|
||||
pr_number,
|
||||
);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"body": body_text,
|
||||
});
|
||||
|
||||
let result = ureq::post(&url)
|
||||
.set("Authorization", &format!("token {forgejo_token}"))
|
||||
.set("Content-Type", "application/json")
|
||||
.send_string(&body.to_string());
|
||||
|
||||
match result {
|
||||
Ok(response) => {
|
||||
let json: serde_json::Value = response.into_json().ok()?;
|
||||
json.get("html_url")?.as_str().map(|s| s.to_string())
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("StatusReporter: failed to post PR comment: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "local")]
|
||||
fn build_pipeline_comment(
|
||||
pipeline_name: &str,
|
||||
pipeline_state: &str,
|
||||
commit_sha: &str,
|
||||
job_outputs: &[JobOutput],
|
||||
) -> String {
|
||||
let mut md = format!("## Pipeline `{pipeline_name}` — {pipeline_state}\n\n");
|
||||
let short_sha = if commit_sha.len() > 7 {
|
||||
&commit_sha[..7]
|
||||
} else {
|
||||
commit_sha
|
||||
};
|
||||
md.push_str(&format!("Commit: `{short_sha}`\n\n"));
|
||||
|
||||
for job in job_outputs {
|
||||
let status_label = if job.passed {
|
||||
"passed".to_string()
|
||||
} else {
|
||||
match &job.failure_reason {
|
||||
Some(reason) => format!("failed: {reason}"),
|
||||
None => "failed".to_string(),
|
||||
}
|
||||
};
|
||||
|
||||
md.push_str(&format!(
|
||||
"<details>\n<summary>{} — {}</summary>\n\n",
|
||||
job.job_name, status_label
|
||||
));
|
||||
|
||||
let max_lines = 100;
|
||||
let total = job.output_lines.len();
|
||||
let lines: &[String] = if total > max_lines {
|
||||
md.push_str(&format!("_Showing last {max_lines} of {total} lines_\n\n"));
|
||||
&job.output_lines[total - max_lines..]
|
||||
} else {
|
||||
&job.output_lines
|
||||
};
|
||||
|
||||
md.push_str("```\n");
|
||||
for line in lines {
|
||||
md.push_str(line);
|
||||
md.push('\n');
|
||||
}
|
||||
md.push_str("```\n\n</details>\n\n");
|
||||
}
|
||||
|
||||
md
|
||||
}
|
||||
|
||||
#[cfg(feature = "local")]
|
||||
fn handle_pipeline_comment(
|
||||
repo_owner: &str,
|
||||
repo_name: &str,
|
||||
commit_sha: &str,
|
||||
branch: &str,
|
||||
pipeline_name: &str,
|
||||
pipeline_state: &str,
|
||||
job_outputs: &[JobOutput],
|
||||
forgejo_url: &str,
|
||||
forgejo_token: &str,
|
||||
) {
|
||||
let pr_number = match Self::find_pr_for_branch(
|
||||
forgejo_url,
|
||||
forgejo_token,
|
||||
repo_owner,
|
||||
repo_name,
|
||||
branch,
|
||||
) {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
eprintln!(
|
||||
"StatusReporter: no open PR for branch '{branch}', skipping comment"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let comment_body =
|
||||
Self::build_pipeline_comment(pipeline_name, pipeline_state, commit_sha, job_outputs);
|
||||
|
||||
let comment_url = Self::post_pr_comment(
|
||||
forgejo_url,
|
||||
forgejo_token,
|
||||
repo_owner,
|
||||
repo_name,
|
||||
pr_number,
|
||||
&comment_body,
|
||||
);
|
||||
|
||||
// Re-post pipeline status with target_url pointing to the comment.
|
||||
if let Some(ref url) = comment_url {
|
||||
let update = StatusUpdate {
|
||||
repo_owner: repo_owner.to_string(),
|
||||
repo_name: repo_name.to_string(),
|
||||
commit_sha: commit_sha.to_string(),
|
||||
state: pipeline_state.to_string(),
|
||||
context: format!("ci/{pipeline_name}"),
|
||||
description: format!("Pipeline '{pipeline_name}' {pipeline_state}"),
|
||||
target_url: Some(url.clone()),
|
||||
};
|
||||
Self::post_status(&update, forgejo_url, forgejo_token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ActorInterface for StatusReporter {
|
||||
type Incoming = StatusReporterMsg;
|
||||
type Response = ();
|
||||
|
||||
fn handle(&mut self, _ctx: &Ctx, msg: StatusReporterMsg) {
|
||||
match msg {
|
||||
StatusReporterMsg::Report {
|
||||
update,
|
||||
forgejo_url,
|
||||
forgejo_token,
|
||||
} => {
|
||||
#[cfg(feature = "local")]
|
||||
Self::post_status(&update, &forgejo_url, &forgejo_token);
|
||||
|
||||
#[cfg(not(feature = "local"))]
|
||||
{
|
||||
let _ = (update, forgejo_url, forgejo_token);
|
||||
}
|
||||
}
|
||||
StatusReporterMsg::PostPipelineComment {
|
||||
repo_owner,
|
||||
repo_name,
|
||||
commit_sha,
|
||||
branch,
|
||||
pipeline_name,
|
||||
pipeline_state,
|
||||
job_outputs,
|
||||
forgejo_url,
|
||||
forgejo_token,
|
||||
} => {
|
||||
#[cfg(feature = "local")]
|
||||
Self::handle_pipeline_comment(
|
||||
&repo_owner,
|
||||
&repo_name,
|
||||
&commit_sha,
|
||||
&branch,
|
||||
&pipeline_name,
|
||||
&pipeline_state,
|
||||
&job_outputs,
|
||||
&forgejo_url,
|
||||
&forgejo_token,
|
||||
);
|
||||
|
||||
#[cfg(not(feature = "local"))]
|
||||
{
|
||||
let _ = (
|
||||
repo_owner,
|
||||
repo_name,
|
||||
commit_sha,
|
||||
branch,
|
||||
pipeline_name,
|
||||
pipeline_state,
|
||||
job_outputs,
|
||||
forgejo_url,
|
||||
forgejo_token,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
//! Webhook HTTP listener: receives Forgejo webhook POSTs and forwards
|
||||
//! them to the LocalCoordinator actor.
|
||||
//!
|
||||
//! Runs as a standard thread (not an actor) using `tiny_http`.
|
||||
|
||||
use crate::{EventType, WebhookEvent};
|
||||
|
||||
/// Start the webhook listener in a new thread.
|
||||
///
|
||||
/// Returns a join handle for the listener thread.
|
||||
#[cfg(feature = "local")]
|
||||
pub fn start_webhook_listener(
|
||||
port: u16,
|
||||
secret: String,
|
||||
runtime: std::sync::Arc<swactor::runtime::Runtime>,
|
||||
coordinator_addr: swactor::actor::ActorAddress,
|
||||
) -> std::thread::JoinHandle<()> {
|
||||
std::thread::Builder::new()
|
||||
.name("webhook-listener".into())
|
||||
.spawn(move || {
|
||||
let server = tiny_http::Server::http(format!("0.0.0.0:{port}"))
|
||||
.expect("failed to start webhook server");
|
||||
|
||||
eprintln!("Webhook listener on http://0.0.0.0:{port}");
|
||||
|
||||
for mut request in server.incoming_requests() {
|
||||
let response = handle_request(&mut request, &secret, &runtime, coordinator_addr);
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
})
|
||||
.expect("failed to spawn webhook listener thread")
|
||||
}
|
||||
|
||||
#[cfg(feature = "local")]
|
||||
fn handle_request(
|
||||
request: &mut tiny_http::Request,
|
||||
secret: &str,
|
||||
runtime: &std::sync::Arc<swactor::runtime::Runtime>,
|
||||
coordinator_addr: swactor::actor::ActorAddress,
|
||||
) -> tiny_http::Response<std::io::Cursor<Vec<u8>>> {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use crate::local_coordinator::LocalCoordinatorMsg;
|
||||
|
||||
// Only accept POST.
|
||||
if request.method() != &tiny_http::Method::Post {
|
||||
return tiny_http::Response::from_string("method not allowed")
|
||||
.with_status_code(405);
|
||||
}
|
||||
|
||||
// Read body.
|
||||
let mut body = String::new();
|
||||
if let Err(e) = std::io::Read::read_to_string(&mut request.as_reader(), &mut body) {
|
||||
eprintln!("webhook: failed to read body: {e}");
|
||||
return tiny_http::Response::from_string("bad request")
|
||||
.with_status_code(400);
|
||||
}
|
||||
|
||||
// Verify HMAC-SHA256 signature if secret is non-empty.
|
||||
if !secret.is_empty() {
|
||||
let sig_header = request
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|h| h.field.equiv("X-Forgejo-Signature"))
|
||||
.map(|h| h.value.as_str().to_string());
|
||||
|
||||
match sig_header {
|
||||
Some(sig_hex) => {
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
|
||||
.expect("HMAC key creation");
|
||||
hmac::Mac::update(&mut mac, body.as_bytes());
|
||||
let expected = hex::encode(mac.finalize().into_bytes());
|
||||
if sig_hex != expected {
|
||||
eprintln!("webhook: signature mismatch");
|
||||
return tiny_http::Response::from_string("unauthorized")
|
||||
.with_status_code(401);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
eprintln!("webhook: missing signature header");
|
||||
return tiny_http::Response::from_string("unauthorized")
|
||||
.with_status_code(401);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine event type from Forgejo header.
|
||||
let event_header = request
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|h| h.field.equiv("X-Forgejo-Event"))
|
||||
.map(|h| h.value.as_str().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let event_type = match event_header.as_str() {
|
||||
"push" => EventType::Push,
|
||||
"create" => EventType::Tag,
|
||||
"pull_request" => EventType::Merge,
|
||||
other => {
|
||||
eprintln!("webhook: ignoring event type '{other}'");
|
||||
return tiny_http::Response::from_string("ignored").with_status_code(200);
|
||||
}
|
||||
};
|
||||
|
||||
// Parse JSON body to extract fields.
|
||||
let json: serde_json::Value = match serde_json::from_str(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("webhook: failed to parse JSON: {e}");
|
||||
return tiny_http::Response::from_string("bad json").with_status_code(400);
|
||||
}
|
||||
};
|
||||
|
||||
let webhook_event = match parse_webhook_json(&json, event_type) {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
eprintln!("webhook: could not extract webhook fields from JSON");
|
||||
return tiny_http::Response::from_string("bad payload").with_status_code(400);
|
||||
}
|
||||
};
|
||||
|
||||
// Send to coordinator.
|
||||
let _ = runtime.send_to(coordinator_addr, LocalCoordinatorMsg::Webhook(webhook_event));
|
||||
|
||||
tiny_http::Response::from_string("ok").with_status_code(200)
|
||||
}
|
||||
|
||||
/// Parse a Forgejo webhook JSON payload into a WebhookEvent.
|
||||
pub fn parse_webhook_json(json: &serde_json::Value, event_type: EventType) -> Option<WebhookEvent> {
|
||||
let repo = json.get("repository")?;
|
||||
let repo_owner = repo
|
||||
.get("owner")
|
||||
.and_then(|o| o.get("login"))
|
||||
.or_else(|| repo.get("owner").and_then(|o| o.get("username")))
|
||||
.and_then(|v| v.as_str())?
|
||||
.to_string();
|
||||
let repo_name = repo.get("name").and_then(|v| v.as_str())?.to_string();
|
||||
|
||||
let (branch, commit_sha, tag) = match event_type {
|
||||
EventType::Push => {
|
||||
let reference = json.get("ref").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let branch = reference.strip_prefix("refs/heads/").unwrap_or(reference);
|
||||
let sha = json
|
||||
.get("after")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
(branch.to_string(), sha, None)
|
||||
}
|
||||
EventType::Tag => {
|
||||
let reference = json.get("ref").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let tag_name = reference.strip_prefix("refs/tags/").unwrap_or(reference);
|
||||
let sha = json
|
||||
.get("sha")
|
||||
.or_else(|| json.get("after"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
(String::new(), sha, Some(tag_name.to_string()))
|
||||
}
|
||||
EventType::Merge => {
|
||||
let pr = json.get("pull_request")?;
|
||||
let branch = pr
|
||||
.get("head")
|
||||
.and_then(|h| h.get("ref"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let sha = pr
|
||||
.get("head")
|
||||
.and_then(|h| h.get("sha"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
(branch, sha, None)
|
||||
}
|
||||
};
|
||||
|
||||
Some(WebhookEvent {
|
||||
event_type,
|
||||
repo_owner,
|
||||
repo_name,
|
||||
branch,
|
||||
commit_sha,
|
||||
tag,
|
||||
})
|
||||
}
|
||||
|
|
@ -9,13 +9,13 @@ to stdout (one object per line). Human-readable output goes to stderr.
|
|||
### Launching
|
||||
|
||||
```bash
|
||||
cargo run -p runtime-dashboard --example investigate_demo
|
||||
cargo run -p dashboard --example investigate_demo
|
||||
```
|
||||
|
||||
Or programmatically against any running runtime:
|
||||
|
||||
```rust
|
||||
use runtime_dashboard::investigate::run_investigate;
|
||||
use dashboard::investigate::run_investigate;
|
||||
run_investigate(runtime_arc, collector_arc)?; // blocks on stdin
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ edition = "2024"
|
|||
|
||||
[dependencies]
|
||||
swactor = { path = "../..", features = ["serde", "tracing"] }
|
||||
swactor-std = { path = "../std", default-features = false }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["registry"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
|
@ -16,25 +15,14 @@ tokio-stream = "0.1"
|
|||
crossbeam-queue = "0.3.12"
|
||||
ratatui = { version = "0.29", optional = true, default-features = false, features = ["crossterm"] }
|
||||
crossterm = { version = "0.28", optional = true }
|
||||
distribution = { path = "../distribution", optional = true }
|
||||
clap = { version = "4", features = ["derive"], optional = true }
|
||||
ctrlc = "3"
|
||||
iroh = { version = "0.96", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["distribution"]
|
||||
default = []
|
||||
tui = ["dep:ratatui", "dep:crossterm"]
|
||||
distribution = ["dep:distribution"]
|
||||
node = ["distribution", "dep:clap", "swactor/transport", "tcp"]
|
||||
tcp = ["distribution/tcp"]
|
||||
iroh = ["distribution/iroh", "dep:iroh"]
|
||||
|
||||
[[bin]]
|
||||
name = "swactor-tui"
|
||||
path = "src/bin/tui.rs"
|
||||
required-features = ["tui"]
|
||||
|
||||
[[bin]]
|
||||
name = "swactor-node"
|
||||
path = "src/bin/swactor-node.rs"
|
||||
required-features = ["node"]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# runtime-dashboard
|
||||
# dashboard
|
||||
|
||||
Visual dashboard for the swactor runtime. Provides a live HTTP dashboard, a
|
||||
terminal UI (TUI), trace recording/replay, and an HTTP API for programmatic
|
||||
|
|
@ -16,7 +16,7 @@ runtime investigation.
|
|||
Start the dashboard demo and open it in a browser:
|
||||
|
||||
```bash
|
||||
cargo run -p runtime-dashboard --example dashboard_demo
|
||||
cargo run -p dashboard --example dashboard_demo
|
||||
```
|
||||
|
||||
Pages:
|
||||
|
|
@ -33,9 +33,9 @@ membership and actor registrations in the directory/cache.
|
|||
A standalone binary that connects to any running dashboard over SSE:
|
||||
|
||||
```bash
|
||||
cargo run -p runtime-dashboard --features tui --bin swactor-tui
|
||||
cargo run -p dashboard --features tui --bin swactor-tui
|
||||
# or point at a specific endpoint
|
||||
cargo run -p runtime-dashboard --features tui --bin swactor-tui -- http://localhost:9090
|
||||
cargo run -p dashboard --features tui --bin swactor-tui -- http://localhost:9090
|
||||
```
|
||||
|
||||
Views (cycle with Tab):
|
||||
|
|
@ -70,7 +70,7 @@ All examples are run from the workspace root.
|
|||
**HTTP dashboard** — live workload with distribution cluster, Ctrl+C to stop:
|
||||
|
||||
```bash
|
||||
cargo run -p runtime-dashboard --example dashboard_demo
|
||||
cargo run -p dashboard --example dashboard_demo
|
||||
# http://localhost:9090 — runtime overview
|
||||
# http://localhost:9090/distribution — cluster view
|
||||
```
|
||||
|
|
@ -78,14 +78,14 @@ cargo run -p runtime-dashboard --example dashboard_demo
|
|||
**Benchmarks** — four automated scenarios (~20 s total):
|
||||
|
||||
```bash
|
||||
cargo run -p runtime-dashboard --example bench_dashboard
|
||||
cargo run -p dashboard --example bench_dashboard
|
||||
# open http://localhost:9090
|
||||
```
|
||||
|
||||
**Record & replay** — records ~10 s of activity, then serves a replay:
|
||||
|
||||
```bash
|
||||
cargo run -p runtime-dashboard --example record_and_replay_demo
|
||||
cargo run -p dashboard --example record_and_replay_demo
|
||||
# live dashboard at http://localhost:9090 during recording
|
||||
# replay dashboard at http://localhost:9091 after recording finishes
|
||||
# Ctrl+C to stop
|
||||
|
|
|
|||
|
|
@ -1,224 +0,0 @@
|
|||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||
use swactor::config::{BackoffPolicy, RuntimeConfig};
|
||||
use swactor::runtime::Runtime;
|
||||
|
||||
use dashboard::collector::StatsCollector;
|
||||
use dashboard::{start_dashboard, DashboardConfig};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Work;
|
||||
|
||||
struct SinkActor {
|
||||
count: u64,
|
||||
}
|
||||
|
||||
impl SinkActor {
|
||||
fn new() -> Self {
|
||||
Self { count: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl ActorInterface for SinkActor {
|
||||
type Incoming = Work;
|
||||
type Response = ();
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Work) {
|
||||
self.count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RingMsg;
|
||||
|
||||
struct RingActor {
|
||||
next: ActorAddress,
|
||||
}
|
||||
|
||||
impl ActorInterface for RingActor {
|
||||
type Incoming = RingMsg;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, _msg: RingMsg) {
|
||||
let _ = ctx.send(self.next, RingMsg);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SpawnCmd;
|
||||
|
||||
struct SpawnerActor {
|
||||
spawned: u64,
|
||||
}
|
||||
|
||||
impl SpawnerActor {
|
||||
fn new() -> Self {
|
||||
Self { spawned: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl ActorInterface for SpawnerActor {
|
||||
type Incoming = SpawnCmd;
|
||||
type Response = ();
|
||||
fn handle(&mut self, ctx: &Ctx, _msg: SpawnCmd) {
|
||||
for _ in 0..20 {
|
||||
let _ = ctx.spawn(SinkActor::new());
|
||||
self.spawned += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn bench_config(threads: usize, max_actors: usize, max_messages: usize) -> RuntimeConfig {
|
||||
RuntimeConfig {
|
||||
num_threads: threads,
|
||||
max_actors,
|
||||
channel_buffer_size: max_messages,
|
||||
backoff_policy: BackoffPolicy {
|
||||
spin_threshold: 32,
|
||||
yield_threshold: 64,
|
||||
sleep_increment_us: 10,
|
||||
sleep_max_us: 100,
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn run_for(duration: Duration, mut tick: impl FnMut()) {
|
||||
let deadline = Instant::now() + duration;
|
||||
while Instant::now() < deadline {
|
||||
tick();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenarios
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn scenario_single_actor(dash: &dashboard::DashboardHandle) {
|
||||
eprintln!(" [1/4] Single-actor bombardment (5s)");
|
||||
let collector = StatsCollector::new(4);
|
||||
let mut rt = Runtime::new(bench_config(4, 64, 100_000));
|
||||
rt.set_stats_hook(collector.clone());
|
||||
let addr = rt.spawn(SinkActor::new()).unwrap();
|
||||
let handle = rt.run().unwrap();
|
||||
dash.set_runtime(handle.runtime.clone(), collector);
|
||||
|
||||
run_for(Duration::from_secs(5), || {
|
||||
for _ in 0..100 {
|
||||
let _ = handle.runtime.send_to(addr, Work);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
});
|
||||
|
||||
handle.shutdown();
|
||||
handle.join();
|
||||
}
|
||||
|
||||
fn scenario_multi_actor(dash: &dashboard::DashboardHandle) {
|
||||
eprintln!(" [2/4] Multi-actor fan-out (5s)");
|
||||
let collector = StatsCollector::new(4);
|
||||
let mut rt = Runtime::new(bench_config(4, 128, 10_000));
|
||||
rt.set_stats_hook(collector.clone());
|
||||
let addrs: Vec<_> = (0..50)
|
||||
.map(|_| rt.spawn(SinkActor::new()).unwrap())
|
||||
.collect();
|
||||
let handle = rt.run().unwrap();
|
||||
dash.set_runtime(handle.runtime.clone(), collector);
|
||||
|
||||
run_for(Duration::from_secs(5), || {
|
||||
for &addr in &addrs {
|
||||
for _ in 0..10 {
|
||||
let _ = handle.runtime.send_to(addr, Work);
|
||||
}
|
||||
}
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
});
|
||||
|
||||
handle.shutdown();
|
||||
handle.join();
|
||||
}
|
||||
|
||||
fn scenario_ring(dash: &dashboard::DashboardHandle) {
|
||||
eprintln!(" [3/4] Ring topology (5s)");
|
||||
let ring_size = 100;
|
||||
let collector = StatsCollector::new(4);
|
||||
let mut rt = Runtime::new(bench_config(4, ring_size + 64, 1_024));
|
||||
rt.set_stats_hook(collector.clone());
|
||||
|
||||
// Build ring backwards: last spawned actor is the entry point
|
||||
let mut addrs = Vec::with_capacity(ring_size);
|
||||
// First actor has no valid next yet — will be the tail of the chain
|
||||
let first = rt.spawn(RingActor { next: ActorAddress::default() }).unwrap();
|
||||
addrs.push(first);
|
||||
let mut prev = first;
|
||||
for _ in 1..ring_size {
|
||||
let addr = rt.spawn(RingActor { next: prev }).unwrap();
|
||||
addrs.push(addr);
|
||||
prev = addr;
|
||||
}
|
||||
// The first actor's "next" should be the last actor to close the ring,
|
||||
// but we can't mutate it. Instead, we inject at the last actor and
|
||||
// the message flows: last -> second-to-last -> ... -> first -> (dead end).
|
||||
// For dashboard visualization, a chain is fine — it creates sustained cross-worker traffic.
|
||||
let entry = *addrs.last().unwrap();
|
||||
|
||||
let handle = rt.run().unwrap();
|
||||
dash.set_runtime(handle.runtime.clone(), collector);
|
||||
|
||||
run_for(Duration::from_secs(5), || {
|
||||
let _ = handle.runtime.send_to(entry, RingMsg);
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
});
|
||||
|
||||
handle.shutdown();
|
||||
handle.join();
|
||||
}
|
||||
|
||||
fn scenario_spawn_storm(dash: &dashboard::DashboardHandle) {
|
||||
eprintln!(" [4/4] Spawn storm (5s)");
|
||||
let collector = StatsCollector::new(4);
|
||||
let mut rt = Runtime::new(bench_config(4, 50_000, 1_024));
|
||||
rt.set_stats_hook(collector.clone());
|
||||
let spawner = rt.spawn(SpawnerActor::new()).unwrap();
|
||||
let handle = rt.run().unwrap();
|
||||
dash.set_runtime(handle.runtime.clone(), collector);
|
||||
|
||||
run_for(Duration::from_secs(5), || {
|
||||
let _ = handle.runtime.send_to(spawner, SpawnCmd);
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
});
|
||||
|
||||
handle.shutdown();
|
||||
handle.join();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn main() {
|
||||
let dash = start_dashboard(DashboardConfig {
|
||||
port: 9090,
|
||||
..Default::default()
|
||||
});
|
||||
dash.install_tracing();
|
||||
|
||||
eprintln!("Dashboard at http://localhost:9090");
|
||||
eprintln!("Running 4 benchmark scenarios (~20s total)...\n");
|
||||
|
||||
scenario_single_actor(&dash);
|
||||
scenario_multi_actor(&dash);
|
||||
scenario_ring(&dash);
|
||||
scenario_spawn_storm(&dash);
|
||||
|
||||
eprintln!("\nAll scenarios complete. Shutting down.");
|
||||
dash.shutdown();
|
||||
}
|
||||
|
|
@ -1,503 +0,0 @@
|
|||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||
use swactor::config::RuntimeConfig;
|
||||
use swactor::runtime::Runtime;
|
||||
|
||||
use distribution::node::{DistributedNode, DistributedNodeConfig, ResolveResult};
|
||||
use distribution::snapshot::DistributionNodeSnapshot;
|
||||
use distribution::swim::node::NodeAction;
|
||||
use distribution::swim::probe::SwimConfig;
|
||||
use distribution::types::NodeId;
|
||||
|
||||
use dashboard::collector::StatsCollector;
|
||||
use dashboard::distribution_collector::DistributionStatsProvider;
|
||||
use dashboard::{start_dashboard, DashboardConfig};
|
||||
|
||||
// ── Demo actors ─────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Ping(ActorAddress);
|
||||
|
||||
struct PingActor {
|
||||
count: u32,
|
||||
}
|
||||
|
||||
impl PingActor {
|
||||
fn new() -> Self {
|
||||
Self { count: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl ActorInterface for PingActor {
|
||||
type Incoming = Ping;
|
||||
type Response = ();
|
||||
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
|
||||
self.count += 1;
|
||||
// Forward to the target — creates cross-worker traffic
|
||||
if self.count < 10_000 {
|
||||
let _ = ctx.send(msg.0, Ping(ctx.self_addr()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Tick;
|
||||
|
||||
struct CounterActor {
|
||||
ticks: u64,
|
||||
}
|
||||
|
||||
impl CounterActor {
|
||||
fn new() -> Self {
|
||||
Self { ticks: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl ActorInterface for CounterActor {
|
||||
type Incoming = Tick;
|
||||
type Response = ();
|
||||
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Tick) {
|
||||
self.ticks += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Snapshot provider ───────────────────────────────────────────────────
|
||||
|
||||
struct SnapshotProvider {
|
||||
snapshot: Arc<Mutex<Option<DistributionNodeSnapshot>>>,
|
||||
}
|
||||
|
||||
impl DistributionStatsProvider for SnapshotProvider {
|
||||
fn snapshot(&self) -> Option<DistributionNodeSnapshot> {
|
||||
self.snapshot.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
// ── In-process action delivery ──────────────────────────────────────────
|
||||
|
||||
/// Tick all live nodes and deliver their actions to other nodes.
|
||||
fn tick_all_and_deliver(
|
||||
nodes: &mut [Option<DistributedNode>],
|
||||
node_ids: &[NodeId],
|
||||
) {
|
||||
let n = nodes.len();
|
||||
|
||||
// Collect tick actions from all live nodes.
|
||||
let mut all_actions: Vec<(usize, Vec<NodeAction>)> = Vec::new();
|
||||
for idx in 0..n {
|
||||
if let Some(ref mut node) = nodes[idx] {
|
||||
let actions = node.tick();
|
||||
if !actions.is_empty() {
|
||||
all_actions.push((idx, actions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deliver all actions and collect responses.
|
||||
for (sender_idx, actions) in all_actions {
|
||||
let tagged_responses = deliver_actions_tagged(
|
||||
&actions,
|
||||
node_ids[sender_idx],
|
||||
nodes,
|
||||
node_ids,
|
||||
);
|
||||
for (responder_idx, response_actions) in tagged_responses {
|
||||
deliver_actions_tagged(
|
||||
&response_actions,
|
||||
node_ids[responder_idx],
|
||||
nodes,
|
||||
node_ids,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deliver actions to the appropriate target nodes.
|
||||
/// Returns responses tagged with the index of the responding node.
|
||||
/// `None` nodes (killed) silently drop actions — simulates network loss.
|
||||
fn deliver_actions_tagged(
|
||||
actions: &[NodeAction],
|
||||
sender_id: NodeId,
|
||||
nodes: &mut [Option<DistributedNode>],
|
||||
node_ids: &[NodeId],
|
||||
) -> Vec<(usize, Vec<NodeAction>)> {
|
||||
let mut tagged_responses: Vec<(usize, Vec<NodeAction>)> = Vec::new();
|
||||
|
||||
for action in actions {
|
||||
match action {
|
||||
NodeAction::SendPing {
|
||||
to,
|
||||
sequence,
|
||||
piggyback,
|
||||
} => {
|
||||
if let Some(idx) = node_ids.iter().position(|id| id == to) {
|
||||
if let Some(ref mut node) = nodes[idx] {
|
||||
let resp =
|
||||
node.handle_ping(sender_id, *sequence, piggyback);
|
||||
if !resp.is_empty() {
|
||||
tagged_responses.push((idx, resp));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeAction::SendAck {
|
||||
to,
|
||||
sequence,
|
||||
piggyback,
|
||||
} => {
|
||||
if let Some(idx) = node_ids.iter().position(|id| id == to) {
|
||||
if let Some(ref mut node) = nodes[idx] {
|
||||
let resp = node.handle_ack(sender_id, *sequence, piggyback);
|
||||
if !resp.is_empty() {
|
||||
tagged_responses.push((idx, resp));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeAction::SendJoinResponse { to, members, .. } => {
|
||||
if let Some(idx) = node_ids.iter().position(|id| id == to) {
|
||||
if let Some(ref mut node) = nodes[idx] {
|
||||
let resp = node.handle_join_response(members.clone());
|
||||
if !resp.is_empty() {
|
||||
tagged_responses.push((idx, resp));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeAction::SendPingReq {
|
||||
relay,
|
||||
target,
|
||||
sequence,
|
||||
piggyback,
|
||||
} => {
|
||||
if let Some(idx) = node_ids.iter().position(|id| id == relay) {
|
||||
if let Some(ref mut node) = nodes[idx] {
|
||||
let resp = node.handle_ping_req(
|
||||
sender_id,
|
||||
*target,
|
||||
*sequence,
|
||||
piggyback,
|
||||
);
|
||||
if !resp.is_empty() {
|
||||
tagged_responses.push((idx, resp));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeAction::ForwardAck { to, target, sequence, piggyback } => {
|
||||
if let Some(idx) = node_ids.iter().position(|id| id == to) {
|
||||
if let Some(ref mut node) = nodes[idx] {
|
||||
let resp = node.handle_indirect_ack(*target, *sequence, piggyback);
|
||||
if !resp.is_empty() {
|
||||
tagged_responses.push((idx, resp));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeAction::MembershipChanged { .. } => {
|
||||
// Notifications — no delivery needed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tagged_responses
|
||||
}
|
||||
|
||||
/// Simulate a join handshake: the joining node sends a join request to the
|
||||
/// seed, and the seed's response is delivered back.
|
||||
fn simulate_join(
|
||||
joining_idx: usize,
|
||||
seed_idx: usize,
|
||||
nodes: &mut [Option<DistributedNode>],
|
||||
node_ids: &[NodeId],
|
||||
) {
|
||||
let joining_id = node_ids[joining_idx];
|
||||
|
||||
// Seed handles the join request
|
||||
let response_actions = if let Some(ref mut seed) = nodes[seed_idx] {
|
||||
seed.handle_join_request(joining_id)
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Deliver responses (SendJoinResponse) back to the joining node
|
||||
let seed_id = node_ids[seed_idx];
|
||||
let tagged = deliver_actions_tagged(&response_actions, seed_id, nodes, node_ids);
|
||||
for (responder_idx, response_actions) in tagged {
|
||||
deliver_actions_tagged(&response_actions, node_ids[responder_idx], nodes, node_ids);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn main() {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
|
||||
// Handle Ctrl+C gracefully
|
||||
{
|
||||
let stop = Arc::clone(&stop);
|
||||
ctrlc::set_handler(move || {
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
})
|
||||
.expect("failed to set Ctrl+C handler");
|
||||
}
|
||||
|
||||
let dash = start_dashboard(DashboardConfig {
|
||||
port: 9090,
|
||||
..Default::default()
|
||||
});
|
||||
dash.install_tracing();
|
||||
|
||||
let num_threads = 4;
|
||||
let collector = StatsCollector::new(num_threads);
|
||||
|
||||
let mut rt = Runtime::new(RuntimeConfig {
|
||||
num_threads,
|
||||
max_actors: 1024,
|
||||
channel_buffer_size: 2000,
|
||||
..Default::default()
|
||||
});
|
||||
rt.set_stats_hook(collector.clone());
|
||||
|
||||
// Spawn ping actors for cross-worker traffic
|
||||
let mut ping_addrs = Vec::new();
|
||||
for _ in 0..16 {
|
||||
let addr = rt.spawn(PingActor::new()).unwrap();
|
||||
ping_addrs.push(addr);
|
||||
}
|
||||
|
||||
// Spawn counter actors for sustained traffic
|
||||
let mut counter_addrs = Vec::new();
|
||||
for _ in 0..20 {
|
||||
let addr = rt.spawn(CounterActor::new()).unwrap();
|
||||
counter_addrs.push(addr);
|
||||
}
|
||||
|
||||
let handle = rt.run().expect("failed to start runtime");
|
||||
dash.set_runtime(handle.runtime.clone(), collector);
|
||||
|
||||
// ── Distribution cluster ────────────────────────────────────────────
|
||||
|
||||
let swim_config = SwimConfig {
|
||||
probe_interval: 5,
|
||||
probe_timeout: 2,
|
||||
indirect_probes: 2,
|
||||
suspicion_timeout: 20,
|
||||
dead_reprobe_interval: 50,
|
||||
};
|
||||
|
||||
let num_nodes = 9; // 1 main + 8 peers
|
||||
let mut nodes: Vec<Option<DistributedNode>> = Vec::with_capacity(num_nodes);
|
||||
let mut node_ids: Vec<NodeId> = Vec::with_capacity(num_nodes);
|
||||
|
||||
for i in 0..num_nodes {
|
||||
let config = DistributedNodeConfig {
|
||||
swim: swim_config.clone(),
|
||||
cache_capacity: if i == 0 { 1000 } else { 100 },
|
||||
republish_interval: 500,
|
||||
..Default::default()
|
||||
};
|
||||
let node = DistributedNode::new(config);
|
||||
node_ids.push(node.node_id());
|
||||
nodes.push(Some(node));
|
||||
}
|
||||
|
||||
// Join handshakes: nodes[1..] join via seed (node 0).
|
||||
for i in 1..num_nodes {
|
||||
simulate_join(i, 0, &mut nodes, &node_ids);
|
||||
}
|
||||
|
||||
// Settle: let SWIM converge initial membership.
|
||||
for _ in 0..5 {
|
||||
tick_all_and_deliver(&mut nodes, &node_ids);
|
||||
}
|
||||
|
||||
// Register spawned actors in the main node's directory.
|
||||
for addr in ping_addrs.iter().chain(counter_addrs.iter()) {
|
||||
if let Some(ref mut node) = nodes[0] {
|
||||
node.register_actor(*addr, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot provider for the dashboard.
|
||||
let cached_snapshot = Arc::new(Mutex::new(
|
||||
nodes[0].as_ref().map(|n| n.snapshot()),
|
||||
));
|
||||
let provider = SnapshotProvider {
|
||||
snapshot: Arc::clone(&cached_snapshot),
|
||||
};
|
||||
dash.set_distribution(Arc::new(provider));
|
||||
|
||||
// ── Run ─────────────────────────────────────────────────────────────
|
||||
|
||||
eprintln!("Dashboard at http://localhost:9090 — press Ctrl+C to stop");
|
||||
eprintln!("Distribution at http://localhost:9090/distribution");
|
||||
|
||||
// Kick off ping-pong chains
|
||||
for i in 0..ping_addrs.len() {
|
||||
let target = ping_addrs[(i + 1) % ping_addrs.len()];
|
||||
let _ = handle.runtime.send_to(ping_addrs[i], Ping(target));
|
||||
}
|
||||
|
||||
let mut round: u64 = 0;
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
// Send ticks to all counter actors
|
||||
for addr in &counter_addrs {
|
||||
let _ = handle.runtime.send_to(*addr, Tick);
|
||||
}
|
||||
|
||||
// Periodically spawn more actors and register them
|
||||
if round % 150 == 75 && counter_addrs.len() < 500 {
|
||||
for _ in 0..8 {
|
||||
match handle.runtime.spawn(CounterActor::new()) {
|
||||
Ok(addr) => {
|
||||
counter_addrs.push(addr);
|
||||
if let Some(ref mut node) = nodes[0] {
|
||||
node.register_actor(addr, 1);
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Periodically re-kick ping chains
|
||||
if round % 80 == 0 && round > 0 {
|
||||
for i in 0..ping_addrs.len() {
|
||||
let target = ping_addrs[(i + 1) % ping_addrs.len()];
|
||||
let _ = handle.runtime.send_to(ping_addrs[i], Ping(target));
|
||||
}
|
||||
}
|
||||
|
||||
// Tick all distribution nodes and deliver SWIM actions
|
||||
tick_all_and_deliver(&mut nodes, &node_ids);
|
||||
|
||||
// Periodically resolve actors from main node
|
||||
if round % 50 == 25 {
|
||||
if let Some(ref mut main_node) = nodes[0] {
|
||||
let actor = ping_addrs[(round as usize / 50) % ping_addrs.len()];
|
||||
match main_node.resolve_actor(&actor) {
|
||||
ResolveResult::Cached(found_on) => {
|
||||
tracing::info!(actor = ?&actor.0[..4], ?found_on, "resolved actor (cached)");
|
||||
}
|
||||
ResolveResult::NeedsLookup { .. } => {
|
||||
tracing::info!(actor = ?&actor.0[..4], "resolve: needs lookup");
|
||||
}
|
||||
ResolveResult::NotFound => {
|
||||
tracing::info!(actor = ?&actor.0[..4], "resolve: not found");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Periodically register actors on a peer and propagate entries to main node
|
||||
if round % 100 == 0 && round > 0 {
|
||||
let peer_idx = 1 + ((round as usize / 100) % (num_nodes - 1));
|
||||
// Register on the peer, collect entries
|
||||
let mut entries = Vec::new();
|
||||
if let Some(ref mut peer) = nodes[peer_idx] {
|
||||
for _ in 0..3 {
|
||||
let actor = ActorAddress::new_random();
|
||||
let entry = peer.register_actor(actor, round);
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
// Propagate to main node (separate borrow)
|
||||
if let Some(ref mut main_node) = nodes[0] {
|
||||
for entry in entries {
|
||||
main_node.store_directory_entry(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Churn cycle (repeats every 400 rounds, starts at round 200) ──
|
||||
//
|
||||
// Offsets within each 400-round cycle:
|
||||
// 0 → kill peer 8 (simulated crash)
|
||||
// 150 → revive peer 8 (rejoin cluster)
|
||||
// 200 → graceful leave for peer 7
|
||||
// 350 → rejoin peer 7
|
||||
if round >= 200 {
|
||||
let churn_pos = (round - 200) % 400;
|
||||
|
||||
// Kill peer 8 (simulated crash — set to None)
|
||||
if churn_pos == 0 {
|
||||
nodes[8] = None;
|
||||
tracing::info!("killed peer 8 (simulated crash)");
|
||||
}
|
||||
|
||||
// Revive peer 8 (new node + rejoin)
|
||||
if churn_pos == 150 {
|
||||
let config = DistributedNodeConfig {
|
||||
swim: swim_config.clone(),
|
||||
cache_capacity: 100,
|
||||
republish_interval: 500,
|
||||
..Default::default()
|
||||
};
|
||||
let revived = DistributedNode::new(config);
|
||||
nodes[8] = Some(revived);
|
||||
node_ids[8] = nodes[8].as_ref().unwrap().node_id();
|
||||
simulate_join(8, 0, &mut nodes, &node_ids);
|
||||
tracing::info!("revived peer 8 (rejoined cluster)");
|
||||
}
|
||||
|
||||
// Graceful leave for peer 7
|
||||
if churn_pos == 200 {
|
||||
let leave_actions = nodes[7]
|
||||
.as_mut()
|
||||
.map(|n| n.leave())
|
||||
.unwrap_or_default();
|
||||
if !leave_actions.is_empty() {
|
||||
let tagged_responses = deliver_actions_tagged(
|
||||
&leave_actions,
|
||||
node_ids[7],
|
||||
&mut nodes,
|
||||
&node_ids,
|
||||
);
|
||||
for (responder_idx, response_actions) in tagged_responses {
|
||||
deliver_actions_tagged(
|
||||
&response_actions,
|
||||
node_ids[responder_idx],
|
||||
&mut nodes,
|
||||
&node_ids,
|
||||
);
|
||||
}
|
||||
}
|
||||
nodes[7] = None;
|
||||
tracing::info!("peer 7 gracefully left the cluster");
|
||||
}
|
||||
|
||||
// Rejoin peer 7
|
||||
if churn_pos == 350 {
|
||||
let config = DistributedNodeConfig {
|
||||
swim: swim_config.clone(),
|
||||
cache_capacity: 100,
|
||||
republish_interval: 500,
|
||||
..Default::default()
|
||||
};
|
||||
let revived = DistributedNode::new(config);
|
||||
nodes[7] = Some(revived);
|
||||
node_ids[7] = nodes[7].as_ref().unwrap().node_id();
|
||||
simulate_join(7, 0, &mut nodes, &node_ids);
|
||||
tracing::info!("peer 7 rejoined the cluster");
|
||||
}
|
||||
}
|
||||
|
||||
// Update cached snapshot for the dashboard
|
||||
*cached_snapshot.lock().unwrap() = nodes[0].as_ref().map(|n| n.snapshot());
|
||||
|
||||
round += 1;
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
|
||||
eprintln!("\nShutting down...");
|
||||
handle.shutdown();
|
||||
dash.shutdown();
|
||||
handle.join();
|
||||
}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||
use swactor::config::RuntimeConfig;
|
||||
use swactor::runtime::Runtime;
|
||||
|
||||
use dashboard::collector::StatsCollector;
|
||||
use dashboard::{serve_replay, start_dashboard, DashboardConfig, ReplayConfig};
|
||||
|
||||
// ── Demo actors ─────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Ping(ActorAddress);
|
||||
|
||||
struct PingActor {
|
||||
count: u32,
|
||||
}
|
||||
|
||||
impl PingActor {
|
||||
fn new() -> Self {
|
||||
Self { count: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl ActorInterface for PingActor {
|
||||
type Incoming = Ping;
|
||||
type Response = ();
|
||||
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
|
||||
self.count += 1;
|
||||
if self.count < 100 {
|
||||
let _ = ctx.send(msg.0, Ping(ctx.self_addr()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Tick;
|
||||
|
||||
struct CounterActor;
|
||||
|
||||
impl ActorInterface for CounterActor {
|
||||
type Incoming = Tick;
|
||||
type Response = ();
|
||||
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Tick) {}
|
||||
}
|
||||
|
||||
// ── Main ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn main() {
|
||||
// ── Phase 1: Record ─────────────────────────────────────────────────
|
||||
|
||||
let dash = start_dashboard(DashboardConfig {
|
||||
port: 9090,
|
||||
record: true,
|
||||
..Default::default()
|
||||
});
|
||||
dash.install_tracing();
|
||||
|
||||
let num_threads = 4;
|
||||
let collector = StatsCollector::new(num_threads);
|
||||
|
||||
let mut rt = Runtime::new(RuntimeConfig {
|
||||
num_threads,
|
||||
max_actors: 512,
|
||||
channel_buffer_size: 1000,
|
||||
..Default::default()
|
||||
});
|
||||
rt.set_stats_hook(collector.clone());
|
||||
|
||||
let mut ping_addrs = Vec::new();
|
||||
for _ in 0..12 {
|
||||
let addr = rt.spawn(PingActor::new()).unwrap();
|
||||
ping_addrs.push(addr);
|
||||
}
|
||||
|
||||
let mut counter_addrs = Vec::new();
|
||||
for _ in 0..16 {
|
||||
let addr = rt.spawn(CounterActor).unwrap();
|
||||
counter_addrs.push(addr);
|
||||
}
|
||||
|
||||
let handle = rt.run().expect("failed to start runtime");
|
||||
dash.set_runtime(handle.runtime.clone(), collector);
|
||||
|
||||
eprintln!("Recording trace for ~10 seconds...");
|
||||
eprintln!("Live dashboard at http://localhost:9090");
|
||||
|
||||
// Kick off ping-pong chains
|
||||
for i in 0..ping_addrs.len() {
|
||||
let target = ping_addrs[(i + 1) % ping_addrs.len()];
|
||||
let _ = handle.runtime.send_to(ping_addrs[i], Ping(target));
|
||||
}
|
||||
|
||||
for round in 0..50 {
|
||||
for addr in &counter_addrs {
|
||||
let _ = handle.runtime.send_to(*addr, Tick);
|
||||
}
|
||||
|
||||
if round == 20 {
|
||||
for _ in 0..8 {
|
||||
let addr = handle.runtime.spawn(CounterActor).unwrap();
|
||||
counter_addrs.push(addr);
|
||||
}
|
||||
eprintln!(" Spawned 8 more actors");
|
||||
}
|
||||
|
||||
if round == 25 {
|
||||
for i in 0..ping_addrs.len() {
|
||||
let target = ping_addrs[(i + 1) % ping_addrs.len()];
|
||||
let _ = handle.runtime.send_to(ping_addrs[i], Ping(target));
|
||||
}
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
|
||||
handle.shutdown();
|
||||
dash.shutdown();
|
||||
handle.join();
|
||||
|
||||
// Save trace
|
||||
let path = "runtime_trace.json";
|
||||
match dash.save_trace(path) {
|
||||
Ok(()) => eprintln!("Trace saved to {path}"),
|
||||
Err(e) => {
|
||||
eprintln!("Failed to save trace: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2: Replay ─────────────────────────────────────────────────
|
||||
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
{
|
||||
let stop = Arc::clone(&stop);
|
||||
ctrlc::set_handler(move || {
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
})
|
||||
.expect("failed to set Ctrl+C handler");
|
||||
}
|
||||
|
||||
eprintln!("\nStarting replay at 2x speed — press Ctrl+C to stop");
|
||||
|
||||
// Spawn replay server in a background thread so we can check Ctrl+C
|
||||
let replay_path = path.to_string();
|
||||
thread::spawn(move || {
|
||||
if let Err(e) = serve_replay(&replay_path, ReplayConfig { port: 9091, speed: 2.0 }) {
|
||||
eprintln!("Replay error: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
|
||||
eprintln!("Done.");
|
||||
}
|
||||
|
|
@ -123,8 +123,8 @@ pub const ACTOR_DETAIL_HTML: &str = r##"<!DOCTYPE html>
|
|||
<nav class="nav-links">
|
||||
<a href="/" class="nav-link">Overview</a>
|
||||
<a href="/actors" class="nav-link">Actors</a>
|
||||
<a href="/distribution" class="nav-link">Distribution</a>
|
||||
<a href="/datastore" class="nav-link">Datastore</a>
|
||||
<a href="/plugin/distribution" class="nav-link">Distribution</a>
|
||||
<a href="/plugin/datastore" class="nav-link">Datastore</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -151,8 +151,8 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
|
|||
<nav class="nav-links">
|
||||
<a href="/" class="nav-link">Overview</a>
|
||||
<a href="/actors" class="nav-link active">Actors</a>
|
||||
<a href="/distribution" class="nav-link">Distribution</a>
|
||||
<a href="/datastore" class="nav-link">Datastore</a>
|
||||
<a href="/plugin/distribution" class="nav-link">Distribution</a>
|
||||
<a href="/plugin/datastore" class="nav-link">Datastore</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
|
|||
|
|
@ -1,307 +0,0 @@
|
|||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use swactor::actor::{ActorInterface, Ctx};
|
||||
use swactor::config::RuntimeConfig;
|
||||
use swactor::runtime::Runtime;
|
||||
|
||||
use distribution::node::DistributedNodeConfig;
|
||||
use distribution::registry::RegistryConfig;
|
||||
use distribution::snapshot::DistributionNodeSnapshot;
|
||||
use distribution::swim::probe::SwimConfig;
|
||||
|
||||
use dashboard::collector::StatsCollector;
|
||||
use dashboard::distribution_collector::DistributionStatsProvider;
|
||||
use dashboard::{start_dashboard, DashboardConfig};
|
||||
|
||||
// ── CLI ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "swactor-node", about = "Swactor distributed node")]
|
||||
struct Args {
|
||||
/// Transport to use: tcp or iroh
|
||||
#[arg(long, default_value = "tcp")]
|
||||
transport: String,
|
||||
|
||||
/// Address to listen on for TCP transport (e.g. 10.0.1.10:7000)
|
||||
#[arg(long)]
|
||||
listen: Option<std::net::SocketAddr>,
|
||||
|
||||
/// Seed node address to join (TCP mode: host:port)
|
||||
#[arg(long)]
|
||||
seed: Option<String>,
|
||||
|
||||
/// Seed node's iroh public key (iroh mode: hex-encoded 32-byte key)
|
||||
#[arg(long)]
|
||||
seed_node_id: Option<String>,
|
||||
|
||||
/// Dashboard HTTP port
|
||||
#[arg(long, default_value = "9090")]
|
||||
dashboard_port: u16,
|
||||
|
||||
/// Number of dummy actors to register in the directory
|
||||
#[arg(long, default_value = "0")]
|
||||
actors: usize,
|
||||
}
|
||||
|
||||
// ── Dummy actor ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Heartbeat;
|
||||
|
||||
struct HeartbeatActor;
|
||||
|
||||
impl ActorInterface for HeartbeatActor {
|
||||
type Incoming = Heartbeat;
|
||||
type Response = ();
|
||||
|
||||
fn handle(&mut self, _ctx: &Ctx, _msg: Heartbeat) {}
|
||||
}
|
||||
|
||||
// ── Snapshot provider ────────────────────────────────────────────────────
|
||||
|
||||
struct SnapshotProvider {
|
||||
snapshot: Arc<Mutex<Option<DistributionNodeSnapshot>>>,
|
||||
}
|
||||
|
||||
impl DistributionStatsProvider for SnapshotProvider {
|
||||
fn snapshot(&self) -> Option<DistributionNodeSnapshot> {
|
||||
self.snapshot.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn main() {
|
||||
let args = Args::parse();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
|
||||
// Handle SIGTERM / Ctrl+C
|
||||
{
|
||||
let stop = Arc::clone(&stop);
|
||||
ctrlc::set_handler(move || {
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
})
|
||||
.expect("failed to set signal handler");
|
||||
}
|
||||
|
||||
// Start dashboard
|
||||
let dash = start_dashboard(DashboardConfig {
|
||||
port: args.dashboard_port,
|
||||
..Default::default()
|
||||
});
|
||||
dash.install_tracing();
|
||||
dash.start_http_standalone();
|
||||
|
||||
// Create actor runtime
|
||||
let num_threads = 2;
|
||||
let collector = StatsCollector::new(num_threads);
|
||||
let mut rt = Runtime::new(RuntimeConfig {
|
||||
num_threads,
|
||||
max_actors: 1024,
|
||||
channel_buffer_size: 2000,
|
||||
..Default::default()
|
||||
});
|
||||
rt.set_stats_hook(collector.clone());
|
||||
|
||||
let handle = rt.run().expect("failed to start runtime");
|
||||
dash.set_runtime(handle.runtime.clone(), collector);
|
||||
|
||||
// Distribution config (shared between transports)
|
||||
let swim_config = SwimConfig {
|
||||
probe_interval: 5,
|
||||
probe_timeout: 3,
|
||||
indirect_probes: 2,
|
||||
suspicion_timeout: 20,
|
||||
dead_reprobe_interval: 50,
|
||||
};
|
||||
let node_config = DistributedNodeConfig {
|
||||
swim: swim_config,
|
||||
cache_capacity: 1000,
|
||||
republish_interval: 500,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match args.transport.as_str() {
|
||||
#[cfg(feature = "tcp")]
|
||||
"tcp" => run_tcp(args, node_config, &handle, &dash, &stop),
|
||||
#[cfg(feature = "iroh")]
|
||||
"iroh" => run_iroh(args, node_config, &handle, &dash, &stop),
|
||||
other => {
|
||||
eprintln!("Unknown or unavailable transport: {other}");
|
||||
eprintln!("Available transports:");
|
||||
#[cfg(feature = "tcp")]
|
||||
eprintln!(" tcp");
|
||||
#[cfg(feature = "iroh")]
|
||||
eprintln!(" iroh");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("\nShutting down...");
|
||||
handle.shutdown();
|
||||
dash.shutdown();
|
||||
handle.join();
|
||||
}
|
||||
|
||||
// ── TCP transport ────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(feature = "tcp")]
|
||||
fn run_tcp(
|
||||
args: Args,
|
||||
node_config: DistributedNodeConfig,
|
||||
handle: &swactor::runtime::RuntimeHandle,
|
||||
dash: &dashboard::DashboardHandle,
|
||||
stop: &Arc<AtomicBool>,
|
||||
) {
|
||||
use distribution::driver::NodeDriver;
|
||||
|
||||
let listen_addr = args.listen.expect("--listen is required for TCP mode");
|
||||
let mut driver = NodeDriver::new(listen_addr, node_config).expect("failed to create node driver");
|
||||
|
||||
eprintln!(
|
||||
"Node {} listening on {} (TCP)",
|
||||
hex(&driver.node_id().0[..4]),
|
||||
driver.listen_addr(),
|
||||
);
|
||||
|
||||
// Join seed if provided
|
||||
if let Some(seed) = args.seed {
|
||||
let seed_addr: std::net::SocketAddr = seed.parse().expect("invalid seed address");
|
||||
eprintln!("Joining cluster via seed {seed_addr}");
|
||||
driver.join(&[seed_addr]);
|
||||
}
|
||||
|
||||
// Spawn and register actors
|
||||
let actor_addrs = spawn_actors(args.actors, handle, driver.node_mut());
|
||||
|
||||
// Wire distribution snapshot to dashboard
|
||||
let cached_snapshot: Arc<Mutex<Option<DistributionNodeSnapshot>>> =
|
||||
Arc::new(Mutex::new(Some(driver.snapshot())));
|
||||
let provider = SnapshotProvider {
|
||||
snapshot: Arc::clone(&cached_snapshot),
|
||||
};
|
||||
dash.set_distribution(Arc::new(provider));
|
||||
|
||||
eprintln!("Dashboard at http://0.0.0.0:{}", args.dashboard_port);
|
||||
|
||||
// Main loop
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
driver.recv();
|
||||
driver.tick();
|
||||
|
||||
for addr in &actor_addrs {
|
||||
let _ = handle.runtime.send_to(*addr, Heartbeat);
|
||||
}
|
||||
|
||||
*cached_snapshot.lock().unwrap() = Some(driver.snapshot());
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
// ── iroh transport ───────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
fn run_iroh(
|
||||
args: Args,
|
||||
node_config: DistributedNodeConfig,
|
||||
handle: &swactor::runtime::RuntimeHandle,
|
||||
dash: &dashboard::DashboardHandle,
|
||||
stop: &Arc<AtomicBool>,
|
||||
) {
|
||||
use distribution::iroh_driver::{IrohDriver, IrohDriverConfig};
|
||||
use iroh::RelayMode;
|
||||
|
||||
let iroh_config = IrohDriverConfig {
|
||||
secret_key: None,
|
||||
relay_mode: RelayMode::Default,
|
||||
node: node_config,
|
||||
peer_auth: None,
|
||||
additional_alpns: vec![],
|
||||
};
|
||||
let mut driver = IrohDriver::new(iroh_config).expect("failed to create iroh driver");
|
||||
|
||||
eprintln!(
|
||||
"Node {} started (iroh)",
|
||||
hex(&driver.node_id().0[..4]),
|
||||
);
|
||||
|
||||
// Join seed if provided
|
||||
if let Some(seed_hex) = args.seed_node_id {
|
||||
let seed_bytes = hex_to_bytes(&seed_hex).expect("invalid seed node ID hex");
|
||||
let seed_key = iroh::PublicKey::from_bytes(&seed_bytes).expect("invalid seed public key");
|
||||
eprintln!("Joining cluster via seed {}", &seed_hex[..8]);
|
||||
driver.join(&[seed_key]);
|
||||
}
|
||||
|
||||
// Spawn and register actors
|
||||
let actor_addrs = spawn_actors(args.actors, handle, driver.node_mut());
|
||||
|
||||
// Wire distribution snapshot to dashboard
|
||||
let cached_snapshot: Arc<Mutex<Option<DistributionNodeSnapshot>>> =
|
||||
Arc::new(Mutex::new(Some(driver.snapshot())));
|
||||
let provider = SnapshotProvider {
|
||||
snapshot: Arc::clone(&cached_snapshot),
|
||||
};
|
||||
dash.set_distribution(Arc::new(provider));
|
||||
|
||||
eprintln!("Dashboard at http://0.0.0.0:{}", args.dashboard_port);
|
||||
|
||||
// Main loop
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
driver.recv();
|
||||
driver.tick();
|
||||
|
||||
for addr in &actor_addrs {
|
||||
let _ = handle.runtime.send_to(*addr, Heartbeat);
|
||||
}
|
||||
|
||||
*cached_snapshot.lock().unwrap() = Some(driver.snapshot());
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
driver.shutdown();
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn spawn_actors(
|
||||
count: usize,
|
||||
handle: &swactor::runtime::RuntimeHandle,
|
||||
node: &mut distribution::node::DistributedNode,
|
||||
) -> Vec<swactor::actor::ActorAddress> {
|
||||
let mut addrs = Vec::new();
|
||||
for _ in 0..count {
|
||||
match handle.runtime.spawn(HeartbeatActor) {
|
||||
Ok(addr) => {
|
||||
node.register_actor(addr, 1);
|
||||
addrs.push(addr);
|
||||
}
|
||||
Err(e) => eprintln!("failed to spawn actor: {e}"),
|
||||
}
|
||||
}
|
||||
if !addrs.is_empty() {
|
||||
eprintln!("Registered {} actors", addrs.len());
|
||||
}
|
||||
addrs
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
fn hex_to_bytes(hex: &str) -> Option<[u8; 32]> {
|
||||
if hex.len() != 64 {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = [0u8; 32];
|
||||
for i in 0..32 {
|
||||
bytes[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
|
||||
}
|
||||
Some(bytes)
|
||||
}
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
//! CI Dashboard extension: provides HTTP API endpoints and stats for CI pipelines.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// CI types defined locally to avoid a cyclic dependency with swactor-ci.
|
||||
|
||||
/// Unique identifier for a pipeline execution.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct PipelineId(pub u64);
|
||||
|
||||
/// Unique identifier for a job within a pipeline.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct JobId {
|
||||
pub pipeline_id: PipelineId,
|
||||
pub job_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum JobStatus {
|
||||
Pending,
|
||||
WaitingForProvisioner,
|
||||
Provisioning,
|
||||
Running,
|
||||
Passed,
|
||||
Failed { reason: String },
|
||||
Skipped,
|
||||
Interrupted,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PipelineStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Passed,
|
||||
Failed,
|
||||
Error { reason: String },
|
||||
}
|
||||
|
||||
// ─── Stats Provider ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Trait for providing CI snapshot data to the dashboard.
|
||||
pub trait CiStatsProvider: Send + Sync {
|
||||
fn snapshot(&self) -> CiSnapshot;
|
||||
}
|
||||
|
||||
/// Point-in-time snapshot of CI system state.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CiSnapshot {
|
||||
pub active_pipelines: Vec<PipelineSnapshot>,
|
||||
pub recent_pipelines: Vec<PipelineSnapshot>,
|
||||
pub provisioner_status: ProvisionerStatus,
|
||||
pub active_instances: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ProvisionerStatus {
|
||||
Online,
|
||||
Offline,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Snapshot of a single pipeline execution.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PipelineSnapshot {
|
||||
pub pipeline_id: PipelineId,
|
||||
pub pipeline_name: String,
|
||||
pub repo_owner: String,
|
||||
pub repo_name: String,
|
||||
pub commit_sha: String,
|
||||
pub branch: String,
|
||||
pub status: PipelineStatus,
|
||||
pub jobs: Vec<JobSnapshot>,
|
||||
}
|
||||
|
||||
/// Snapshot of a single job within a pipeline.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JobSnapshot {
|
||||
pub job_id: JobId,
|
||||
pub job_name: String,
|
||||
pub status: JobStatus,
|
||||
pub output_line_count: usize,
|
||||
}
|
||||
|
||||
// ─── HTTP API Responses ─────────────────────────────────────────────────────
|
||||
|
||||
/// Response for GET /api/ci/pipelines
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PipelineListResponse {
|
||||
pub pipelines: Vec<PipelineSnapshot>,
|
||||
}
|
||||
|
||||
/// Response for GET /api/ci/pipelines/{id}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PipelineDetailResponse {
|
||||
pub pipeline: PipelineSnapshot,
|
||||
}
|
||||
|
||||
/// Response for GET /api/ci/status
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SystemStatusResponse {
|
||||
pub provisioner_status: ProvisionerStatus,
|
||||
pub active_pipelines: usize,
|
||||
pub active_instances: usize,
|
||||
}
|
||||
|
||||
/// Response for GET /api/ci/pipelines/{id}/jobs/{job_id}/log
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JobLogResponse {
|
||||
pub job_id: JobId,
|
||||
pub lines: Vec<String>,
|
||||
}
|
||||
|
||||
// ─── Route Matching ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Parsed API route for the CI dashboard.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CiRoute {
|
||||
ListPipelines,
|
||||
PipelineDetail { id: u64 },
|
||||
JobLog { pipeline_id: u64, job_name: String },
|
||||
Artifact { job_id: String, path: String },
|
||||
SystemStatus,
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// Parse a request path into a CiRoute.
|
||||
pub fn parse_route(path: &str) -> CiRoute {
|
||||
let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
|
||||
match parts.as_slice() {
|
||||
["api", "ci", "pipelines"] => CiRoute::ListPipelines,
|
||||
["api", "ci", "pipelines", id] => {
|
||||
if let Ok(id) = id.parse() {
|
||||
CiRoute::PipelineDetail { id }
|
||||
} else {
|
||||
CiRoute::NotFound
|
||||
}
|
||||
}
|
||||
["api", "ci", "pipelines", id, "jobs", job_name, "log"] => {
|
||||
if let Ok(pipeline_id) = id.parse() {
|
||||
CiRoute::JobLog {
|
||||
pipeline_id,
|
||||
job_name: job_name.to_string(),
|
||||
}
|
||||
} else {
|
||||
CiRoute::NotFound
|
||||
}
|
||||
}
|
||||
["api", "ci", "artifacts", job_id, rest @ ..] if !rest.is_empty() => CiRoute::Artifact {
|
||||
job_id: job_id.to_string(),
|
||||
path: rest.join("/"),
|
||||
},
|
||||
["api", "ci", "status"] => CiRoute::SystemStatus,
|
||||
_ => CiRoute::NotFound,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a CiSnapshot into a JSON response for the given route.
|
||||
pub fn handle_route(route: &CiRoute, snapshot: &CiSnapshot) -> Option<String> {
|
||||
match route {
|
||||
CiRoute::ListPipelines => {
|
||||
let resp = PipelineListResponse {
|
||||
pipelines: snapshot
|
||||
.active_pipelines
|
||||
.iter()
|
||||
.chain(snapshot.recent_pipelines.iter())
|
||||
.cloned()
|
||||
.collect(),
|
||||
};
|
||||
serde_json::to_string(&resp).ok()
|
||||
}
|
||||
CiRoute::PipelineDetail { id } => {
|
||||
let pipeline = snapshot
|
||||
.active_pipelines
|
||||
.iter()
|
||||
.chain(snapshot.recent_pipelines.iter())
|
||||
.find(|p| p.pipeline_id.0 == *id)?;
|
||||
let resp = PipelineDetailResponse {
|
||||
pipeline: pipeline.clone(),
|
||||
};
|
||||
serde_json::to_string(&resp).ok()
|
||||
}
|
||||
CiRoute::SystemStatus => {
|
||||
let resp = SystemStatusResponse {
|
||||
provisioner_status: snapshot.provisioner_status.clone(),
|
||||
active_pipelines: snapshot.active_pipelines.len(),
|
||||
active_instances: snapshot.active_instances,
|
||||
};
|
||||
serde_json::to_string(&resp).ok()
|
||||
}
|
||||
CiRoute::JobLog { .. } | CiRoute::Artifact { .. } => {
|
||||
// These require access to stored data beyond the snapshot.
|
||||
None
|
||||
}
|
||||
CiRoute::NotFound => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn route_parsing() {
|
||||
assert_eq!(parse_route("/api/ci/pipelines"), CiRoute::ListPipelines);
|
||||
assert_eq!(
|
||||
parse_route("/api/ci/pipelines/42"),
|
||||
CiRoute::PipelineDetail { id: 42 }
|
||||
);
|
||||
assert_eq!(
|
||||
parse_route("/api/ci/pipelines/1/jobs/test/log"),
|
||||
CiRoute::JobLog {
|
||||
pipeline_id: 1,
|
||||
job_name: "test".into()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_route("/api/ci/artifacts/job-1/target/release/bin"),
|
||||
CiRoute::Artifact {
|
||||
job_id: "job-1".into(),
|
||||
path: "target/release/bin".into()
|
||||
}
|
||||
);
|
||||
assert_eq!(parse_route("/api/ci/status"), CiRoute::SystemStatus);
|
||||
assert_eq!(parse_route("/api/ci/unknown"), CiRoute::NotFound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_system_status() {
|
||||
let snapshot = CiSnapshot {
|
||||
active_pipelines: vec![],
|
||||
recent_pipelines: vec![],
|
||||
provisioner_status: ProvisionerStatus::Online,
|
||||
active_instances: 2,
|
||||
};
|
||||
|
||||
let route = CiRoute::SystemStatus;
|
||||
let json = handle_route(&route, &snapshot).unwrap();
|
||||
let resp: SystemStatusResponse = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(resp.provisioner_status, ProvisionerStatus::Online);
|
||||
assert_eq!(resp.active_instances, 2);
|
||||
}
|
||||
}
|
||||
|
|
@ -55,7 +55,7 @@ pub fn enrich_names(stats: &mut RuntimeStats, runtime: &Runtime) {
|
|||
Some(ext) => ext,
|
||||
None => return,
|
||||
};
|
||||
let std_ext = match ext.as_any().downcast_ref::<swactor_std::StdExtension>() {
|
||||
let std_ext = match ext.as_any().downcast_ref::<swactor::std::StdExtension>() {
|
||||
Some(ext) => ext,
|
||||
None => return,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Built-in command handlers for runtime inspection and management.
|
||||
//!
|
||||
//! Extracted from `crates/runtime-dashboard/src/investigate.rs`.
|
||||
//! Extracted from `crates/dashboard/src/investigate.rs`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
|
@ -435,11 +435,10 @@ impl CommandHandler for PhasesCommand {
|
|||
|
||||
let mut results = Vec::new();
|
||||
for (i, timings) in stats.tick_timings.iter().enumerate() {
|
||||
if let Some(wid) = worker_filter {
|
||||
if i != wid {
|
||||
if let Some(wid) = worker_filter
|
||||
&& i != wid {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let breakdown = compute_phase_breakdown(timings);
|
||||
results.push(serde_json::json!({
|
||||
"worker_id": i,
|
||||
|
|
|
|||
|
|
@ -153,6 +153,12 @@ pub struct CommandRouter {
|
|||
handlers: HashMap<String, Box<dyn CommandHandler>>,
|
||||
}
|
||||
|
||||
impl Default for CommandRouter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CommandRouter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
|
|
|
|||
|
|
@ -152,8 +152,8 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
|
|||
<nav class="nav-links">
|
||||
<a href="/" class="nav-link active">Overview</a>
|
||||
<a href="/actors" class="nav-link">Actors</a>
|
||||
<a href="/distribution" class="nav-link">Distribution</a>
|
||||
<a href="/datastore" class="nav-link">Datastore</a>
|
||||
<a href="/plugin/distribution" class="nav-link">Distribution</a>
|
||||
<a href="/plugin/datastore" class="nav-link">Datastore</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
//! Datastore stats provider for the runtime dashboard.
|
||||
//!
|
||||
//! The trait returns a pre-serialized JSON string so that `runtime-dashboard`
|
||||
//! has no compile-time dependency on `swactor-datastore` (which would create a
|
||||
//! circular dependency since `swactor-datastore[node]` depends on us).
|
||||
//!
|
||||
//! The `swactor-datastore` crate implements this trait in its `node` feature.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Scope filter for listing objects.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ListScope {
|
||||
Local,
|
||||
Swarm,
|
||||
}
|
||||
|
||||
/// Trait for providing datastore stats and CRUD operations to the dashboard.
|
||||
///
|
||||
/// Implementations capture a point-in-time snapshot as serialized JSON.
|
||||
/// The dashboard polls this every ~200ms via SSE.
|
||||
///
|
||||
/// All command methods have default implementations returning `Err` so that
|
||||
/// existing `DatastoreMetrics` impls continue to compile without changes.
|
||||
pub trait DatastoreStatsProvider: Send + Sync {
|
||||
/// Return a JSON-serialized datastore snapshot, or `None` if unavailable.
|
||||
fn snapshot_json(&self) -> Option<String>;
|
||||
|
||||
/// List objects as a JSON string. `scope` selects local-only or swarm-wide.
|
||||
fn list_objects(&self, _name_filter: Option<&str>, _scope: ListScope) -> Result<String, String> {
|
||||
Err("not supported".into())
|
||||
}
|
||||
|
||||
/// Get a single object's metadata + manifest as JSON.
|
||||
fn get_object(&self, _hash: &str) -> Result<String, String> {
|
||||
Err("not supported".into())
|
||||
}
|
||||
|
||||
/// Get the raw binary data for an object.
|
||||
fn get_data(&self, _hash: &str) -> Result<Vec<u8>, String> {
|
||||
Err("not supported".into())
|
||||
}
|
||||
|
||||
/// Store data, optionally with a name. Returns JSON with `content_hash`.
|
||||
fn put_data(&self, _data: Vec<u8>, _name: Option<String>) -> Result<String, String> {
|
||||
Err("not supported".into())
|
||||
}
|
||||
|
||||
/// Delete an object by hash. Returns JSON confirmation.
|
||||
fn delete_object(&self, _hash: &str) -> Result<String, String> {
|
||||
Err("not supported".into())
|
||||
}
|
||||
|
||||
/// Get node status as JSON.
|
||||
fn node_status(&self) -> Result<String, String> {
|
||||
Err("not supported".into())
|
||||
}
|
||||
|
||||
/// Whether the datastore is currently running.
|
||||
fn is_running(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Shut down the datastore actors.
|
||||
fn shutdown_datastore(&self) -> Result<(), String> {
|
||||
Err("not supported".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory for creating a new datastore instance from the dashboard.
|
||||
pub trait DatastoreFactory: Send + Sync {
|
||||
/// Start a datastore with optional persistent storage path.
|
||||
/// Returns a provider that can be installed into the dashboard.
|
||||
fn start_datastore(&self, storage_path: Option<String>) -> Result<Arc<dyn DatastoreStatsProvider>, String>;
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
//! Distribution stats provider for the runtime dashboard.
|
||||
//!
|
||||
//! The application implements `DistributionStatsProvider` to let the dashboard
|
||||
//! read a single node's distribution state (SWIM membership, Kademlia routing,
|
||||
//! LRU cache, etc.) without reaching out to other nodes.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use distribution::snapshot::DistributionNodeSnapshot;
|
||||
|
||||
/// Trait for providing distribution stats to the dashboard.
|
||||
///
|
||||
/// Implementations capture a point-in-time snapshot of the local
|
||||
/// `DistributedNode`'s state. The dashboard polls this every ~200ms.
|
||||
pub trait DistributionStatsProvider: Send + Sync {
|
||||
fn snapshot(&self) -> Option<DistributionNodeSnapshot>;
|
||||
}
|
||||
|
||||
/// Simple implementation wrapping an `Arc<Mutex<T>>` where T implements
|
||||
/// a `snapshot()` method (e.g. `DistributedNode`).
|
||||
pub struct DistributionCollector<T> {
|
||||
inner: Arc<Mutex<T>>,
|
||||
}
|
||||
|
||||
impl<T> DistributionCollector<T> {
|
||||
pub fn new(inner: Arc<Mutex<T>>) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
impl DistributionStatsProvider for DistributionCollector<distribution::node::DistributedNode> {
|
||||
fn snapshot(&self) -> Option<DistributionNodeSnapshot> {
|
||||
self.inner.lock().ok().map(|node| node.snapshot())
|
||||
}
|
||||
}
|
||||
|
|
@ -212,16 +212,14 @@ where
|
|||
if let Some(scope) = ctx.event_scope(event) {
|
||||
for span in scope {
|
||||
let exts = span.extensions();
|
||||
if worker_id.is_none() {
|
||||
if let Some(wid) = exts.get::<WorkerIdField>() {
|
||||
if worker_id.is_none()
|
||||
&& let Some(wid) = exts.get::<WorkerIdField>() {
|
||||
worker_id = Some(wid.0);
|
||||
}
|
||||
}
|
||||
if actor_addr.is_none() {
|
||||
if let Some(aa) = exts.get::<ActorAddrField>() {
|
||||
if actor_addr.is_none()
|
||||
&& let Some(aa) = exts.get::<ActorAddrField>() {
|
||||
actor_addr = Some(aa.0.clone());
|
||||
}
|
||||
}
|
||||
if worker_id.is_some() && actor_addr.is_some() {
|
||||
break;
|
||||
}
|
||||
|
|
@ -229,16 +227,14 @@ where
|
|||
}
|
||||
|
||||
// Also check if worker_id or actor_addr was a field on the event itself
|
||||
if worker_id.is_none() {
|
||||
if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") {
|
||||
if worker_id.is_none()
|
||||
&& let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") {
|
||||
worker_id = n.as_u64().map(|v| v as usize);
|
||||
}
|
||||
}
|
||||
if actor_addr.is_none() {
|
||||
if let Some(serde_json::Value::String(s)) = visitor.fields.get("actor_addr") {
|
||||
if actor_addr.is_none()
|
||||
&& let Some(serde_json::Value::String(s)) = visitor.fields.get("actor_addr") {
|
||||
actor_addr = Some(s.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let dashboard_event = DashboardEvent {
|
||||
seq: 0, // filled by push()
|
||||
|
|
@ -259,11 +255,10 @@ where
|
|||
attrs.record(&mut visitor);
|
||||
|
||||
if let Some(span) = ctx.span(id) {
|
||||
if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") {
|
||||
if let Some(wid) = n.as_u64() {
|
||||
if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id")
|
||||
&& let Some(wid) = n.as_u64() {
|
||||
span.extensions_mut().insert(WorkerIdField(wid as usize));
|
||||
}
|
||||
}
|
||||
if let Some(serde_json::Value::String(s)) = visitor.fields.get("actor_addr") {
|
||||
span.extensions_mut().insert(ActorAddrField(s.clone()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ pub mod command;
|
|||
pub mod history;
|
||||
pub mod investigate;
|
||||
pub mod layer;
|
||||
pub mod plugin;
|
||||
pub mod trace;
|
||||
pub mod warnings;
|
||||
mod actor_detail_html;
|
||||
|
|
@ -15,16 +16,6 @@ mod topology_html;
|
|||
#[cfg(feature = "tui")]
|
||||
pub mod tui;
|
||||
|
||||
#[cfg(feature = "distribution")]
|
||||
mod distribution_html;
|
||||
#[cfg(feature = "distribution")]
|
||||
pub mod distribution_collector;
|
||||
|
||||
mod datastore_html;
|
||||
pub mod datastore_collector;
|
||||
|
||||
pub mod ci_collector;
|
||||
|
||||
use std::io;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
|
@ -40,6 +31,7 @@ use tracing_subscriber::util::SubscriberInitExt;
|
|||
use crate::collector::StatsCollector;
|
||||
use crate::history::{DashboardHistory, HistoryConfig};
|
||||
use crate::layer::{now_ms, DashboardLayer, EventStore};
|
||||
use crate::plugin::PluginRegistry;
|
||||
use crate::trace::{RuntimeTrace, TimestampedStats};
|
||||
|
||||
/// Peer info sent through the join channel: (public_key, optional_relay_url).
|
||||
|
|
@ -99,13 +91,7 @@ pub struct DashboardHandle {
|
|||
history: Arc<DashboardHistory>,
|
||||
recording: bool,
|
||||
port: u16,
|
||||
#[cfg(feature = "distribution")]
|
||||
distribution: Arc<Mutex<Option<Arc<dyn distribution_collector::DistributionStatsProvider>>>>,
|
||||
datastore: Arc<Mutex<Option<Arc<dyn datastore_collector::DatastoreStatsProvider>>>>,
|
||||
datastore_factory: Arc<Mutex<Option<Arc<dyn datastore_collector::DatastoreFactory>>>>,
|
||||
ci: Arc<Mutex<Option<Arc<dyn ci_collector::CiStatsProvider>>>>,
|
||||
peer_auth: Arc<Mutex<Option<Arc<Mutex<distribution::peer_auth::PeerAllowList>>>>>,
|
||||
join_sender: Arc<Mutex<Option<std::sync::mpsc::Sender<JoinPeerInfo>>>>,
|
||||
plugin_registry: Arc<PluginRegistry>,
|
||||
standalone_rt: Mutex<Option<tokio::runtime::Runtime>>,
|
||||
}
|
||||
|
||||
|
|
@ -133,40 +119,9 @@ impl DashboardHandle {
|
|||
self.recording
|
||||
}
|
||||
|
||||
/// Attach a distribution stats provider, enabling the `/distribution` page.
|
||||
#[cfg(feature = "distribution")]
|
||||
pub fn set_distribution(&self, provider: Arc<dyn distribution_collector::DistributionStatsProvider>) {
|
||||
*self.distribution.lock().unwrap() = Some(provider);
|
||||
}
|
||||
|
||||
/// Attach a datastore stats provider, enabling the `/datastore` page.
|
||||
pub fn set_datastore(&self, provider: Arc<dyn datastore_collector::DatastoreStatsProvider>) {
|
||||
*self.datastore.lock().unwrap() = Some(provider);
|
||||
}
|
||||
|
||||
/// Attach a datastore factory, enabling start/stop from the dashboard.
|
||||
pub fn set_datastore_factory(&self, factory: Arc<dyn datastore_collector::DatastoreFactory>) {
|
||||
*self.datastore_factory.lock().unwrap() = Some(factory);
|
||||
}
|
||||
|
||||
/// Get the shared datastore provider mutex (for external wiring).
|
||||
pub fn datastore_provider(&self) -> &Arc<Mutex<Option<Arc<dyn datastore_collector::DatastoreStatsProvider>>>> {
|
||||
&self.datastore
|
||||
}
|
||||
|
||||
/// Attach a CI stats provider, enabling the `/api/ci/*` endpoints.
|
||||
pub fn set_ci(&self, provider: Arc<dyn ci_collector::CiStatsProvider>) {
|
||||
*self.ci.lock().unwrap() = Some(provider);
|
||||
}
|
||||
|
||||
/// Attach a peer allow-list for the peer management API.
|
||||
pub fn set_peer_auth(&self, auth: Arc<Mutex<distribution::peer_auth::PeerAllowList>>) {
|
||||
*self.peer_auth.lock().unwrap() = Some(auth);
|
||||
}
|
||||
|
||||
/// Set a sender that triggers `driver.join()` when a peer is added via the dashboard.
|
||||
pub fn set_join_sender(&self, tx: std::sync::mpsc::Sender<JoinPeerInfo>) {
|
||||
*self.join_sender.lock().unwrap() = Some(tx);
|
||||
/// Register a plugin with the dashboard.
|
||||
pub fn register_plugin(&self, plugin: Arc<dyn plugin::DashboardPlugin>) {
|
||||
self.plugin_registry.register(plugin);
|
||||
}
|
||||
|
||||
/// Access the time-series history store (for TUI sparklines, etc.).
|
||||
|
|
@ -212,13 +167,7 @@ impl DashboardHandle {
|
|||
shutdown_notify: Arc::clone(&self.shutdown_notify),
|
||||
history: Arc::clone(&self.history),
|
||||
cmd_router: Arc::new(crate::command::CommandRouter::with_builtins()),
|
||||
#[cfg(feature = "distribution")]
|
||||
distribution: Arc::clone(&self.distribution),
|
||||
datastore: Arc::clone(&self.datastore),
|
||||
datastore_factory: Arc::clone(&self.datastore_factory),
|
||||
ci: Arc::clone(&self.ci),
|
||||
peer_auth: Arc::clone(&self.peer_auth),
|
||||
join_sender: Arc::clone(&self.join_sender),
|
||||
plugins: Arc::clone(&self.plugin_registry),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -228,8 +177,7 @@ impl DashboardHandle {
|
|||
/// This drains the recording buffers — each call consumes the buffered data.
|
||||
pub fn save_trace(&self, path: &str) -> io::Result<()> {
|
||||
let events = self.store.all_events().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
io::Error::other(
|
||||
"recording not enabled (set DashboardConfig::record = true)",
|
||||
)
|
||||
})?;
|
||||
|
|
@ -242,7 +190,7 @@ impl DashboardHandle {
|
|||
stats_timeline,
|
||||
};
|
||||
let json = serde_json::to_string(&trace)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
||||
.map_err(|e| io::Error::other(e))?;
|
||||
std::fs::write(path, json)
|
||||
}
|
||||
}
|
||||
|
|
@ -266,25 +214,6 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
|
|||
let stats_timeline = Arc::new(ArrayQueue::new(config.record_stats_capacity.max(1)));
|
||||
let history = Arc::new(DashboardHistory::new(HistoryConfig::default()));
|
||||
|
||||
#[cfg(feature = "distribution")]
|
||||
let distribution: Arc<Mutex<Option<Arc<dyn distribution_collector::DistributionStatsProvider>>>> =
|
||||
Arc::new(Mutex::new(None));
|
||||
|
||||
let datastore: Arc<Mutex<Option<Arc<dyn datastore_collector::DatastoreStatsProvider>>>> =
|
||||
Arc::new(Mutex::new(None));
|
||||
|
||||
let datastore_factory: Arc<Mutex<Option<Arc<dyn datastore_collector::DatastoreFactory>>>> =
|
||||
Arc::new(Mutex::new(None));
|
||||
|
||||
let ci: Arc<Mutex<Option<Arc<dyn ci_collector::CiStatsProvider>>>> =
|
||||
Arc::new(Mutex::new(None));
|
||||
|
||||
let peer_auth: Arc<Mutex<Option<Arc<Mutex<distribution::peer_auth::PeerAllowList>>>>> =
|
||||
Arc::new(Mutex::new(None));
|
||||
|
||||
let join_sender: Arc<Mutex<Option<std::sync::mpsc::Sender<JoinPeerInfo>>>> =
|
||||
Arc::new(Mutex::new(None));
|
||||
|
||||
// Start stats recorder thread when recording is enabled
|
||||
if config.record {
|
||||
let rt_ref = Arc::clone(&runtime);
|
||||
|
|
@ -314,6 +243,7 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
|
|||
}
|
||||
|
||||
let port = config.port;
|
||||
let plugin_registry = Arc::new(PluginRegistry::new());
|
||||
|
||||
DashboardHandle {
|
||||
store,
|
||||
|
|
@ -325,13 +255,7 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
|
|||
history,
|
||||
recording: config.record,
|
||||
port,
|
||||
#[cfg(feature = "distribution")]
|
||||
distribution,
|
||||
datastore,
|
||||
datastore_factory,
|
||||
ci,
|
||||
peer_auth,
|
||||
join_sender,
|
||||
plugin_registry,
|
||||
standalone_rt: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
|
@ -368,7 +292,7 @@ pub fn serve_replay(path: &str, config: ReplayConfig) -> io::Result<()> {
|
|||
.worker_threads(1)
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
||||
.map_err(|e| io::Error::other(e))?;
|
||||
|
||||
let state = server::ReplayState {
|
||||
trace: Arc::new(trace),
|
||||
|
|
|
|||
87
crates/dashboard/src/plugin.rs
Normal file
87
crates/dashboard/src/plugin.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
//! Dashboard plugin system.
|
||||
//!
|
||||
//! Plugins provide subsystem-specific metrics, API endpoints, and UI pages
|
||||
//! to the dashboard without the dashboard knowing about the subsystem.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Response from a plugin's request handler.
|
||||
pub enum PluginResponse {
|
||||
Json(String),
|
||||
Binary { content_type: String, data: Vec<u8> },
|
||||
Error { status: u16, message: String },
|
||||
NotFound,
|
||||
}
|
||||
|
||||
impl PluginResponse {
|
||||
pub fn not_found() -> Self {
|
||||
Self::NotFound
|
||||
}
|
||||
|
||||
pub fn json(s: String) -> Self {
|
||||
Self::Json(s)
|
||||
}
|
||||
|
||||
pub fn error(status: u16, msg: impl Into<String>) -> Self {
|
||||
Self::Error {
|
||||
status,
|
||||
message: msg.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A composable dashboard plugin.
|
||||
///
|
||||
/// Plugins provide subsystem-specific metrics, API endpoints, and UI pages
|
||||
/// to the dashboard without the dashboard knowing about the subsystem.
|
||||
pub trait DashboardPlugin: Send + Sync {
|
||||
/// Unique name — used as SSE event type and API route prefix `/api/plugin/{name}/...`
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// JSON snapshot polled every ~200ms via SSE. Return None if no data available.
|
||||
fn snapshot_json(&self) -> Option<String>;
|
||||
|
||||
/// Handle an API request to `/api/plugin/{name}/{path}`.
|
||||
fn handle_request(
|
||||
&self,
|
||||
_method: &str,
|
||||
_path: &str,
|
||||
_query: &HashMap<String, String>,
|
||||
_body: &[u8],
|
||||
) -> PluginResponse {
|
||||
PluginResponse::not_found()
|
||||
}
|
||||
|
||||
/// Optional HTML page content. Dashboard will serve at `/plugin/{name}`.
|
||||
fn html_page(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe registry of plugins.
|
||||
pub struct PluginRegistry {
|
||||
plugins: Mutex<Vec<Arc<dyn DashboardPlugin>>>,
|
||||
}
|
||||
|
||||
impl Default for PluginRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
plugins: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(&self, plugin: Arc<dyn DashboardPlugin>) {
|
||||
self.plugins.lock().unwrap().push(plugin);
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> Vec<Arc<dyn DashboardPlugin>> {
|
||||
self.plugins.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,392 +0,0 @@
|
|||
pub const POOL_HTML: &str = r##"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Swactor Runtime – Pool</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Menlo', 'Consolas', 'Monaco', monospace; background: #0f1117; color: #e0e0e0; font-size: 13px; }
|
||||
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 20px; background: #161822; border-bottom: 1px solid #2a2d3e;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; }
|
||||
.header h1 { font-size: 16px; font-weight: 600; color: #fff; }
|
||||
.status-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%; background: #4caf50;
|
||||
display: inline-block; margin-left: 8px; vertical-align: middle;
|
||||
}
|
||||
.status-dot.disconnected { background: #f44336; }
|
||||
.status-dot.done { background: #ff9800; }
|
||||
|
||||
.nav-links { display: flex; gap: 4px; margin-left: 20px; }
|
||||
.nav-link {
|
||||
color: #888; text-decoration: none; font-size: 12px;
|
||||
padding: 4px 10px; border-radius: 3px; transition: color 0.2s;
|
||||
}
|
||||
.nav-link:hover { color: #e0e0e0; }
|
||||
.nav-link.active { color: #fff; background: #2a2d3e; }
|
||||
|
||||
.header-right { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px; padding: 12px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
|
||||
padding: 14px; overflow: hidden;
|
||||
}
|
||||
.panel h2 { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; }
|
||||
|
||||
.full-width { grid-column: 1 / -1; }
|
||||
|
||||
.stats-cards {
|
||||
display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #1c1f2e; border-radius: 4px; padding: 10px; text-align: center;
|
||||
}
|
||||
.stat-card .value { font-size: 22px; font-weight: 700; color: #fff; }
|
||||
.stat-card .label { font-size: 10px; color: #888; text-transform: uppercase; margin-top: 2px; }
|
||||
|
||||
.members-table { max-height: 400px; overflow-y: auto; }
|
||||
.members-table table { width: 100%; border-collapse: collapse; }
|
||||
.members-table th, .members-table td {
|
||||
padding: 4px 8px; text-align: left; border-bottom: 1px solid #1c1f2e; font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.members-table th { color: #888; font-weight: 500; position: sticky; top: 0; background: #161822; }
|
||||
|
||||
.content-table { max-height: 400px; overflow-y: auto; }
|
||||
.content-table table { width: 100%; border-collapse: collapse; }
|
||||
.content-table th, .content-table td {
|
||||
padding: 4px 8px; text-align: left; border-bottom: 1px solid #1c1f2e; font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.content-table th { color: #888; font-weight: 500; position: sticky; top: 0; background: #161822; }
|
||||
|
||||
/* Capacity bars */
|
||||
.cap-bar-wrap { margin-bottom: 6px; }
|
||||
.cap-bar-label {
|
||||
display: flex; justify-content: space-between; font-size: 10px; color: #888; margin-bottom: 2px;
|
||||
}
|
||||
.cap-bar {
|
||||
height: 14px; background: #1c1f2e; border-radius: 3px; overflow: hidden;
|
||||
}
|
||||
.cap-bar-fill {
|
||||
height: 100%; background: #6366f1; border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.cap-bar-fill.warn { background: #ff9800; }
|
||||
.cap-bar-fill.crit { background: #f44336; }
|
||||
|
||||
/* Buttons */
|
||||
button {
|
||||
background: #1e2030; color: #e0e0e0; border: 1px solid #2a2d3e;
|
||||
border-radius: 4px; padding: 6px 14px; font-family: inherit;
|
||||
font-size: 13px; cursor: pointer; min-height: 38px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
button:hover { border-color: #6366f1; color: #fff; }
|
||||
button:disabled { opacity: 0.4; cursor: default; }
|
||||
button.primary { background: #6366f1; border-color: #6366f1; color: #fff; font-weight: 600; }
|
||||
button.primary:hover { background: #5558e6; }
|
||||
button.danger:hover { border-color: #f44336; }
|
||||
|
||||
/* No-pool message */
|
||||
.no-pool {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: calc(100vh - 49px); color: #555; font-size: 16px;
|
||||
flex-direction: column; gap: 8px;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed; bottom: 20px; right: 20px; padding: 10px 16px;
|
||||
border-radius: 4px; font-size: 12px; z-index: 100; opacity: 0;
|
||||
transition: opacity 0.3s; pointer-events: none;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
.toast.success { background: #4caf50; color: #fff; }
|
||||
.toast.error { background: #f44336; color: #fff; }
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0f1117; }
|
||||
::-webkit-scrollbar-thumb { background: #2a2d3e; border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<h1>
|
||||
Swactor Runtime Dashboard
|
||||
<span id="statusDot" class="status-dot disconnected"></span>
|
||||
</h1>
|
||||
<nav class="nav-links">
|
||||
<a href="/" class="nav-link">Overview</a>
|
||||
<a href="/actors" class="nav-link">Actors</a>
|
||||
<a href="/distribution" class="nav-link">Distribution</a>
|
||||
<a href="/datastore" class="nav-link">Datastore</a>
|
||||
<a href="/pool" class="nav-link active">Pool</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button id="joinBtn" class="primary" onclick="joinPool()" style="display:none">Join Pool</button>
|
||||
<button id="leaveBtn" class="danger" onclick="leavePool()" style="display:none">Leave Pool</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="noPool" class="no-pool" style="display:none">
|
||||
<div>No pool configured</div>
|
||||
<div style="font-size:12px;color:#444;">Start the node with --pool-name to enable pooled storage</div>
|
||||
</div>
|
||||
|
||||
<div id="poolContent" style="display:none">
|
||||
<div class="grid">
|
||||
<!-- Summary cards -->
|
||||
<div class="panel full-width">
|
||||
<h2>Pool Summary</h2>
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card"><div class="value" id="statPoolName">-</div><div class="label">Pool Name</div></div>
|
||||
<div class="stat-card"><div class="value" id="statMembers">0</div><div class="label">Members</div></div>
|
||||
<div class="stat-card"><div class="value" id="statContent">0</div><div class="label">Content Items</div></div>
|
||||
<div class="stat-card"><div class="value" id="statTotal">0</div><div class="label">Total Capacity</div></div>
|
||||
<div class="stat-card"><div class="value" id="statUsed">0</div><div class="label">Used</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Capacity chart -->
|
||||
<div class="panel">
|
||||
<h2>Capacity by Node</h2>
|
||||
<div id="capacityChart"></div>
|
||||
<div id="capEmpty" style="color:#555;font-size:11px;">No members yet</div>
|
||||
</div>
|
||||
|
||||
<!-- Members table -->
|
||||
<div class="panel">
|
||||
<h2>Members <span id="memberCount" style="color:#555;font-weight:400;"></span></h2>
|
||||
<div class="members-table">
|
||||
<table>
|
||||
<thead><tr><th>Node ID</th><th>State</th><th>Total</th><th>Used</th><th>Free</th></tr></thead>
|
||||
<tbody id="membersBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content locations -->
|
||||
<div class="panel full-width">
|
||||
<h2>Content Location Map <span id="contentCount" style="color:#555;font-weight:400;"></span></h2>
|
||||
<div class="content-table">
|
||||
<table>
|
||||
<thead><tr><th>Content Hash</th><th>Replicas</th><th>Nodes</th></tr></thead>
|
||||
<tbody id="contentBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ACL panel -->
|
||||
<div class="panel full-width">
|
||||
<h2>Access Control <span id="aclMode" style="color:#555;font-weight:400;"></span></h2>
|
||||
<div id="aclOpen" style="color:#555;font-size:11px;">Open mode — any node may join the pool</div>
|
||||
<div id="aclTable" class="content-table" style="display:none">
|
||||
<table>
|
||||
<thead><tr><th>Node ID</th><th>Granted By</th><th>Status</th></tr></thead>
|
||||
<tbody id="aclBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var dot = document.getElementById('statusDot');
|
||||
var poolConfigured = false;
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
function formatBytes(b) {
|
||||
if (b === 0) return '0 B';
|
||||
var units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
var i = Math.floor(Math.log(b) / Math.log(1024));
|
||||
if (i >= units.length) i = units.length - 1;
|
||||
return (b / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (!s) return '';
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
window.toast = function(msg, type) {
|
||||
var t = $('toast');
|
||||
t.textContent = msg;
|
||||
t.className = 'toast show ' + type;
|
||||
setTimeout(function() { t.className = 'toast'; }, 2500);
|
||||
};
|
||||
|
||||
function updatePool(snap) {
|
||||
if (!snap) {
|
||||
if (!poolConfigured) {
|
||||
$('noPool').style.display = 'flex';
|
||||
$('poolContent').style.display = 'none';
|
||||
$('joinBtn').style.display = 'none';
|
||||
$('leaveBtn').style.display = 'none';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
poolConfigured = true;
|
||||
$('noPool').style.display = 'none';
|
||||
$('poolContent').style.display = 'block';
|
||||
$('joinBtn').style.display = 'inline-block';
|
||||
$('leaveBtn').style.display = 'inline-block';
|
||||
|
||||
// Summary cards
|
||||
$('statPoolName').textContent = snap.pool_name || '-';
|
||||
$('statPoolName').style.fontSize = '14px';
|
||||
$('statMembers').textContent = snap.member_count;
|
||||
$('statContent').textContent = snap.content_count;
|
||||
$('statTotal').textContent = formatBytes(snap.total_bytes);
|
||||
$('statUsed').textContent = formatBytes(snap.used_bytes);
|
||||
|
||||
// Members table
|
||||
var mbody = $('membersBody');
|
||||
mbody.innerHTML = '';
|
||||
$('memberCount').textContent = '(' + snap.members.length + ')';
|
||||
|
||||
for (var i = 0; i < snap.members.length; i++) {
|
||||
var m = snap.members[i];
|
||||
var free = m.total_bytes - m.used_bytes;
|
||||
var tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
'<td style="color:#6366f1;font-size:11px;" title="' + escapeHtml(m.node_id) + '">' + m.node_id.substring(0, 16) + '\u2026</td>' +
|
||||
'<td style="color:#4caf50;">' + escapeHtml(m.state) + '</td>' +
|
||||
'<td style="color:#888;">' + formatBytes(m.total_bytes) + '</td>' +
|
||||
'<td style="color:#888;">' + formatBytes(m.used_bytes) + '</td>' +
|
||||
'<td style="color:#888;">' + formatBytes(free) + '</td>';
|
||||
mbody.appendChild(tr);
|
||||
}
|
||||
|
||||
// Capacity chart
|
||||
var chart = $('capacityChart');
|
||||
chart.innerHTML = '';
|
||||
var capEmpty = $('capEmpty');
|
||||
|
||||
if (snap.members.length === 0) {
|
||||
capEmpty.style.display = 'block';
|
||||
} else {
|
||||
capEmpty.style.display = 'none';
|
||||
for (var i = 0; i < snap.members.length; i++) {
|
||||
var m = snap.members[i];
|
||||
var pct = m.total_bytes > 0 ? Math.round((m.used_bytes / m.total_bytes) * 100) : 0;
|
||||
var fillClass = 'cap-bar-fill';
|
||||
if (pct > 90) fillClass += ' crit';
|
||||
else if (pct > 70) fillClass += ' warn';
|
||||
|
||||
var wrap = document.createElement('div');
|
||||
wrap.className = 'cap-bar-wrap';
|
||||
wrap.innerHTML =
|
||||
'<div class="cap-bar-label"><span>' + m.node_id.substring(0, 12) + '\u2026</span><span>' + pct + '% (' + formatBytes(m.used_bytes) + ' / ' + formatBytes(m.total_bytes) + ')</span></div>' +
|
||||
'<div class="cap-bar"><div class="' + fillClass + '" style="width:' + pct + '%"></div></div>';
|
||||
chart.appendChild(wrap);
|
||||
}
|
||||
}
|
||||
|
||||
// Content locations
|
||||
var cbody = $('contentBody');
|
||||
cbody.innerHTML = '';
|
||||
$('contentCount').textContent = '(' + snap.content_locations.length + ')';
|
||||
|
||||
for (var i = 0; i < snap.content_locations.length; i++) {
|
||||
var cl = snap.content_locations[i];
|
||||
var nodeList = cl.nodes.map(function(n) { return n.substring(0, 12) + '\u2026'; }).join(', ');
|
||||
var tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
'<td style="color:#6366f1;font-size:11px;" title="' + escapeHtml(cl.content_hash) + '">' + cl.content_hash.substring(0, 16) + '\u2026</td>' +
|
||||
'<td>' + cl.replica_count + '</td>' +
|
||||
'<td style="color:#888;font-size:10px;">' + escapeHtml(nodeList) + '</td>';
|
||||
cbody.appendChild(tr);
|
||||
}
|
||||
|
||||
// ACL panel
|
||||
var aclMode = snap.acl_mode || 'open';
|
||||
var aclEntries = snap.acl || [];
|
||||
$('aclMode').textContent = '(' + aclMode + ')';
|
||||
|
||||
if (aclMode === 'open' || aclEntries.length === 0) {
|
||||
$('aclOpen').style.display = 'block';
|
||||
$('aclTable').style.display = 'none';
|
||||
} else {
|
||||
$('aclOpen').style.display = 'none';
|
||||
$('aclTable').style.display = 'block';
|
||||
var abody = $('aclBody');
|
||||
abody.innerHTML = '';
|
||||
for (var i = 0; i < aclEntries.length; i++) {
|
||||
var a = aclEntries[i];
|
||||
var status = a.revoked ? 'revoked' : 'granted';
|
||||
var statusColor = a.revoked ? '#f44336' : '#4caf50';
|
||||
var tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
'<td style="color:#6366f1;font-size:11px;" title="' + escapeHtml(a.node_id) + '">' + a.node_id.substring(0, 16) + '\u2026</td>' +
|
||||
'<td style="color:#888;font-size:10px;" title="' + escapeHtml(a.granted_by) + '">' + a.granted_by.substring(0, 16) + '\u2026</td>' +
|
||||
'<td style="color:' + statusColor + ';font-size:10px;">' + status + '</td>';
|
||||
abody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Join/Leave actions
|
||||
window.joinPool = function() {
|
||||
$('joinBtn').disabled = true;
|
||||
fetch('/api/pool/join', { method: 'POST' })
|
||||
.then(function(r) {
|
||||
if (!r.ok) return r.json().then(function(j) { throw new Error(j.error || r.statusText); });
|
||||
return r.json();
|
||||
})
|
||||
.then(function() { toast('Joined pool', 'success'); })
|
||||
.catch(function(e) { toast('Join failed: ' + e.message, 'error'); })
|
||||
.finally(function() { $('joinBtn').disabled = false; });
|
||||
};
|
||||
|
||||
window.leavePool = function() {
|
||||
$('leaveBtn').disabled = true;
|
||||
fetch('/api/pool/leave', { method: 'POST' })
|
||||
.then(function(r) {
|
||||
if (!r.ok) return r.json().then(function(j) { throw new Error(j.error || r.statusText); });
|
||||
return r.json();
|
||||
})
|
||||
.then(function() { toast('Left pool', 'success'); })
|
||||
.catch(function(e) { toast('Leave failed: ' + e.message, 'error'); })
|
||||
.finally(function() { $('leaveBtn').disabled = false; });
|
||||
};
|
||||
|
||||
// SSE connection
|
||||
var es = new EventSource('/events');
|
||||
|
||||
es.addEventListener('pool', function(e) {
|
||||
try {
|
||||
var snap = JSON.parse(e.data);
|
||||
updatePool(snap);
|
||||
} catch(err) { console.error('pool parse error', err); }
|
||||
});
|
||||
|
||||
es.addEventListener('done', function() {
|
||||
dot.className = 'status-dot done';
|
||||
es.close();
|
||||
});
|
||||
|
||||
es.onerror = function() { dot.className = 'status-dot disconnected'; };
|
||||
es.onopen = function() { dot.className = 'status-dot'; };
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"##;
|
||||
|
|
@ -9,7 +9,7 @@ use axum::extract::{Path, Query, State};
|
|||
use axum::http::{header, StatusCode};
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tokio_stream::StreamExt;
|
||||
|
|
@ -21,20 +21,13 @@ use crate::actors_html::ACTORS_HTML;
|
|||
use crate::collector::StatsCollector;
|
||||
use crate::command::CommandRouter;
|
||||
use crate::dashboard_html::DASHBOARD_HTML;
|
||||
use crate::datastore_collector::{DatastoreFactory, DatastoreStatsProvider, ListScope};
|
||||
use crate::datastore_html::DATASTORE_HTML;
|
||||
use crate::history::DashboardHistory;
|
||||
use crate::layer::EventStore;
|
||||
use crate::topology;
|
||||
use crate::topology_html::TOPOLOGY_HTML;
|
||||
use crate::warnings::{WarningConfig, WarningDetector};
|
||||
|
||||
#[cfg(feature = "distribution")]
|
||||
use crate::distribution_collector::DistributionStatsProvider;
|
||||
#[cfg(feature = "distribution")]
|
||||
use crate::distribution_html::DISTRIBUTION_HTML;
|
||||
|
||||
use crate::ci_collector::CiStatsProvider;
|
||||
use crate::plugin::PluginRegistry;
|
||||
|
||||
use crate::trace::RuntimeTrace;
|
||||
|
||||
|
|
@ -54,13 +47,7 @@ pub(crate) struct AppState {
|
|||
pub shutdown_notify: Arc<tokio::sync::Notify>,
|
||||
pub history: Arc<DashboardHistory>,
|
||||
pub cmd_router: Arc<CommandRouter>,
|
||||
#[cfg(feature = "distribution")]
|
||||
pub distribution: Arc<Mutex<Option<Arc<dyn DistributionStatsProvider>>>>,
|
||||
pub datastore: Arc<Mutex<Option<Arc<dyn DatastoreStatsProvider>>>>,
|
||||
pub datastore_factory: Arc<Mutex<Option<Arc<dyn DatastoreFactory>>>>,
|
||||
pub ci: Arc<Mutex<Option<Arc<dyn CiStatsProvider>>>>,
|
||||
pub peer_auth: Arc<Mutex<Option<Arc<Mutex<distribution::peer_auth::PeerAllowList>>>>>,
|
||||
pub join_sender: Arc<Mutex<Option<std::sync::mpsc::Sender<crate::JoinPeerInfo>>>>,
|
||||
pub plugins: Arc<PluginRegistry>,
|
||||
}
|
||||
|
||||
// ── Router builders ─────────────────────────────────────────────────────
|
||||
|
|
@ -70,34 +57,16 @@ pub(crate) fn build_live_router(state: AppState) -> Router {
|
|||
.route("/", get(page_dashboard))
|
||||
.route("/actors", get(page_actors))
|
||||
.route("/topology", get(page_topology))
|
||||
.route("/datastore", get(page_datastore))
|
||||
.route("/events", get(handle_live_sse))
|
||||
.route("/api/stats", get(handle_stats_api))
|
||||
.route("/api/history", get(handle_history_api))
|
||||
.route("/api/topology", get(handle_topology_api))
|
||||
.route("/api/investigate", get(handle_investigate_api))
|
||||
.route("/api/datastore", get(handle_datastore_api))
|
||||
.route("/api/logs", get(handle_logs_api))
|
||||
.route("/api/datastore/list", get(handle_ds_list))
|
||||
.route("/api/datastore/get", get(handle_ds_get))
|
||||
.route("/api/datastore/data", get(handle_ds_data))
|
||||
.route("/api/datastore/status", get(handle_ds_status))
|
||||
.route("/api/datastore/put", post(handle_ds_put))
|
||||
.route("/api/datastore/delete", post(handle_ds_delete))
|
||||
.route("/api/datastore/start", post(handle_ds_start))
|
||||
.route("/api/datastore/shutdown", post(handle_ds_shutdown))
|
||||
.route("/api/peers", get(handle_peers_list))
|
||||
.route("/api/peers/add", post(handle_peers_add))
|
||||
.route("/api/peers/sync", post(handle_peers_sync))
|
||||
.route("/api/peers/remove", post(handle_peers_remove))
|
||||
.route("/actor/{hex}", get(handle_actor_detail));
|
||||
|
||||
#[cfg(feature = "distribution")]
|
||||
let router = router
|
||||
.route("/distribution", get(page_distribution))
|
||||
.route("/api/distribution", get(handle_distribution_api));
|
||||
|
||||
let router = router.route("/api/ci/{*rest}", get(handle_ci_api));
|
||||
.route("/actor/{hex}", get(handle_actor_detail))
|
||||
// Plugin routes
|
||||
.route("/api/plugin/{name}/{*rest}", get(handle_plugin_get).post(handle_plugin_post))
|
||||
.route("/plugin/{name}", get(handle_plugin_page));
|
||||
|
||||
router.with_state(state)
|
||||
}
|
||||
|
|
@ -151,15 +120,6 @@ async fn page_topology() -> Response {
|
|||
html_response(TOPOLOGY_HTML, "live")
|
||||
}
|
||||
|
||||
async fn page_datastore() -> Response {
|
||||
html_response(DATASTORE_HTML, "live")
|
||||
}
|
||||
|
||||
#[cfg(feature = "distribution")]
|
||||
async fn page_distribution() -> Response {
|
||||
html_response(DISTRIBUTION_HTML, "live")
|
||||
}
|
||||
|
||||
async fn handle_actor_detail(Path(hex_addr): Path<String>) -> Response {
|
||||
let html = ACTOR_DETAIL_HTML
|
||||
.replace("__DASHBOARD_MODE__", "live")
|
||||
|
|
@ -201,13 +161,11 @@ async fn handle_live_sse(
|
|||
|
||||
// Run warning detection
|
||||
let warnings = warning_detector.check(&stats);
|
||||
if !warnings.is_empty() {
|
||||
if let Ok(wjson) = serde_json::to_string(&warnings) {
|
||||
if tx.send(format_sse("warnings", &wjson)).await.is_err() {
|
||||
if !warnings.is_empty()
|
||||
&& let Ok(wjson) = serde_json::to_string(&warnings)
|
||||
&& tx.send(format_sse("warnings", &wjson)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let json = serde_json::to_string(&stats).unwrap();
|
||||
if tx.send(format_sse("stats", &json)).await.is_err() {
|
||||
|
|
@ -216,67 +174,22 @@ async fn handle_live_sse(
|
|||
|
||||
// Send topology every 5th tick (~1/sec)
|
||||
tick_count += 1;
|
||||
if tick_count % 5 == 0 {
|
||||
if tick_count.is_multiple_of(5) {
|
||||
let topo = topology::worker_topology(&stats);
|
||||
if let Ok(tjson) = serde_json::to_string(&topo) {
|
||||
if tx.send(format_sse("topology", &tjson)).await.is_err() {
|
||||
if let Ok(tjson) = serde_json::to_string(&topo)
|
||||
&& tx.send(format_sse("topology", &tjson)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send distribution snapshot if provider is attached
|
||||
#[cfg(feature = "distribution")]
|
||||
{
|
||||
let maybe_dist = state.distribution.lock().unwrap().clone();
|
||||
if let Some(provider) = maybe_dist {
|
||||
if let Some(snapshot) = provider.snapshot() {
|
||||
if let Ok(json) = serde_json::to_string(&snapshot) {
|
||||
if tx.send(format_sse("distribution", &json)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Poll all registered plugins
|
||||
for plugin in state.plugins.snapshot() {
|
||||
if let Some(json) = plugin.snapshot_json()
|
||||
&& tx.send(format_sse(plugin.name(), &json)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send datastore snapshot if provider is attached
|
||||
{
|
||||
let maybe_ds = state.datastore.lock().unwrap().clone();
|
||||
match maybe_ds {
|
||||
Some(provider) => {
|
||||
let is_running = provider.is_running();
|
||||
let snap_json = provider.snapshot_json().unwrap_or_else(|| "null".into());
|
||||
let envelope = format!(
|
||||
r#"{{"is_running":{},"snapshot":{}}}"#,
|
||||
is_running, snap_json
|
||||
);
|
||||
if tx.send(format_sse("datastore", &envelope)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let envelope = r#"{"is_running":false,"snapshot":null}"#;
|
||||
if tx.send(format_sse("datastore", envelope)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send CI snapshot if provider is attached
|
||||
{
|
||||
let maybe_ci = state.ci.lock().unwrap().clone();
|
||||
if let Some(provider) = maybe_ci {
|
||||
let snapshot = provider.snapshot();
|
||||
if let Ok(json) = serde_json::to_string(&snapshot) {
|
||||
if tx.send(format_sse("ci", &json)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send new activity events
|
||||
|
|
@ -361,54 +274,6 @@ async fn handle_investigate_api(
|
|||
json_response(json)
|
||||
}
|
||||
|
||||
#[cfg(feature = "distribution")]
|
||||
async fn handle_distribution_api(State(state): State<AppState>) -> Response {
|
||||
let json = match state.distribution.lock().unwrap().as_ref() {
|
||||
Some(provider) => match provider.snapshot() {
|
||||
Some(snapshot) => serde_json::to_string(&snapshot).unwrap_or_else(|_| "{}".into()),
|
||||
None => "{}".to_string(),
|
||||
},
|
||||
None => serde_json::json!({
|
||||
"error": "distribution provider not attached"
|
||||
})
|
||||
.to_string(),
|
||||
};
|
||||
json_response(json)
|
||||
}
|
||||
|
||||
async fn handle_datastore_api(State(state): State<AppState>) -> Response {
|
||||
let json = match state.datastore.lock().unwrap().as_ref() {
|
||||
Some(provider) => provider.snapshot_json().unwrap_or_else(|| "{}".into()),
|
||||
None => serde_json::json!({
|
||||
"error": "datastore provider not attached"
|
||||
})
|
||||
.to_string(),
|
||||
};
|
||||
json_response(json)
|
||||
}
|
||||
|
||||
async fn handle_ci_api(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
|
||||
) -> Response {
|
||||
use crate::ci_collector;
|
||||
|
||||
let path = uri.path();
|
||||
let route = ci_collector::parse_route(path);
|
||||
let json = match state.ci.lock().unwrap().as_ref() {
|
||||
Some(provider) => {
|
||||
let snapshot = provider.snapshot();
|
||||
ci_collector::handle_route(&route, &snapshot)
|
||||
.unwrap_or_else(|| r#"{"error":"not found"}"#.to_string())
|
||||
}
|
||||
None => serde_json::json!({
|
||||
"error": "CI provider not attached"
|
||||
})
|
||||
.to_string(),
|
||||
};
|
||||
json_response(json)
|
||||
}
|
||||
|
||||
async fn handle_topology_api(State(state): State<AppState>) -> Response {
|
||||
let maybe_rt = state.runtime.lock().unwrap().clone();
|
||||
let json = match maybe_rt {
|
||||
|
|
@ -453,397 +318,77 @@ async fn handle_history_api(State(state): State<AppState>) -> Response {
|
|||
json_response(json)
|
||||
}
|
||||
|
||||
// ── Datastore CRUD API handlers ─────────────────────────────────────────
|
||||
// ── Plugin handlers ─────────────────────────────────────────────────────
|
||||
|
||||
fn get_ds_provider(
|
||||
datastore: &Arc<Mutex<Option<Arc<dyn DatastoreStatsProvider>>>>,
|
||||
) -> Option<Arc<dyn DatastoreStatsProvider>> {
|
||||
datastore.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
async fn handle_ds_list(
|
||||
async fn handle_plugin_get(
|
||||
State(state): State<AppState>,
|
||||
Path((name, rest)): Path<(String, String)>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let provider = match get_ds_provider(&state.datastore) {
|
||||
let plugins = state.plugins.snapshot();
|
||||
let plugin = match plugins.iter().find(|p| p.name() == name) {
|
||||
Some(p) => p,
|
||||
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
|
||||
None => return json_error(StatusCode::NOT_FOUND, &format!("plugin '{name}' not found")),
|
||||
};
|
||||
let scope = match params.get("scope").map(|s| s.as_str()) {
|
||||
Some("local") => ListScope::Local,
|
||||
_ => ListScope::Swarm,
|
||||
};
|
||||
let name_filter = params.get("name").map(|s| s.as_str());
|
||||
match provider.list_objects(name_filter, scope) {
|
||||
Ok(json) => json_response(json),
|
||||
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ds_get(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let provider = match get_ds_provider(&state.datastore) {
|
||||
Some(p) => p,
|
||||
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
|
||||
};
|
||||
let hash = match params.get("hash") {
|
||||
Some(h) => h.as_str(),
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "missing ?hash= parameter"),
|
||||
};
|
||||
match provider.get_object(hash) {
|
||||
Ok(json) => json_response(json),
|
||||
Err(e) if e.contains("not found") => json_error(StatusCode::NOT_FOUND, &e),
|
||||
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ds_data(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let provider = match get_ds_provider(&state.datastore) {
|
||||
Some(p) => p,
|
||||
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
|
||||
};
|
||||
let hash = match params.get("hash") {
|
||||
Some(h) => h.as_str(),
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "missing ?hash= parameter"),
|
||||
};
|
||||
match provider.get_data(hash) {
|
||||
Ok(data) => {
|
||||
([(header::CONTENT_TYPE, "application/octet-stream")], data).into_response()
|
||||
match plugin.handle_request("GET", &rest, ¶ms, &[]) {
|
||||
crate::plugin::PluginResponse::Json(json) => json_response(json),
|
||||
crate::plugin::PluginResponse::Binary { content_type, data } => {
|
||||
([(header::CONTENT_TYPE, content_type)], data).into_response()
|
||||
}
|
||||
crate::plugin::PluginResponse::Error { status, message } => {
|
||||
let code = StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
json_error(code, &message)
|
||||
}
|
||||
crate::plugin::PluginResponse::NotFound => {
|
||||
json_error(StatusCode::NOT_FOUND, "not found")
|
||||
}
|
||||
Err(e) if e.contains("not found") => json_error(StatusCode::NOT_FOUND, &e),
|
||||
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ds_status(State(state): State<AppState>) -> Response {
|
||||
let provider = match get_ds_provider(&state.datastore) {
|
||||
Some(p) => p,
|
||||
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
|
||||
};
|
||||
match provider.node_status() {
|
||||
Ok(json) => json_response(json),
|
||||
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ds_put(
|
||||
async fn handle_plugin_post(
|
||||
State(state): State<AppState>,
|
||||
Path((name, rest)): Path<(String, String)>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let provider = match get_ds_provider(&state.datastore) {
|
||||
let plugins = state.plugins.snapshot();
|
||||
let plugin = match plugins.iter().find(|p| p.name() == name) {
|
||||
Some(p) => p,
|
||||
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
|
||||
None => return json_error(StatusCode::NOT_FOUND, &format!("plugin '{name}' not found")),
|
||||
};
|
||||
let name = params.get("name").cloned();
|
||||
match provider.put_data(body.to_vec(), name) {
|
||||
Ok(json) => json_response(json),
|
||||
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
match plugin.handle_request("POST", &rest, ¶ms, &body) {
|
||||
crate::plugin::PluginResponse::Json(json) => json_response(json),
|
||||
crate::plugin::PluginResponse::Binary { content_type, data } => {
|
||||
([(header::CONTENT_TYPE, content_type)], data).into_response()
|
||||
}
|
||||
crate::plugin::PluginResponse::Error { status, message } => {
|
||||
let code = StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
json_error(code, &message)
|
||||
}
|
||||
crate::plugin::PluginResponse::NotFound => {
|
||||
json_error(StatusCode::NOT_FOUND, "not found")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ds_delete(
|
||||
async fn handle_plugin_page(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
Path(name): Path<String>,
|
||||
) -> Response {
|
||||
let provider = match get_ds_provider(&state.datastore) {
|
||||
let plugins = state.plugins.snapshot();
|
||||
let plugin = match plugins.iter().find(|p| p.name() == name) {
|
||||
Some(p) => p,
|
||||
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
|
||||
};
|
||||
let hash = match params.get("hash") {
|
||||
Some(h) => h.as_str(),
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "missing ?hash= parameter"),
|
||||
};
|
||||
match provider.delete_object(hash) {
|
||||
Ok(json) => json_response(json),
|
||||
Err(e) if e.contains("not found") => json_error(StatusCode::NOT_FOUND, &e),
|
||||
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ds_start(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
// Check if already running
|
||||
{
|
||||
let ds = state.datastore.lock().unwrap();
|
||||
if ds.is_some() {
|
||||
return json_error(StatusCode::CONFLICT, "datastore already running");
|
||||
}
|
||||
}
|
||||
|
||||
let fac = match state.datastore_factory.lock().unwrap().clone() {
|
||||
Some(f) => f,
|
||||
None => return json_error(StatusCode::NOT_IMPLEMENTED, "no datastore factory configured"),
|
||||
};
|
||||
|
||||
let storage_path = params.get("storage_path").cloned();
|
||||
|
||||
match fac.start_datastore(storage_path) {
|
||||
Ok(provider) => {
|
||||
*state.datastore.lock().unwrap() = Some(provider);
|
||||
json_response(r#"{"ok":true}"#.to_string())
|
||||
}
|
||||
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ds_shutdown(State(state): State<AppState>) -> Response {
|
||||
let provider = match get_ds_provider(&state.datastore) {
|
||||
Some(p) => p,
|
||||
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
|
||||
};
|
||||
|
||||
match provider.shutdown_datastore() {
|
||||
Ok(()) => {
|
||||
*state.datastore.lock().unwrap() = None;
|
||||
json_response(r#"{"ok":true}"#.to_string())
|
||||
}
|
||||
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Peer Management API ─────────────────────────────────────────────────
|
||||
|
||||
async fn handle_peers_list(State(state): State<AppState>) -> Response {
|
||||
let maybe_auth = state.peer_auth.lock().unwrap().clone();
|
||||
match maybe_auth {
|
||||
Some(auth) => {
|
||||
let list = auth.lock().unwrap();
|
||||
let is_open = list.is_open();
|
||||
let peers: Vec<serde_json::Value> = list
|
||||
.list_peers()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
serde_json::json!({
|
||||
"node_id": p.node_id,
|
||||
"label": p.label,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let json = serde_json::json!({
|
||||
"mode": if is_open { "open" } else { "allow-list" },
|
||||
"peers": peers,
|
||||
})
|
||||
.to_string();
|
||||
json_response(json)
|
||||
}
|
||||
None => {
|
||||
let json = serde_json::json!({
|
||||
"mode": "open",
|
||||
"peers": [],
|
||||
})
|
||||
.to_string();
|
||||
json_response(json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_peers_add(State(state): State<AppState>, body: String) -> Response {
|
||||
let maybe_auth = state.peer_auth.lock().unwrap().clone();
|
||||
let auth = match maybe_auth {
|
||||
Some(a) => a,
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "peer auth not configured"),
|
||||
};
|
||||
|
||||
let parsed: serde_json::Value = match serde_json::from_str(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, &format!("invalid JSON: {e}")),
|
||||
};
|
||||
|
||||
let node_id_str = match parsed.get("node_id").and_then(|v| v.as_str()) {
|
||||
Some(s) => s,
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "missing node_id field"),
|
||||
};
|
||||
let label = parsed
|
||||
.get("label")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// Accept hex (64 chars) or base58 (~44 chars)
|
||||
let bytes: [u8; 32] = if let Some(b) = distribution::identity::hex_decode(node_id_str) {
|
||||
match b.try_into() {
|
||||
Ok(arr) => arr,
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid node_id (hex decoded to wrong length)",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if let Some(arr) = distribution::identity::base58_decode(node_id_str) {
|
||||
arr
|
||||
} else {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid node_id (expected 64-char hex or base58)",
|
||||
);
|
||||
};
|
||||
|
||||
let relay_url = parsed
|
||||
.get("relay_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let node_id = distribution::types::NodeId(bytes);
|
||||
let mut list = auth.lock().unwrap();
|
||||
list.add_peer(node_id, label);
|
||||
if let Err(e) = list.save() {
|
||||
eprintln!("warning: failed to persist peers.json: {e}");
|
||||
}
|
||||
drop(list);
|
||||
|
||||
// Trigger a SWIM join for the newly added peer
|
||||
if let Some(tx) = state.join_sender.lock().unwrap().as_ref() {
|
||||
let _ = tx.send((bytes, relay_url));
|
||||
}
|
||||
|
||||
json_response(r#"{"ok":true}"#.to_string())
|
||||
}
|
||||
|
||||
async fn handle_peers_sync(State(state): State<AppState>, body: String) -> Response {
|
||||
let maybe_auth = state.peer_auth.lock().unwrap().clone();
|
||||
let auth = match maybe_auth {
|
||||
Some(a) => a,
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "peer auth not configured"),
|
||||
};
|
||||
|
||||
let parsed: serde_json::Value = match serde_json::from_str(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, &format!("invalid JSON: {e}")),
|
||||
};
|
||||
|
||||
let peers = match parsed.get("peers").and_then(|v| v.as_array()) {
|
||||
Some(arr) => arr,
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "missing peers array"),
|
||||
};
|
||||
|
||||
// Parse all peers first, bail on any error
|
||||
let mut parsed_peers: Vec<(distribution::types::NodeId, String)> = Vec::new();
|
||||
for peer in peers {
|
||||
let node_id_str = match peer.get("node_id").and_then(|v| v.as_str()) {
|
||||
Some(s) => s,
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "peer missing node_id"),
|
||||
};
|
||||
let label = peer
|
||||
.get("label")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let bytes: [u8; 32] = if let Some(b) = distribution::identity::hex_decode(node_id_str) {
|
||||
match b.try_into() {
|
||||
Ok(arr) => arr,
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("invalid node_id hex length for {node_id_str}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if let Some(arr) = distribution::identity::base58_decode(node_id_str) {
|
||||
arr
|
||||
} else {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("invalid node_id: {node_id_str}"),
|
||||
);
|
||||
};
|
||||
|
||||
parsed_peers.push((distribution::types::NodeId(bytes), label));
|
||||
}
|
||||
|
||||
// Add all peers in a single lock acquisition
|
||||
{
|
||||
let mut list = auth.lock().unwrap();
|
||||
for (node_id, label) in &parsed_peers {
|
||||
list.add_peer(*node_id, label.clone());
|
||||
}
|
||||
if let Err(e) = list.save() {
|
||||
eprintln!("warning: failed to persist peers.json: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger a SWIM join to the seed peer if specified
|
||||
let join_seed = parsed.get("join_seed").and_then(|v| v.as_str());
|
||||
if let Some(seed_str) = join_seed {
|
||||
let seed_bytes: Option<[u8; 32]> =
|
||||
if let Some(b) = distribution::identity::hex_decode(seed_str) {
|
||||
b.try_into().ok()
|
||||
} else {
|
||||
distribution::identity::base58_decode(seed_str)
|
||||
};
|
||||
|
||||
if let Some(bytes) = seed_bytes {
|
||||
// Find the relay_url for the seed from the peers array
|
||||
let relay_url = peers.iter().find_map(|p| {
|
||||
let nid = p.get("node_id").and_then(|v| v.as_str())?;
|
||||
// Match by checking if this peer's node_id resolves to the same bytes
|
||||
let peer_bytes: [u8; 32] =
|
||||
if let Some(b) = distribution::identity::hex_decode(nid) {
|
||||
b.try_into().ok()?
|
||||
} else {
|
||||
distribution::identity::base58_decode(nid)?
|
||||
};
|
||||
if peer_bytes == bytes {
|
||||
p.get("relay_url").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(tx) = state.join_sender.lock().unwrap().as_ref() {
|
||||
let _ = tx.send((bytes, relay_url));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let added = parsed_peers.len();
|
||||
json_response(format!(r#"{{"ok":true,"added":{added}}}"#))
|
||||
}
|
||||
|
||||
async fn handle_peers_remove(State(state): State<AppState>, body: String) -> Response {
|
||||
let maybe_auth = state.peer_auth.lock().unwrap().clone();
|
||||
let auth = match maybe_auth {
|
||||
Some(a) => a,
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "peer auth not configured"),
|
||||
};
|
||||
|
||||
let parsed: serde_json::Value = match serde_json::from_str(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, &format!("invalid JSON: {e}")),
|
||||
};
|
||||
|
||||
let node_id_hex = match parsed.get("node_id").and_then(|v| v.as_str()) {
|
||||
Some(s) => s,
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "missing node_id field"),
|
||||
};
|
||||
|
||||
let bytes = match distribution::identity::hex_decode(node_id_hex) {
|
||||
Some(b) if b.len() == 32 => b,
|
||||
_ => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid node_id hex (must be 64 hex chars)",
|
||||
);
|
||||
return (StatusCode::NOT_FOUND, "plugin not found").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let node_id = distribution::types::NodeId(bytes.try_into().unwrap());
|
||||
let mut list = auth.lock().unwrap();
|
||||
list.remove_peer(&node_id);
|
||||
if let Err(e) = list.save() {
|
||||
eprintln!("warning: failed to persist peers.json: {e}");
|
||||
match plugin.html_page() {
|
||||
Some(html) => {
|
||||
let rendered = html.replace("__DASHBOARD_MODE__", "live");
|
||||
([(header::CONTENT_TYPE, "text/html; charset=utf-8")], rendered).into_response()
|
||||
}
|
||||
None => (StatusCode::NOT_FOUND, "no page for this plugin").into_response(),
|
||||
}
|
||||
|
||||
json_response(r#"{"ok":true}"#.to_string())
|
||||
}
|
||||
|
||||
// ── Replay server ───────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -46,8 +46,8 @@ pub const TOPOLOGY_HTML: &str = r##"<!DOCTYPE html>
|
|||
<a href="/" class="nav-link">Overview</a>
|
||||
<a href="/actors" class="nav-link">Actors</a>
|
||||
<a href="/topology" class="nav-link active">Topology</a>
|
||||
<a href="/distribution" class="nav-link">Distribution</a>
|
||||
<a href="/datastore" class="nav-link">Datastore</a>
|
||||
<a href="/plugin/distribution" class="nav-link">Distribution</a>
|
||||
<a href="/plugin/datastore" class="nav-link">Datastore</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -79,8 +79,6 @@ pub enum ViewMode {
|
|||
Overview,
|
||||
WorkerDetail,
|
||||
ActorDetail,
|
||||
#[cfg(feature = "distribution")]
|
||||
Distribution,
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
|
|
@ -104,11 +102,6 @@ pub struct App {
|
|||
pub search_locked: bool,
|
||||
pub warnings: Vec<Warning>,
|
||||
|
||||
#[cfg(feature = "distribution")]
|
||||
pub distribution: Option<distribution::snapshot::DistributionNodeSnapshot>,
|
||||
#[cfg(feature = "distribution")]
|
||||
pub dist_member_selected: usize,
|
||||
|
||||
prev_messages: Vec<u64>,
|
||||
prev_time: Instant,
|
||||
/// Rolling msg rates (smoothed)
|
||||
|
|
@ -152,10 +145,6 @@ impl App {
|
|||
search_query: String::new(),
|
||||
search_locked: false,
|
||||
warnings: Vec::new(),
|
||||
#[cfg(feature = "distribution")]
|
||||
distribution: None,
|
||||
#[cfg(feature = "distribution")]
|
||||
dist_member_selected: 0,
|
||||
prev_messages: Vec::new(),
|
||||
prev_time: Instant::now(),
|
||||
msg_rates: Vec::new(),
|
||||
|
|
@ -175,13 +164,6 @@ impl App {
|
|||
self.event_store = Some(store);
|
||||
}
|
||||
|
||||
#[cfg(feature = "distribution")]
|
||||
pub fn update_distribution(&mut self, snapshot: distribution::snapshot::DistributionNodeSnapshot) {
|
||||
let max = if snapshot.members.is_empty() { 0 } else { snapshot.members.len() - 1 };
|
||||
self.dist_member_selected = self.dist_member_selected.min(max);
|
||||
self.distribution = Some(snapshot);
|
||||
}
|
||||
|
||||
/// Get visible actor rows (filtered by search query if active).
|
||||
pub fn visible_actor_rows(&self) -> Vec<&ActorRow> {
|
||||
if self.search_query.is_empty() {
|
||||
|
|
@ -451,12 +433,7 @@ impl App {
|
|||
self.view_mode = match self.view_mode {
|
||||
ViewMode::Overview => ViewMode::WorkerDetail,
|
||||
ViewMode::ActorDetail => ViewMode::Overview,
|
||||
#[cfg(feature = "distribution")]
|
||||
ViewMode::WorkerDetail => ViewMode::Distribution,
|
||||
#[cfg(not(feature = "distribution"))]
|
||||
ViewMode::WorkerDetail => ViewMode::Overview,
|
||||
#[cfg(feature = "distribution")]
|
||||
ViewMode::Distribution => ViewMode::Overview,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
|
@ -467,8 +444,6 @@ impl App {
|
|||
ViewMode::Overview => self.handle_key_overview(key),
|
||||
ViewMode::WorkerDetail => self.handle_key_worker_detail(key),
|
||||
ViewMode::ActorDetail => self.handle_key_actor_detail(key),
|
||||
#[cfg(feature = "distribution")]
|
||||
ViewMode::Distribution => self.handle_key_distribution(key),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -560,35 +535,6 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "distribution")]
|
||||
fn handle_key_distribution(&mut self, key: KeyEvent) {
|
||||
let max = self
|
||||
.distribution
|
||||
.as_ref()
|
||||
.map(|d| if d.members.is_empty() { 0 } else { d.members.len() - 1 })
|
||||
.unwrap_or(0);
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => {
|
||||
self.view_mode = ViewMode::Overview;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.dist_member_selected = self.dist_member_selected.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
self.dist_member_selected = (self.dist_member_selected + 1).min(max);
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
self.dist_member_selected = self.dist_member_selected.saturating_sub(20);
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
self.dist_member_selected = (self.dist_member_selected + 20).min(max);
|
||||
}
|
||||
KeyCode::Home => { self.dist_member_selected = 0; }
|
||||
KeyCode::End => { self.dist_member_selected = max; }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn visible_table_height(&self) -> usize {
|
||||
// Will be set by the UI based on actual chunk size
|
||||
20
|
||||
|
|
|
|||
|
|
@ -14,10 +14,6 @@ pub enum AppEvent {
|
|||
source: RuntimeEndpoint,
|
||||
stats: Box<RuntimeStats>,
|
||||
},
|
||||
#[cfg(feature = "distribution")]
|
||||
DistributionUpdate {
|
||||
snapshot: Box<distribution::snapshot::DistributionNodeSnapshot>,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct EventLoop {
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@ use ratatui::widgets::TableState;
|
|||
use swactor::runtime::Runtime;
|
||||
|
||||
use crate::collector::StatsCollector;
|
||||
#[cfg(feature = "distribution")]
|
||||
use crate::distribution_collector::DistributionStatsProvider;
|
||||
use crate::layer::EventStore;
|
||||
use self::app::App;
|
||||
use self::event::{AppEvent, EventLoop};
|
||||
|
|
@ -45,8 +43,6 @@ impl Default for TuiConfig {
|
|||
pub fn start_tui(
|
||||
runtime: Arc<Runtime>,
|
||||
collector: Arc<StatsCollector>,
|
||||
#[cfg(feature = "distribution")]
|
||||
distribution: Option<Arc<dyn DistributionStatsProvider>>,
|
||||
config: TuiConfig,
|
||||
event_store: Option<Arc<EventStore>>,
|
||||
) -> io::Result<()> {
|
||||
|
|
@ -71,8 +67,6 @@ pub fn start_tui(
|
|||
&mut terminal,
|
||||
runtime,
|
||||
collector,
|
||||
#[cfg(feature = "distribution")]
|
||||
distribution,
|
||||
config,
|
||||
event_store,
|
||||
);
|
||||
|
|
@ -92,8 +86,6 @@ fn run_loop(
|
|||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
runtime: Arc<Runtime>,
|
||||
collector: Arc<StatsCollector>,
|
||||
#[cfg(feature = "distribution")]
|
||||
distribution: Option<Arc<dyn DistributionStatsProvider>>,
|
||||
config: TuiConfig,
|
||||
event_store: Option<Arc<EventStore>>,
|
||||
) -> io::Result<()> {
|
||||
|
|
@ -119,12 +111,6 @@ fn run_loop(
|
|||
collector.enrich(&mut stats);
|
||||
crate::collector::enrich_names(&mut stats, &runtime);
|
||||
app.update(stats);
|
||||
#[cfg(feature = "distribution")]
|
||||
if let Some(ref provider) = distribution {
|
||||
if let Some(snapshot) = provider.snapshot() {
|
||||
app.update_distribution(snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(AppEvent::Key(key)) => {
|
||||
app.handle_key(key);
|
||||
|
|
@ -132,10 +118,6 @@ fn run_loop(
|
|||
Ok(AppEvent::StatsUpdate { stats, .. }) => {
|
||||
app.update(*stats);
|
||||
}
|
||||
#[cfg(feature = "distribution")]
|
||||
Ok(AppEvent::DistributionUpdate { snapshot }) => {
|
||||
app.update_distribution(*snapshot);
|
||||
}
|
||||
Err(_) => {
|
||||
// Channel closed, exit
|
||||
break;
|
||||
|
|
@ -209,10 +191,6 @@ fn run_loop_remote(
|
|||
Ok(AppEvent::Key(key)) => {
|
||||
app.handle_key(key);
|
||||
}
|
||||
#[cfg(feature = "distribution")]
|
||||
Ok(AppEvent::DistributionUpdate { snapshot }) => {
|
||||
app.update_distribution(*snapshot);
|
||||
}
|
||||
Err(_) => {
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,27 +151,6 @@ fn parse_sse_events<R: BufRead>(
|
|||
debug_log("received done event");
|
||||
return Ok(());
|
||||
}
|
||||
#[cfg(feature = "distribution")]
|
||||
if current_event == "distribution" && !data_buf.is_empty() {
|
||||
match serde_json::from_str::<distribution::snapshot::DistributionNodeSnapshot>(&data_buf) {
|
||||
Ok(snapshot) => {
|
||||
let event = AppEvent::DistributionUpdate {
|
||||
snapshot: Box::new(snapshot),
|
||||
};
|
||||
if tx.send(event).is_err() {
|
||||
debug_log("channel closed, exiting");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug_log(&format!(
|
||||
"distribution JSON parse error: {} data={}",
|
||||
e,
|
||||
&data_buf[..data_buf.len().min(200)]
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
current_event.clear();
|
||||
data_buf.clear();
|
||||
} else if let Some(event_type) = trimmed.strip_prefix("event: ") {
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ pub fn draw(f: &mut Frame, app: &App, table_state: &mut TableState) {
|
|||
ViewMode::Overview => draw_overview(f, app, table_state),
|
||||
ViewMode::WorkerDetail => draw_worker_detail(f, app, table_state),
|
||||
ViewMode::ActorDetail => draw_actor_detail(f, app),
|
||||
#[cfg(feature = "distribution")]
|
||||
ViewMode::Distribution => draw_distribution(f, app, table_state),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -776,248 +774,6 @@ fn draw_actor_logs(f: &mut Frame, app: &App, area: Rect) {
|
|||
f.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
// ─── Distribution View ──────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(feature = "distribution")]
|
||||
fn draw_distribution(f: &mut Frame, app: &App, table_state: &mut TableState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(3), // Summary bar
|
||||
Constraint::Fill(1), // Members table
|
||||
Constraint::Length(10), // Bottom panels: cache + routing
|
||||
Constraint::Length(1), // Help bar
|
||||
])
|
||||
.split(f.area());
|
||||
|
||||
draw_dist_summary(f, app, chunks[0]);
|
||||
draw_dist_members(f, app, table_state, chunks[1]);
|
||||
|
||||
let bottom = Layout::horizontal([
|
||||
Constraint::Percentage(40),
|
||||
Constraint::Percentage(60),
|
||||
])
|
||||
.split(chunks[2]);
|
||||
|
||||
draw_dist_cache(f, app, bottom[0]);
|
||||
draw_dist_routing(f, app, bottom[1]);
|
||||
draw_dist_help(f, chunks[3]);
|
||||
}
|
||||
|
||||
#[cfg(feature = "distribution")]
|
||||
fn draw_dist_summary(f: &mut Frame, app: &App, area: Rect) {
|
||||
let (node_id, listen_addr, alive, suspect, dead, cache, dir, rt_size, rt_buckets, repair) =
|
||||
match &app.distribution {
|
||||
Some(d) => (
|
||||
&d.node_id[..d.node_id.len().min(16)],
|
||||
d.listen_addr.as_deref().unwrap_or("—"),
|
||||
d.alive_count,
|
||||
d.suspect_count,
|
||||
d.dead_count,
|
||||
d.cache_size,
|
||||
d.directory_entry_count,
|
||||
d.routing_table_size,
|
||||
d.routing_buckets.len(),
|
||||
d.repair_queue_size,
|
||||
),
|
||||
None => ("—", "—", 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
};
|
||||
|
||||
let lines = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" Node: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
node_id.to_string(),
|
||||
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" Addr: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(listen_addr.to_string(), Style::default().fg(Color::Cyan)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Members: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(format!("{alive}"), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(" alive, ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
format!("{suspect}"),
|
||||
if suspect > 0 { Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) },
|
||||
),
|
||||
Span::styled(" suspect, ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
format!("{dead}"),
|
||||
if dead > 0 { Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) },
|
||||
),
|
||||
Span::styled(" dead", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(format!(" Cache: {cache}"), Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(format!(" Directory: {dir}"), Style::default().fg(Color::DarkGray)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" Routing: {rt_size} nodes, {rt_buckets} buckets"), Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(format!(" Repair queue: {repair}"), Style::default().fg(Color::DarkGray)),
|
||||
]),
|
||||
];
|
||||
|
||||
let block = Block::default().borders(Borders::ALL).title(" Distribution ");
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
f.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
#[cfg(feature = "distribution")]
|
||||
fn draw_dist_members(f: &mut Frame, app: &App, table_state: &mut TableState, area: Rect) {
|
||||
let header_cells = ["STATE", "NODE ID", "ADDRESS", "INCARNATION"].iter().map(|&h| {
|
||||
Cell::from(h).style(
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
});
|
||||
let header = Row::new(header_cells).height(1);
|
||||
|
||||
let rows: Vec<Row> = match &app.distribution {
|
||||
Some(d) => d
|
||||
.members
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let state_style = match m.state.as_str() {
|
||||
"alive" => Style::default().fg(Color::Green),
|
||||
"suspect" => Style::default().fg(Color::Yellow),
|
||||
"dead" => Style::default().fg(Color::Red),
|
||||
_ => Style::default(),
|
||||
};
|
||||
let id_short = if m.node_id.len() > 16 {
|
||||
format!("{}...", &m.node_id[..14])
|
||||
} else {
|
||||
m.node_id.clone()
|
||||
};
|
||||
Row::new(vec![
|
||||
Cell::from(m.state.clone()).style(state_style),
|
||||
Cell::from(id_short),
|
||||
Cell::from(m.addr.clone().unwrap_or_default()),
|
||||
Cell::from(format!("{}", m.incarnation)),
|
||||
])
|
||||
})
|
||||
.collect(),
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
table_state.select(Some(app.dist_member_selected));
|
||||
|
||||
let table = Table::new(
|
||||
rows,
|
||||
[
|
||||
Constraint::Length(10),
|
||||
Constraint::Min(18),
|
||||
Constraint::Length(22),
|
||||
Constraint::Length(12),
|
||||
],
|
||||
)
|
||||
.header(header)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Members "),
|
||||
)
|
||||
.row_highlight_style(
|
||||
Style::default()
|
||||
.bg(Color::DarkGray)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol("> ");
|
||||
|
||||
f.render_stateful_widget(table, area, table_state);
|
||||
}
|
||||
|
||||
#[cfg(feature = "distribution")]
|
||||
fn draw_dist_cache(f: &mut Frame, app: &App, area: Rect) {
|
||||
let rows: Vec<Row> = match &app.distribution {
|
||||
Some(d) => d
|
||||
.cache_entries
|
||||
.iter()
|
||||
.take(area.height.saturating_sub(2) as usize)
|
||||
.map(|e| {
|
||||
let actor_short = if e.actor_addr.len() > 16 {
|
||||
format!("{}...", &e.actor_addr[..14])
|
||||
} else {
|
||||
e.actor_addr.clone()
|
||||
};
|
||||
let node_short = if e.node_id.len() > 12 {
|
||||
format!("{}...", &e.node_id[..10])
|
||||
} else {
|
||||
e.node_id.clone()
|
||||
};
|
||||
Row::new(vec![
|
||||
Cell::from(actor_short),
|
||||
Cell::from(node_short),
|
||||
])
|
||||
})
|
||||
.collect(),
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
let header = Row::new(vec![
|
||||
Cell::from("ACTOR").style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
||||
Cell::from("NODE").style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
||||
])
|
||||
.height(1);
|
||||
|
||||
let table = Table::new(
|
||||
rows,
|
||||
[Constraint::Percentage(55), Constraint::Percentage(45)],
|
||||
)
|
||||
.header(header)
|
||||
.block(Block::default().borders(Borders::ALL).title(" Cache "));
|
||||
|
||||
f.render_widget(table, area);
|
||||
}
|
||||
|
||||
#[cfg(feature = "distribution")]
|
||||
fn draw_dist_routing(f: &mut Frame, app: &App, area: Rect) {
|
||||
let buckets: Vec<(usize, usize)> = match &app.distribution {
|
||||
Some(d) => d.routing_buckets.clone(),
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
let max_count = buckets.iter().map(|(_, c)| *c).max().unwrap_or(1).max(1);
|
||||
let bar_max_width = area.width.saturating_sub(16) as usize; // space for "[NNN] " + " N"
|
||||
|
||||
let lines: Vec<Line> = buckets
|
||||
.iter()
|
||||
.take(area.height.saturating_sub(2) as usize)
|
||||
.map(|(idx, count)| {
|
||||
let bar_len = (*count as f64 / max_count as f64 * bar_max_width as f64).round() as usize;
|
||||
let bar_len = bar_len.max(1);
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" [{:>3}] ", idx),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
Span::styled(
|
||||
"\u{2588}".repeat(bar_len),
|
||||
Style::default().fg(Color::Cyan),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {}", count),
|
||||
Style::default().fg(Color::White),
|
||||
),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Routing Buckets ");
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
f.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
#[cfg(feature = "distribution")]
|
||||
fn draw_dist_help(f: &mut Frame, area: Rect) {
|
||||
let help = Line::from(vec![
|
||||
Span::styled(
|
||||
" Tab: views \u{2191}\u{2193}: scroll Esc: overview q: quit",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
]);
|
||||
f.render_widget(Paragraph::new(help), area);
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn short_type_name(full: Option<&str>) -> String {
|
||||
|
|
|
|||
|
|
@ -8,17 +8,19 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)'] }
|
|||
|
||||
[dependencies]
|
||||
swactor = { path = "../..", features = ["serde", "transport"] }
|
||||
distribution = { path = "../distribution" }
|
||||
shared-types = { path = "../shared-types" }
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
blake3 = "1"
|
||||
crossbeam-queue = "0.3.12"
|
||||
tokio = { version = "1", features = ["rt", "sync", "io-util"] }
|
||||
iroh = "0.96"
|
||||
getrandom = "0.2"
|
||||
tiny_http = { version = "0.12", optional = true }
|
||||
clap = { version = "4", features = ["derive"], optional = true }
|
||||
ureq = { version = "2", features = ["json"], optional = true }
|
||||
getrandom = { version = "0.2", optional = true }
|
||||
ctrlc = { version = "3", optional = true }
|
||||
runtime-dashboard = { path = "../runtime-dashboard", optional = true }
|
||||
toml = { version = "0.8", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
@ -26,22 +28,13 @@ serde_json = "1"
|
|||
proptest = "1"
|
||||
proptest-state-machine = "0.3"
|
||||
tempfile = "3"
|
||||
distribution = { path = "../distribution" }
|
||||
swactor = { path = "../.." }
|
||||
swactor-std = { path = "../std" }
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
tiny_http = "0.12"
|
||||
runtime-dashboard = { path = "../runtime-dashboard" }
|
||||
stateright = "0.31"
|
||||
|
||||
[features]
|
||||
node = ["dep:tiny_http", "dep:clap", "dep:ctrlc", "dep:runtime-dashboard", "dep:toml"]
|
||||
cli = ["dep:clap", "dep:ureq", "dep:getrandom"]
|
||||
|
||||
[[bin]]
|
||||
name = "swactor-store-node"
|
||||
path = "src/bin/store_node.rs"
|
||||
required-features = ["node"]
|
||||
node = ["dep:tiny_http", "dep:clap", "dep:ctrlc", "dep:toml"]
|
||||
cli = ["dep:clap", "dep:ureq"]
|
||||
|
||||
[[bin]]
|
||||
name = "swactor-store"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use std::sync::Arc;
|
|||
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||
use swactor::runtime::Runtime;
|
||||
|
||||
use distribution::types::NodeId;
|
||||
use swactor::transport::NodeId;
|
||||
|
||||
use crate::actors::stream_downloader::StreamDownloader;
|
||||
use crate::actors::stream_server::StreamServer;
|
||||
|
|
@ -310,7 +310,7 @@ impl DatastoreNode {
|
|||
fn handle_stream_offer(
|
||||
&self,
|
||||
ctx: &Ctx,
|
||||
stream_id: swactor_streams::types::StreamId,
|
||||
stream_id: crate::streams::types::StreamId,
|
||||
content_hash: ContentHash,
|
||||
_from_node: [u8; 32],
|
||||
stream_manager: ActorAddress,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
|||
|
||||
use crate::auth::{AccessRequestInfo, AuthzEngine, AuthzResult, DatastoreAction, DeniedReason};
|
||||
use crate::messages::{DatastoreNodeMsg, DatastoreResponse, GatewayMsg};
|
||||
use distribution::types::NodeId;
|
||||
use swactor::transport::NodeId;
|
||||
|
||||
/// The auth gateway actor wrapping an `AuthzEngine`.
|
||||
pub struct GatewayActor {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use std::collections::{HashMap, HashSet};
|
|||
|
||||
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||
|
||||
use distribution::types::NodeId;
|
||||
use swactor::transport::NodeId;
|
||||
|
||||
use crate::messages::{BlobStoreMsg, DatastoreResponse, MetadataMsg};
|
||||
use crate::types::{ContentHash, DatastoreConfig, ObjectEntry, ObjectManifest};
|
||||
|
|
@ -120,7 +120,7 @@ impl MetadataActor {
|
|||
/// `GcUnreferenced` to BlobStoreActor to delete orphaned chunks.
|
||||
fn gc_tick(&mut self, ctx: &Ctx) {
|
||||
self.tick_count += 1;
|
||||
if self.tick_count % self.gc_interval != 0 {
|
||||
if !self.tick_count.is_multiple_of(self.gc_interval) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ use std::sync::Arc;
|
|||
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||
use swactor::runtime::Runtime;
|
||||
|
||||
use swactor_streams::messages::{StreamManagerMsg, StreamNotification};
|
||||
use swactor_streams::types::{StreamConfig, StreamMode};
|
||||
use crate::streams::messages::{StreamManagerMsg, StreamNotification};
|
||||
use crate::streams::types::{StreamConfig, StreamMode};
|
||||
|
||||
use crate::blob_transfer::{encode_metadata, recv_blob, BlobTransferMetadata};
|
||||
use crate::messages::{BlobStoreMsg, DatastoreNodeMsg};
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
|
||||
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||
|
||||
use swactor_streams::messages::{StreamManagerMsg, StreamNotification};
|
||||
use swactor_streams::types::StreamMode;
|
||||
use crate::streams::messages::{StreamManagerMsg, StreamNotification};
|
||||
use crate::streams::types::StreamMode;
|
||||
|
||||
use crate::blob_transfer::parse_metadata;
|
||||
use crate::messages::DatastoreNodeMsg;
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ use std::time::Duration;
|
|||
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||
use swactor::runtime::Runtime;
|
||||
|
||||
use swactor_streams::messages::{StreamManagerMsg, StreamNotification};
|
||||
use swactor_streams::types::StreamId;
|
||||
use crate::streams::messages::{StreamManagerMsg, StreamNotification};
|
||||
use crate::streams::types::StreamId;
|
||||
|
||||
use crate::blob_transfer::{poll_inbox, send_blob, BlobTransferError};
|
||||
use crate::messages::{BlobStoreMsg, DatastoreResponse};
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use std::collections::HashSet;
|
|||
|
||||
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||
|
||||
use distribution::types::NodeId;
|
||||
use swactor::transport::NodeId;
|
||||
|
||||
use crate::messages::{DatastoreResponse, TransferMsg};
|
||||
use crate::types::{ContentHash, ObjectManifest, TransferStatus};
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use std::time::{Duration, Instant};
|
|||
use swactor::actor::ActorAddress;
|
||||
use swactor::runtime::{Inbox, Runtime};
|
||||
|
||||
use distribution::types::NodeId;
|
||||
use swactor::transport::NodeId;
|
||||
|
||||
use crate::auth::SignedRequest;
|
||||
use crate::chunking::reassemble_blob;
|
||||
|
|
@ -753,7 +753,7 @@ fn try_remote_get(
|
|||
let _ = state.runtime.send_to(
|
||||
peer.metadata,
|
||||
MetadataMsg::HandleFindObject {
|
||||
from: distribution::types::NodeId([0; 32]), // placeholder
|
||||
from: swactor::transport::NodeId([0; 32]), // placeholder
|
||||
content_hash,
|
||||
reply_to: *find_inbox.addr(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,9 +12,10 @@ use std::path::Path;
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use distribution::crypto;
|
||||
use distribution::types::{NodeId, Signature};
|
||||
use shared_types::ContentHash;
|
||||
use crate::crypto;
|
||||
use swactor::transport::NodeId;
|
||||
use crate::crypto::Signature;
|
||||
use crate::content_hash::ContentHash;
|
||||
|
||||
// ─── Access Request / Authorized Key Info ──────────────────────────────────
|
||||
|
||||
|
|
@ -132,7 +133,7 @@ impl AccessControlList {
|
|||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(self)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
||||
.map_err(|e| io::Error::other(e))?;
|
||||
std::fs::write(path, json)
|
||||
}
|
||||
}
|
||||
|
|
@ -228,7 +229,7 @@ impl AuthzEngine {
|
|||
|
||||
// 2. Timestamp freshness
|
||||
let ts = request.payload.timestamp;
|
||||
let diff = if now >= ts { now - ts } else { ts - now };
|
||||
let diff = now.abs_diff(ts);
|
||||
if diff > self.timestamp_window {
|
||||
return AuthzResult::Denied(DeniedReason::RequestExpired);
|
||||
}
|
||||
|
|
@ -257,7 +258,7 @@ impl AuthzEngine {
|
|||
|
||||
// 2. Timestamp freshness
|
||||
let ts = request.payload.timestamp;
|
||||
let diff = if now >= ts { now - ts } else { ts - now };
|
||||
let diff = now.abs_diff(ts);
|
||||
if diff > self.timestamp_window {
|
||||
return AuthzResult::Denied(DeniedReason::RequestExpired);
|
||||
}
|
||||
|
|
@ -320,7 +321,7 @@ impl AuthzEngine {
|
|||
/// Evict nonces whose timestamps fall outside the current window.
|
||||
pub fn gc_nonces(&mut self, now: u64) {
|
||||
self.seen_nonces.retain(|_nonce, ts| {
|
||||
let diff = if now >= *ts { now - *ts } else { *ts - now };
|
||||
let diff = now.abs_diff(*ts);
|
||||
diff <= self.timestamp_window
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use distribution::crypto::Keypair;
|
||||
use shared_types::ContentHash;
|
||||
use swactor_datastore::crypto::Keypair;
|
||||
use swactor_datastore::content_hash::ContentHash;
|
||||
use swactor_datastore::auth::{sign_request, DatastoreAction, SignedRequestPayload};
|
||||
|
||||
#[derive(Parser)]
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ use std::time::Duration;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use swactor::runtime::Inbox;
|
||||
use swactor::actor::Message;
|
||||
use swactor_streams::handle::{RecvHalf, SendHalf};
|
||||
use crate::streams::handle::{RecvHalf, SendHalf};
|
||||
|
||||
use crate::types::{ContentHash, ObjectManifest};
|
||||
|
||||
|
|
@ -264,8 +264,8 @@ pub async fn poll_inbox<M: Message>(inbox: &Inbox<M>, timeout: Duration) -> Opti
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::chunking::chunk_blob;
|
||||
use swactor_streams::handle::create_stream_handle;
|
||||
use swactor_streams::types::{StreamConfig, StreamId};
|
||||
use crate::streams::handle::create_stream_handle;
|
||||
use crate::streams::types::{StreamConfig, StreamId};
|
||||
|
||||
/// Helper: create a pair of (SendHalf, RecvHalf) connected via tokio tasks
|
||||
/// that relay data through a DuplexStream.
|
||||
|
|
@ -299,39 +299,39 @@ mod tests {
|
|||
}
|
||||
|
||||
fn spawn_send_task(
|
||||
mut cmd_rx: tokio::sync::mpsc::Receiver<swactor_streams::channel::SendCommand>,
|
||||
evt_tx: tokio::sync::mpsc::Sender<swactor_streams::channel::SendEvent>,
|
||||
pool: swactor_streams::BufferPool,
|
||||
mut cmd_rx: tokio::sync::mpsc::Receiver<crate::streams::channel::SendCommand>,
|
||||
evt_tx: tokio::sync::mpsc::Sender<crate::streams::channel::SendEvent>,
|
||||
pool: crate::streams::BufferPool,
|
||||
mut writer: tokio::io::WriteHalf<tokio::io::DuplexStream>,
|
||||
) {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
tokio::spawn(async move {
|
||||
while let Some(cmd) = cmd_rx.recv().await {
|
||||
match cmd {
|
||||
swactor_streams::channel::SendCommand::Data(buf) => {
|
||||
crate::streams::channel::SendCommand::Data(buf) => {
|
||||
let data = buf.written();
|
||||
// Write length-prefixed frame
|
||||
let len = (data.len() as u32).to_be_bytes();
|
||||
if writer.write_all(&len).await.is_err() {
|
||||
pool.checkin(buf);
|
||||
let _ = evt_tx.send(swactor_streams::channel::SendEvent::Error(
|
||||
swactor_streams::StreamError::Disconnected,
|
||||
let _ = evt_tx.send(crate::streams::channel::SendEvent::Error(
|
||||
crate::streams::StreamError::Disconnected,
|
||||
)).await;
|
||||
return;
|
||||
}
|
||||
if writer.write_all(data).await.is_err() {
|
||||
pool.checkin(buf);
|
||||
let _ = evt_tx.send(swactor_streams::channel::SendEvent::Error(
|
||||
swactor_streams::StreamError::Disconnected,
|
||||
let _ = evt_tx.send(crate::streams::channel::SendEvent::Error(
|
||||
crate::streams::StreamError::Disconnected,
|
||||
)).await;
|
||||
return;
|
||||
}
|
||||
pool.checkin(buf);
|
||||
}
|
||||
swactor_streams::channel::SendCommand::Flush => {
|
||||
crate::streams::channel::SendCommand::Flush => {
|
||||
let _ = writer.flush().await;
|
||||
}
|
||||
swactor_streams::channel::SendCommand::Close => {
|
||||
crate::streams::channel::SendCommand::Close => {
|
||||
let _ = writer.shutdown().await;
|
||||
break;
|
||||
}
|
||||
|
|
@ -342,8 +342,8 @@ mod tests {
|
|||
|
||||
fn spawn_recv_task(
|
||||
mut reader: tokio::io::ReadHalf<tokio::io::DuplexStream>,
|
||||
evt_tx: tokio::sync::mpsc::Sender<swactor_streams::channel::RecvEvent>,
|
||||
pool: swactor_streams::BufferPool,
|
||||
evt_tx: tokio::sync::mpsc::Sender<crate::streams::channel::RecvEvent>,
|
||||
pool: crate::streams::BufferPool,
|
||||
) {
|
||||
use tokio::io::AsyncReadExt;
|
||||
tokio::spawn(async move {
|
||||
|
|
@ -353,7 +353,7 @@ mod tests {
|
|||
match reader.read_exact(&mut len_buf).await {
|
||||
Ok(_) => {}
|
||||
Err(_) => {
|
||||
let _ = evt_tx.send(swactor_streams::channel::RecvEvent::Closed).await;
|
||||
let _ = evt_tx.send(crate::streams::channel::RecvEvent::Closed).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -362,7 +362,7 @@ mod tests {
|
|||
match reader.read_exact(&mut data).await {
|
||||
Ok(_) => {}
|
||||
Err(_) => {
|
||||
let _ = evt_tx.send(swactor_streams::channel::RecvEvent::Closed).await;
|
||||
let _ = evt_tx.send(crate::streams::channel::RecvEvent::Closed).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -373,15 +373,15 @@ mod tests {
|
|||
let mut buf = match pool.checkout() {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
let _ = evt_tx.send(swactor_streams::channel::RecvEvent::Error(
|
||||
swactor_streams::StreamError::BufferExhausted,
|
||||
let _ = evt_tx.send(crate::streams::channel::RecvEvent::Error(
|
||||
crate::streams::StreamError::BufferExhausted,
|
||||
)).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let written = buf.write(&data[offset..]);
|
||||
offset += written;
|
||||
let _ = evt_tx.send(swactor_streams::channel::RecvEvent::Data(buf)).await;
|
||||
let _ = evt_tx.send(crate::streams::channel::RecvEvent::Data(buf)).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,364 +1,23 @@
|
|||
//! Bridge between the runtime dashboard's `DatastoreStatsProvider` trait and
|
||||
//! the datastore actor system. Allows the dashboard to perform CRUD operations
|
||||
//! and lifecycle management without depending on `swactor-datastore` types.
|
||||
//! Datastore actor group lifecycle management.
|
||||
//!
|
||||
//! `DatastoreGroup` owns the full lifecycle of a datastore actor group:
|
||||
//! BlobStore, Metadata, DatastoreNode, and optional GatewayActor.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use swactor::actor::ActorAddress;
|
||||
use swactor::runtime::{Inbox, Runtime};
|
||||
use swactor_std::RuntimeNaming;
|
||||
use swactor::runtime::Runtime;
|
||||
use swactor::std::RuntimeNaming;
|
||||
|
||||
use distribution::types::NodeId;
|
||||
use dashboard::datastore_collector::{
|
||||
DatastoreFactory, DatastoreStatsProvider, ListScope,
|
||||
};
|
||||
use swactor::transport::NodeId;
|
||||
|
||||
use crate::actors::{BlobStoreActor, DatastoreNode, GatewayActor, MetadataActor};
|
||||
use crate::auth::{AccessControlList, AuthzEngine};
|
||||
use crate::chunking::reassemble_blob;
|
||||
use crate::messages::{DatastoreNodeMsg, DatastoreResponse, GatewayMsg, MetadataMsg};
|
||||
use crate::messages::{DatastoreNodeMsg, GatewayMsg, MetadataMsg};
|
||||
use crate::metrics::DatastoreMetrics;
|
||||
use crate::storage::{FilesystemBackend, InMemoryBackend};
|
||||
use crate::types::{ContentHash, DatastoreConfig};
|
||||
|
||||
const POLL_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(1);
|
||||
|
||||
fn poll_response(inbox: &Inbox<DatastoreResponse>, timeout: Duration) -> Option<DatastoreResponse> {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if let Some(resp) = inbox.try_recv() {
|
||||
return Some(resp);
|
||||
}
|
||||
if start.elapsed() > timeout {
|
||||
return None;
|
||||
}
|
||||
thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_to_json(entry: &crate::types::ObjectEntry) -> serde_json::Value {
|
||||
let node_hex: String = entry.node_id.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||
serde_json::json!({
|
||||
"content_hash": entry.content_hash.to_hex(),
|
||||
"name": entry.name,
|
||||
"node_id": node_hex,
|
||||
"tags": entry.tags,
|
||||
"size_bytes": entry.size_bytes,
|
||||
"created_at": entry.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
fn manifest_to_json(manifest: &crate::types::ObjectManifest) -> serde_json::Value {
|
||||
let chunks: Vec<serde_json::Value> = manifest
|
||||
.chunks
|
||||
.iter()
|
||||
.map(|c| {
|
||||
serde_json::json!({
|
||||
"hash": c.hash.to_hex(),
|
||||
"offset": c.offset,
|
||||
"size": c.size,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::json!({
|
||||
"content_hash": manifest.content_hash.to_hex(),
|
||||
"chunks": chunks,
|
||||
"total_size": manifest.total_size,
|
||||
"chunk_size": manifest.chunk_size,
|
||||
"content_type": manifest.content_type,
|
||||
})
|
||||
}
|
||||
|
||||
fn entries_to_json(entries: &[crate::types::ObjectEntry]) -> Vec<serde_json::Value> {
|
||||
entries.iter().map(entry_to_json).collect()
|
||||
}
|
||||
|
||||
/// Bridges the dashboard trait to the datastore actor system.
|
||||
pub struct DatastoreBridge {
|
||||
metrics: Arc<DatastoreMetrics>,
|
||||
runtime: Arc<Runtime>,
|
||||
datastore_addr: ActorAddress,
|
||||
metadata_addr: ActorAddress,
|
||||
#[allow(dead_code)]
|
||||
blob_store_addr: ActorAddress,
|
||||
}
|
||||
|
||||
impl DatastoreBridge {
|
||||
pub fn new(
|
||||
metrics: Arc<DatastoreMetrics>,
|
||||
runtime: Arc<Runtime>,
|
||||
datastore_addr: ActorAddress,
|
||||
metadata_addr: ActorAddress,
|
||||
blob_store_addr: ActorAddress,
|
||||
) -> Self {
|
||||
Self {
|
||||
metrics,
|
||||
runtime,
|
||||
datastore_addr,
|
||||
metadata_addr,
|
||||
blob_store_addr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DatastoreStatsProvider for DatastoreBridge {
|
||||
fn snapshot_json(&self) -> Option<String> {
|
||||
let snap = self.metrics.snapshot();
|
||||
serde_json::to_string(&snap).ok()
|
||||
}
|
||||
|
||||
fn is_running(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn list_objects(&self, name_filter: Option<&str>, scope: ListScope) -> Result<String, String> {
|
||||
let inbox = self.runtime.new_inbox::<DatastoreResponse>()
|
||||
.map_err(|e| format!("failed to create inbox: {e}"))?;
|
||||
|
||||
match scope {
|
||||
ListScope::Local => {
|
||||
let _ = self.runtime.send_to(
|
||||
self.datastore_addr,
|
||||
DatastoreNodeMsg::List {
|
||||
name_filter: name_filter.map(|s| s.to_string()),
|
||||
all: false,
|
||||
reply_to: *inbox.addr(),
|
||||
},
|
||||
);
|
||||
}
|
||||
ListScope::Swarm => {
|
||||
let _ = self.runtime.send_to(
|
||||
self.metadata_addr,
|
||||
MetadataMsg::ListLocal {
|
||||
name_filter: name_filter.map(|s| s.to_string()),
|
||||
reply_to: *inbox.addr(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
match poll_response(&inbox, POLL_TIMEOUT) {
|
||||
Some(DatastoreResponse::ListOk { entries }) => {
|
||||
let json = serde_json::json!({ "entries": entries_to_json(&entries) }).to_string();
|
||||
Ok(json)
|
||||
}
|
||||
Some(DatastoreResponse::Error { reason }) => Err(reason),
|
||||
_ => Err("timeout".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_object(&self, hash: &str) -> Result<String, String> {
|
||||
let content_hash = ContentHash::from_hex(hash)
|
||||
.ok_or_else(|| "invalid content hash hex".to_string())?;
|
||||
|
||||
let inbox = self.runtime.new_inbox::<DatastoreResponse>()
|
||||
.map_err(|e| format!("failed to create inbox: {e}"))?;
|
||||
|
||||
let _ = self.runtime.send_to(
|
||||
self.datastore_addr,
|
||||
DatastoreNodeMsg::Get {
|
||||
content_hash,
|
||||
reply_to: *inbox.addr(),
|
||||
},
|
||||
);
|
||||
|
||||
match poll_response(&inbox, POLL_TIMEOUT) {
|
||||
Some(DatastoreResponse::GetOk { entry, manifest }) => {
|
||||
self.metrics.record_get(&content_hash.to_hex());
|
||||
let json = serde_json::json!({
|
||||
"entry": entry_to_json(&entry),
|
||||
"manifest": manifest_to_json(&manifest),
|
||||
})
|
||||
.to_string();
|
||||
Ok(json)
|
||||
}
|
||||
Some(DatastoreResponse::NotFound) => Err("not found".into()),
|
||||
Some(DatastoreResponse::Error { reason }) => Err(reason),
|
||||
_ => Err("timeout".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_data(&self, hash: &str) -> Result<Vec<u8>, String> {
|
||||
let content_hash = ContentHash::from_hex(hash)
|
||||
.ok_or_else(|| "invalid content hash hex".to_string())?;
|
||||
|
||||
// Get manifest
|
||||
let inbox = self.runtime.new_inbox::<DatastoreResponse>()
|
||||
.map_err(|e| format!("failed to create inbox: {e}"))?;
|
||||
|
||||
let _ = self.runtime.send_to(
|
||||
self.datastore_addr,
|
||||
DatastoreNodeMsg::Get {
|
||||
content_hash,
|
||||
reply_to: *inbox.addr(),
|
||||
},
|
||||
);
|
||||
|
||||
self.metrics.record_get(&content_hash.to_hex());
|
||||
|
||||
let manifest = match poll_response(&inbox, POLL_TIMEOUT) {
|
||||
Some(DatastoreResponse::GetOk { manifest, .. }) => manifest,
|
||||
Some(DatastoreResponse::NotFound) => return Err("not found".into()),
|
||||
Some(DatastoreResponse::Error { reason }) => return Err(reason),
|
||||
_ => return Err("timeout".into()),
|
||||
};
|
||||
|
||||
// Read chunks
|
||||
let mut chunk_data = Vec::new();
|
||||
for chunk_ref in &manifest.chunks {
|
||||
let chunk_inbox = self.runtime.new_inbox::<DatastoreResponse>()
|
||||
.map_err(|e| format!("failed to create inbox: {e}"))?;
|
||||
|
||||
let _ = self.runtime.send_to(
|
||||
self.datastore_addr,
|
||||
DatastoreNodeMsg::ReadChunk {
|
||||
hash: chunk_ref.hash,
|
||||
reply_to: *chunk_inbox.addr(),
|
||||
},
|
||||
);
|
||||
|
||||
match poll_response(&chunk_inbox, POLL_TIMEOUT) {
|
||||
Some(DatastoreResponse::ChunkOk { hash, data }) => {
|
||||
chunk_data.push((hash, data));
|
||||
}
|
||||
_ => return Err("failed to read chunk".into()),
|
||||
}
|
||||
}
|
||||
|
||||
reassemble_blob(&manifest, &chunk_data)
|
||||
.map_err(|e| format!("reassembly failed: {e:?}"))
|
||||
}
|
||||
|
||||
fn put_data(&self, data: Vec<u8>, name: Option<String>) -> Result<String, String> {
|
||||
let body_len = data.len();
|
||||
|
||||
let inbox = self.runtime.new_inbox::<DatastoreResponse>()
|
||||
.map_err(|e| format!("failed to create inbox: {e}"))?;
|
||||
|
||||
let _ = self.runtime.send_to(
|
||||
self.datastore_addr,
|
||||
DatastoreNodeMsg::Put {
|
||||
data,
|
||||
name: name.clone(),
|
||||
tags: BTreeMap::new(),
|
||||
reply_to: *inbox.addr(),
|
||||
},
|
||||
);
|
||||
|
||||
match poll_response(&inbox, POLL_TIMEOUT) {
|
||||
Some(DatastoreResponse::PutOk { content_hash }) => {
|
||||
let hex = content_hash.to_hex();
|
||||
self.metrics.record_put(&hex, name.as_deref(), body_len as u64);
|
||||
let json = serde_json::json!({ "content_hash": hex }).to_string();
|
||||
Ok(json)
|
||||
}
|
||||
Some(DatastoreResponse::Error { reason }) => Err(reason),
|
||||
_ => Err("timeout waiting for put response".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_object(&self, hash: &str) -> Result<String, String> {
|
||||
let content_hash = ContentHash::from_hex(hash)
|
||||
.ok_or_else(|| "invalid content hash hex".to_string())?;
|
||||
|
||||
let inbox = self.runtime.new_inbox::<DatastoreResponse>()
|
||||
.map_err(|e| format!("failed to create inbox: {e}"))?;
|
||||
|
||||
let _ = self.runtime.send_to(
|
||||
self.datastore_addr,
|
||||
DatastoreNodeMsg::Delete {
|
||||
content_hash,
|
||||
reply_to: *inbox.addr(),
|
||||
},
|
||||
);
|
||||
|
||||
match poll_response(&inbox, POLL_TIMEOUT) {
|
||||
Some(DatastoreResponse::DeleteOk { content_hash }) => {
|
||||
let hex = content_hash.to_hex();
|
||||
self.metrics.record_delete(&hex, 0);
|
||||
let json = serde_json::json!({ "content_hash": hex }).to_string();
|
||||
Ok(json)
|
||||
}
|
||||
Some(DatastoreResponse::NotFound) => Err("not found".into()),
|
||||
Some(DatastoreResponse::Error { reason }) => Err(reason),
|
||||
_ => Err("timeout".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_status(&self) -> Result<String, String> {
|
||||
let inbox = self.runtime.new_inbox::<DatastoreResponse>()
|
||||
.map_err(|e| format!("failed to create inbox: {e}"))?;
|
||||
|
||||
let _ = self.runtime.send_to(
|
||||
self.datastore_addr,
|
||||
DatastoreNodeMsg::Status {
|
||||
reply_to: *inbox.addr(),
|
||||
},
|
||||
);
|
||||
|
||||
match poll_response(&inbox, POLL_TIMEOUT) {
|
||||
Some(DatastoreResponse::NodeStatus { node_id }) => {
|
||||
let hex: String = node_id.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let json = serde_json::json!({ "node_id": hex }).to_string();
|
||||
Ok(json)
|
||||
}
|
||||
_ => Err("timeout".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn shutdown_datastore(&self) -> Result<(), String> {
|
||||
// We can't actually stop the actors from here without a runtime handle,
|
||||
// but we can signal shutdown. The caller (server handler) clears the
|
||||
// provider reference which effectively disables the datastore.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory that can spawn a new set of datastore actors on a shared runtime.
|
||||
pub struct DatastoreNodeFactory {
|
||||
runtime: Arc<Runtime>,
|
||||
default_chunk_size: u32,
|
||||
}
|
||||
|
||||
impl DatastoreNodeFactory {
|
||||
pub fn new(runtime: Arc<Runtime>, default_chunk_size: u32) -> Self {
|
||||
Self {
|
||||
runtime,
|
||||
default_chunk_size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DatastoreFactory for DatastoreNodeFactory {
|
||||
fn start_datastore(
|
||||
&self,
|
||||
storage_path: Option<String>,
|
||||
) -> Result<Arc<dyn DatastoreStatsProvider>, String> {
|
||||
// Generate a unique node ID
|
||||
let node_id = generate_node_id();
|
||||
let node_hex: String = node_id.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||
|
||||
let group = DatastoreGroup::spawn(
|
||||
Arc::clone(&self.runtime),
|
||||
DatastoreGroupConfig {
|
||||
node_id,
|
||||
node_id_hex: node_hex,
|
||||
chunk_size: self.default_chunk_size,
|
||||
storage_path,
|
||||
auth: None,
|
||||
gc_interval: 1000,
|
||||
disseminate_interval: 50,
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(group.bridge().clone())
|
||||
}
|
||||
}
|
||||
use crate::types::DatastoreConfig;
|
||||
|
||||
// ─── DatastoreGroup ─────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -384,7 +43,7 @@ pub struct DatastoreGroup {
|
|||
datastore_addr: ActorAddress,
|
||||
metadata_addr: ActorAddress,
|
||||
gateway_addr: Option<ActorAddress>,
|
||||
bridge: Arc<dyn DatastoreStatsProvider>,
|
||||
metrics: Arc<DatastoreMetrics>,
|
||||
gc_interval: u64,
|
||||
disseminate_interval: u64,
|
||||
runtime: Arc<Runtime>,
|
||||
|
|
@ -455,14 +114,6 @@ impl DatastoreGroup {
|
|||
let metrics = Arc::new(DatastoreMetrics::new());
|
||||
metrics.set_node_id(config.node_id_hex.clone());
|
||||
|
||||
let bridge: Arc<dyn DatastoreStatsProvider> = Arc::new(DatastoreBridge::new(
|
||||
metrics,
|
||||
Arc::clone(&runtime),
|
||||
datastore_addr,
|
||||
metadata_addr,
|
||||
blob_store_addr,
|
||||
));
|
||||
|
||||
if config.storage_path.is_some() {
|
||||
eprintln!(
|
||||
"Datastore: persistent ({})",
|
||||
|
|
@ -476,7 +127,7 @@ impl DatastoreGroup {
|
|||
datastore_addr,
|
||||
metadata_addr,
|
||||
gateway_addr,
|
||||
bridge,
|
||||
metrics,
|
||||
gc_interval: config.gc_interval,
|
||||
disseminate_interval: config.disseminate_interval,
|
||||
runtime,
|
||||
|
|
@ -485,22 +136,32 @@ impl DatastoreGroup {
|
|||
|
||||
/// Send periodic ticks to the datastore actors based on the current round.
|
||||
pub fn tick(&self, round: u64) {
|
||||
if round % self.gc_interval == 0 {
|
||||
if round.is_multiple_of(self.gc_interval) {
|
||||
let _ = self.runtime.send_to(self.metadata_addr, MetadataMsg::GcTick);
|
||||
if let Some(gw) = self.gateway_addr {
|
||||
let _ = self.runtime.send_to(gw, GatewayMsg::NonceGcTick);
|
||||
}
|
||||
}
|
||||
if round % self.disseminate_interval == 0 {
|
||||
if round.is_multiple_of(self.disseminate_interval) {
|
||||
let _ = self
|
||||
.runtime
|
||||
.send_to(self.metadata_addr, MetadataMsg::DisseminateTick);
|
||||
}
|
||||
}
|
||||
|
||||
/// Access the bridge (as a trait object for the dashboard).
|
||||
pub fn bridge(&self) -> &Arc<dyn DatastoreStatsProvider> {
|
||||
&self.bridge
|
||||
/// Access the metrics accumulator.
|
||||
pub fn metrics(&self) -> &Arc<DatastoreMetrics> {
|
||||
&self.metrics
|
||||
}
|
||||
|
||||
/// Address of the DatastoreNode actor.
|
||||
pub fn datastore_addr(&self) -> ActorAddress {
|
||||
self.datastore_addr
|
||||
}
|
||||
|
||||
/// Address of the MetadataActor.
|
||||
pub fn metadata_addr(&self) -> ActorAddress {
|
||||
self.metadata_addr
|
||||
}
|
||||
|
||||
/// Configure stream support: sends ConfigureStreams to DatastoreNode and
|
||||
|
|
@ -521,25 +182,9 @@ impl DatastoreGroup {
|
|||
|
||||
// Spawn StreamListener
|
||||
use crate::actors::stream_listener::StreamListener;
|
||||
use swactor_std::RuntimeNaming;
|
||||
use swactor::std::RuntimeNaming;
|
||||
if let Ok(addr) = self.runtime.spawn(StreamListener::new(self.datastore_addr, stream_manager)) {
|
||||
let _ = self.runtime.register_name("StreamListener", addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_node_id() -> NodeId {
|
||||
let mut bytes = [0u8; 32];
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
for (i, b) in nanos.to_le_bytes().iter().enumerate() {
|
||||
bytes[i % 32] ^= *b;
|
||||
}
|
||||
let pid = std::process::id();
|
||||
for (i, b) in pid.to_le_bytes().iter().enumerate() {
|
||||
bytes[i + 16] ^= *b;
|
||||
}
|
||||
NodeId(bytes)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use distribution::types::NodeId;
|
||||
use swactor::transport::NodeId;
|
||||
|
||||
/// Top-level CLI commands for `swactor-store`.
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
//! Shared types used across the swactor crate ecosystem.
|
||||
//!
|
||||
//! Contains `ContentHash` — the blake3-based content address used by
|
||||
//! both the datastore and distribution layers.
|
||||
//! Blake3-based content hash for blob addressing.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ─── ContentHash ────────────────────────────────────────────────────────────
|
||||
|
||||
/// A blake3 content hash (32 bytes).
|
||||
///
|
||||
/// The primary identifier for blobs and the DHT key. XOR distance for DHT
|
||||
96
crates/datastore/src/crypto.rs
Normal file
96
crates/datastore/src/crypto.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
//! Ed25519 cryptographic primitives for the datastore.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use ed25519_dalek::{Signer, Verifier};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use swactor::transport::NodeId;
|
||||
|
||||
// ─── Signature ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// An ed25519 signature (64 bytes).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Signature(pub [u8; 64]);
|
||||
|
||||
impl Serialize for Signature {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_bytes(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Signature {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let bytes: Vec<u8> = Deserialize::deserialize(deserializer)?;
|
||||
if bytes.len() != 64 {
|
||||
return Err(serde::de::Error::custom(format!(
|
||||
"expected 64 bytes for Signature, got {}",
|
||||
bytes.len()
|
||||
)));
|
||||
}
|
||||
let mut arr = [0u8; 64];
|
||||
arr.copy_from_slice(&bytes);
|
||||
Ok(Signature(arr))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Signature {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "Sig(")?;
|
||||
for b in &self.0[..4] {
|
||||
write!(f, "{:02x}", b)?;
|
||||
}
|
||||
write!(f, "\u{2026})")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Keypair ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Node identity keypair — wraps ed25519-dalek.
|
||||
pub struct Keypair {
|
||||
inner: ed25519_dalek::SigningKey,
|
||||
}
|
||||
|
||||
impl Keypair {
|
||||
/// Generate a new random keypair.
|
||||
pub fn generate() -> Self {
|
||||
let mut csprng = rand_core::OsRng;
|
||||
Self {
|
||||
inner: ed25519_dalek::SigningKey::generate(&mut csprng),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct from raw secret key bytes (32 bytes).
|
||||
pub fn from_bytes(secret: &[u8; 32]) -> Self {
|
||||
Self {
|
||||
inner: ed25519_dalek::SigningKey::from_bytes(secret),
|
||||
}
|
||||
}
|
||||
|
||||
/// The public key as a `NodeId`.
|
||||
pub fn node_id(&self) -> NodeId {
|
||||
NodeId(self.inner.verifying_key().to_bytes())
|
||||
}
|
||||
|
||||
/// Raw secret key bytes.
|
||||
pub fn secret_bytes(&self) -> [u8; 32] {
|
||||
self.inner.to_bytes()
|
||||
}
|
||||
|
||||
/// Sign arbitrary bytes.
|
||||
pub fn sign(&self, msg: &[u8]) -> Signature {
|
||||
let sig = self.inner.sign(msg);
|
||||
Signature(sig.to_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Verification ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Verify a signature against a `NodeId` (public key) and message bytes.
|
||||
pub fn verify(node_id: &NodeId, msg: &[u8], sig: &Signature) -> bool {
|
||||
let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(&node_id.0) else {
|
||||
return false;
|
||||
};
|
||||
let signature = ed25519_dalek::Signature::from_bytes(&sig.0);
|
||||
vk.verify(msg, &signature).is_ok()
|
||||
}
|
||||
|
|
@ -1,11 +1,16 @@
|
|||
pub mod content_hash;
|
||||
pub mod crypto;
|
||||
pub mod types;
|
||||
pub mod messages;
|
||||
pub mod chunking;
|
||||
pub mod storage;
|
||||
pub mod actors;
|
||||
pub mod auth;
|
||||
pub mod blob_transfer;
|
||||
pub mod bridge;
|
||||
pub mod cli;
|
||||
pub mod metrics;
|
||||
pub mod streams;
|
||||
#[cfg(feature = "node")]
|
||||
pub mod api;
|
||||
#[cfg(feature = "node")]
|
||||
|
|
@ -18,3 +23,4 @@ pub use messages::{BlobStoreMsg, DatastoreNodeMsg, DatastoreResponse, MetadataMs
|
|||
pub use chunking::{chunk_blob, reassemble_blob, verify_integrity, ChunkingError};
|
||||
pub use storage::{StorageBackend, FilesystemBackend, InMemoryBackend};
|
||||
pub use actors::{BlobStoreActor, DatastoreNode, MetadataActor, TransferActor};
|
||||
pub use bridge::{DatastoreAuthConfig, DatastoreGroup, DatastoreGroupConfig};
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ use swactor::actor::ActorAddress;
|
|||
use swactor::runtime::Runtime;
|
||||
use swactor::transport::NetworkMessage;
|
||||
|
||||
use distribution::types::NodeId;
|
||||
use swactor_streams::types::StreamId;
|
||||
use swactor::transport::NodeId;
|
||||
use crate::streams::types::StreamId;
|
||||
|
||||
use crate::auth::{AccessRequestInfo, AuthorizedKeyInfo, DeniedReason, SignedRequest};
|
||||
use crate::types::{ContentHash, ObjectEntry, ObjectManifest};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
//! Thread-safe metrics for the datastore, consumed by the runtime dashboard.
|
||||
//! Thread-safe metrics for the datastore.
|
||||
//!
|
||||
//! `DatastoreMetrics` accumulates counters and event history from any thread
|
||||
//! (API handlers run on `tiny_http` worker threads). The dashboard polls
|
||||
//! `snapshot()` every ~200ms via the `DatastoreStatsProvider` trait.
|
||||
//! `snapshot()` every ~200ms via the plugin system.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
|
@ -71,6 +71,12 @@ pub struct DatastoreMetrics {
|
|||
transfers: Mutex<Vec<TransferProgress>>,
|
||||
}
|
||||
|
||||
impl Default for DatastoreMetrics {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DatastoreMetrics {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
|
|
@ -186,11 +192,3 @@ fn now_ms() -> u64 {
|
|||
.as_millis() as u64
|
||||
}
|
||||
|
||||
// ── Dashboard integration ────────────────────────────────────────────────────
|
||||
|
||||
impl dashboard::datastore_collector::DatastoreStatsProvider for DatastoreMetrics {
|
||||
fn snapshot_json(&self) -> Option<String> {
|
||||
let snap = self.snapshot();
|
||||
serde_json::to_string(&snap).ok()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,274 +0,0 @@
|
|||
//! Pool coordinator actor — placement-aware CRUD facade.
|
||||
//!
|
||||
//! Owns an `Arc<Mutex<PoolDisseminator>>` for query access and delegates
|
||||
//! storage operations to the co-located `DatastoreNode` actor.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||
|
||||
use distribution::types::NodeId;
|
||||
use shared_types::ContentHash;
|
||||
use shared_types::pool::PoolConfig;
|
||||
|
||||
use crate::messages::{DatastoreNodeMsg, DatastoreResponse};
|
||||
use super::disseminator::PoolDisseminator;
|
||||
use super::messages::PoolCoordinatorMsg;
|
||||
|
||||
/// The pool coordinator actor.
|
||||
pub struct PoolCoordinator {
|
||||
node_id: NodeId,
|
||||
pool_config: PoolConfig,
|
||||
disseminator: Arc<Mutex<PoolDisseminator>>,
|
||||
|
||||
// Co-located actor addresses
|
||||
datastore_addr: ActorAddress,
|
||||
|
||||
tick_count: u64,
|
||||
}
|
||||
|
||||
impl PoolCoordinator {
|
||||
pub fn new(
|
||||
node_id: NodeId,
|
||||
pool_config: PoolConfig,
|
||||
disseminator: Arc<Mutex<PoolDisseminator>>,
|
||||
datastore_addr: ActorAddress,
|
||||
) -> Self {
|
||||
Self {
|
||||
node_id,
|
||||
pool_config,
|
||||
disseminator,
|
||||
datastore_addr,
|
||||
tick_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Access the shared disseminator.
|
||||
pub fn disseminator(&self) -> &Arc<Mutex<PoolDisseminator>> {
|
||||
&self.disseminator
|
||||
}
|
||||
|
||||
fn cluster_size(&self) -> usize {
|
||||
let d = self.disseminator.lock().unwrap();
|
||||
d.member_count().max(1)
|
||||
}
|
||||
|
||||
// ─── Message handlers ──────────────────────────────────────────────
|
||||
|
||||
fn handle_pool_put(
|
||||
&self,
|
||||
ctx: &Ctx,
|
||||
data: Vec<u8>,
|
||||
name: Option<String>,
|
||||
tags: BTreeMap<String, String>,
|
||||
reply_to: ActorAddress,
|
||||
) {
|
||||
// Delegate to local DatastoreNode for now.
|
||||
// Future: check capacity and redirect to best node.
|
||||
let _ = ctx.send(
|
||||
self.datastore_addr,
|
||||
DatastoreNodeMsg::Put {
|
||||
data,
|
||||
name,
|
||||
tags,
|
||||
reply_to,
|
||||
},
|
||||
);
|
||||
|
||||
// Note: content announcement happens after PutOk is received.
|
||||
// For now, caller is responsible for announcing content via PoolTick
|
||||
// or a future PutOk callback.
|
||||
}
|
||||
|
||||
fn handle_pool_get(
|
||||
&self,
|
||||
ctx: &Ctx,
|
||||
content_hash: ContentHash,
|
||||
reply_to: ActorAddress,
|
||||
) {
|
||||
// Try local first via DatastoreNode
|
||||
let _ = ctx.send(
|
||||
self.datastore_addr,
|
||||
DatastoreNodeMsg::Get {
|
||||
content_hash,
|
||||
reply_to,
|
||||
},
|
||||
);
|
||||
|
||||
// Future: if local not found, use disseminator.locate_content()
|
||||
// to fetch from a specific peer instead of fan-out.
|
||||
}
|
||||
|
||||
fn handle_pool_delete(
|
||||
&self,
|
||||
ctx: &Ctx,
|
||||
content_hash: ContentHash,
|
||||
reply_to: ActorAddress,
|
||||
) {
|
||||
// Delete locally
|
||||
let _ = ctx.send(
|
||||
self.datastore_addr,
|
||||
DatastoreNodeMsg::Delete {
|
||||
content_hash,
|
||||
reply_to,
|
||||
},
|
||||
);
|
||||
|
||||
// Announce tombstone via gossip
|
||||
let cluster_size = self.cluster_size();
|
||||
self.disseminator
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove_content(content_hash, cluster_size);
|
||||
}
|
||||
|
||||
fn handle_pool_list(
|
||||
&self,
|
||||
ctx: &Ctx,
|
||||
name_filter: Option<String>,
|
||||
reply_to: ActorAddress,
|
||||
) {
|
||||
let _ = ctx.send(
|
||||
self.datastore_addr,
|
||||
DatastoreNodeMsg::List {
|
||||
name_filter,
|
||||
all: false,
|
||||
reply_to,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn handle_pool_status(&self, ctx: &Ctx, reply_to: ActorAddress) {
|
||||
let d = self.disseminator.lock().unwrap();
|
||||
let (total_bytes, used_bytes) = d.pool_capacity_summary();
|
||||
let members: Vec<String> = d
|
||||
.active_members()
|
||||
.iter()
|
||||
.map(|id| id.0.iter().map(|b| format!("{b:02x}")).collect())
|
||||
.collect();
|
||||
|
||||
let member_count = d.member_count();
|
||||
let content_count = d.content_count();
|
||||
drop(d);
|
||||
|
||||
let json = serde_json::json!({
|
||||
"pool_name": self.pool_config.pool_name,
|
||||
"pool_id": self.pool_config.pool_id.to_hex(),
|
||||
"member_count": member_count,
|
||||
"content_count": content_count,
|
||||
"total_bytes": total_bytes,
|
||||
"used_bytes": used_bytes,
|
||||
"members": members,
|
||||
});
|
||||
|
||||
let _ = ctx.send(
|
||||
reply_to,
|
||||
DatastoreResponse::PoolStatus {
|
||||
json: json.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn handle_join_pool(&mut self, ctx: &Ctx, reply_to: ActorAddress) {
|
||||
let cluster_size = self.cluster_size();
|
||||
let mut d = self.disseminator.lock().unwrap();
|
||||
|
||||
if !d.is_node_authorized(&self.node_id) {
|
||||
let _ = ctx.send(
|
||||
reply_to,
|
||||
DatastoreResponse::Error {
|
||||
reason: "not authorized to join pool".into(),
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
d.join(cluster_size);
|
||||
d.announce_capacity(self.pool_config.capacity_bytes, 0, cluster_size);
|
||||
drop(d);
|
||||
|
||||
let _ = ctx.send(reply_to, DatastoreResponse::Bool(true));
|
||||
}
|
||||
|
||||
fn handle_leave_pool(&mut self, ctx: &Ctx, reply_to: ActorAddress) {
|
||||
let cluster_size = self.cluster_size();
|
||||
self.disseminator.lock().unwrap().leave(cluster_size);
|
||||
let _ = ctx.send(reply_to, DatastoreResponse::Bool(true));
|
||||
}
|
||||
|
||||
fn handle_grant_access(&mut self, ctx: &Ctx, target: NodeId, reply_to: ActorAddress) {
|
||||
let cluster_size = self.cluster_size();
|
||||
self.disseminator
|
||||
.lock()
|
||||
.unwrap()
|
||||
.grant_access(target, cluster_size);
|
||||
let _ = ctx.send(reply_to, DatastoreResponse::Bool(true));
|
||||
}
|
||||
|
||||
fn handle_revoke_access(&mut self, ctx: &Ctx, target: NodeId, reply_to: ActorAddress) {
|
||||
let cluster_size = self.cluster_size();
|
||||
self.disseminator
|
||||
.lock()
|
||||
.unwrap()
|
||||
.revoke_access(target, cluster_size);
|
||||
let _ = ctx.send(reply_to, DatastoreResponse::Bool(true));
|
||||
}
|
||||
|
||||
fn handle_pool_tick(&mut self) {
|
||||
self.tick_count += 1;
|
||||
// Periodic capacity re-announcement (every 100 ticks)
|
||||
if self.tick_count % 100 == 0 {
|
||||
let cluster_size = self.cluster_size();
|
||||
self.disseminator.lock().unwrap().announce_capacity(
|
||||
self.pool_config.capacity_bytes,
|
||||
0, // TODO: query actual usage from BlobStore
|
||||
cluster_size,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ActorInterface for PoolCoordinator {
|
||||
type Incoming = PoolCoordinatorMsg;
|
||||
type Response = DatastoreResponse;
|
||||
|
||||
fn handle(&mut self, ctx: &Ctx, msg: PoolCoordinatorMsg) {
|
||||
match msg {
|
||||
PoolCoordinatorMsg::PoolPut {
|
||||
data,
|
||||
name,
|
||||
tags,
|
||||
reply_to,
|
||||
} => self.handle_pool_put(ctx, data, name, tags, reply_to),
|
||||
PoolCoordinatorMsg::PoolGet {
|
||||
content_hash,
|
||||
reply_to,
|
||||
} => self.handle_pool_get(ctx, content_hash, reply_to),
|
||||
PoolCoordinatorMsg::PoolDelete {
|
||||
content_hash,
|
||||
reply_to,
|
||||
} => self.handle_pool_delete(ctx, content_hash, reply_to),
|
||||
PoolCoordinatorMsg::PoolList {
|
||||
name_filter,
|
||||
reply_to,
|
||||
} => self.handle_pool_list(ctx, name_filter, reply_to),
|
||||
PoolCoordinatorMsg::PoolStatus { reply_to } => {
|
||||
self.handle_pool_status(ctx, reply_to)
|
||||
}
|
||||
PoolCoordinatorMsg::JoinPool { reply_to } => {
|
||||
self.handle_join_pool(ctx, reply_to)
|
||||
}
|
||||
PoolCoordinatorMsg::LeavePool { reply_to } => {
|
||||
self.handle_leave_pool(ctx, reply_to)
|
||||
}
|
||||
PoolCoordinatorMsg::GrantPoolAccess { target, reply_to } => {
|
||||
self.handle_grant_access(ctx, target, reply_to)
|
||||
}
|
||||
PoolCoordinatorMsg::RevokePoolAccess { target, reply_to } => {
|
||||
self.handle_revoke_access(ctx, target, reply_to)
|
||||
}
|
||||
PoolCoordinatorMsg::PoolTick => self.handle_pool_tick(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,716 +0,0 @@
|
|||
//! Pool disseminator — gossip-converged state for pool membership,
|
||||
//! capacity, content locations, and ACL.
|
||||
//!
|
||||
//! Implements `GossipChannel` to plug into the generic gossip system
|
||||
//! via `DistributedNode::register_channel()`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use distribution::gossip_channel::{DisseminationBuffer, GossipChannel, deserialize_each, serialize_each};
|
||||
use distribution::types::NodeId;
|
||||
use shared_types::ContentHash;
|
||||
use shared_types::pool::*;
|
||||
|
||||
// ─── PoolDisseminator ──────────────────────────────────────────────────────
|
||||
|
||||
/// Configuration for the pool disseminator.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PoolDisseminatorConfig {
|
||||
pub tombstone_ttl: u64,
|
||||
pub gc_interval: u64,
|
||||
}
|
||||
|
||||
impl Default for PoolDisseminatorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tombstone_ttl: 3600,
|
||||
gc_interval: 1000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages converged pool state via gossip dissemination.
|
||||
#[derive(Debug)]
|
||||
pub struct PoolDisseminator {
|
||||
pool_id: PoolId,
|
||||
pool_name: String,
|
||||
local_node_id: NodeId,
|
||||
|
||||
// Converged state maps
|
||||
members: HashMap<[u8; 32], PoolMemberEntry>,
|
||||
capacity: HashMap<[u8; 32], PoolCapacityEntry>,
|
||||
content_locations: HashMap<(ContentHash, [u8; 32]), ContentLocationEntry>,
|
||||
acl: HashMap<[u8; 32], PoolACLEntry>,
|
||||
|
||||
// Dissemination buffer
|
||||
buffer: DisseminationBuffer<PoolEntry>,
|
||||
|
||||
// Local generation counters
|
||||
local_member_gen: u64,
|
||||
local_capacity_gen: u64,
|
||||
|
||||
config: PoolDisseminatorConfig,
|
||||
tick_count: u64,
|
||||
}
|
||||
|
||||
impl PoolDisseminator {
|
||||
pub fn new(pool_id: PoolId, pool_name: String, local_node_id: NodeId, lambda: usize) -> Self {
|
||||
Self {
|
||||
pool_id,
|
||||
pool_name,
|
||||
local_node_id,
|
||||
members: HashMap::new(),
|
||||
capacity: HashMap::new(),
|
||||
content_locations: HashMap::new(),
|
||||
acl: HashMap::new(),
|
||||
buffer: DisseminationBuffer::new(lambda),
|
||||
local_member_gen: 0,
|
||||
local_capacity_gen: 0,
|
||||
config: PoolDisseminatorConfig::default(),
|
||||
tick_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_config(mut self, config: PoolDisseminatorConfig) -> Self {
|
||||
self.config = config;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn pool_id(&self) -> PoolId {
|
||||
self.pool_id
|
||||
}
|
||||
|
||||
// ─── Lifecycle methods ──────────────────────────────────────────────
|
||||
|
||||
/// Join the pool. Announces Active membership.
|
||||
pub fn join(&mut self, cluster_size: usize) {
|
||||
self.local_member_gen += 1;
|
||||
let entry = PoolMemberEntry {
|
||||
pool_id: self.pool_id,
|
||||
node_id: self.local_node_id.0,
|
||||
state: PoolMemberState::Active,
|
||||
generation: self.local_member_gen,
|
||||
};
|
||||
self.merge_membership(entry.clone());
|
||||
self.buffer.enqueue(PoolEntry::Membership(entry), cluster_size);
|
||||
}
|
||||
|
||||
/// Leave the pool. Announces Left membership.
|
||||
pub fn leave(&mut self, cluster_size: usize) {
|
||||
self.local_member_gen += 1;
|
||||
let entry = PoolMemberEntry {
|
||||
pool_id: self.pool_id,
|
||||
node_id: self.local_node_id.0,
|
||||
state: PoolMemberState::Left,
|
||||
generation: self.local_member_gen,
|
||||
};
|
||||
self.merge_membership(entry.clone());
|
||||
self.buffer.enqueue(PoolEntry::Membership(entry), cluster_size);
|
||||
}
|
||||
|
||||
/// Announce storage capacity.
|
||||
pub fn announce_capacity(&mut self, total: u64, used: u64, cluster_size: usize) {
|
||||
self.local_capacity_gen += 1;
|
||||
let entry = PoolCapacityEntry {
|
||||
pool_id: self.pool_id,
|
||||
node_id: self.local_node_id.0,
|
||||
total_bytes: total,
|
||||
used_bytes: used,
|
||||
generation: self.local_capacity_gen,
|
||||
};
|
||||
self.merge_capacity(entry.clone());
|
||||
self.buffer.enqueue(PoolEntry::Capacity(entry), cluster_size);
|
||||
}
|
||||
|
||||
/// Announce that this node has a piece of content.
|
||||
pub fn announce_content(&mut self, hash: ContentHash, cluster_size: usize) {
|
||||
let key = (hash, self.local_node_id.0);
|
||||
let next_gen = self.content_locations.get(&key).map_or(1, |e| e.generation + 1);
|
||||
let entry = ContentLocationEntry {
|
||||
pool_id: self.pool_id,
|
||||
content_hash: hash,
|
||||
node_id: self.local_node_id.0,
|
||||
generation: next_gen,
|
||||
tombstone: false,
|
||||
};
|
||||
self.merge_content_location(entry.clone());
|
||||
self.buffer.enqueue(PoolEntry::ContentLocation(entry), cluster_size);
|
||||
}
|
||||
|
||||
/// Remove content announcement (tombstone).
|
||||
pub fn remove_content(&mut self, hash: ContentHash, cluster_size: usize) {
|
||||
let key = (hash, self.local_node_id.0);
|
||||
let next_gen = self.content_locations.get(&key).map_or(1, |e| e.generation + 1);
|
||||
let entry = ContentLocationEntry {
|
||||
pool_id: self.pool_id,
|
||||
content_hash: hash,
|
||||
node_id: self.local_node_id.0,
|
||||
generation: next_gen,
|
||||
tombstone: true,
|
||||
};
|
||||
self.merge_content_location(entry.clone());
|
||||
self.buffer.enqueue(PoolEntry::ContentLocation(entry), cluster_size);
|
||||
}
|
||||
|
||||
/// Grant access to a node.
|
||||
pub fn grant_access(&mut self, target: NodeId, cluster_size: usize) {
|
||||
let next_gen = self.acl.get(&target.0).map_or(1, |e| e.generation + 1);
|
||||
let entry = PoolACLEntry {
|
||||
pool_id: self.pool_id,
|
||||
node_id: target.0,
|
||||
granted_by: self.local_node_id.0,
|
||||
generation: next_gen,
|
||||
revoked: false,
|
||||
};
|
||||
self.merge_acl(entry.clone());
|
||||
self.buffer.enqueue(PoolEntry::ACL(entry), cluster_size);
|
||||
}
|
||||
|
||||
/// Revoke access from a node.
|
||||
pub fn revoke_access(&mut self, target: NodeId, cluster_size: usize) {
|
||||
let next_gen = self.acl.get(&target.0).map_or(1, |e| e.generation + 1);
|
||||
let entry = PoolACLEntry {
|
||||
pool_id: self.pool_id,
|
||||
node_id: target.0,
|
||||
granted_by: self.local_node_id.0,
|
||||
generation: next_gen,
|
||||
revoked: true,
|
||||
};
|
||||
self.merge_acl(entry.clone());
|
||||
self.buffer.enqueue(PoolEntry::ACL(entry), cluster_size);
|
||||
}
|
||||
|
||||
// ─── Query API ──────────────────────────────────────────────────────
|
||||
|
||||
/// All active pool members.
|
||||
pub fn active_members(&self) -> Vec<NodeId> {
|
||||
self.members
|
||||
.values()
|
||||
.filter(|m| m.state == PoolMemberState::Active)
|
||||
.map(|m| NodeId(m.node_id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Total and used capacity across the pool.
|
||||
pub fn pool_capacity_summary(&self) -> (u64, u64) {
|
||||
let mut total = 0u64;
|
||||
let mut used = 0u64;
|
||||
for cap in self.capacity.values() {
|
||||
// Only count active members
|
||||
if let Some(m) = self.members.get(&cap.node_id) {
|
||||
if m.state == PoolMemberState::Active {
|
||||
total = total.saturating_add(cap.total_bytes);
|
||||
used = used.saturating_add(cap.used_bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
(total, used)
|
||||
}
|
||||
|
||||
/// Find which nodes have a given content hash.
|
||||
pub fn locate_content(&self, hash: &ContentHash) -> Vec<NodeId> {
|
||||
self.content_locations
|
||||
.iter()
|
||||
.filter(|((h, _), entry)| h == hash && !entry.tombstone)
|
||||
.map(|((_, node_id), _)| NodeId(*node_id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Find the node with the most free space.
|
||||
pub fn node_with_most_free_space(&self) -> Option<NodeId> {
|
||||
self.capacity
|
||||
.values()
|
||||
.filter(|cap| {
|
||||
self.members
|
||||
.get(&cap.node_id)
|
||||
.is_some_and(|m| m.state == PoolMemberState::Active)
|
||||
})
|
||||
.max_by_key(|cap| cap.total_bytes.saturating_sub(cap.used_bytes))
|
||||
.map(|cap| NodeId(cap.node_id))
|
||||
}
|
||||
|
||||
/// Check if a node is authorized to join this pool.
|
||||
pub fn is_node_authorized(&self, node_id: &NodeId) -> bool {
|
||||
// If no ACL entries exist, the pool is open
|
||||
if self.acl.is_empty() {
|
||||
return true;
|
||||
}
|
||||
self.acl
|
||||
.get(&node_id.0)
|
||||
.is_some_and(|entry| !entry.revoked)
|
||||
}
|
||||
|
||||
/// Number of active members.
|
||||
pub fn member_count(&self) -> usize {
|
||||
self.members
|
||||
.values()
|
||||
.filter(|m| m.state == PoolMemberState::Active)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Number of live content location entries (non-tombstone).
|
||||
pub fn content_count(&self) -> usize {
|
||||
self.content_locations
|
||||
.values()
|
||||
.filter(|e| !e.tombstone)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Serialize the current pool state to a JSON string for the dashboard.
|
||||
pub fn snapshot_json(&self) -> String {
|
||||
fn hex(bytes: &[u8; 32]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
let (total_bytes, used_bytes) = self.pool_capacity_summary();
|
||||
|
||||
let members: Vec<serde_json::Value> = self
|
||||
.members
|
||||
.values()
|
||||
.filter(|m| m.state == PoolMemberState::Active)
|
||||
.map(|m| {
|
||||
let cap = self.capacity.get(&m.node_id);
|
||||
serde_json::json!({
|
||||
"node_id": hex(&m.node_id),
|
||||
"state": format!("{:?}", m.state),
|
||||
"generation": m.generation,
|
||||
"total_bytes": cap.map_or(0, |c| c.total_bytes),
|
||||
"used_bytes": cap.map_or(0, |c| c.used_bytes),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Group content locations by hash
|
||||
let mut by_hash: HashMap<ContentHash, Vec<[u8; 32]>> = HashMap::new();
|
||||
for ((hash, _), entry) in &self.content_locations {
|
||||
if !entry.tombstone {
|
||||
by_hash.entry(*hash).or_default().push(entry.node_id);
|
||||
}
|
||||
}
|
||||
let content_locations: Vec<serde_json::Value> = by_hash
|
||||
.iter()
|
||||
.map(|(hash, nodes)| {
|
||||
serde_json::json!({
|
||||
"content_hash": hash.to_hex(),
|
||||
"nodes": nodes.iter().map(hex).collect::<Vec<_>>(),
|
||||
"replica_count": nodes.len(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let acl: Vec<serde_json::Value> = self
|
||||
.acl
|
||||
.values()
|
||||
.map(|a| {
|
||||
serde_json::json!({
|
||||
"node_id": hex(&a.node_id),
|
||||
"granted_by": hex(&a.granted_by),
|
||||
"revoked": a.revoked,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let acl_mode = if self.acl.is_empty() { "open" } else { "allow-list" };
|
||||
|
||||
serde_json::json!({
|
||||
"pool_name": self.pool_name,
|
||||
"pool_id": self.pool_id.to_hex(),
|
||||
"member_count": self.member_count(),
|
||||
"content_count": self.content_count(),
|
||||
"total_bytes": total_bytes,
|
||||
"used_bytes": used_bytes,
|
||||
"members": members,
|
||||
"content_locations": content_locations,
|
||||
"acl": acl,
|
||||
"acl_mode": acl_mode,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
// ─── Internal merge logic ───────────────────────────────────────────
|
||||
|
||||
fn merge_membership(&mut self, entry: PoolMemberEntry) -> bool {
|
||||
let key = entry.node_id;
|
||||
if let Some(existing) = self.members.get(&key) {
|
||||
if entry.generation <= existing.generation {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
self.members.insert(key, entry);
|
||||
true
|
||||
}
|
||||
|
||||
fn merge_capacity(&mut self, entry: PoolCapacityEntry) -> bool {
|
||||
let key = entry.node_id;
|
||||
if let Some(existing) = self.capacity.get(&key) {
|
||||
if entry.generation <= existing.generation {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
self.capacity.insert(key, entry);
|
||||
true
|
||||
}
|
||||
|
||||
fn merge_content_location(&mut self, entry: ContentLocationEntry) -> bool {
|
||||
let key = (entry.content_hash, entry.node_id);
|
||||
if let Some(existing) = self.content_locations.get(&key) {
|
||||
if entry.generation <= existing.generation {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
self.content_locations.insert(key, entry);
|
||||
true
|
||||
}
|
||||
|
||||
fn merge_acl(&mut self, entry: PoolACLEntry) -> bool {
|
||||
let key = entry.node_id;
|
||||
if let Some(existing) = self.acl.get(&key) {
|
||||
if entry.generation <= existing.generation {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
self.acl.insert(key, entry);
|
||||
true
|
||||
}
|
||||
|
||||
/// Merge a single pool entry and return whether state changed.
|
||||
fn merge_entry(&mut self, entry: PoolEntry) -> bool {
|
||||
match entry {
|
||||
PoolEntry::Membership(m) => self.merge_membership(m),
|
||||
PoolEntry::Capacity(c) => self.merge_capacity(c),
|
||||
PoolEntry::ContentLocation(cl) => self.merge_content_location(cl),
|
||||
PoolEntry::ACL(a) => self.merge_acl(a),
|
||||
}
|
||||
}
|
||||
|
||||
/// Take pending entries (internal, typed).
|
||||
fn take_pending_inner(&mut self, max_count: usize) -> Vec<PoolEntry> {
|
||||
self.buffer.take(max_count)
|
||||
}
|
||||
|
||||
/// Apply incoming entries (internal, typed).
|
||||
fn apply_incoming_inner(&mut self, entries: Vec<PoolEntry>, cluster_size: usize) {
|
||||
for entry in entries {
|
||||
if self.merge_entry(entry.clone()) {
|
||||
self.buffer.enqueue(entry, cluster_size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-enqueue all state (internal).
|
||||
fn re_disseminate_all_inner(&mut self, cluster_size: usize) {
|
||||
let mut all_entries: Vec<PoolEntry> = Vec::new();
|
||||
|
||||
for m in self.members.values().cloned() {
|
||||
all_entries.push(PoolEntry::Membership(m));
|
||||
}
|
||||
for c in self.capacity.values().cloned() {
|
||||
all_entries.push(PoolEntry::Capacity(c));
|
||||
}
|
||||
for cl in self.content_locations.values().cloned() {
|
||||
all_entries.push(PoolEntry::ContentLocation(cl));
|
||||
}
|
||||
for a in self.acl.values().cloned() {
|
||||
all_entries.push(PoolEntry::ACL(a));
|
||||
}
|
||||
|
||||
self.buffer.re_enqueue_all(all_entries, cluster_size);
|
||||
}
|
||||
|
||||
/// GC: evict tombstones past TTL.
|
||||
fn gc_tick_inner(&mut self) {
|
||||
self.tick_count += 1;
|
||||
if self.tick_count % self.config.gc_interval != 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let ttl = self.config.tombstone_ttl;
|
||||
let tick = self.tick_count;
|
||||
|
||||
// GC left members
|
||||
self.members.retain(|_, m| {
|
||||
if m.state == PoolMemberState::Left {
|
||||
m.generation + ttl > tick
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
|
||||
// GC tombstoned content locations
|
||||
self.content_locations.retain(|_, cl| {
|
||||
if cl.tombstone {
|
||||
cl.generation + ttl > tick
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
|
||||
// GC revoked ACL entries
|
||||
self.acl.retain(|_, a| {
|
||||
if a.revoked {
|
||||
a.generation + ttl > tick
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SharedPoolChannel ─────────────────────────────────────────────────────
|
||||
|
||||
/// Wrapper around `Arc<Mutex<PoolDisseminator>>` that implements `GossipChannel`.
|
||||
///
|
||||
/// This enables shared ownership between the `PoolCoordinator` actor
|
||||
/// (which needs query/lifecycle access) and `DistributedNode` (which
|
||||
/// drives gossip piggyback).
|
||||
pub struct SharedPoolChannel {
|
||||
inner: Arc<Mutex<PoolDisseminator>>,
|
||||
}
|
||||
|
||||
impl SharedPoolChannel {
|
||||
pub fn new(disseminator: Arc<Mutex<PoolDisseminator>>) -> Self {
|
||||
Self { inner: disseminator }
|
||||
}
|
||||
|
||||
/// Consume the channel and return the underlying `Arc<Mutex<PoolDisseminator>>`.
|
||||
pub fn into_inner(self) -> Arc<Mutex<PoolDisseminator>> {
|
||||
self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl GossipChannel for SharedPoolChannel {
|
||||
fn topic_tag(&self) -> &'static str {
|
||||
"pool"
|
||||
}
|
||||
|
||||
fn take_pending_bytes(&mut self, max_entries: usize) -> Vec<Vec<u8>> {
|
||||
let entries = self.inner.lock().unwrap().take_pending_inner(max_entries);
|
||||
serialize_each(&entries)
|
||||
}
|
||||
|
||||
fn apply_incoming_bytes(&mut self, entries: &[Vec<u8>], cluster_size: usize) {
|
||||
let parsed: Vec<PoolEntry> = deserialize_each(entries);
|
||||
self.inner.lock().unwrap().apply_incoming_inner(parsed, cluster_size);
|
||||
}
|
||||
|
||||
fn re_disseminate_all(&mut self, cluster_size: usize) {
|
||||
self.inner.lock().unwrap().re_disseminate_all_inner(cluster_size);
|
||||
}
|
||||
|
||||
fn on_node_death(&mut self, _node_id: &NodeId) {
|
||||
// Pool membership is explicit (join/leave), not auto-removed on node death.
|
||||
// Capacity becomes unreliable but we don't remove it.
|
||||
}
|
||||
|
||||
fn gc_tick(&mut self) {
|
||||
self.inner.lock().unwrap().gc_tick_inner();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn node_id(b: u8) -> NodeId {
|
||||
NodeId([b; 32])
|
||||
}
|
||||
|
||||
fn make_disseminator(b: u8) -> PoolDisseminator {
|
||||
PoolDisseminator::new(
|
||||
PoolId::from_name("test-pool"),
|
||||
"test-pool".into(),
|
||||
node_id(b),
|
||||
3,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_and_query_members() {
|
||||
let mut d = make_disseminator(1);
|
||||
d.join(2);
|
||||
|
||||
assert_eq!(d.member_count(), 1);
|
||||
assert_eq!(d.active_members(), vec![node_id(1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leave_removes_from_active() {
|
||||
let mut d = make_disseminator(1);
|
||||
d.join(2);
|
||||
d.leave(2);
|
||||
|
||||
assert_eq!(d.member_count(), 0);
|
||||
assert!(d.active_members().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn announce_and_locate_content() {
|
||||
let mut d = make_disseminator(1);
|
||||
d.join(2);
|
||||
let hash = ContentHash::of(b"test-data");
|
||||
d.announce_content(hash, 2);
|
||||
|
||||
let locations = d.locate_content(&hash);
|
||||
assert_eq!(locations, vec![node_id(1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_content_tombstones() {
|
||||
let mut d = make_disseminator(1);
|
||||
d.join(2);
|
||||
let hash = ContentHash::of(b"test-data");
|
||||
d.announce_content(hash, 2);
|
||||
d.remove_content(hash, 2);
|
||||
|
||||
assert!(d.locate_content(&hash).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_summary() {
|
||||
let mut d = make_disseminator(1);
|
||||
d.join(2);
|
||||
d.announce_capacity(1000, 300, 2);
|
||||
|
||||
let (total, used) = d.pool_capacity_summary();
|
||||
assert_eq!(total, 1000);
|
||||
assert_eq!(used, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acl_grant_and_check() {
|
||||
let mut d = make_disseminator(1);
|
||||
d.grant_access(node_id(2), 2);
|
||||
|
||||
assert!(d.is_node_authorized(&node_id(2)));
|
||||
assert!(!d.is_node_authorized(&node_id(3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acl_revoke() {
|
||||
let mut d = make_disseminator(1);
|
||||
d.grant_access(node_id(2), 2);
|
||||
d.revoke_access(node_id(2), 2);
|
||||
|
||||
assert!(!d.is_node_authorized(&node_id(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_acl_means_open() {
|
||||
let d = make_disseminator(1);
|
||||
assert!(d.is_node_authorized(&node_id(99)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higher_generation_wins_merge() {
|
||||
let mut d1 = make_disseminator(1);
|
||||
let mut d2 = make_disseminator(2);
|
||||
|
||||
// d1 joins
|
||||
d1.join(2);
|
||||
// d1 leaves
|
||||
d1.leave(2);
|
||||
|
||||
// Gossip d1's entries to d2 out of order:
|
||||
// First send the Active (gen 1), then the Left (gen 2)
|
||||
let active_entry = PoolEntry::Membership(PoolMemberEntry {
|
||||
pool_id: PoolId::from_name("test-pool"),
|
||||
node_id: [1u8; 32],
|
||||
state: PoolMemberState::Active,
|
||||
generation: 1,
|
||||
});
|
||||
let left_entry = PoolEntry::Membership(PoolMemberEntry {
|
||||
pool_id: PoolId::from_name("test-pool"),
|
||||
node_id: [1u8; 32],
|
||||
state: PoolMemberState::Left,
|
||||
generation: 2,
|
||||
});
|
||||
|
||||
// Apply Left first (gen 2), then Active (gen 1) — Active should be rejected
|
||||
d2.merge_entry(left_entry);
|
||||
let changed = d2.merge_entry(active_entry);
|
||||
assert!(!changed, "lower generation should not win");
|
||||
|
||||
// d2 should see node_id(1) as Left
|
||||
assert_eq!(d2.member_count(), 0); // Active count is 0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_disseminators_converge_via_gossip_exchange() {
|
||||
let mut d1 = make_disseminator(1);
|
||||
let mut d2 = make_disseminator(2);
|
||||
|
||||
// d1 joins and announces content
|
||||
d1.join(2);
|
||||
let hash = ContentHash::of(b"shared-file");
|
||||
d1.announce_content(hash, 2);
|
||||
|
||||
// d2 joins
|
||||
d2.join(2);
|
||||
|
||||
// Simulate gossip: d1 → d2
|
||||
let pending = d1.take_pending_inner(100);
|
||||
let bytes = serialize_each(&pending);
|
||||
let parsed: Vec<PoolEntry> = deserialize_each(&bytes);
|
||||
d2.apply_incoming_inner(parsed, 2);
|
||||
|
||||
// d2 should now see d1 as a member and know about the content
|
||||
assert_eq!(d2.member_count(), 2);
|
||||
assert_eq!(d2.locate_content(&hash), vec![node_id(1)]);
|
||||
|
||||
// Simulate gossip: d2 → d1
|
||||
let pending = d2.take_pending_inner(100);
|
||||
let bytes = serialize_each(&pending);
|
||||
let parsed: Vec<PoolEntry> = deserialize_each(&bytes);
|
||||
d1.apply_incoming_inner(parsed, 2);
|
||||
|
||||
// d1 should now see d2 as a member
|
||||
assert_eq!(d1.member_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_node_convergence_loop() {
|
||||
let pool = PoolId::from_name("test-pool");
|
||||
let mut nodes: Vec<PoolDisseminator> = (0..3)
|
||||
.map(|i| PoolDisseminator::new(pool, "test-pool".into(), node_id(i as u8), 3))
|
||||
.collect();
|
||||
|
||||
// Each node joins
|
||||
for n in &mut nodes {
|
||||
n.join(3);
|
||||
}
|
||||
|
||||
// Node 0 announces content
|
||||
let hash = ContentHash::of(b"convergence-test");
|
||||
nodes[0].announce_content(hash, 3);
|
||||
|
||||
// Run 5 gossip rounds where each node exchanges with all others
|
||||
for _ in 0..5 {
|
||||
// Collect pending from each node
|
||||
let pending_bytes: Vec<Vec<Vec<u8>>> = nodes
|
||||
.iter_mut()
|
||||
.map(|n| serialize_each(&n.take_pending_inner(100)))
|
||||
.collect();
|
||||
|
||||
// Apply each node's pending to all other nodes
|
||||
for (sender_idx, bytes) in pending_bytes.iter().enumerate() {
|
||||
for (receiver_idx, node) in nodes.iter_mut().enumerate() {
|
||||
if sender_idx != receiver_idx {
|
||||
let parsed: Vec<PoolEntry> = deserialize_each(bytes);
|
||||
node.apply_incoming_inner(parsed, 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All nodes should agree on membership and content locations
|
||||
for (i, node) in nodes.iter().enumerate() {
|
||||
assert_eq!(node.member_count(), 3, "node {i} should see 3 members");
|
||||
assert_eq!(
|
||||
node.locate_content(&hash),
|
||||
vec![node_id(0)],
|
||||
"node {i} should know content is on node 0"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
//! Messages for the `PoolCoordinator` actor.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use distribution::types::NodeId;
|
||||
use shared_types::ContentHash;
|
||||
|
||||
/// Messages handled by the `PoolCoordinator` actor.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PoolCoordinatorMsg {
|
||||
// ── User-facing operations ──────────────────────────────────────────
|
||||
/// Store data in the pool (placement-aware).
|
||||
PoolPut {
|
||||
data: Vec<u8>,
|
||||
name: Option<String>,
|
||||
tags: BTreeMap<String, String>,
|
||||
reply_to: swactor::actor::ActorAddress,
|
||||
},
|
||||
/// Retrieve data from the pool (location-aware).
|
||||
PoolGet {
|
||||
content_hash: ContentHash,
|
||||
reply_to: swactor::actor::ActorAddress,
|
||||
},
|
||||
/// Delete data from the pool.
|
||||
PoolDelete {
|
||||
content_hash: ContentHash,
|
||||
reply_to: swactor::actor::ActorAddress,
|
||||
},
|
||||
/// List objects in the pool.
|
||||
PoolList {
|
||||
name_filter: Option<String>,
|
||||
reply_to: swactor::actor::ActorAddress,
|
||||
},
|
||||
/// Pool status (members, capacity, content count).
|
||||
PoolStatus {
|
||||
reply_to: swactor::actor::ActorAddress,
|
||||
},
|
||||
|
||||
// ── Pool lifecycle ──────────────────────────────────────────────────
|
||||
/// Join the pool.
|
||||
JoinPool {
|
||||
reply_to: swactor::actor::ActorAddress,
|
||||
},
|
||||
/// Leave the pool.
|
||||
LeavePool {
|
||||
reply_to: swactor::actor::ActorAddress,
|
||||
},
|
||||
|
||||
// ── Auth management ─────────────────────────────────────────────────
|
||||
/// Grant a node access to the pool.
|
||||
GrantPoolAccess {
|
||||
target: NodeId,
|
||||
reply_to: swactor::actor::ActorAddress,
|
||||
},
|
||||
/// Revoke a node's access to the pool.
|
||||
RevokePoolAccess {
|
||||
target: NodeId,
|
||||
reply_to: swactor::actor::ActorAddress,
|
||||
},
|
||||
|
||||
// ── Periodic ────────────────────────────────────────────────────────
|
||||
/// Periodic tick: announce capacity, drive dissemination.
|
||||
PoolTick,
|
||||
}
|
||||
|
||||
/// Pool-specific response variants.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PoolResponse {
|
||||
/// Pool status snapshot.
|
||||
PoolStatus {
|
||||
pool_name: String,
|
||||
pool_id_hex: String,
|
||||
member_count: usize,
|
||||
content_count: usize,
|
||||
total_bytes: u64,
|
||||
used_bytes: u64,
|
||||
members: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
//! Pooled datastore protocol.
|
||||
//!
|
||||
//! A shared storage pool where multiple nodes contribute storage capacity
|
||||
//! and converge on a shared view of what content lives where.
|
||||
|
||||
pub mod disseminator;
|
||||
pub mod messages;
|
||||
pub mod coordinator;
|
||||
|
|
@ -98,11 +98,10 @@ impl FilesystemBackend {
|
|||
continue;
|
||||
};
|
||||
for file in files.flatten() {
|
||||
if let Some(name) = file.file_name().to_str() {
|
||||
if let Some(hash) = ContentHash::from_hex(name) {
|
||||
if let Some(name) = file.file_name().to_str()
|
||||
&& let Some(hash) = ContentHash::from_hex(name) {
|
||||
self.chunk_index.insert(hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ use iroh::endpoint::Connection;
|
|||
use swactor::actor::ActorAddress;
|
||||
use swactor::runtime::Runtime;
|
||||
|
||||
use crate::messages::{OneShot, StreamManagerMsg};
|
||||
use crate::wire;
|
||||
use crate::streams::messages::{OneShot, StreamManagerMsg};
|
||||
use crate::streams::wire;
|
||||
|
||||
/// Spawn a bridge task that processes incoming stream connections and
|
||||
/// forwards them to the StreamManager actor.
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
use crate::buffer::FrameBuf;
|
||||
use crate::types::StreamError;
|
||||
use crate::streams::buffer::FrameBuf;
|
||||
use crate::streams::types::StreamError;
|
||||
|
||||
/// Commands sent from the actor to the send-side data-plane task.
|
||||
pub enum SendCommand {
|
||||
|
|
@ -3,8 +3,8 @@ use std::collections::HashMap;
|
|||
use iroh::endpoint::Connection;
|
||||
use iroh::{Endpoint, PublicKey};
|
||||
|
||||
use crate::types::StreamError;
|
||||
use crate::wire::ALPN;
|
||||
use crate::streams::types::StreamError;
|
||||
use crate::streams::wire::ALPN;
|
||||
|
||||
/// Cache of QUIC connections used for stream data transfer.
|
||||
///
|
||||
|
|
@ -14,6 +14,12 @@ pub struct StreamConnectionCache {
|
|||
connections: HashMap<[u8; 32], Connection>,
|
||||
}
|
||||
|
||||
impl Default for StreamConnectionCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamConnectionCache {
|
||||
pub fn new() -> Self {
|
||||
StreamConnectionCache {
|
||||
|
|
@ -8,11 +8,11 @@
|
|||
|
||||
use swactor::actor::{ActorAddress, Ctx};
|
||||
use swactor::runtime::Runtime;
|
||||
use swactor_std::CtxNaming;
|
||||
use swactor_std::RuntimeNaming;
|
||||
use swactor::std::CtxNaming;
|
||||
use swactor::std::RuntimeNaming;
|
||||
|
||||
use crate::messages::StreamManagerMsg;
|
||||
use crate::types::{StreamConfig, StreamError, StreamId, StreamMode};
|
||||
use crate::streams::messages::StreamManagerMsg;
|
||||
use crate::streams::types::{StreamConfig, StreamError, StreamId, StreamMode};
|
||||
|
||||
fn mgr_not_found() -> StreamError {
|
||||
StreamError::BrokenPipe("StreamManager not found in name registry".into())
|
||||
|
|
@ -51,7 +51,7 @@ impl CtxStreams for Ctx<'_> {
|
|||
config: StreamConfig,
|
||||
) -> Result<(), StreamError> {
|
||||
let mgr = self
|
||||
.where_is(crate::manager::STREAM_MANAGER_NAME)
|
||||
.where_is(crate::streams::manager::STREAM_MANAGER_NAME)
|
||||
.ok_or_else(mgr_not_found)?;
|
||||
self.send(
|
||||
mgr,
|
||||
|
|
@ -67,7 +67,7 @@ impl CtxStreams for Ctx<'_> {
|
|||
|
||||
fn stream_listen(&self, mode: StreamMode) -> Result<(), StreamError> {
|
||||
let mgr = self
|
||||
.where_is(crate::manager::STREAM_MANAGER_NAME)
|
||||
.where_is(crate::streams::manager::STREAM_MANAGER_NAME)
|
||||
.ok_or_else(mgr_not_found)?;
|
||||
self.send(
|
||||
mgr,
|
||||
|
|
@ -81,7 +81,7 @@ impl CtxStreams for Ctx<'_> {
|
|||
|
||||
fn stream_accept(&self, stream_id: StreamId) -> Result<(), StreamError> {
|
||||
let mgr = self
|
||||
.where_is(crate::manager::STREAM_MANAGER_NAME)
|
||||
.where_is(crate::streams::manager::STREAM_MANAGER_NAME)
|
||||
.ok_or_else(mgr_not_found)?;
|
||||
self.send(
|
||||
mgr,
|
||||
|
|
@ -95,7 +95,7 @@ impl CtxStreams for Ctx<'_> {
|
|||
|
||||
fn stream_reject(&self, stream_id: StreamId) -> Result<(), StreamError> {
|
||||
let mgr = self
|
||||
.where_is(crate::manager::STREAM_MANAGER_NAME)
|
||||
.where_is(crate::streams::manager::STREAM_MANAGER_NAME)
|
||||
.ok_or_else(mgr_not_found)?;
|
||||
self.send(mgr, StreamManagerMsg::Reject { stream_id })
|
||||
.map_err(|e| StreamError::BrokenPipe(e.to_string()))
|
||||
|
|
@ -103,7 +103,7 @@ impl CtxStreams for Ctx<'_> {
|
|||
|
||||
fn stream_close(&self, stream_id: StreamId) -> Result<(), StreamError> {
|
||||
let mgr = self
|
||||
.where_is(crate::manager::STREAM_MANAGER_NAME)
|
||||
.where_is(crate::streams::manager::STREAM_MANAGER_NAME)
|
||||
.ok_or_else(mgr_not_found)?;
|
||||
self.send(mgr, StreamManagerMsg::Close { stream_id })
|
||||
.map_err(|e| StreamError::BrokenPipe(e.to_string()))
|
||||
|
|
@ -149,7 +149,7 @@ impl RuntimeStreams for Runtime {
|
|||
reply_to: ActorAddress,
|
||||
) -> Result<(), StreamError> {
|
||||
let mgr = self
|
||||
.where_is(crate::manager::STREAM_MANAGER_NAME)
|
||||
.where_is(crate::streams::manager::STREAM_MANAGER_NAME)
|
||||
.ok_or_else(mgr_not_found)?;
|
||||
self.send_to(
|
||||
mgr,
|
||||
|
|
@ -165,7 +165,7 @@ impl RuntimeStreams for Runtime {
|
|||
|
||||
fn stream_listen(&self, mode: StreamMode, listener: ActorAddress) -> Result<(), StreamError> {
|
||||
let mgr = self
|
||||
.where_is(crate::manager::STREAM_MANAGER_NAME)
|
||||
.where_is(crate::streams::manager::STREAM_MANAGER_NAME)
|
||||
.ok_or_else(mgr_not_found)?;
|
||||
self.send_to(mgr, StreamManagerMsg::Listen { mode, listener })
|
||||
.map_err(|e| StreamError::BrokenPipe(e.to_string()))
|
||||
|
|
@ -177,7 +177,7 @@ impl RuntimeStreams for Runtime {
|
|||
reply_to: ActorAddress,
|
||||
) -> Result<(), StreamError> {
|
||||
let mgr = self
|
||||
.where_is(crate::manager::STREAM_MANAGER_NAME)
|
||||
.where_is(crate::streams::manager::STREAM_MANAGER_NAME)
|
||||
.ok_or_else(mgr_not_found)?;
|
||||
self.send_to(mgr, StreamManagerMsg::Accept { stream_id, reply_to })
|
||||
.map_err(|e| StreamError::BrokenPipe(e.to_string()))
|
||||
|
|
@ -185,7 +185,7 @@ impl RuntimeStreams for Runtime {
|
|||
|
||||
fn stream_reject(&self, stream_id: StreamId) -> Result<(), StreamError> {
|
||||
let mgr = self
|
||||
.where_is(crate::manager::STREAM_MANAGER_NAME)
|
||||
.where_is(crate::streams::manager::STREAM_MANAGER_NAME)
|
||||
.ok_or_else(mgr_not_found)?;
|
||||
self.send_to(mgr, StreamManagerMsg::Reject { stream_id })
|
||||
.map_err(|e| StreamError::BrokenPipe(e.to_string()))
|
||||
|
|
@ -193,7 +193,7 @@ impl RuntimeStreams for Runtime {
|
|||
|
||||
fn stream_close(&self, stream_id: StreamId) -> Result<(), StreamError> {
|
||||
let mgr = self
|
||||
.where_is(crate::manager::STREAM_MANAGER_NAME)
|
||||
.where_is(crate::streams::manager::STREAM_MANAGER_NAME)
|
||||
.ok_or_else(mgr_not_found)?;
|
||||
self.send_to(mgr, StreamManagerMsg::Close { stream_id })
|
||||
.map_err(|e| StreamError::BrokenPipe(e.to_string()))
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::buffer::BufferPool;
|
||||
use crate::channel::{RecvEvent, SendCommand};
|
||||
use crate::notify::NotifySink;
|
||||
use crate::types::StreamError;
|
||||
use crate::wire;
|
||||
use crate::streams::buffer::BufferPool;
|
||||
use crate::streams::channel::{RecvEvent, SendCommand};
|
||||
use crate::streams::notify::NotifySink;
|
||||
use crate::streams::types::StreamError;
|
||||
use crate::streams::wire;
|
||||
|
||||
/// A send-side data-plane task for a single stripe.
|
||||
///
|
||||
|
|
@ -215,8 +215,8 @@ where
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::buffer::BufferPool;
|
||||
use crate::types::StreamId;
|
||||
use crate::streams::buffer::BufferPool;
|
||||
use crate::streams::types::StreamId;
|
||||
|
||||
fn make_test_pool(count: usize, capacity: usize) -> BufferPool {
|
||||
BufferPool::new(count, capacity)
|
||||
|
|
@ -425,7 +425,7 @@ mod tests {
|
|||
/// Notification coalescing through NotifySink.
|
||||
#[tokio::test]
|
||||
async fn notification_coalescing() {
|
||||
use crate::notify::*;
|
||||
use crate::streams::notify::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -459,8 +459,8 @@ mod tests {
|
|||
/// Backpressure: when channel and active buffer are saturated, try_write returns 0.
|
||||
#[tokio::test]
|
||||
async fn send_backpressure() {
|
||||
use crate::handle::create_stream_handle;
|
||||
use crate::types::StreamConfig;
|
||||
use crate::streams::handle::create_stream_handle;
|
||||
use crate::streams::types::StreamConfig;
|
||||
|
||||
let config = StreamConfig {
|
||||
stripe_count: 1,
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue