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]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
".",
|
".",
|
||||||
"crates/python",
|
"crates/bindings/python",
|
||||||
"crates/wasm/runtime",
|
"crates/bindings/wasm-runtime",
|
||||||
"crates/bin-runner",
|
|
||||||
"crates/simulation",
|
"crates/simulation",
|
||||||
"crates/dashboard",
|
"crates/dashboard",
|
||||||
"crates/distribution",
|
"crates/distribution",
|
||||||
"crates/std",
|
|
||||||
"crates/process",
|
"crates/process",
|
||||||
"crates/datastore",
|
"crates/datastore",
|
||||||
"crates/shared-types",
|
"crates/transport",
|
||||||
"crates/swactor-node",
|
"crates/node",
|
||||||
"crates/streams",
|
|
||||||
"tests/docker",
|
"tests/docker",
|
||||||
"crates/ci",
|
"tests/integration",
|
||||||
"xtask",
|
"xtask",
|
||||||
]
|
]
|
||||||
exclude = ["tools/depgraph", "crates/wasm/crypto"]
|
exclude = ["crates/bindings/wasm-crypto"]
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "swactor"
|
name = "swactor"
|
||||||
|
|
@ -33,7 +30,8 @@ strip = false
|
||||||
crate-type = ["rlib"]
|
crate-type = ["rlib"]
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["getrandom"]
|
default = ["getrandom", "std"]
|
||||||
|
std = [] # OTP patterns: supervisors, registries, timers, routers
|
||||||
getrandom = ["dep:getrandom"]
|
getrandom = ["dep:getrandom"]
|
||||||
serde = ["dep:serde"]
|
serde = ["dep:serde"]
|
||||||
tracing = ["dep:tracing"]
|
tracing = ["dep:tracing"]
|
||||||
|
|
@ -54,7 +52,6 @@ crossbeam-utils = "0.8.21"
|
||||||
criterion = { version = "0.5", features = ["html_reports"] }
|
criterion = { version = "0.5", features = ["html_reports"] }
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
proptest-state-machine = "0.3"
|
proptest-state-machine = "0.3"
|
||||||
swactor-std = { path = "crates/std" }
|
|
||||||
|
|
||||||
[[bench]]
|
[[bench]]
|
||||||
name = "runtime_benchmarks"
|
name = "runtime_benchmarks"
|
||||||
|
|
@ -68,6 +65,3 @@ harness = false
|
||||||
name = "hasher_benchmarks"
|
name = "hasher_benchmarks"
|
||||||
harness = false
|
harness = false
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "tcp_ping_pong"
|
|
||||||
required-features = ["transport"]
|
|
||||||
|
|
|
||||||
154
README.md
154
README.md
|
|
@ -1,152 +1,24 @@
|
||||||
# swactor
|
# swactor
|
||||||
|
|
||||||
Minimal actor runtime for Rust. Single-threaded or multi-threaded, with
|
Minimal actor runtime for Rust. One trait, one message type.
|
||||||
Python and WebAssembly bindings.
|
Single-threaded (`tick()`) or multi-threaded (`run()`).
|
||||||
|
|
||||||
## Quick Start
|
## Description
|
||||||
|
|
||||||
```rust
|
Core runtime is `src/`. Actors implement `ActorInterface` (in `actor.rs`),
|
||||||
use swactor::actor::{ActorAddress, ActorInterface};
|
interact through `Ctx` (in `runtime.rs`), and run on worker threads (`worker.rs`).
|
||||||
use swactor::runtime::{Ctx, Runtime, RuntimeConfig};
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
`crates/` builds upward: `std` adds OTP patterns (supervision, monitoring, groups),
|
||||||
struct Greet { name: String, reply_to: ActorAddress }
|
`distribution` adds clustering, everything else composes from there.
|
||||||
|
|
||||||
#[derive(Clone)]
|
## Dev commands
|
||||||
struct Greeting(String);
|
|
||||||
|
|
||||||
struct Greeter;
|
`cargo xtask --help` for available test groups.
|
||||||
|
|
||||||
impl ActorInterface for Greeter {
|
## Testing
|
||||||
type Incoming = Greet;
|
|
||||||
type Response = Greeting;
|
|
||||||
|
|
||||||
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!"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
cargo check --workspace
|
||||||
## Features
|
cargo xtask test <your-feature-crate>
|
||||||
|
cargo xtask test essential
|
||||||
### 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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,
|
config::RuntimeConfig,
|
||||||
runtime::{Ctx, Runtime},
|
runtime::{Ctx, Runtime},
|
||||||
};
|
};
|
||||||
use swactor_std::{RuntimeGroups, RuntimeNaming, StdExtension};
|
use swactor::std::{RuntimeGroups, RuntimeNaming, StdExtension};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helper
|
// 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"]
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
swactor = { path = "../.." }
|
swactor = { path = "../../.." }
|
||||||
pyo3 = { version = "0.23", features = ["extension-module"] }
|
pyo3 = { version = "0.23", features = ["extension-module"] }
|
||||||
|
|
@ -7,6 +7,5 @@ edition = "2024"
|
||||||
crate-type = ["cdylib"]
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
swactor = { path = "../../..", default-features = false, features = ["wasm"] }
|
swactor = { path = "../../..", default-features = false, features = ["wasm", "std"] }
|
||||||
swactor-std = { path = "../../std", default-features = false, features = ["wasm"] }
|
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
|
|
@ -4,7 +4,7 @@ use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
use swactor::actor::{ActorAddress, ActorExited, ActorInterface};
|
use swactor::actor::{ActorAddress, ActorExited, ActorInterface};
|
||||||
use swactor::runtime::{Ctx, Inbox, Runtime, RuntimeConfig};
|
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 ───────────────────────────────────────────────────
|
// ─── 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
|
### Launching
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run -p runtime-dashboard --example investigate_demo
|
cargo run -p dashboard --example investigate_demo
|
||||||
```
|
```
|
||||||
|
|
||||||
Or programmatically against any running runtime:
|
Or programmatically against any running runtime:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use runtime_dashboard::investigate::run_investigate;
|
use dashboard::investigate::run_investigate;
|
||||||
run_investigate(runtime_arc, collector_arc)?; // blocks on stdin
|
run_investigate(runtime_arc, collector_arc)?; // blocks on stdin
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
swactor = { path = "../..", features = ["serde", "tracing"] }
|
swactor = { path = "../..", features = ["serde", "tracing"] }
|
||||||
swactor-std = { path = "../std", default-features = false }
|
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["registry"] }
|
tracing-subscriber = { version = "0.3", features = ["registry"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|
@ -16,25 +15,14 @@ tokio-stream = "0.1"
|
||||||
crossbeam-queue = "0.3.12"
|
crossbeam-queue = "0.3.12"
|
||||||
ratatui = { version = "0.29", optional = true, default-features = false, features = ["crossterm"] }
|
ratatui = { version = "0.29", optional = true, default-features = false, features = ["crossterm"] }
|
||||||
crossterm = { version = "0.28", optional = true }
|
crossterm = { version = "0.28", optional = true }
|
||||||
distribution = { path = "../distribution", optional = true }
|
|
||||||
clap = { version = "4", features = ["derive"], optional = true }
|
clap = { version = "4", features = ["derive"], optional = true }
|
||||||
ctrlc = "3"
|
ctrlc = "3"
|
||||||
iroh = { version = "0.96", optional = true }
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["distribution"]
|
default = []
|
||||||
tui = ["dep:ratatui", "dep:crossterm"]
|
tui = ["dep:ratatui", "dep:crossterm"]
|
||||||
distribution = ["dep:distribution"]
|
|
||||||
node = ["distribution", "dep:clap", "swactor/transport", "tcp"]
|
|
||||||
tcp = ["distribution/tcp"]
|
|
||||||
iroh = ["distribution/iroh", "dep:iroh"]
|
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "swactor-tui"
|
name = "swactor-tui"
|
||||||
path = "src/bin/tui.rs"
|
path = "src/bin/tui.rs"
|
||||||
required-features = ["tui"]
|
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
|
Visual dashboard for the swactor runtime. Provides a live HTTP dashboard, a
|
||||||
terminal UI (TUI), trace recording/replay, and an HTTP API for programmatic
|
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:
|
Start the dashboard demo and open it in a browser:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run -p runtime-dashboard --example dashboard_demo
|
cargo run -p dashboard --example dashboard_demo
|
||||||
```
|
```
|
||||||
|
|
||||||
Pages:
|
Pages:
|
||||||
|
|
@ -33,9 +33,9 @@ membership and actor registrations in the directory/cache.
|
||||||
A standalone binary that connects to any running dashboard over SSE:
|
A standalone binary that connects to any running dashboard over SSE:
|
||||||
|
|
||||||
```bash
|
```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
|
# 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):
|
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:
|
**HTTP dashboard** — live workload with distribution cluster, Ctrl+C to stop:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run -p runtime-dashboard --example dashboard_demo
|
cargo run -p dashboard --example dashboard_demo
|
||||||
# http://localhost:9090 — runtime overview
|
# http://localhost:9090 — runtime overview
|
||||||
# http://localhost:9090/distribution — cluster view
|
# 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):
|
**Benchmarks** — four automated scenarios (~20 s total):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run -p runtime-dashboard --example bench_dashboard
|
cargo run -p dashboard --example bench_dashboard
|
||||||
# open http://localhost:9090
|
# open http://localhost:9090
|
||||||
```
|
```
|
||||||
|
|
||||||
**Record & replay** — records ~10 s of activity, then serves a replay:
|
**Record & replay** — records ~10 s of activity, then serves a replay:
|
||||||
|
|
||||||
```bash
|
```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
|
# live dashboard at http://localhost:9090 during recording
|
||||||
# replay dashboard at http://localhost:9091 after recording finishes
|
# replay dashboard at http://localhost:9091 after recording finishes
|
||||||
# Ctrl+C to stop
|
# 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">
|
<nav class="nav-links">
|
||||||
<a href="/" class="nav-link">Overview</a>
|
<a href="/" class="nav-link">Overview</a>
|
||||||
<a href="/actors" class="nav-link">Actors</a>
|
<a href="/actors" class="nav-link">Actors</a>
|
||||||
<a href="/distribution" class="nav-link">Distribution</a>
|
<a href="/plugin/distribution" class="nav-link">Distribution</a>
|
||||||
<a href="/datastore" class="nav-link">Datastore</a>
|
<a href="/plugin/datastore" class="nav-link">Datastore</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -151,8 +151,8 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
|
||||||
<nav class="nav-links">
|
<nav class="nav-links">
|
||||||
<a href="/" class="nav-link">Overview</a>
|
<a href="/" class="nav-link">Overview</a>
|
||||||
<a href="/actors" class="nav-link active">Actors</a>
|
<a href="/actors" class="nav-link active">Actors</a>
|
||||||
<a href="/distribution" class="nav-link">Distribution</a>
|
<a href="/plugin/distribution" class="nav-link">Distribution</a>
|
||||||
<a href="/datastore" class="nav-link">Datastore</a>
|
<a href="/plugin/datastore" class="nav-link">Datastore</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<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,
|
Some(ext) => ext,
|
||||||
None => return,
|
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,
|
Some(ext) => ext,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! Built-in command handlers for runtime inspection and management.
|
//! 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::collections::HashMap;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
@ -435,11 +435,10 @@ impl CommandHandler for PhasesCommand {
|
||||||
|
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
for (i, timings) in stats.tick_timings.iter().enumerate() {
|
for (i, timings) in stats.tick_timings.iter().enumerate() {
|
||||||
if let Some(wid) = worker_filter {
|
if let Some(wid) = worker_filter
|
||||||
if i != wid {
|
&& i != wid {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
let breakdown = compute_phase_breakdown(timings);
|
let breakdown = compute_phase_breakdown(timings);
|
||||||
results.push(serde_json::json!({
|
results.push(serde_json::json!({
|
||||||
"worker_id": i,
|
"worker_id": i,
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,12 @@ pub struct CommandRouter {
|
||||||
handlers: HashMap<String, Box<dyn CommandHandler>>,
|
handlers: HashMap<String, Box<dyn CommandHandler>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for CommandRouter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl CommandRouter {
|
impl CommandRouter {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
|
||||||
|
|
@ -152,8 +152,8 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
|
||||||
<nav class="nav-links">
|
<nav class="nav-links">
|
||||||
<a href="/" class="nav-link active">Overview</a>
|
<a href="/" class="nav-link active">Overview</a>
|
||||||
<a href="/actors" class="nav-link">Actors</a>
|
<a href="/actors" class="nav-link">Actors</a>
|
||||||
<a href="/distribution" class="nav-link">Distribution</a>
|
<a href="/plugin/distribution" class="nav-link">Distribution</a>
|
||||||
<a href="/datastore" class="nav-link">Datastore</a>
|
<a href="/plugin/datastore" class="nav-link">Datastore</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<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) {
|
if let Some(scope) = ctx.event_scope(event) {
|
||||||
for span in scope {
|
for span in scope {
|
||||||
let exts = span.extensions();
|
let exts = span.extensions();
|
||||||
if worker_id.is_none() {
|
if worker_id.is_none()
|
||||||
if let Some(wid) = exts.get::<WorkerIdField>() {
|
&& let Some(wid) = exts.get::<WorkerIdField>() {
|
||||||
worker_id = Some(wid.0);
|
worker_id = Some(wid.0);
|
||||||
}
|
}
|
||||||
}
|
if actor_addr.is_none()
|
||||||
if actor_addr.is_none() {
|
&& let Some(aa) = exts.get::<ActorAddrField>() {
|
||||||
if let Some(aa) = exts.get::<ActorAddrField>() {
|
|
||||||
actor_addr = Some(aa.0.clone());
|
actor_addr = Some(aa.0.clone());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if worker_id.is_some() && actor_addr.is_some() {
|
if worker_id.is_some() && actor_addr.is_some() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -229,16 +227,14 @@ where
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also check if worker_id or actor_addr was a field on the event itself
|
// Also check if worker_id or actor_addr was a field on the event itself
|
||||||
if worker_id.is_none() {
|
if worker_id.is_none()
|
||||||
if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") {
|
&& let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") {
|
||||||
worker_id = n.as_u64().map(|v| v as usize);
|
worker_id = n.as_u64().map(|v| v as usize);
|
||||||
}
|
}
|
||||||
}
|
if actor_addr.is_none()
|
||||||
if actor_addr.is_none() {
|
&& let Some(serde_json::Value::String(s)) = visitor.fields.get("actor_addr") {
|
||||||
if let Some(serde_json::Value::String(s)) = visitor.fields.get("actor_addr") {
|
|
||||||
actor_addr = Some(s.clone());
|
actor_addr = Some(s.clone());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let dashboard_event = DashboardEvent {
|
let dashboard_event = DashboardEvent {
|
||||||
seq: 0, // filled by push()
|
seq: 0, // filled by push()
|
||||||
|
|
@ -259,11 +255,10 @@ where
|
||||||
attrs.record(&mut visitor);
|
attrs.record(&mut visitor);
|
||||||
|
|
||||||
if let Some(span) = ctx.span(id) {
|
if let Some(span) = ctx.span(id) {
|
||||||
if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") {
|
if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id")
|
||||||
if let Some(wid) = n.as_u64() {
|
&& let Some(wid) = n.as_u64() {
|
||||||
span.extensions_mut().insert(WorkerIdField(wid as usize));
|
span.extensions_mut().insert(WorkerIdField(wid as usize));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if let Some(serde_json::Value::String(s)) = visitor.fields.get("actor_addr") {
|
if let Some(serde_json::Value::String(s)) = visitor.fields.get("actor_addr") {
|
||||||
span.extensions_mut().insert(ActorAddrField(s.clone()));
|
span.extensions_mut().insert(ActorAddrField(s.clone()));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ pub mod command;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod investigate;
|
pub mod investigate;
|
||||||
pub mod layer;
|
pub mod layer;
|
||||||
|
pub mod plugin;
|
||||||
pub mod trace;
|
pub mod trace;
|
||||||
pub mod warnings;
|
pub mod warnings;
|
||||||
mod actor_detail_html;
|
mod actor_detail_html;
|
||||||
|
|
@ -15,16 +16,6 @@ mod topology_html;
|
||||||
#[cfg(feature = "tui")]
|
#[cfg(feature = "tui")]
|
||||||
pub mod 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::io;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
@ -40,6 +31,7 @@ use tracing_subscriber::util::SubscriberInitExt;
|
||||||
use crate::collector::StatsCollector;
|
use crate::collector::StatsCollector;
|
||||||
use crate::history::{DashboardHistory, HistoryConfig};
|
use crate::history::{DashboardHistory, HistoryConfig};
|
||||||
use crate::layer::{now_ms, DashboardLayer, EventStore};
|
use crate::layer::{now_ms, DashboardLayer, EventStore};
|
||||||
|
use crate::plugin::PluginRegistry;
|
||||||
use crate::trace::{RuntimeTrace, TimestampedStats};
|
use crate::trace::{RuntimeTrace, TimestampedStats};
|
||||||
|
|
||||||
/// Peer info sent through the join channel: (public_key, optional_relay_url).
|
/// Peer info sent through the join channel: (public_key, optional_relay_url).
|
||||||
|
|
@ -99,13 +91,7 @@ pub struct DashboardHandle {
|
||||||
history: Arc<DashboardHistory>,
|
history: Arc<DashboardHistory>,
|
||||||
recording: bool,
|
recording: bool,
|
||||||
port: u16,
|
port: u16,
|
||||||
#[cfg(feature = "distribution")]
|
plugin_registry: Arc<PluginRegistry>,
|
||||||
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>>>>,
|
|
||||||
standalone_rt: Mutex<Option<tokio::runtime::Runtime>>,
|
standalone_rt: Mutex<Option<tokio::runtime::Runtime>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,40 +119,9 @@ impl DashboardHandle {
|
||||||
self.recording
|
self.recording
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attach a distribution stats provider, enabling the `/distribution` page.
|
/// Register a plugin with the dashboard.
|
||||||
#[cfg(feature = "distribution")]
|
pub fn register_plugin(&self, plugin: Arc<dyn plugin::DashboardPlugin>) {
|
||||||
pub fn set_distribution(&self, provider: Arc<dyn distribution_collector::DistributionStatsProvider>) {
|
self.plugin_registry.register(plugin);
|
||||||
*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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Access the time-series history store (for TUI sparklines, etc.).
|
/// Access the time-series history store (for TUI sparklines, etc.).
|
||||||
|
|
@ -212,13 +167,7 @@ impl DashboardHandle {
|
||||||
shutdown_notify: Arc::clone(&self.shutdown_notify),
|
shutdown_notify: Arc::clone(&self.shutdown_notify),
|
||||||
history: Arc::clone(&self.history),
|
history: Arc::clone(&self.history),
|
||||||
cmd_router: Arc::new(crate::command::CommandRouter::with_builtins()),
|
cmd_router: Arc::new(crate::command::CommandRouter::with_builtins()),
|
||||||
#[cfg(feature = "distribution")]
|
plugins: Arc::clone(&self.plugin_registry),
|
||||||
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),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,8 +177,7 @@ impl DashboardHandle {
|
||||||
/// This drains the recording buffers — each call consumes the buffered data.
|
/// This drains the recording buffers — each call consumes the buffered data.
|
||||||
pub fn save_trace(&self, path: &str) -> io::Result<()> {
|
pub fn save_trace(&self, path: &str) -> io::Result<()> {
|
||||||
let events = self.store.all_events().ok_or_else(|| {
|
let events = self.store.all_events().ok_or_else(|| {
|
||||||
io::Error::new(
|
io::Error::other(
|
||||||
io::ErrorKind::Other,
|
|
||||||
"recording not enabled (set DashboardConfig::record = true)",
|
"recording not enabled (set DashboardConfig::record = true)",
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
@ -242,7 +190,7 @@ impl DashboardHandle {
|
||||||
stats_timeline,
|
stats_timeline,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&trace)
|
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)
|
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 stats_timeline = Arc::new(ArrayQueue::new(config.record_stats_capacity.max(1)));
|
||||||
let history = Arc::new(DashboardHistory::new(HistoryConfig::default()));
|
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
|
// Start stats recorder thread when recording is enabled
|
||||||
if config.record {
|
if config.record {
|
||||||
let rt_ref = Arc::clone(&runtime);
|
let rt_ref = Arc::clone(&runtime);
|
||||||
|
|
@ -314,6 +243,7 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
|
||||||
}
|
}
|
||||||
|
|
||||||
let port = config.port;
|
let port = config.port;
|
||||||
|
let plugin_registry = Arc::new(PluginRegistry::new());
|
||||||
|
|
||||||
DashboardHandle {
|
DashboardHandle {
|
||||||
store,
|
store,
|
||||||
|
|
@ -325,13 +255,7 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
|
||||||
history,
|
history,
|
||||||
recording: config.record,
|
recording: config.record,
|
||||||
port,
|
port,
|
||||||
#[cfg(feature = "distribution")]
|
plugin_registry,
|
||||||
distribution,
|
|
||||||
datastore,
|
|
||||||
datastore_factory,
|
|
||||||
ci,
|
|
||||||
peer_auth,
|
|
||||||
join_sender,
|
|
||||||
standalone_rt: Mutex::new(None),
|
standalone_rt: Mutex::new(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -368,7 +292,7 @@ pub fn serve_replay(path: &str, config: ReplayConfig) -> io::Result<()> {
|
||||||
.worker_threads(1)
|
.worker_threads(1)
|
||||||
.enable_all()
|
.enable_all()
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
.map_err(|e| io::Error::other(e))?;
|
||||||
|
|
||||||
let state = server::ReplayState {
|
let state = server::ReplayState {
|
||||||
trace: Arc::new(trace),
|
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::http::{header, StatusCode};
|
||||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::{get, post};
|
use axum::routing::get;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use tokio_stream::wrappers::ReceiverStream;
|
use tokio_stream::wrappers::ReceiverStream;
|
||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
|
|
@ -21,20 +21,13 @@ use crate::actors_html::ACTORS_HTML;
|
||||||
use crate::collector::StatsCollector;
|
use crate::collector::StatsCollector;
|
||||||
use crate::command::CommandRouter;
|
use crate::command::CommandRouter;
|
||||||
use crate::dashboard_html::DASHBOARD_HTML;
|
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::history::DashboardHistory;
|
||||||
use crate::layer::EventStore;
|
use crate::layer::EventStore;
|
||||||
use crate::topology;
|
use crate::topology;
|
||||||
use crate::topology_html::TOPOLOGY_HTML;
|
use crate::topology_html::TOPOLOGY_HTML;
|
||||||
use crate::warnings::{WarningConfig, WarningDetector};
|
use crate::warnings::{WarningConfig, WarningDetector};
|
||||||
|
|
||||||
#[cfg(feature = "distribution")]
|
use crate::plugin::PluginRegistry;
|
||||||
use crate::distribution_collector::DistributionStatsProvider;
|
|
||||||
#[cfg(feature = "distribution")]
|
|
||||||
use crate::distribution_html::DISTRIBUTION_HTML;
|
|
||||||
|
|
||||||
use crate::ci_collector::CiStatsProvider;
|
|
||||||
|
|
||||||
use crate::trace::RuntimeTrace;
|
use crate::trace::RuntimeTrace;
|
||||||
|
|
||||||
|
|
@ -54,13 +47,7 @@ pub(crate) struct AppState {
|
||||||
pub shutdown_notify: Arc<tokio::sync::Notify>,
|
pub shutdown_notify: Arc<tokio::sync::Notify>,
|
||||||
pub history: Arc<DashboardHistory>,
|
pub history: Arc<DashboardHistory>,
|
||||||
pub cmd_router: Arc<CommandRouter>,
|
pub cmd_router: Arc<CommandRouter>,
|
||||||
#[cfg(feature = "distribution")]
|
pub plugins: Arc<PluginRegistry>,
|
||||||
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>>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Router builders ─────────────────────────────────────────────────────
|
// ── Router builders ─────────────────────────────────────────────────────
|
||||||
|
|
@ -70,34 +57,16 @@ pub(crate) fn build_live_router(state: AppState) -> Router {
|
||||||
.route("/", get(page_dashboard))
|
.route("/", get(page_dashboard))
|
||||||
.route("/actors", get(page_actors))
|
.route("/actors", get(page_actors))
|
||||||
.route("/topology", get(page_topology))
|
.route("/topology", get(page_topology))
|
||||||
.route("/datastore", get(page_datastore))
|
|
||||||
.route("/events", get(handle_live_sse))
|
.route("/events", get(handle_live_sse))
|
||||||
.route("/api/stats", get(handle_stats_api))
|
.route("/api/stats", get(handle_stats_api))
|
||||||
.route("/api/history", get(handle_history_api))
|
.route("/api/history", get(handle_history_api))
|
||||||
.route("/api/topology", get(handle_topology_api))
|
.route("/api/topology", get(handle_topology_api))
|
||||||
.route("/api/investigate", get(handle_investigate_api))
|
.route("/api/investigate", get(handle_investigate_api))
|
||||||
.route("/api/datastore", get(handle_datastore_api))
|
|
||||||
.route("/api/logs", get(handle_logs_api))
|
.route("/api/logs", get(handle_logs_api))
|
||||||
.route("/api/datastore/list", get(handle_ds_list))
|
.route("/actor/{hex}", get(handle_actor_detail))
|
||||||
.route("/api/datastore/get", get(handle_ds_get))
|
// Plugin routes
|
||||||
.route("/api/datastore/data", get(handle_ds_data))
|
.route("/api/plugin/{name}/{*rest}", get(handle_plugin_get).post(handle_plugin_post))
|
||||||
.route("/api/datastore/status", get(handle_ds_status))
|
.route("/plugin/{name}", get(handle_plugin_page));
|
||||||
.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));
|
|
||||||
|
|
||||||
router.with_state(state)
|
router.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
@ -151,15 +120,6 @@ async fn page_topology() -> Response {
|
||||||
html_response(TOPOLOGY_HTML, "live")
|
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 {
|
async fn handle_actor_detail(Path(hex_addr): Path<String>) -> Response {
|
||||||
let html = ACTOR_DETAIL_HTML
|
let html = ACTOR_DETAIL_HTML
|
||||||
.replace("__DASHBOARD_MODE__", "live")
|
.replace("__DASHBOARD_MODE__", "live")
|
||||||
|
|
@ -201,13 +161,11 @@ async fn handle_live_sse(
|
||||||
|
|
||||||
// Run warning detection
|
// Run warning detection
|
||||||
let warnings = warning_detector.check(&stats);
|
let warnings = warning_detector.check(&stats);
|
||||||
if !warnings.is_empty() {
|
if !warnings.is_empty()
|
||||||
if let Ok(wjson) = serde_json::to_string(&warnings) {
|
&& let Ok(wjson) = serde_json::to_string(&warnings)
|
||||||
if tx.send(format_sse("warnings", &wjson)).await.is_err() {
|
&& tx.send(format_sse("warnings", &wjson)).await.is_err() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let json = serde_json::to_string(&stats).unwrap();
|
let json = serde_json::to_string(&stats).unwrap();
|
||||||
if tx.send(format_sse("stats", &json)).await.is_err() {
|
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)
|
// Send topology every 5th tick (~1/sec)
|
||||||
tick_count += 1;
|
tick_count += 1;
|
||||||
if tick_count % 5 == 0 {
|
if tick_count.is_multiple_of(5) {
|
||||||
let topo = topology::worker_topology(&stats);
|
let topo = topology::worker_topology(&stats);
|
||||||
if let Ok(tjson) = serde_json::to_string(&topo) {
|
if let Ok(tjson) = serde_json::to_string(&topo)
|
||||||
if tx.send(format_sse("topology", &tjson)).await.is_err() {
|
&& tx.send(format_sse("topology", &tjson)).await.is_err() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send distribution snapshot if provider is attached
|
// Poll all registered plugins
|
||||||
#[cfg(feature = "distribution")]
|
for plugin in state.plugins.snapshot() {
|
||||||
{
|
if let Some(json) = plugin.snapshot_json()
|
||||||
let maybe_dist = state.distribution.lock().unwrap().clone();
|
&& tx.send(format_sse(plugin.name(), &json)).await.is_err() {
|
||||||
if let Some(provider) = maybe_dist {
|
return;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
// Send new activity events
|
||||||
|
|
@ -361,54 +274,6 @@ async fn handle_investigate_api(
|
||||||
json_response(json)
|
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 {
|
async fn handle_topology_api(State(state): State<AppState>) -> Response {
|
||||||
let maybe_rt = state.runtime.lock().unwrap().clone();
|
let maybe_rt = state.runtime.lock().unwrap().clone();
|
||||||
let json = match maybe_rt {
|
let json = match maybe_rt {
|
||||||
|
|
@ -453,397 +318,77 @@ async fn handle_history_api(State(state): State<AppState>) -> Response {
|
||||||
json_response(json)
|
json_response(json)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Datastore CRUD API handlers ─────────────────────────────────────────
|
// ── Plugin handlers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn get_ds_provider(
|
async fn handle_plugin_get(
|
||||||
datastore: &Arc<Mutex<Option<Arc<dyn DatastoreStatsProvider>>>>,
|
|
||||||
) -> Option<Arc<dyn DatastoreStatsProvider>> {
|
|
||||||
datastore.lock().unwrap().clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_ds_list(
|
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
Path((name, rest)): Path<(String, String)>,
|
||||||
Query(params): Query<HashMap<String, String>>,
|
Query(params): Query<HashMap<String, String>>,
|
||||||
) -> Response {
|
) -> 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,
|
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()) {
|
match plugin.handle_request("GET", &rest, ¶ms, &[]) {
|
||||||
Some("local") => ListScope::Local,
|
crate::plugin::PluginResponse::Json(json) => json_response(json),
|
||||||
_ => ListScope::Swarm,
|
crate::plugin::PluginResponse::Binary { content_type, data } => {
|
||||||
};
|
([(header::CONTENT_TYPE, content_type)], data).into_response()
|
||||||
let name_filter = params.get("name").map(|s| s.as_str());
|
}
|
||||||
match provider.list_objects(name_filter, scope) {
|
crate::plugin::PluginResponse::Error { status, message } => {
|
||||||
Ok(json) => json_response(json),
|
let code = StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
json_error(code, &message)
|
||||||
}
|
}
|
||||||
}
|
crate::plugin::PluginResponse::NotFound => {
|
||||||
|
json_error(StatusCode::NOT_FOUND, "not found")
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
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 {
|
async fn handle_plugin_post(
|
||||||
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(
|
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
Path((name, rest)): Path<(String, String)>,
|
||||||
Query(params): Query<HashMap<String, String>>,
|
Query(params): Query<HashMap<String, String>>,
|
||||||
body: Bytes,
|
body: Bytes,
|
||||||
) -> Response {
|
) -> 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,
|
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 plugin.handle_request("POST", &rest, ¶ms, &body) {
|
||||||
match provider.put_data(body.to_vec(), name) {
|
crate::plugin::PluginResponse::Json(json) => json_response(json),
|
||||||
Ok(json) => json_response(json),
|
crate::plugin::PluginResponse::Binary { content_type, data } => {
|
||||||
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
([(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>,
|
State(state): State<AppState>,
|
||||||
Query(params): Query<HashMap<String, String>>,
|
Path(name): Path<String>,
|
||||||
) -> Response {
|
) -> 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,
|
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 => {
|
None => {
|
||||||
let json = serde_json::json!({
|
return (StatusCode::NOT_FOUND, "plugin not found").into_response();
|
||||||
"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)",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
match plugin.html_page() {
|
||||||
let node_id = distribution::types::NodeId(bytes.try_into().unwrap());
|
Some(html) => {
|
||||||
let mut list = auth.lock().unwrap();
|
let rendered = html.replace("__DASHBOARD_MODE__", "live");
|
||||||
list.remove_peer(&node_id);
|
([(header::CONTENT_TYPE, "text/html; charset=utf-8")], rendered).into_response()
|
||||||
if let Err(e) = list.save() {
|
}
|
||||||
eprintln!("warning: failed to persist peers.json: {e}");
|
None => (StatusCode::NOT_FOUND, "no page for this plugin").into_response(),
|
||||||
}
|
}
|
||||||
|
|
||||||
json_response(r#"{"ok":true}"#.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Replay server ───────────────────────────────────────────────────────
|
// ── Replay server ───────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,8 @@ pub const TOPOLOGY_HTML: &str = r##"<!DOCTYPE html>
|
||||||
<a href="/" class="nav-link">Overview</a>
|
<a href="/" class="nav-link">Overview</a>
|
||||||
<a href="/actors" class="nav-link">Actors</a>
|
<a href="/actors" class="nav-link">Actors</a>
|
||||||
<a href="/topology" class="nav-link active">Topology</a>
|
<a href="/topology" class="nav-link active">Topology</a>
|
||||||
<a href="/distribution" class="nav-link">Distribution</a>
|
<a href="/plugin/distribution" class="nav-link">Distribution</a>
|
||||||
<a href="/datastore" class="nav-link">Datastore</a>
|
<a href="/plugin/datastore" class="nav-link">Datastore</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -79,8 +79,6 @@ pub enum ViewMode {
|
||||||
Overview,
|
Overview,
|
||||||
WorkerDetail,
|
WorkerDetail,
|
||||||
ActorDetail,
|
ActorDetail,
|
||||||
#[cfg(feature = "distribution")]
|
|
||||||
Distribution,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
|
|
@ -104,11 +102,6 @@ pub struct App {
|
||||||
pub search_locked: bool,
|
pub search_locked: bool,
|
||||||
pub warnings: Vec<Warning>,
|
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_messages: Vec<u64>,
|
||||||
prev_time: Instant,
|
prev_time: Instant,
|
||||||
/// Rolling msg rates (smoothed)
|
/// Rolling msg rates (smoothed)
|
||||||
|
|
@ -152,10 +145,6 @@ impl App {
|
||||||
search_query: String::new(),
|
search_query: String::new(),
|
||||||
search_locked: false,
|
search_locked: false,
|
||||||
warnings: Vec::new(),
|
warnings: Vec::new(),
|
||||||
#[cfg(feature = "distribution")]
|
|
||||||
distribution: None,
|
|
||||||
#[cfg(feature = "distribution")]
|
|
||||||
dist_member_selected: 0,
|
|
||||||
prev_messages: Vec::new(),
|
prev_messages: Vec::new(),
|
||||||
prev_time: Instant::now(),
|
prev_time: Instant::now(),
|
||||||
msg_rates: Vec::new(),
|
msg_rates: Vec::new(),
|
||||||
|
|
@ -175,13 +164,6 @@ impl App {
|
||||||
self.event_store = Some(store);
|
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).
|
/// Get visible actor rows (filtered by search query if active).
|
||||||
pub fn visible_actor_rows(&self) -> Vec<&ActorRow> {
|
pub fn visible_actor_rows(&self) -> Vec<&ActorRow> {
|
||||||
if self.search_query.is_empty() {
|
if self.search_query.is_empty() {
|
||||||
|
|
@ -451,12 +433,7 @@ impl App {
|
||||||
self.view_mode = match self.view_mode {
|
self.view_mode = match self.view_mode {
|
||||||
ViewMode::Overview => ViewMode::WorkerDetail,
|
ViewMode::Overview => ViewMode::WorkerDetail,
|
||||||
ViewMode::ActorDetail => ViewMode::Overview,
|
ViewMode::ActorDetail => ViewMode::Overview,
|
||||||
#[cfg(feature = "distribution")]
|
|
||||||
ViewMode::WorkerDetail => ViewMode::Distribution,
|
|
||||||
#[cfg(not(feature = "distribution"))]
|
|
||||||
ViewMode::WorkerDetail => ViewMode::Overview,
|
ViewMode::WorkerDetail => ViewMode::Overview,
|
||||||
#[cfg(feature = "distribution")]
|
|
||||||
ViewMode::Distribution => ViewMode::Overview,
|
|
||||||
};
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -467,8 +444,6 @@ impl App {
|
||||||
ViewMode::Overview => self.handle_key_overview(key),
|
ViewMode::Overview => self.handle_key_overview(key),
|
||||||
ViewMode::WorkerDetail => self.handle_key_worker_detail(key),
|
ViewMode::WorkerDetail => self.handle_key_worker_detail(key),
|
||||||
ViewMode::ActorDetail => self.handle_key_actor_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 {
|
pub fn visible_table_height(&self) -> usize {
|
||||||
// Will be set by the UI based on actual chunk size
|
// Will be set by the UI based on actual chunk size
|
||||||
20
|
20
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,6 @@ pub enum AppEvent {
|
||||||
source: RuntimeEndpoint,
|
source: RuntimeEndpoint,
|
||||||
stats: Box<RuntimeStats>,
|
stats: Box<RuntimeStats>,
|
||||||
},
|
},
|
||||||
#[cfg(feature = "distribution")]
|
|
||||||
DistributionUpdate {
|
|
||||||
snapshot: Box<distribution::snapshot::DistributionNodeSnapshot>,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct EventLoop {
|
pub struct EventLoop {
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,6 @@ use ratatui::widgets::TableState;
|
||||||
use swactor::runtime::Runtime;
|
use swactor::runtime::Runtime;
|
||||||
|
|
||||||
use crate::collector::StatsCollector;
|
use crate::collector::StatsCollector;
|
||||||
#[cfg(feature = "distribution")]
|
|
||||||
use crate::distribution_collector::DistributionStatsProvider;
|
|
||||||
use crate::layer::EventStore;
|
use crate::layer::EventStore;
|
||||||
use self::app::App;
|
use self::app::App;
|
||||||
use self::event::{AppEvent, EventLoop};
|
use self::event::{AppEvent, EventLoop};
|
||||||
|
|
@ -45,8 +43,6 @@ impl Default for TuiConfig {
|
||||||
pub fn start_tui(
|
pub fn start_tui(
|
||||||
runtime: Arc<Runtime>,
|
runtime: Arc<Runtime>,
|
||||||
collector: Arc<StatsCollector>,
|
collector: Arc<StatsCollector>,
|
||||||
#[cfg(feature = "distribution")]
|
|
||||||
distribution: Option<Arc<dyn DistributionStatsProvider>>,
|
|
||||||
config: TuiConfig,
|
config: TuiConfig,
|
||||||
event_store: Option<Arc<EventStore>>,
|
event_store: Option<Arc<EventStore>>,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
|
|
@ -71,8 +67,6 @@ pub fn start_tui(
|
||||||
&mut terminal,
|
&mut terminal,
|
||||||
runtime,
|
runtime,
|
||||||
collector,
|
collector,
|
||||||
#[cfg(feature = "distribution")]
|
|
||||||
distribution,
|
|
||||||
config,
|
config,
|
||||||
event_store,
|
event_store,
|
||||||
);
|
);
|
||||||
|
|
@ -92,8 +86,6 @@ fn run_loop(
|
||||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
runtime: Arc<Runtime>,
|
runtime: Arc<Runtime>,
|
||||||
collector: Arc<StatsCollector>,
|
collector: Arc<StatsCollector>,
|
||||||
#[cfg(feature = "distribution")]
|
|
||||||
distribution: Option<Arc<dyn DistributionStatsProvider>>,
|
|
||||||
config: TuiConfig,
|
config: TuiConfig,
|
||||||
event_store: Option<Arc<EventStore>>,
|
event_store: Option<Arc<EventStore>>,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
|
|
@ -119,12 +111,6 @@ fn run_loop(
|
||||||
collector.enrich(&mut stats);
|
collector.enrich(&mut stats);
|
||||||
crate::collector::enrich_names(&mut stats, &runtime);
|
crate::collector::enrich_names(&mut stats, &runtime);
|
||||||
app.update(stats);
|
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)) => {
|
Ok(AppEvent::Key(key)) => {
|
||||||
app.handle_key(key);
|
app.handle_key(key);
|
||||||
|
|
@ -132,10 +118,6 @@ fn run_loop(
|
||||||
Ok(AppEvent::StatsUpdate { stats, .. }) => {
|
Ok(AppEvent::StatsUpdate { stats, .. }) => {
|
||||||
app.update(*stats);
|
app.update(*stats);
|
||||||
}
|
}
|
||||||
#[cfg(feature = "distribution")]
|
|
||||||
Ok(AppEvent::DistributionUpdate { snapshot }) => {
|
|
||||||
app.update_distribution(*snapshot);
|
|
||||||
}
|
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// Channel closed, exit
|
// Channel closed, exit
|
||||||
break;
|
break;
|
||||||
|
|
@ -209,10 +191,6 @@ fn run_loop_remote(
|
||||||
Ok(AppEvent::Key(key)) => {
|
Ok(AppEvent::Key(key)) => {
|
||||||
app.handle_key(key);
|
app.handle_key(key);
|
||||||
}
|
}
|
||||||
#[cfg(feature = "distribution")]
|
|
||||||
Ok(AppEvent::DistributionUpdate { snapshot }) => {
|
|
||||||
app.update_distribution(*snapshot);
|
|
||||||
}
|
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -151,27 +151,6 @@ fn parse_sse_events<R: BufRead>(
|
||||||
debug_log("received done event");
|
debug_log("received done event");
|
||||||
return Ok(());
|
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();
|
current_event.clear();
|
||||||
data_buf.clear();
|
data_buf.clear();
|
||||||
} else if let Some(event_type) = trimmed.strip_prefix("event: ") {
|
} 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::Overview => draw_overview(f, app, table_state),
|
||||||
ViewMode::WorkerDetail => draw_worker_detail(f, app, table_state),
|
ViewMode::WorkerDetail => draw_worker_detail(f, app, table_state),
|
||||||
ViewMode::ActorDetail => draw_actor_detail(f, app),
|
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);
|
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 ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn short_type_name(full: Option<&str>) -> String {
|
fn short_type_name(full: Option<&str>) -> String {
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,19 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)'] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
swactor = { path = "../..", features = ["serde", "transport"] }
|
swactor = { path = "../..", features = ["serde", "transport"] }
|
||||||
distribution = { path = "../distribution" }
|
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||||
shared-types = { path = "../shared-types" }
|
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
blake3 = "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 }
|
tiny_http = { version = "0.12", optional = true }
|
||||||
clap = { version = "4", features = ["derive"], optional = true }
|
clap = { version = "4", features = ["derive"], optional = true }
|
||||||
ureq = { version = "2", features = ["json"], optional = true }
|
ureq = { version = "2", features = ["json"], optional = true }
|
||||||
getrandom = { version = "0.2", optional = true }
|
|
||||||
ctrlc = { version = "3", optional = true }
|
ctrlc = { version = "3", optional = true }
|
||||||
runtime-dashboard = { path = "../runtime-dashboard", optional = true }
|
|
||||||
toml = { version = "0.8", optional = true }
|
toml = { version = "0.8", optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|
@ -26,22 +28,13 @@ serde_json = "1"
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
proptest-state-machine = "0.3"
|
proptest-state-machine = "0.3"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
distribution = { path = "../distribution" }
|
|
||||||
swactor = { path = "../.." }
|
swactor = { path = "../.." }
|
||||||
swactor-std = { path = "../std" }
|
|
||||||
ureq = { version = "2", features = ["json"] }
|
ureq = { version = "2", features = ["json"] }
|
||||||
tiny_http = "0.12"
|
|
||||||
runtime-dashboard = { path = "../runtime-dashboard" }
|
|
||||||
stateright = "0.31"
|
stateright = "0.31"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
node = ["dep:tiny_http", "dep:clap", "dep:ctrlc", "dep:runtime-dashboard", "dep:toml"]
|
node = ["dep:tiny_http", "dep:clap", "dep:ctrlc", "dep:toml"]
|
||||||
cli = ["dep:clap", "dep:ureq", "dep:getrandom"]
|
cli = ["dep:clap", "dep:ureq"]
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "swactor-store-node"
|
|
||||||
path = "src/bin/store_node.rs"
|
|
||||||
required-features = ["node"]
|
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "swactor-store"
|
name = "swactor-store"
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ use std::sync::Arc;
|
||||||
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||||
use swactor::runtime::Runtime;
|
use swactor::runtime::Runtime;
|
||||||
|
|
||||||
use distribution::types::NodeId;
|
use swactor::transport::NodeId;
|
||||||
|
|
||||||
use crate::actors::stream_downloader::StreamDownloader;
|
use crate::actors::stream_downloader::StreamDownloader;
|
||||||
use crate::actors::stream_server::StreamServer;
|
use crate::actors::stream_server::StreamServer;
|
||||||
|
|
@ -310,7 +310,7 @@ impl DatastoreNode {
|
||||||
fn handle_stream_offer(
|
fn handle_stream_offer(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Ctx,
|
ctx: &Ctx,
|
||||||
stream_id: swactor_streams::types::StreamId,
|
stream_id: crate::streams::types::StreamId,
|
||||||
content_hash: ContentHash,
|
content_hash: ContentHash,
|
||||||
_from_node: [u8; 32],
|
_from_node: [u8; 32],
|
||||||
stream_manager: ActorAddress,
|
stream_manager: ActorAddress,
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||||
|
|
||||||
use crate::auth::{AccessRequestInfo, AuthzEngine, AuthzResult, DatastoreAction, DeniedReason};
|
use crate::auth::{AccessRequestInfo, AuthzEngine, AuthzResult, DatastoreAction, DeniedReason};
|
||||||
use crate::messages::{DatastoreNodeMsg, DatastoreResponse, GatewayMsg};
|
use crate::messages::{DatastoreNodeMsg, DatastoreResponse, GatewayMsg};
|
||||||
use distribution::types::NodeId;
|
use swactor::transport::NodeId;
|
||||||
|
|
||||||
/// The auth gateway actor wrapping an `AuthzEngine`.
|
/// The auth gateway actor wrapping an `AuthzEngine`.
|
||||||
pub struct GatewayActor {
|
pub struct GatewayActor {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||||
|
|
||||||
use distribution::types::NodeId;
|
use swactor::transport::NodeId;
|
||||||
|
|
||||||
use crate::messages::{BlobStoreMsg, DatastoreResponse, MetadataMsg};
|
use crate::messages::{BlobStoreMsg, DatastoreResponse, MetadataMsg};
|
||||||
use crate::types::{ContentHash, DatastoreConfig, ObjectEntry, ObjectManifest};
|
use crate::types::{ContentHash, DatastoreConfig, ObjectEntry, ObjectManifest};
|
||||||
|
|
@ -120,7 +120,7 @@ impl MetadataActor {
|
||||||
/// `GcUnreferenced` to BlobStoreActor to delete orphaned chunks.
|
/// `GcUnreferenced` to BlobStoreActor to delete orphaned chunks.
|
||||||
fn gc_tick(&mut self, ctx: &Ctx) {
|
fn gc_tick(&mut self, ctx: &Ctx) {
|
||||||
self.tick_count += 1;
|
self.tick_count += 1;
|
||||||
if self.tick_count % self.gc_interval != 0 {
|
if !self.tick_count.is_multiple_of(self.gc_interval) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ use std::sync::Arc;
|
||||||
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||||
use swactor::runtime::Runtime;
|
use swactor::runtime::Runtime;
|
||||||
|
|
||||||
use swactor_streams::messages::{StreamManagerMsg, StreamNotification};
|
use crate::streams::messages::{StreamManagerMsg, StreamNotification};
|
||||||
use swactor_streams::types::{StreamConfig, StreamMode};
|
use crate::streams::types::{StreamConfig, StreamMode};
|
||||||
|
|
||||||
use crate::blob_transfer::{encode_metadata, recv_blob, BlobTransferMetadata};
|
use crate::blob_transfer::{encode_metadata, recv_blob, BlobTransferMetadata};
|
||||||
use crate::messages::{BlobStoreMsg, DatastoreNodeMsg};
|
use crate::messages::{BlobStoreMsg, DatastoreNodeMsg};
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
|
|
||||||
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||||
|
|
||||||
use swactor_streams::messages::{StreamManagerMsg, StreamNotification};
|
use crate::streams::messages::{StreamManagerMsg, StreamNotification};
|
||||||
use swactor_streams::types::StreamMode;
|
use crate::streams::types::StreamMode;
|
||||||
|
|
||||||
use crate::blob_transfer::parse_metadata;
|
use crate::blob_transfer::parse_metadata;
|
||||||
use crate::messages::DatastoreNodeMsg;
|
use crate::messages::DatastoreNodeMsg;
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ use std::time::Duration;
|
||||||
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||||
use swactor::runtime::Runtime;
|
use swactor::runtime::Runtime;
|
||||||
|
|
||||||
use swactor_streams::messages::{StreamManagerMsg, StreamNotification};
|
use crate::streams::messages::{StreamManagerMsg, StreamNotification};
|
||||||
use swactor_streams::types::StreamId;
|
use crate::streams::types::StreamId;
|
||||||
|
|
||||||
use crate::blob_transfer::{poll_inbox, send_blob, BlobTransferError};
|
use crate::blob_transfer::{poll_inbox, send_blob, BlobTransferError};
|
||||||
use crate::messages::{BlobStoreMsg, DatastoreResponse};
|
use crate::messages::{BlobStoreMsg, DatastoreResponse};
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ use std::collections::HashSet;
|
||||||
|
|
||||||
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||||
|
|
||||||
use distribution::types::NodeId;
|
use swactor::transport::NodeId;
|
||||||
|
|
||||||
use crate::messages::{DatastoreResponse, TransferMsg};
|
use crate::messages::{DatastoreResponse, TransferMsg};
|
||||||
use crate::types::{ContentHash, ObjectManifest, TransferStatus};
|
use crate::types::{ContentHash, ObjectManifest, TransferStatus};
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ use std::time::{Duration, Instant};
|
||||||
use swactor::actor::ActorAddress;
|
use swactor::actor::ActorAddress;
|
||||||
use swactor::runtime::{Inbox, Runtime};
|
use swactor::runtime::{Inbox, Runtime};
|
||||||
|
|
||||||
use distribution::types::NodeId;
|
use swactor::transport::NodeId;
|
||||||
|
|
||||||
use crate::auth::SignedRequest;
|
use crate::auth::SignedRequest;
|
||||||
use crate::chunking::reassemble_blob;
|
use crate::chunking::reassemble_blob;
|
||||||
|
|
@ -753,7 +753,7 @@ fn try_remote_get(
|
||||||
let _ = state.runtime.send_to(
|
let _ = state.runtime.send_to(
|
||||||
peer.metadata,
|
peer.metadata,
|
||||||
MetadataMsg::HandleFindObject {
|
MetadataMsg::HandleFindObject {
|
||||||
from: distribution::types::NodeId([0; 32]), // placeholder
|
from: swactor::transport::NodeId([0; 32]), // placeholder
|
||||||
content_hash,
|
content_hash,
|
||||||
reply_to: *find_inbox.addr(),
|
reply_to: *find_inbox.addr(),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,10 @@ use std::path::Path;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use distribution::crypto;
|
use crate::crypto;
|
||||||
use distribution::types::{NodeId, Signature};
|
use swactor::transport::NodeId;
|
||||||
use shared_types::ContentHash;
|
use crate::crypto::Signature;
|
||||||
|
use crate::content_hash::ContentHash;
|
||||||
|
|
||||||
// ─── Access Request / Authorized Key Info ──────────────────────────────────
|
// ─── Access Request / Authorized Key Info ──────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -132,7 +133,7 @@ impl AccessControlList {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
let json = serde_json::to_string_pretty(self)
|
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)
|
std::fs::write(path, json)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -228,7 +229,7 @@ impl AuthzEngine {
|
||||||
|
|
||||||
// 2. Timestamp freshness
|
// 2. Timestamp freshness
|
||||||
let ts = request.payload.timestamp;
|
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 {
|
if diff > self.timestamp_window {
|
||||||
return AuthzResult::Denied(DeniedReason::RequestExpired);
|
return AuthzResult::Denied(DeniedReason::RequestExpired);
|
||||||
}
|
}
|
||||||
|
|
@ -257,7 +258,7 @@ impl AuthzEngine {
|
||||||
|
|
||||||
// 2. Timestamp freshness
|
// 2. Timestamp freshness
|
||||||
let ts = request.payload.timestamp;
|
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 {
|
if diff > self.timestamp_window {
|
||||||
return AuthzResult::Denied(DeniedReason::RequestExpired);
|
return AuthzResult::Denied(DeniedReason::RequestExpired);
|
||||||
}
|
}
|
||||||
|
|
@ -320,7 +321,7 @@ impl AuthzEngine {
|
||||||
/// Evict nonces whose timestamps fall outside the current window.
|
/// Evict nonces whose timestamps fall outside the current window.
|
||||||
pub fn gc_nonces(&mut self, now: u64) {
|
pub fn gc_nonces(&mut self, now: u64) {
|
||||||
self.seen_nonces.retain(|_nonce, ts| {
|
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
|
diff <= self.timestamp_window
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
use distribution::crypto::Keypair;
|
use swactor_datastore::crypto::Keypair;
|
||||||
use shared_types::ContentHash;
|
use swactor_datastore::content_hash::ContentHash;
|
||||||
use swactor_datastore::auth::{sign_request, DatastoreAction, SignedRequestPayload};
|
use swactor_datastore::auth::{sign_request, DatastoreAction, SignedRequestPayload};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ use std::time::Duration;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use swactor::runtime::Inbox;
|
use swactor::runtime::Inbox;
|
||||||
use swactor::actor::Message;
|
use swactor::actor::Message;
|
||||||
use swactor_streams::handle::{RecvHalf, SendHalf};
|
use crate::streams::handle::{RecvHalf, SendHalf};
|
||||||
|
|
||||||
use crate::types::{ContentHash, ObjectManifest};
|
use crate::types::{ContentHash, ObjectManifest};
|
||||||
|
|
||||||
|
|
@ -264,8 +264,8 @@ pub async fn poll_inbox<M: Message>(inbox: &Inbox<M>, timeout: Duration) -> Opti
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::chunking::chunk_blob;
|
use crate::chunking::chunk_blob;
|
||||||
use swactor_streams::handle::create_stream_handle;
|
use crate::streams::handle::create_stream_handle;
|
||||||
use swactor_streams::types::{StreamConfig, StreamId};
|
use crate::streams::types::{StreamConfig, StreamId};
|
||||||
|
|
||||||
/// Helper: create a pair of (SendHalf, RecvHalf) connected via tokio tasks
|
/// Helper: create a pair of (SendHalf, RecvHalf) connected via tokio tasks
|
||||||
/// that relay data through a DuplexStream.
|
/// that relay data through a DuplexStream.
|
||||||
|
|
@ -299,39 +299,39 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_send_task(
|
fn spawn_send_task(
|
||||||
mut cmd_rx: tokio::sync::mpsc::Receiver<swactor_streams::channel::SendCommand>,
|
mut cmd_rx: tokio::sync::mpsc::Receiver<crate::streams::channel::SendCommand>,
|
||||||
evt_tx: tokio::sync::mpsc::Sender<swactor_streams::channel::SendEvent>,
|
evt_tx: tokio::sync::mpsc::Sender<crate::streams::channel::SendEvent>,
|
||||||
pool: swactor_streams::BufferPool,
|
pool: crate::streams::BufferPool,
|
||||||
mut writer: tokio::io::WriteHalf<tokio::io::DuplexStream>,
|
mut writer: tokio::io::WriteHalf<tokio::io::DuplexStream>,
|
||||||
) {
|
) {
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(cmd) = cmd_rx.recv().await {
|
while let Some(cmd) = cmd_rx.recv().await {
|
||||||
match cmd {
|
match cmd {
|
||||||
swactor_streams::channel::SendCommand::Data(buf) => {
|
crate::streams::channel::SendCommand::Data(buf) => {
|
||||||
let data = buf.written();
|
let data = buf.written();
|
||||||
// Write length-prefixed frame
|
// Write length-prefixed frame
|
||||||
let len = (data.len() as u32).to_be_bytes();
|
let len = (data.len() as u32).to_be_bytes();
|
||||||
if writer.write_all(&len).await.is_err() {
|
if writer.write_all(&len).await.is_err() {
|
||||||
pool.checkin(buf);
|
pool.checkin(buf);
|
||||||
let _ = evt_tx.send(swactor_streams::channel::SendEvent::Error(
|
let _ = evt_tx.send(crate::streams::channel::SendEvent::Error(
|
||||||
swactor_streams::StreamError::Disconnected,
|
crate::streams::StreamError::Disconnected,
|
||||||
)).await;
|
)).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if writer.write_all(data).await.is_err() {
|
if writer.write_all(data).await.is_err() {
|
||||||
pool.checkin(buf);
|
pool.checkin(buf);
|
||||||
let _ = evt_tx.send(swactor_streams::channel::SendEvent::Error(
|
let _ = evt_tx.send(crate::streams::channel::SendEvent::Error(
|
||||||
swactor_streams::StreamError::Disconnected,
|
crate::streams::StreamError::Disconnected,
|
||||||
)).await;
|
)).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pool.checkin(buf);
|
pool.checkin(buf);
|
||||||
}
|
}
|
||||||
swactor_streams::channel::SendCommand::Flush => {
|
crate::streams::channel::SendCommand::Flush => {
|
||||||
let _ = writer.flush().await;
|
let _ = writer.flush().await;
|
||||||
}
|
}
|
||||||
swactor_streams::channel::SendCommand::Close => {
|
crate::streams::channel::SendCommand::Close => {
|
||||||
let _ = writer.shutdown().await;
|
let _ = writer.shutdown().await;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -342,8 +342,8 @@ mod tests {
|
||||||
|
|
||||||
fn spawn_recv_task(
|
fn spawn_recv_task(
|
||||||
mut reader: tokio::io::ReadHalf<tokio::io::DuplexStream>,
|
mut reader: tokio::io::ReadHalf<tokio::io::DuplexStream>,
|
||||||
evt_tx: tokio::sync::mpsc::Sender<swactor_streams::channel::RecvEvent>,
|
evt_tx: tokio::sync::mpsc::Sender<crate::streams::channel::RecvEvent>,
|
||||||
pool: swactor_streams::BufferPool,
|
pool: crate::streams::BufferPool,
|
||||||
) {
|
) {
|
||||||
use tokio::io::AsyncReadExt;
|
use tokio::io::AsyncReadExt;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
|
@ -353,7 +353,7 @@ mod tests {
|
||||||
match reader.read_exact(&mut len_buf).await {
|
match reader.read_exact(&mut len_buf).await {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let _ = evt_tx.send(swactor_streams::channel::RecvEvent::Closed).await;
|
let _ = evt_tx.send(crate::streams::channel::RecvEvent::Closed).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -362,7 +362,7 @@ mod tests {
|
||||||
match reader.read_exact(&mut data).await {
|
match reader.read_exact(&mut data).await {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let _ = evt_tx.send(swactor_streams::channel::RecvEvent::Closed).await;
|
let _ = evt_tx.send(crate::streams::channel::RecvEvent::Closed).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -373,15 +373,15 @@ mod tests {
|
||||||
let mut buf = match pool.checkout() {
|
let mut buf = match pool.checkout() {
|
||||||
Some(b) => b,
|
Some(b) => b,
|
||||||
None => {
|
None => {
|
||||||
let _ = evt_tx.send(swactor_streams::channel::RecvEvent::Error(
|
let _ = evt_tx.send(crate::streams::channel::RecvEvent::Error(
|
||||||
swactor_streams::StreamError::BufferExhausted,
|
crate::streams::StreamError::BufferExhausted,
|
||||||
)).await;
|
)).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let written = buf.write(&data[offset..]);
|
let written = buf.write(&data[offset..]);
|
||||||
offset += written;
|
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
|
//! Datastore actor group lifecycle management.
|
||||||
//! the datastore actor system. Allows the dashboard to perform CRUD operations
|
//!
|
||||||
//! and lifecycle management without depending on `swactor-datastore` types.
|
//! `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::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread;
|
|
||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use swactor::actor::ActorAddress;
|
use swactor::actor::ActorAddress;
|
||||||
use swactor::runtime::{Inbox, Runtime};
|
use swactor::runtime::Runtime;
|
||||||
use swactor_std::RuntimeNaming;
|
use swactor::std::RuntimeNaming;
|
||||||
|
|
||||||
use distribution::types::NodeId;
|
use swactor::transport::NodeId;
|
||||||
use dashboard::datastore_collector::{
|
|
||||||
DatastoreFactory, DatastoreStatsProvider, ListScope,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::actors::{BlobStoreActor, DatastoreNode, GatewayActor, MetadataActor};
|
use crate::actors::{BlobStoreActor, DatastoreNode, GatewayActor, MetadataActor};
|
||||||
use crate::auth::{AccessControlList, AuthzEngine};
|
use crate::auth::{AccessControlList, AuthzEngine};
|
||||||
use crate::chunking::reassemble_blob;
|
use crate::messages::{DatastoreNodeMsg, GatewayMsg, MetadataMsg};
|
||||||
use crate::messages::{DatastoreNodeMsg, DatastoreResponse, GatewayMsg, MetadataMsg};
|
|
||||||
use crate::metrics::DatastoreMetrics;
|
use crate::metrics::DatastoreMetrics;
|
||||||
use crate::storage::{FilesystemBackend, InMemoryBackend};
|
use crate::storage::{FilesystemBackend, InMemoryBackend};
|
||||||
use crate::types::{ContentHash, DatastoreConfig};
|
use crate::types::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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── DatastoreGroup ─────────────────────────────────────────────────────────
|
// ─── DatastoreGroup ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -384,7 +43,7 @@ pub struct DatastoreGroup {
|
||||||
datastore_addr: ActorAddress,
|
datastore_addr: ActorAddress,
|
||||||
metadata_addr: ActorAddress,
|
metadata_addr: ActorAddress,
|
||||||
gateway_addr: Option<ActorAddress>,
|
gateway_addr: Option<ActorAddress>,
|
||||||
bridge: Arc<dyn DatastoreStatsProvider>,
|
metrics: Arc<DatastoreMetrics>,
|
||||||
gc_interval: u64,
|
gc_interval: u64,
|
||||||
disseminate_interval: u64,
|
disseminate_interval: u64,
|
||||||
runtime: Arc<Runtime>,
|
runtime: Arc<Runtime>,
|
||||||
|
|
@ -455,14 +114,6 @@ impl DatastoreGroup {
|
||||||
let metrics = Arc::new(DatastoreMetrics::new());
|
let metrics = Arc::new(DatastoreMetrics::new());
|
||||||
metrics.set_node_id(config.node_id_hex.clone());
|
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() {
|
if config.storage_path.is_some() {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Datastore: persistent ({})",
|
"Datastore: persistent ({})",
|
||||||
|
|
@ -476,7 +127,7 @@ impl DatastoreGroup {
|
||||||
datastore_addr,
|
datastore_addr,
|
||||||
metadata_addr,
|
metadata_addr,
|
||||||
gateway_addr,
|
gateway_addr,
|
||||||
bridge,
|
metrics,
|
||||||
gc_interval: config.gc_interval,
|
gc_interval: config.gc_interval,
|
||||||
disseminate_interval: config.disseminate_interval,
|
disseminate_interval: config.disseminate_interval,
|
||||||
runtime,
|
runtime,
|
||||||
|
|
@ -485,22 +136,32 @@ impl DatastoreGroup {
|
||||||
|
|
||||||
/// Send periodic ticks to the datastore actors based on the current round.
|
/// Send periodic ticks to the datastore actors based on the current round.
|
||||||
pub fn tick(&self, round: u64) {
|
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);
|
let _ = self.runtime.send_to(self.metadata_addr, MetadataMsg::GcTick);
|
||||||
if let Some(gw) = self.gateway_addr {
|
if let Some(gw) = self.gateway_addr {
|
||||||
let _ = self.runtime.send_to(gw, GatewayMsg::NonceGcTick);
|
let _ = self.runtime.send_to(gw, GatewayMsg::NonceGcTick);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if round % self.disseminate_interval == 0 {
|
if round.is_multiple_of(self.disseminate_interval) {
|
||||||
let _ = self
|
let _ = self
|
||||||
.runtime
|
.runtime
|
||||||
.send_to(self.metadata_addr, MetadataMsg::DisseminateTick);
|
.send_to(self.metadata_addr, MetadataMsg::DisseminateTick);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Access the bridge (as a trait object for the dashboard).
|
/// Access the metrics accumulator.
|
||||||
pub fn bridge(&self) -> &Arc<dyn DatastoreStatsProvider> {
|
pub fn metrics(&self) -> &Arc<DatastoreMetrics> {
|
||||||
&self.bridge
|
&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
|
/// Configure stream support: sends ConfigureStreams to DatastoreNode and
|
||||||
|
|
@ -521,25 +182,9 @@ impl DatastoreGroup {
|
||||||
|
|
||||||
// Spawn StreamListener
|
// Spawn StreamListener
|
||||||
use crate::actors::stream_listener::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)) {
|
if let Ok(addr) = self.runtime.spawn(StreamListener::new(self.datastore_addr, stream_manager)) {
|
||||||
let _ = self.runtime.register_name("StreamListener", addr);
|
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::collections::BTreeMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use distribution::types::NodeId;
|
use swactor::transport::NodeId;
|
||||||
|
|
||||||
/// Top-level CLI commands for `swactor-store`.
|
/// Top-level CLI commands for `swactor-store`.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,9 @@
|
||||||
//! Shared types used across the swactor crate ecosystem.
|
//! Blake3-based content hash for blob addressing.
|
||||||
//!
|
|
||||||
//! Contains `ContentHash` — the blake3-based content address used by
|
|
||||||
//! both the datastore and distribution layers.
|
|
||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
// ─── ContentHash ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// A blake3 content hash (32 bytes).
|
/// A blake3 content hash (32 bytes).
|
||||||
///
|
///
|
||||||
/// The primary identifier for blobs and the DHT key. XOR distance for DHT
|
/// 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 types;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
pub mod chunking;
|
pub mod chunking;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod actors;
|
pub mod actors;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod blob_transfer;
|
||||||
|
pub mod bridge;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
|
pub mod streams;
|
||||||
#[cfg(feature = "node")]
|
#[cfg(feature = "node")]
|
||||||
pub mod api;
|
pub mod api;
|
||||||
#[cfg(feature = "node")]
|
#[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 chunking::{chunk_blob, reassemble_blob, verify_integrity, ChunkingError};
|
||||||
pub use storage::{StorageBackend, FilesystemBackend, InMemoryBackend};
|
pub use storage::{StorageBackend, FilesystemBackend, InMemoryBackend};
|
||||||
pub use actors::{BlobStoreActor, DatastoreNode, MetadataActor, TransferActor};
|
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::runtime::Runtime;
|
||||||
use swactor::transport::NetworkMessage;
|
use swactor::transport::NetworkMessage;
|
||||||
|
|
||||||
use distribution::types::NodeId;
|
use swactor::transport::NodeId;
|
||||||
use swactor_streams::types::StreamId;
|
use crate::streams::types::StreamId;
|
||||||
|
|
||||||
use crate::auth::{AccessRequestInfo, AuthorizedKeyInfo, DeniedReason, SignedRequest};
|
use crate::auth::{AccessRequestInfo, AuthorizedKeyInfo, DeniedReason, SignedRequest};
|
||||||
use crate::types::{ContentHash, ObjectEntry, ObjectManifest};
|
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
|
//! `DatastoreMetrics` accumulates counters and event history from any thread
|
||||||
//! (API handlers run on `tiny_http` worker threads). The dashboard polls
|
//! (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::collections::VecDeque;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
@ -71,6 +71,12 @@ pub struct DatastoreMetrics {
|
||||||
transfers: Mutex<Vec<TransferProgress>>,
|
transfers: Mutex<Vec<TransferProgress>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for DatastoreMetrics {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl DatastoreMetrics {
|
impl DatastoreMetrics {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -186,11 +192,3 @@ fn now_ms() -> u64 {
|
||||||
.as_millis() as 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;
|
continue;
|
||||||
};
|
};
|
||||||
for file in files.flatten() {
|
for file in files.flatten() {
|
||||||
if let Some(name) = file.file_name().to_str() {
|
if let Some(name) = file.file_name().to_str()
|
||||||
if let Some(hash) = ContentHash::from_hex(name) {
|
&& let Some(hash) = ContentHash::from_hex(name) {
|
||||||
self.chunk_index.insert(hash);
|
self.chunk_index.insert(hash);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ use iroh::endpoint::Connection;
|
||||||
use swactor::actor::ActorAddress;
|
use swactor::actor::ActorAddress;
|
||||||
use swactor::runtime::Runtime;
|
use swactor::runtime::Runtime;
|
||||||
|
|
||||||
use crate::messages::{OneShot, StreamManagerMsg};
|
use crate::streams::messages::{OneShot, StreamManagerMsg};
|
||||||
use crate::wire;
|
use crate::streams::wire;
|
||||||
|
|
||||||
/// Spawn a bridge task that processes incoming stream connections and
|
/// Spawn a bridge task that processes incoming stream connections and
|
||||||
/// forwards them to the StreamManager actor.
|
/// forwards them to the StreamManager actor.
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use crate::buffer::FrameBuf;
|
use crate::streams::buffer::FrameBuf;
|
||||||
use crate::types::StreamError;
|
use crate::streams::types::StreamError;
|
||||||
|
|
||||||
/// Commands sent from the actor to the send-side data-plane task.
|
/// Commands sent from the actor to the send-side data-plane task.
|
||||||
pub enum SendCommand {
|
pub enum SendCommand {
|
||||||
|
|
@ -3,8 +3,8 @@ use std::collections::HashMap;
|
||||||
use iroh::endpoint::Connection;
|
use iroh::endpoint::Connection;
|
||||||
use iroh::{Endpoint, PublicKey};
|
use iroh::{Endpoint, PublicKey};
|
||||||
|
|
||||||
use crate::types::StreamError;
|
use crate::streams::types::StreamError;
|
||||||
use crate::wire::ALPN;
|
use crate::streams::wire::ALPN;
|
||||||
|
|
||||||
/// Cache of QUIC connections used for stream data transfer.
|
/// Cache of QUIC connections used for stream data transfer.
|
||||||
///
|
///
|
||||||
|
|
@ -14,6 +14,12 @@ pub struct StreamConnectionCache {
|
||||||
connections: HashMap<[u8; 32], Connection>,
|
connections: HashMap<[u8; 32], Connection>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for StreamConnectionCache {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl StreamConnectionCache {
|
impl StreamConnectionCache {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
StreamConnectionCache {
|
StreamConnectionCache {
|
||||||
|
|
@ -8,11 +8,11 @@
|
||||||
|
|
||||||
use swactor::actor::{ActorAddress, Ctx};
|
use swactor::actor::{ActorAddress, Ctx};
|
||||||
use swactor::runtime::Runtime;
|
use swactor::runtime::Runtime;
|
||||||
use swactor_std::CtxNaming;
|
use swactor::std::CtxNaming;
|
||||||
use swactor_std::RuntimeNaming;
|
use swactor::std::RuntimeNaming;
|
||||||
|
|
||||||
use crate::messages::StreamManagerMsg;
|
use crate::streams::messages::StreamManagerMsg;
|
||||||
use crate::types::{StreamConfig, StreamError, StreamId, StreamMode};
|
use crate::streams::types::{StreamConfig, StreamError, StreamId, StreamMode};
|
||||||
|
|
||||||
fn mgr_not_found() -> StreamError {
|
fn mgr_not_found() -> StreamError {
|
||||||
StreamError::BrokenPipe("StreamManager not found in name registry".into())
|
StreamError::BrokenPipe("StreamManager not found in name registry".into())
|
||||||
|
|
@ -51,7 +51,7 @@ impl CtxStreams for Ctx<'_> {
|
||||||
config: StreamConfig,
|
config: StreamConfig,
|
||||||
) -> Result<(), StreamError> {
|
) -> Result<(), StreamError> {
|
||||||
let mgr = self
|
let mgr = self
|
||||||
.where_is(crate::manager::STREAM_MANAGER_NAME)
|
.where_is(crate::streams::manager::STREAM_MANAGER_NAME)
|
||||||
.ok_or_else(mgr_not_found)?;
|
.ok_or_else(mgr_not_found)?;
|
||||||
self.send(
|
self.send(
|
||||||
mgr,
|
mgr,
|
||||||
|
|
@ -67,7 +67,7 @@ impl CtxStreams for Ctx<'_> {
|
||||||
|
|
||||||
fn stream_listen(&self, mode: StreamMode) -> Result<(), StreamError> {
|
fn stream_listen(&self, mode: StreamMode) -> Result<(), StreamError> {
|
||||||
let mgr = self
|
let mgr = self
|
||||||
.where_is(crate::manager::STREAM_MANAGER_NAME)
|
.where_is(crate::streams::manager::STREAM_MANAGER_NAME)
|
||||||
.ok_or_else(mgr_not_found)?;
|
.ok_or_else(mgr_not_found)?;
|
||||||
self.send(
|
self.send(
|
||||||
mgr,
|
mgr,
|
||||||
|
|
@ -81,7 +81,7 @@ impl CtxStreams for Ctx<'_> {
|
||||||
|
|
||||||
fn stream_accept(&self, stream_id: StreamId) -> Result<(), StreamError> {
|
fn stream_accept(&self, stream_id: StreamId) -> Result<(), StreamError> {
|
||||||
let mgr = self
|
let mgr = self
|
||||||
.where_is(crate::manager::STREAM_MANAGER_NAME)
|
.where_is(crate::streams::manager::STREAM_MANAGER_NAME)
|
||||||
.ok_or_else(mgr_not_found)?;
|
.ok_or_else(mgr_not_found)?;
|
||||||
self.send(
|
self.send(
|
||||||
mgr,
|
mgr,
|
||||||
|
|
@ -95,7 +95,7 @@ impl CtxStreams for Ctx<'_> {
|
||||||
|
|
||||||
fn stream_reject(&self, stream_id: StreamId) -> Result<(), StreamError> {
|
fn stream_reject(&self, stream_id: StreamId) -> Result<(), StreamError> {
|
||||||
let mgr = self
|
let mgr = self
|
||||||
.where_is(crate::manager::STREAM_MANAGER_NAME)
|
.where_is(crate::streams::manager::STREAM_MANAGER_NAME)
|
||||||
.ok_or_else(mgr_not_found)?;
|
.ok_or_else(mgr_not_found)?;
|
||||||
self.send(mgr, StreamManagerMsg::Reject { stream_id })
|
self.send(mgr, StreamManagerMsg::Reject { stream_id })
|
||||||
.map_err(|e| StreamError::BrokenPipe(e.to_string()))
|
.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> {
|
fn stream_close(&self, stream_id: StreamId) -> Result<(), StreamError> {
|
||||||
let mgr = self
|
let mgr = self
|
||||||
.where_is(crate::manager::STREAM_MANAGER_NAME)
|
.where_is(crate::streams::manager::STREAM_MANAGER_NAME)
|
||||||
.ok_or_else(mgr_not_found)?;
|
.ok_or_else(mgr_not_found)?;
|
||||||
self.send(mgr, StreamManagerMsg::Close { stream_id })
|
self.send(mgr, StreamManagerMsg::Close { stream_id })
|
||||||
.map_err(|e| StreamError::BrokenPipe(e.to_string()))
|
.map_err(|e| StreamError::BrokenPipe(e.to_string()))
|
||||||
|
|
@ -149,7 +149,7 @@ impl RuntimeStreams for Runtime {
|
||||||
reply_to: ActorAddress,
|
reply_to: ActorAddress,
|
||||||
) -> Result<(), StreamError> {
|
) -> Result<(), StreamError> {
|
||||||
let mgr = self
|
let mgr = self
|
||||||
.where_is(crate::manager::STREAM_MANAGER_NAME)
|
.where_is(crate::streams::manager::STREAM_MANAGER_NAME)
|
||||||
.ok_or_else(mgr_not_found)?;
|
.ok_or_else(mgr_not_found)?;
|
||||||
self.send_to(
|
self.send_to(
|
||||||
mgr,
|
mgr,
|
||||||
|
|
@ -165,7 +165,7 @@ impl RuntimeStreams for Runtime {
|
||||||
|
|
||||||
fn stream_listen(&self, mode: StreamMode, listener: ActorAddress) -> Result<(), StreamError> {
|
fn stream_listen(&self, mode: StreamMode, listener: ActorAddress) -> Result<(), StreamError> {
|
||||||
let mgr = self
|
let mgr = self
|
||||||
.where_is(crate::manager::STREAM_MANAGER_NAME)
|
.where_is(crate::streams::manager::STREAM_MANAGER_NAME)
|
||||||
.ok_or_else(mgr_not_found)?;
|
.ok_or_else(mgr_not_found)?;
|
||||||
self.send_to(mgr, StreamManagerMsg::Listen { mode, listener })
|
self.send_to(mgr, StreamManagerMsg::Listen { mode, listener })
|
||||||
.map_err(|e| StreamError::BrokenPipe(e.to_string()))
|
.map_err(|e| StreamError::BrokenPipe(e.to_string()))
|
||||||
|
|
@ -177,7 +177,7 @@ impl RuntimeStreams for Runtime {
|
||||||
reply_to: ActorAddress,
|
reply_to: ActorAddress,
|
||||||
) -> Result<(), StreamError> {
|
) -> Result<(), StreamError> {
|
||||||
let mgr = self
|
let mgr = self
|
||||||
.where_is(crate::manager::STREAM_MANAGER_NAME)
|
.where_is(crate::streams::manager::STREAM_MANAGER_NAME)
|
||||||
.ok_or_else(mgr_not_found)?;
|
.ok_or_else(mgr_not_found)?;
|
||||||
self.send_to(mgr, StreamManagerMsg::Accept { stream_id, reply_to })
|
self.send_to(mgr, StreamManagerMsg::Accept { stream_id, reply_to })
|
||||||
.map_err(|e| StreamError::BrokenPipe(e.to_string()))
|
.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> {
|
fn stream_reject(&self, stream_id: StreamId) -> Result<(), StreamError> {
|
||||||
let mgr = self
|
let mgr = self
|
||||||
.where_is(crate::manager::STREAM_MANAGER_NAME)
|
.where_is(crate::streams::manager::STREAM_MANAGER_NAME)
|
||||||
.ok_or_else(mgr_not_found)?;
|
.ok_or_else(mgr_not_found)?;
|
||||||
self.send_to(mgr, StreamManagerMsg::Reject { stream_id })
|
self.send_to(mgr, StreamManagerMsg::Reject { stream_id })
|
||||||
.map_err(|e| StreamError::BrokenPipe(e.to_string()))
|
.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> {
|
fn stream_close(&self, stream_id: StreamId) -> Result<(), StreamError> {
|
||||||
let mgr = self
|
let mgr = self
|
||||||
.where_is(crate::manager::STREAM_MANAGER_NAME)
|
.where_is(crate::streams::manager::STREAM_MANAGER_NAME)
|
||||||
.ok_or_else(mgr_not_found)?;
|
.ok_or_else(mgr_not_found)?;
|
||||||
self.send_to(mgr, StreamManagerMsg::Close { stream_id })
|
self.send_to(mgr, StreamManagerMsg::Close { stream_id })
|
||||||
.map_err(|e| StreamError::BrokenPipe(e.to_string()))
|
.map_err(|e| StreamError::BrokenPipe(e.to_string()))
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::buffer::BufferPool;
|
use crate::streams::buffer::BufferPool;
|
||||||
use crate::channel::{RecvEvent, SendCommand};
|
use crate::streams::channel::{RecvEvent, SendCommand};
|
||||||
use crate::notify::NotifySink;
|
use crate::streams::notify::NotifySink;
|
||||||
use crate::types::StreamError;
|
use crate::streams::types::StreamError;
|
||||||
use crate::wire;
|
use crate::streams::wire;
|
||||||
|
|
||||||
/// A send-side data-plane task for a single stripe.
|
/// A send-side data-plane task for a single stripe.
|
||||||
///
|
///
|
||||||
|
|
@ -215,8 +215,8 @@ where
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::buffer::BufferPool;
|
use crate::streams::buffer::BufferPool;
|
||||||
use crate::types::StreamId;
|
use crate::streams::types::StreamId;
|
||||||
|
|
||||||
fn make_test_pool(count: usize, capacity: usize) -> BufferPool {
|
fn make_test_pool(count: usize, capacity: usize) -> BufferPool {
|
||||||
BufferPool::new(count, capacity)
|
BufferPool::new(count, capacity)
|
||||||
|
|
@ -425,7 +425,7 @@ mod tests {
|
||||||
/// Notification coalescing through NotifySink.
|
/// Notification coalescing through NotifySink.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn notification_coalescing() {
|
async fn notification_coalescing() {
|
||||||
use crate::notify::*;
|
use crate::streams::notify::*;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
|
@ -459,8 +459,8 @@ mod tests {
|
||||||
/// Backpressure: when channel and active buffer are saturated, try_write returns 0.
|
/// Backpressure: when channel and active buffer are saturated, try_write returns 0.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn send_backpressure() {
|
async fn send_backpressure() {
|
||||||
use crate::handle::create_stream_handle;
|
use crate::streams::handle::create_stream_handle;
|
||||||
use crate::types::StreamConfig;
|
use crate::streams::types::StreamConfig;
|
||||||
|
|
||||||
let config = StreamConfig {
|
let config = StreamConfig {
|
||||||
stripe_count: 1,
|
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