diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..17eb168 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +* +!target/x86_64-unknown-linux-musl/release/swactor diff --git a/.gitignore b/.gitignore index ffbe531..255f80e 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,11 @@ docs/architecture.html crates/simulation/traces # xtask personal config -xtask/config.toml \ No newline at end of file +xtask/config.toml + +# Deploy config (machine-specific) +deploy.toml +.deploy/ + +# Local dev node state +.dev-node/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index a1351a9..e00b73d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -287,6 +287,58 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backon" version = "1.6.0" @@ -337,12 +389,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.11.0" @@ -489,21 +535,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" -[[package]] -name = "ci-relay" -version = "0.1.0" -dependencies = [ - "clap", - "hex", - "hmac", - "iroh", - "serde_json", - "sha2 0.10.9", - "swactor-ci", - "tiny_http", - "tokio", -] - [[package]] name = "ciborium" version = "0.2.2" @@ -619,15 +650,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "const-oid" version = "0.9.6" @@ -916,7 +938,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.11.0", + "bitflags", "crossterm_winapi", "mio", "parking_lot", @@ -1085,6 +1107,28 @@ dependencies = [ "syn", ] +[[package]] +name = "dashboard" +version = "0.1.0" +dependencies = [ + "axum", + "clap", + "crossbeam-queue", + "crossterm", + "ctrlc", + "distribution", + "iroh", + "ratatui", + "serde", + "serde_json", + "swactor", + "swactor-std", + "tokio", + "tokio-stream", + "tracing", + "tracing-subscriber", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -1267,7 +1311,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.11.0", + "bitflags", "block2", "libc", "objc2", @@ -1512,17 +1556,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "flume" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" -dependencies = [ - "futures-core", - "futures-sink", - "spin 0.9.8", -] - [[package]] name = "fnv" version = "1.0.7" @@ -1700,7 +1733,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27d12c0aed7f1e24276a241aadc4cb8ea9f83000f34bc062b7cc2d51e3b0fabd" dependencies = [ - "bitflags 2.11.0", + "bitflags", "debugid", "fxhash", "serde", @@ -2241,16 +2274,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "if-addrs" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - [[package]] name = "igd-next" version = "0.16.2" @@ -2709,7 +2732,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags 2.11.0", + "bitflags", "libc", ] @@ -2737,20 +2760,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" -[[package]] -name = "local-runner" -version = "0.1.0" -dependencies = [ - "clap", - "ctrlc", - "iroh", - "runtime-dashboard", - "serde_json", - "swactor", - "swactor-ci", - "tokio", -] - [[package]] name = "lock_api" version = "0.4.14" @@ -2828,17 +2837,10 @@ dependencies = [ ] [[package]] -name = "mdns-sd" -version = "0.11.5" +name = "matchit" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fe7c11a1eb3cfbfcf702d1601c1f5f4c102cdc8665b8a557783ef634741676e" -dependencies = [ - "flume", - "if-addrs", - "log", - "polling", - "socket2 0.5.10", -] +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" @@ -3022,7 +3024,7 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ec2f5b6839be2a19d7fa5aab5bc444380f6311c2b693551cb80f45caaa7b5ef" dependencies = [ - "bitflags 2.11.0", + "bitflags", "libc", "log", "netlink-packet-core", @@ -3034,7 +3036,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445" dependencies = [ - "bitflags 2.11.0", + "bitflags", "libc", "log", "netlink-packet-core", @@ -3109,7 +3111,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.11.0", + "bitflags", "cfg-if", "cfg_aliases", "libc", @@ -3229,7 +3231,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.0", + "bitflags", "block2", "dispatch2", "libc", @@ -3248,7 +3250,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ - "bitflags 2.11.0", + "bitflags", "objc2", "objc2-core-foundation", ] @@ -3259,7 +3261,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" dependencies = [ - "bitflags 2.11.0", + "bitflags", "dispatch2", "libc", "objc2", @@ -3325,7 +3327,7 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.11.0", + "bitflags", "cfg-if", "foreign-types", "libc", @@ -3573,22 +3575,6 @@ dependencies = [ "plotters-backend", ] -[[package]] -name = "polling" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" -dependencies = [ - "autocfg", - "bitflags 1.3.2", - "cfg-if", - "concurrent-queue", - "libc", - "log", - "pin-project-lite", - "windows-sys 0.48.0", -] - [[package]] name = "portable-atomic" version = "1.13.1" @@ -3710,7 +3696,7 @@ checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" dependencies = [ "bit-set", "bit-vec", - "bitflags 2.11.0", + "bitflags", "num-traits", "rand", "rand_chacha", @@ -3962,7 +3948,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags 2.11.0", + "bitflags", "cassowary", "compact_str", "crossterm", @@ -4017,7 +4003,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags", ] [[package]] @@ -4194,26 +4180,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "runtime-dashboard" -version = "0.1.0" -dependencies = [ - "clap", - "crossbeam-queue", - "crossterm", - "ctrlc", - "distribution", - "iroh", - "ratatui", - "serde", - "serde_json", - "swactor", - "swactor-ci", - "tiny_http", - "tracing", - "tracing-subscriber", -] - [[package]] name = "rustc-demangle" version = "0.1.27" @@ -4250,7 +4216,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.0", + "bitflags", "errno", "libc", "linux-raw-sys 0.4.15", @@ -4263,7 +4229,7 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.11.0", + "bitflags", "errno", "libc", "linux-raw-sys 0.11.0", @@ -4441,7 +4407,7 @@ version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" dependencies = [ - "bitflags 2.11.0", + "bitflags", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -4543,6 +4509,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -4712,7 +4689,7 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dee851d0e5e7af3721faea1843e8015e820a234f81fda3dea9247e15bac9a86a" dependencies = [ - "bitflags 2.11.0", + "bitflags", ] [[package]] @@ -4928,35 +4905,32 @@ dependencies = [ name = "swactor-ci" version = "0.1.0" dependencies = [ + "clap", + "ctrlc", + "dashboard", "hex", "hmac", + "iroh", "serde", "serde_json", "serde_yaml", "sha2 0.10.9", "swactor", "tiny_http", + "tokio", "ureq", ] -[[package]] -name = "swactor-crypto-wasm" -version = "0.1.0" -dependencies = [ - "ed25519-dalek 2.2.0", -] - [[package]] name = "swactor-datastore" version = "0.1.0" dependencies = [ "blake3", "clap", - "ctrlc", + "dashboard", "distribution", "getrandom 0.2.17", "proptest", - "runtime-dashboard", "serde", "serde_json", "shared-types", @@ -4964,7 +4938,6 @@ dependencies = [ "swactor-std", "tempfile", "tiny_http", - "toml 0.8.23", "ureq", ] @@ -4974,14 +4947,14 @@ version = "0.1.0" dependencies = [ "clap", "ctrlc", + "dashboard", "distribution", "iroh", "libc", - "mdns-sd", - "runtime-dashboard", "serde", "swactor", "swactor-datastore", + "swactor-std", "toml 0.8.23", ] @@ -5030,7 +5003,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.0", + "bitflags", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -5443,6 +5416,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -5451,7 +5425,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.11.0", + "bitflags", "bytes", "futures-util", "http", @@ -5797,15 +5771,6 @@ dependencies = [ "wit-bindgen", ] -[[package]] -name = "wasm" -version = "0.1.0" -dependencies = [ - "swactor", - "swactor-std", - "wasm-bindgen", -] - [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -5907,6 +5872,15 @@ dependencies = [ "wasmparser 0.244.0", ] +[[package]] +name = "wasm-runtime" +version = "0.1.0" +dependencies = [ + "swactor", + "swactor-std", + "wasm-bindgen", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -5926,7 +5900,7 @@ version = "0.221.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185" dependencies = [ - "bitflags 2.11.0", + "bitflags", "hashbrown 0.15.5", "indexmap", "semver", @@ -5939,7 +5913,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.0", + "bitflags", "hashbrown 0.15.5", "indexmap", "semver", @@ -5951,7 +5925,7 @@ version = "0.245.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f08c9adee0428b7bddf3890fc27e015ac4b761cc608c822667102b8bfd6995e" dependencies = [ - "bitflags 2.11.0", + "bitflags", "indexmap", "semver", ] @@ -5976,7 +5950,7 @@ dependencies = [ "addr2line", "anyhow", "async-trait", - "bitflags 2.11.0", + "bitflags", "bumpalo", "cc", "cfg-if", @@ -6849,7 +6823,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.0", + "bitflags", "indexmap", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index 7787d60..071ee24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,23 +2,20 @@ members = [ ".", "crates/python", - "crates/wasm", + "crates/wasm/runtime", "crates/bin-runner", "crates/simulation", - "crates/runtime-dashboard", + "crates/dashboard", "crates/distribution", "crates/std", "crates/datastore", "crates/shared-types", - "crates/crypto-wasm", "crates/swactor-node", "tests/docker", "crates/ci", - "crates/local-runner", - "crates/ci-relay", "xtask", ] -exclude = ["tools/depgraph"] +exclude = ["tools/depgraph", "crates/wasm/crypto"] [package] name = "swactor" diff --git a/Dockerfile b/Dockerfile index f0b6929..1e65f4a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,3 @@ -FROM rust:1.93-slim AS builder -WORKDIR /build -COPY . . -RUN cargo build --release -p swactor-node - -FROM debian:bookworm-slim -COPY --from=builder /build/target/release/swactor /usr/local/bin/ -ENTRYPOINT ["swactor"] +FROM scratch +COPY target/x86_64-unknown-linux-musl/release/swactor /swactor +ENTRYPOINT ["/swactor"] diff --git a/crates/ci-relay/Cargo.toml b/crates/ci-relay/Cargo.toml deleted file mode 100644 index 215dbcd..0000000 --- a/crates/ci-relay/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "ci-relay" -version = "0.1.0" -edition = "2024" - -[[bin]] -name = "ci-relay" -path = "src/main.rs" - -[dependencies] -swactor-ci = { path = "../ci", features = ["local"] } -iroh = "0.96" -tokio = { version = "1", features = ["rt-multi-thread"] } -tiny_http = "0.12" -serde_json = "1" -hmac = "0.12" -sha2 = "0.10" -hex = "0.4" -clap = { version = "4", features = ["derive"] } diff --git a/crates/ci/Cargo.toml b/crates/ci/Cargo.toml index 5a28b8e..4473d9c 100644 --- a/crates/ci/Cargo.toml +++ b/crates/ci/Cargo.toml @@ -9,13 +9,20 @@ serde = { version = "1", features = ["derive"] } serde_yaml = "0.9" serde_json = "1" -# Local runner library dependencies +# 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 = [ @@ -25,5 +32,29 @@ local = [ "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"] diff --git a/crates/ci-relay/src/main.rs b/crates/ci/src/bin/ci_relay.rs similarity index 100% rename from crates/ci-relay/src/main.rs rename to crates/ci/src/bin/ci_relay.rs diff --git a/crates/local-runner/src/main.rs b/crates/ci/src/bin/local_runner.rs similarity index 91% rename from crates/local-runner/src/main.rs rename to crates/ci/src/bin/local_runner.rs index 4519663..67adac4 100644 --- a/crates/local-runner/src/main.rs +++ b/crates/ci/src/bin/local_runner.rs @@ -14,7 +14,7 @@ use swactor::actor::ActorAddress; use swactor::config::RuntimeConfig; use swactor::runtime::Runtime; -use runtime_dashboard::ci_collector::{ +use dashboard::ci_collector::{ CiSnapshot, CiStatsProvider, JobSnapshot, PipelineSnapshot, ProvisionerStatus, }; @@ -92,22 +92,34 @@ impl CiStatsProvider for LocalCiSnapshotProvider { } 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: p.pipeline_id, + 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: p.status.clone(), + status, jobs: p .jobs .values() - .map(|j| JobSnapshot { - job_id: j.job_id.clone(), - job_name: j.definition.name.clone(), - status: j.status.clone(), - output_line_count: j.output_lines.len(), + .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(), } @@ -128,7 +140,7 @@ fn main() { // Optionally start dashboard. let dash = args.dashboard_port.map(|port| { - let d = runtime_dashboard::start_dashboard(runtime_dashboard::DashboardConfig { + let d = dashboard::start_dashboard(dashboard::DashboardConfig { port, ..Default::default() }); @@ -138,7 +150,7 @@ fn main() { // Create 2-thread runtime. let num_threads = 2; - let collector = runtime_dashboard::collector::StatsCollector::new(num_threads); + let collector = dashboard::collector::StatsCollector::new(num_threads); let mut rt = Runtime::new(RuntimeConfig { num_threads, max_actors: 256, diff --git a/crates/runtime-dashboard/.gitignore b/crates/dashboard/.gitignore similarity index 100% rename from crates/runtime-dashboard/.gitignore rename to crates/dashboard/.gitignore diff --git a/crates/runtime-dashboard/AGENTS.md b/crates/dashboard/AGENTS.md similarity index 100% rename from crates/runtime-dashboard/AGENTS.md rename to crates/dashboard/AGENTS.md diff --git a/crates/runtime-dashboard/Cargo.toml b/crates/dashboard/Cargo.toml similarity index 84% rename from crates/runtime-dashboard/Cargo.toml rename to crates/dashboard/Cargo.toml index 4556bd8..5d7fd03 100644 --- a/crates/runtime-dashboard/Cargo.toml +++ b/crates/dashboard/Cargo.toml @@ -1,15 +1,18 @@ [package] -name = "runtime-dashboard" +name = "dashboard" version = "0.1.0" edition = "2024" [dependencies] swactor = { path = "../..", features = ["serde", "tracing"] } +swactor-std = { path = "../std", default-features = false } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["registry"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -tiny_http = "0.12" +axum = "0.8" +tokio = { version = "1", features = ["net", "rt-multi-thread", "sync", "time"] } +tokio-stream = "0.1" crossbeam-queue = "0.3.12" ratatui = { version = "0.29", optional = true, default-features = false, features = ["crossterm"] } crossterm = { version = "0.28", optional = true } @@ -17,7 +20,6 @@ distribution = { path = "../distribution", optional = true } clap = { version = "4", features = ["derive"], optional = true } ctrlc = "3" iroh = { version = "0.96", optional = true } -swactor-ci = { path = "../ci", optional = true } [features] default = ["distribution"] @@ -26,7 +28,6 @@ distribution = ["dep:distribution"] node = ["distribution", "dep:clap", "swactor/transport", "tcp"] tcp = ["distribution/tcp"] iroh = ["distribution/iroh", "dep:iroh"] -ci = ["dep:swactor-ci"] [[bin]] name = "swactor-tui" diff --git a/crates/runtime-dashboard/README.md b/crates/dashboard/README.md similarity index 100% rename from crates/runtime-dashboard/README.md rename to crates/dashboard/README.md diff --git a/crates/runtime-dashboard/examples/bench_dashboard.rs b/crates/dashboard/examples/bench_dashboard.rs similarity index 94% rename from crates/runtime-dashboard/examples/bench_dashboard.rs rename to crates/dashboard/examples/bench_dashboard.rs index aef4283..a2ed48f 100644 --- a/crates/runtime-dashboard/examples/bench_dashboard.rs +++ b/crates/dashboard/examples/bench_dashboard.rs @@ -5,8 +5,8 @@ use swactor::actor::{ActorAddress, ActorInterface, Ctx}; use swactor::config::{BackoffPolicy, RuntimeConfig}; use swactor::runtime::Runtime; -use runtime_dashboard::collector::StatsCollector; -use runtime_dashboard::{start_dashboard, DashboardConfig}; +use dashboard::collector::StatsCollector; +use dashboard::{start_dashboard, DashboardConfig}; // --------------------------------------------------------------------------- // Actors @@ -102,7 +102,7 @@ fn run_for(duration: Duration, mut tick: impl FnMut()) { // Scenarios // --------------------------------------------------------------------------- -fn scenario_single_actor(dash: &runtime_dashboard::DashboardHandle) { +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)); @@ -122,7 +122,7 @@ fn scenario_single_actor(dash: &runtime_dashboard::DashboardHandle) { handle.join(); } -fn scenario_multi_actor(dash: &runtime_dashboard::DashboardHandle) { +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)); @@ -146,7 +146,7 @@ fn scenario_multi_actor(dash: &runtime_dashboard::DashboardHandle) { handle.join(); } -fn scenario_ring(dash: &runtime_dashboard::DashboardHandle) { +fn scenario_ring(dash: &dashboard::DashboardHandle) { eprintln!(" [3/4] Ring topology (5s)"); let ring_size = 100; let collector = StatsCollector::new(4); @@ -182,7 +182,7 @@ fn scenario_ring(dash: &runtime_dashboard::DashboardHandle) { handle.join(); } -fn scenario_spawn_storm(dash: &runtime_dashboard::DashboardHandle) { +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)); diff --git a/crates/runtime-dashboard/examples/dashboard_demo.rs b/crates/dashboard/examples/dashboard_demo.rs similarity index 98% rename from crates/runtime-dashboard/examples/dashboard_demo.rs rename to crates/dashboard/examples/dashboard_demo.rs index f115b2f..0baf838 100644 --- a/crates/runtime-dashboard/examples/dashboard_demo.rs +++ b/crates/dashboard/examples/dashboard_demo.rs @@ -13,9 +13,9 @@ use distribution::swim::node::NodeAction; use distribution::swim::probe::SwimConfig; use distribution::types::NodeId; -use runtime_dashboard::collector::StatsCollector; -use runtime_dashboard::distribution_collector::DistributionStatsProvider; -use runtime_dashboard::{start_dashboard, DashboardConfig}; +use dashboard::collector::StatsCollector; +use dashboard::distribution_collector::DistributionStatsProvider; +use dashboard::{start_dashboard, DashboardConfig}; // ── Demo actors ───────────────────────────────────────────────────────── diff --git a/crates/runtime-dashboard/examples/record_and_replay_demo.rs b/crates/dashboard/examples/record_and_replay_demo.rs similarity index 97% rename from crates/runtime-dashboard/examples/record_and_replay_demo.rs rename to crates/dashboard/examples/record_and_replay_demo.rs index 71e0494..13d5292 100644 --- a/crates/runtime-dashboard/examples/record_and_replay_demo.rs +++ b/crates/dashboard/examples/record_and_replay_demo.rs @@ -7,8 +7,8 @@ use swactor::actor::{ActorAddress, ActorInterface, Ctx}; use swactor::config::RuntimeConfig; use swactor::runtime::Runtime; -use runtime_dashboard::collector::StatsCollector; -use runtime_dashboard::{serve_replay, start_dashboard, DashboardConfig, ReplayConfig}; +use dashboard::collector::StatsCollector; +use dashboard::{serve_replay, start_dashboard, DashboardConfig, ReplayConfig}; // ── Demo actors ───────────────────────────────────────────────────────── diff --git a/crates/runtime-dashboard/src/actor_detail_html.rs b/crates/dashboard/src/actor_detail_html.rs similarity index 95% rename from crates/runtime-dashboard/src/actor_detail_html.rs rename to crates/dashboard/src/actor_detail_html.rs index c5c2c27..4b4c41e 100644 --- a/crates/runtime-dashboard/src/actor_detail_html.rs +++ b/crates/dashboard/src/actor_detail_html.rs @@ -135,8 +135,11 @@ pub const ACTOR_DETAIL_HTML: &str = r##"
+
-
Address
—
+
Address
—
Worker
—
Status
—
@@ -237,7 +240,14 @@ pub const ACTOR_DETAIL_HTML: &str = r##" if (!actor) return; var hex = formatAddr(actor.address); - document.getElementById('addrBreadcrumb').textContent = shortAddr(hex); + if (actor.name) { + document.getElementById('addrBreadcrumb').textContent = actor.name; + document.getElementById('nameRow').style.display = ''; + document.getElementById('addrName').textContent = actor.name; + } else { + document.getElementById('addrBreadcrumb').textContent = shortAddr(hex); + document.getElementById('nameRow').style.display = 'none'; + } document.getElementById('addrFull').textContent = hex; document.getElementById('addrWorker').textContent = 'W' + actor.worker_id; diff --git a/crates/runtime-dashboard/src/actors_html.rs b/crates/dashboard/src/actors_html.rs similarity index 95% rename from crates/runtime-dashboard/src/actors_html.rs rename to crates/dashboard/src/actors_html.rs index 2c139fb..9595175 100644 --- a/crates/runtime-dashboard/src/actors_html.rs +++ b/crates/dashboard/src/actors_html.rs @@ -478,7 +478,8 @@ pub const ACTORS_HTML: &str = r##" if (filter) { var hex = addrToHex(a.address).toLowerCase(); var msgType = (a.last_msg_type || '').toLowerCase(); - if (hex.indexOf(filter) < 0 && ('w' + a.worker_id).indexOf(filter) < 0 && msgType.indexOf(filter) < 0) { + var actorName = (a.name || '').toLowerCase(); + if (hex.indexOf(filter) < 0 && ('w' + a.worker_id).indexOf(filter) < 0 && msgType.indexOf(filter) < 0 && actorName.indexOf(filter) < 0) { return false; } } @@ -542,8 +543,11 @@ pub const ACTORS_HTML: &str = r##" tr.className = 'clickable' + (fullHex === focusedAddrHex ? ' focused' : ''); if (a.poisoned) tr.style.opacity = '0.6'; tr.setAttribute('data-addr', fullHex); + var addrCell = a.name + ? '' + escapeHtml(a.name) + ' ' + escapeHtml(hex) + '' + (a.poisoned ? ' DEAD' : '') + '' + : '' + escapeHtml(hex) + (a.poisoned ? ' DEAD' : '') + ''; tr.innerHTML = - '' + escapeHtml(hex) + (a.poisoned ? ' DEAD' : '') + '' + + addrCell + 'W' + a.worker_id + '' + '' + a.mailbox_depth + '' + '' + (a.messages_processed || 0).toLocaleString() + '' + @@ -593,8 +597,14 @@ pub const ACTORS_HTML: &str = r##" return; } - document.getElementById('detailAddr').innerHTML = '' + escapeHtml(addrToHex(actor.address)) + ''; - document.getElementById('detailFullAddr').innerHTML = '' + escapeHtml(focusedAddrHex) + ''; + var detailAddrText = actor.name + ? '' + escapeHtml(actor.name) + '' + : '' + escapeHtml(addrToHex(actor.address)) + ''; + document.getElementById('detailAddr').innerHTML = detailAddrText; + var detailFullText = actor.name + ? '' + escapeHtml(actor.name) + ' ' + escapeHtml(focusedAddrHex) + '' + : '' + escapeHtml(focusedAddrHex) + ''; + document.getElementById('detailFullAddr').innerHTML = detailFullText; document.getElementById('detailWorker').textContent = 'W' + actor.worker_id; document.getElementById('detailMailbox').textContent = actor.mailbox_depth; document.getElementById('detailMsgCount').textContent = (actor.messages_processed || 0).toLocaleString(); diff --git a/crates/runtime-dashboard/src/bin/swactor-node.rs b/crates/dashboard/src/bin/swactor-node.rs similarity index 97% rename from crates/runtime-dashboard/src/bin/swactor-node.rs rename to crates/dashboard/src/bin/swactor-node.rs index d74dbfa..d2cd4f0 100644 --- a/crates/runtime-dashboard/src/bin/swactor-node.rs +++ b/crates/dashboard/src/bin/swactor-node.rs @@ -14,9 +14,9 @@ use distribution::registry::RegistryConfig; use distribution::snapshot::DistributionNodeSnapshot; use distribution::swim::probe::SwimConfig; -use runtime_dashboard::collector::StatsCollector; -use runtime_dashboard::distribution_collector::DistributionStatsProvider; -use runtime_dashboard::{start_dashboard, DashboardConfig}; +use dashboard::collector::StatsCollector; +use dashboard::distribution_collector::DistributionStatsProvider; +use dashboard::{start_dashboard, DashboardConfig}; // ── CLI ────────────────────────────────────────────────────────────────── @@ -95,6 +95,7 @@ fn main() { ..Default::default() }); dash.install_tracing(); + dash.start_http_standalone(); // Create actor runtime let num_threads = 2; @@ -154,7 +155,7 @@ fn run_tcp( args: Args, node_config: DistributedNodeConfig, handle: &swactor::runtime::RuntimeHandle, - dash: &runtime_dashboard::DashboardHandle, + dash: &dashboard::DashboardHandle, stop: &Arc, ) { use distribution::driver::NodeDriver; @@ -209,7 +210,7 @@ fn run_iroh( args: Args, node_config: DistributedNodeConfig, handle: &swactor::runtime::RuntimeHandle, - dash: &runtime_dashboard::DashboardHandle, + dash: &dashboard::DashboardHandle, stop: &Arc, ) { use distribution::iroh_driver::{IrohDriver, IrohDriverConfig}; diff --git a/crates/runtime-dashboard/src/bin/tui.rs b/crates/dashboard/src/bin/tui.rs similarity index 72% rename from crates/runtime-dashboard/src/bin/tui.rs rename to crates/dashboard/src/bin/tui.rs index 00060d9..1b58344 100644 --- a/crates/runtime-dashboard/src/bin/tui.rs +++ b/crates/dashboard/src/bin/tui.rs @@ -1,5 +1,5 @@ -use runtime_dashboard::tui::types::RuntimeEndpoint; -use runtime_dashboard::tui::{start_tui_remote, TuiConfig}; +use dashboard::tui::types::RuntimeEndpoint; +use dashboard::tui::{start_tui_remote, TuiConfig}; fn main() -> std::io::Result<()> { let url = std::env::args() diff --git a/crates/runtime-dashboard/src/ci_collector.rs b/crates/dashboard/src/ci_collector.rs similarity index 89% rename from crates/runtime-dashboard/src/ci_collector.rs rename to crates/dashboard/src/ci_collector.rs index 85b321e..8629fa2 100644 --- a/crates/runtime-dashboard/src/ci_collector.rs +++ b/crates/dashboard/src/ci_collector.rs @@ -2,7 +2,39 @@ use serde::{Deserialize, Serialize}; -use swactor_ci::{JobId, JobStatus, PipelineId, PipelineStatus}; +// 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 ───────────────────────────────────────────────────────── diff --git a/crates/runtime-dashboard/src/collector.rs b/crates/dashboard/src/collector.rs similarity index 80% rename from crates/runtime-dashboard/src/collector.rs rename to crates/dashboard/src/collector.rs index 47837c3..ecd45b7 100644 --- a/crates/runtime-dashboard/src/collector.rs +++ b/crates/dashboard/src/collector.rs @@ -6,6 +6,7 @@ use std::sync::{Arc, RwLock}; +use swactor::runtime::Runtime; use swactor::stats::{ActorInfo, ActorSnapshot, RuntimeStats, StatsHook}; /// Collects per-actor snapshots pushed by worker threads. @@ -48,6 +49,21 @@ impl crate::command::StatsEnricher for StatsCollector { } } +/// Enrich actor names from the runtime's StdExtension name registry. +pub fn enrich_names(stats: &mut RuntimeStats, runtime: &Runtime) { + let ext = match runtime.extension() { + Some(ext) => ext, + None => return, + }; + let std_ext = match ext.as_any().downcast_ref::() { + Some(ext) => ext, + None => return, + }; + for actor in &mut stats.actor_details { + actor.name = std_ext.resolve_name(&actor.address); + } +} + impl StatsHook for StatsCollector { fn on_tick(&self, worker_id: usize, snapshots: &[ActorSnapshot]) { if let Some(slot) = self.slots.get(worker_id) { @@ -60,6 +76,7 @@ impl StatsHook for StatsCollector { last_msg_type: s.last_msg_type.map(|t| t.to_string()), messages_processed: s.messages_processed, poisoned: s.poisoned, + name: None, message_type_counts: s.message_type_counts.iter().map(|(k, v)| (k.to_string(), *v)).collect(), })); } diff --git a/crates/runtime-dashboard/src/command/builtins.rs b/crates/dashboard/src/command/builtins.rs similarity index 100% rename from crates/runtime-dashboard/src/command/builtins.rs rename to crates/dashboard/src/command/builtins.rs diff --git a/crates/runtime-dashboard/src/command/mod.rs b/crates/dashboard/src/command/mod.rs similarity index 100% rename from crates/runtime-dashboard/src/command/mod.rs rename to crates/dashboard/src/command/mod.rs diff --git a/crates/runtime-dashboard/src/command/parse.rs b/crates/dashboard/src/command/parse.rs similarity index 100% rename from crates/runtime-dashboard/src/command/parse.rs rename to crates/dashboard/src/command/parse.rs diff --git a/crates/runtime-dashboard/src/dashboard_html.rs b/crates/dashboard/src/dashboard_html.rs similarity index 95% rename from crates/runtime-dashboard/src/dashboard_html.rs rename to crates/dashboard/src/dashboard_html.rs index 0054723..0aa4f7f 100644 --- a/crates/runtime-dashboard/src/dashboard_html.rs +++ b/crates/dashboard/src/dashboard_html.rs @@ -356,6 +356,14 @@ pub const DASHBOARD_HTML: &str = r##" renderWorkerCards(data); + var nameMap = {}; + if (data.actor_details) { + data.actor_details.forEach(function(a) { + var h = formatAddr(a.address); + if (a.name) nameMap[h] = a.name; + }); + } + var tbody = document.getElementById('actorTableBody'); tbody.innerHTML = ''; if (data.actors) { @@ -372,7 +380,13 @@ pub const DASHBOARD_HTML: &str = r##" hex += '\u2026'; } var tr = document.createElement('tr'); - tr.innerHTML = '' + hex + 'W' + wid + ''; + var addrCell; + if (nameMap[hex]) { + addrCell = '' + escapeHtml(nameMap[hex]) + ' ' + hex + ''; + } else { + addrCell = '' + hex + ''; + } + tr.innerHTML = addrCell + 'W' + wid + ''; tbody.appendChild(tr); }); if (data.actors.length > 200) { @@ -493,7 +507,13 @@ pub const DASHBOARD_HTML: &str = r##" var msgShort = hasMsg ? shortTypeName(a.last_msg_type) : 'none'; var msgClass = hasMsg ? 'msg-type' : 'msg-type none'; var title = hasMsg ? ' title="' + escapeHtml(a.last_msg_type) + '"' : ''; - rows += '' + hex + '' + + var addrHtml; + if (a.name) { + addrHtml = '' + escapeHtml(a.name) + ' ' + hex + ''; + } else { + addrHtml = '' + hex + ''; + } + rows += '' + addrHtml + '' + a.mailbox_depth + '' + '' + escapeHtml(msgShort) + ''; }); diff --git a/crates/runtime-dashboard/src/datastore_collector.rs b/crates/dashboard/src/datastore_collector.rs similarity index 100% rename from crates/runtime-dashboard/src/datastore_collector.rs rename to crates/dashboard/src/datastore_collector.rs diff --git a/crates/runtime-dashboard/src/datastore_html.rs b/crates/dashboard/src/datastore_html.rs similarity index 100% rename from crates/runtime-dashboard/src/datastore_html.rs rename to crates/dashboard/src/datastore_html.rs diff --git a/crates/runtime-dashboard/src/distribution_collector.rs b/crates/dashboard/src/distribution_collector.rs similarity index 100% rename from crates/runtime-dashboard/src/distribution_collector.rs rename to crates/dashboard/src/distribution_collector.rs diff --git a/crates/runtime-dashboard/src/distribution_html.rs b/crates/dashboard/src/distribution_html.rs similarity index 97% rename from crates/runtime-dashboard/src/distribution_html.rs rename to crates/dashboard/src/distribution_html.rs index b7b78f0..00ff106 100644 --- a/crates/runtime-dashboard/src/distribution_html.rs +++ b/crates/dashboard/src/distribution_html.rs @@ -94,6 +94,19 @@ pub const DISTRIBUTION_HTML: &str = r##" flex: 1; overflow-y: auto; font-size: 11px; } + .relay-badge { + display: inline-block; + background: #14b8a6; + color: #fff; + font-size: 9px; + font-weight: 700; + padding: 1px 5px; + border-radius: 3px; + margin-left: 6px; + vertical-align: middle; + letter-spacing: 0.5px; + } + .ego-hint { position: absolute; bottom: 12px; left: 12px; font-size: 10px; color: #555; pointer-events: none; @@ -600,6 +613,18 @@ pub const DISTRIBUTION_HTML: &str = r##" ctx.stroke(); } + // Teal ring around relay nodes + var isRelay = (i === 0 && data && data.relay_url) + || (i > 0 && data && data.members[i-1] && data.members[i-1].relay_url); + if (isRelay) { + ctx.beginPath(); + ctx.arc(posX[i], posY[i], nr + 3/vs, 0, Math.PI * 2); + ctx.lineWidth = 2/vs; + ctx.strokeStyle = '#14b8a6'; + ctx.globalAlpha = opacity * 0.9; + ctx.stroke(); + } + ctx.globalAlpha = 1; // Label (name for self, short ID for others) @@ -699,9 +724,12 @@ pub const DISTRIBUTION_HTML: &str = r##" var tr = document.createElement('tr'); var idCell = m.node_id.substring(0, 16) + '\u2026'; if (m.label) idCell = m.label + ' (' + m.node_id.substring(0, 8) + ')'; + var relayBadge = m.relay_url + ? ' RELAY' + : ''; tr.innerHTML = '' + m.state + '' + - '' + idCell + '' + + '' + idCell + relayBadge + '' + '' + m.addr + '' + '' + m.incarnation + ''; body.appendChild(tr); diff --git a/crates/runtime-dashboard/src/history.rs b/crates/dashboard/src/history.rs similarity index 99% rename from crates/runtime-dashboard/src/history.rs rename to crates/dashboard/src/history.rs index 51be11d..8a0b629 100644 --- a/crates/runtime-dashboard/src/history.rs +++ b/crates/dashboard/src/history.rs @@ -253,6 +253,7 @@ mod tests { fn make_actor(id: u8, worker: usize, depth: usize, msgs: u64) -> ActorInfo { ActorInfo { address: ActorAddress([id; 32]), + name: None, worker_id: worker, mailbox_depth: depth, last_msg_type: None, diff --git a/crates/runtime-dashboard/src/investigate.rs b/crates/dashboard/src/investigate.rs similarity index 100% rename from crates/runtime-dashboard/src/investigate.rs rename to crates/dashboard/src/investigate.rs diff --git a/crates/runtime-dashboard/src/layer.rs b/crates/dashboard/src/layer.rs similarity index 100% rename from crates/runtime-dashboard/src/layer.rs rename to crates/dashboard/src/layer.rs diff --git a/crates/runtime-dashboard/src/lib.rs b/crates/dashboard/src/lib.rs similarity index 75% rename from crates/runtime-dashboard/src/lib.rs rename to crates/dashboard/src/lib.rs index 14522d2..9c3ce92 100644 --- a/crates/runtime-dashboard/src/lib.rs +++ b/crates/dashboard/src/lib.rs @@ -23,7 +23,6 @@ pub mod distribution_collector; mod datastore_html; pub mod datastore_collector; -#[cfg(feature = "ci")] pub mod ci_collector; use std::io; @@ -43,6 +42,9 @@ use crate::history::{DashboardHistory, HistoryConfig}; use crate::layer::{now_ms, DashboardLayer, EventStore}; use crate::trace::{RuntimeTrace, TimestampedStats}; +/// Peer info sent through the join channel: (public_key, optional_relay_url). +pub type JoinPeerInfo = ([u8; 32], Option); + /// Configuration for the runtime dashboard. #[derive(Debug, Clone)] pub struct DashboardConfig { @@ -92,18 +94,19 @@ pub struct DashboardHandle { runtime: Arc>>>, collector: Arc>>>, shutdown: Arc, + shutdown_notify: Arc, stats_timeline: Arc>, history: Arc, recording: bool, + port: u16, #[cfg(feature = "distribution")] distribution: Arc>>>, datastore: Arc>>>, datastore_factory: Arc>>>, - #[cfg(feature = "ci")] ci: Arc>>>, - dashboard_token: Arc>>, peer_auth: Arc>>>>, - join_sender: Arc>>>, + join_sender: Arc>>>, + standalone_rt: Mutex>, } impl DashboardHandle { @@ -152,23 +155,17 @@ impl DashboardHandle { } /// Attach a CI stats provider, enabling the `/api/ci/*` endpoints. - #[cfg(feature = "ci")] pub fn set_ci(&self, provider: Arc) { *self.ci.lock().unwrap() = Some(provider); } - /// Set a bearer token for protecting mutating dashboard API endpoints. - pub fn set_dashboard_token(&self, token: String) { - *self.dashboard_token.lock().unwrap() = Some(token); - } - /// Attach a peer allow-list for the peer management API. pub fn set_peer_auth(&self, auth: Arc>) { *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<[u8; 32]>) { + pub fn set_join_sender(&self, tx: std::sync::mpsc::Sender) { *self.join_sender.lock().unwrap() = Some(tx); } @@ -180,6 +177,49 @@ impl DashboardHandle { /// Signal the dashboard to shut down (SSE clients receive "done"). pub fn shutdown(&self) { self.shutdown.store(true, Ordering::Release); + self.shutdown_notify.notify_waiters(); + } + + /// Start the HTTP server on the provided tokio handle. + /// Use this when a tokio runtime already exists (e.g. IrohDriver's runtime). + pub fn start_http(&self, handle: tokio::runtime::Handle) { + let state = self.build_app_state(); + let port = self.port; + handle.spawn(async move { + server::run_server(state, port).await; + }); + } + + /// Start the HTTP server on a standalone tokio runtime (1 worker thread). + /// Use this when no external tokio runtime is available (e.g. TCP transport). + pub fn start_http_standalone(&self) { + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .expect("failed to create tokio runtime for dashboard HTTP"); + let handle = rt.handle().clone(); + *self.standalone_rt.lock().unwrap() = Some(rt); + self.start_http(handle); + } + + fn build_app_state(&self) -> server::AppState { + server::AppState { + store: Arc::clone(&self.store), + runtime: Arc::clone(&self.runtime), + collector: Arc::clone(&self.collector), + shutdown: Arc::clone(&self.shutdown), + shutdown_notify: Arc::clone(&self.shutdown_notify), + history: Arc::clone(&self.history), + cmd_router: Arc::new(crate::command::CommandRouter::with_builtins()), + #[cfg(feature = "distribution")] + distribution: Arc::clone(&self.distribution), + datastore: Arc::clone(&self.datastore), + datastore_factory: Arc::clone(&self.datastore_factory), + ci: Arc::clone(&self.ci), + peer_auth: Arc::clone(&self.peer_auth), + join_sender: Arc::clone(&self.join_sender), + } } /// Save the recorded trace to a JSON file. @@ -207,10 +247,12 @@ impl DashboardHandle { } } -/// Start a dashboard server and return a handle. +/// Start a dashboard and return a handle. /// -/// The dashboard starts serving immediately. Call `install_tracing()` to set up -/// the global subscriber, and `set_runtime()` to enable stats polling. +/// The dashboard state is created immediately but the HTTP server is NOT started. +/// Call `start_http()` or `start_http_standalone()` to begin serving. +/// Call `install_tracing()` to set up the global subscriber, and `set_runtime()` +/// to enable stats polling. pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle { let store = Arc::new(EventStore::new( config.event_capacity, @@ -220,6 +262,7 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle { let runtime: Arc>>> = Arc::new(Mutex::new(None)); let collector: Arc>>> = Arc::new(Mutex::new(None)); let shutdown = Arc::new(AtomicBool::new(false)); + let shutdown_notify = Arc::new(tokio::sync::Notify::new()); let stats_timeline = Arc::new(ArrayQueue::new(config.record_stats_capacity.max(1))); let history = Arc::new(DashboardHistory::new(HistoryConfig::default())); @@ -233,35 +276,15 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle { let datastore_factory: Arc>>> = Arc::new(Mutex::new(None)); - #[cfg(feature = "ci")] - let ci: Arc>>> = + let ci: Arc>>> = Arc::new(Mutex::new(None)); - let dashboard_token: Arc>> = Arc::new(Mutex::new(None)); let peer_auth: Arc>>>> = Arc::new(Mutex::new(None)); - let join_sender: Arc>>> = + let join_sender: Arc>>> = Arc::new(Mutex::new(None)); - server::spawn_http_server( - Arc::clone(&store), - Arc::clone(&runtime), - Arc::clone(&collector), - Arc::clone(&shutdown), - Arc::clone(&history), - config.port, - #[cfg(feature = "distribution")] - Arc::clone(&distribution), - Arc::clone(&datastore), - Arc::clone(&datastore_factory), - #[cfg(feature = "ci")] - Arc::clone(&ci), - Arc::clone(&dashboard_token), - Arc::clone(&peer_auth), - Arc::clone(&join_sender), - ); - // Start stats recorder thread when recording is enabled if config.record { let rt_ref = Arc::clone(&runtime); @@ -290,25 +313,26 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle { }); } - eprintln!("Runtime dashboard at http://localhost:{}", config.port); + let port = config.port; DashboardHandle { store, runtime, collector, shutdown, + shutdown_notify, stats_timeline, history, recording: config.record, + port, #[cfg(feature = "distribution")] distribution, datastore, datastore_factory, - #[cfg(feature = "ci")] - ci, - dashboard_token, + ci, peer_auth, join_sender, + standalone_rt: Mutex::new(None), } } @@ -321,6 +345,7 @@ pub fn run_with_dashboard( ) -> (RuntimeHandle, DashboardHandle) { let dash = start_dashboard(dash_config); dash.install_tracing(); + dash.start_http_standalone(); let num_workers = if rt_config.num_threads < 2 { 1 } else { rt_config.num_threads }; let collector = StatsCollector::new(num_workers); @@ -339,7 +364,21 @@ pub fn serve_replay(path: &str, config: ReplayConfig) -> io::Result<()> { let trace: RuntimeTrace = serde_json::from_str(&data) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - server::spawn_replay_server(Arc::new(trace), config.port, config.speed); + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + + let state = server::ReplayState { + trace: Arc::new(trace), + speed: config.speed, + }; + let port = config.port; + + rt.spawn(async move { + server::run_replay_server(state, port).await; + }); eprintln!("Replay dashboard at http://localhost:{}", config.port); eprintln!("Press Ctrl+C to stop"); diff --git a/crates/dashboard/src/server.rs b/crates/dashboard/src/server.rs new file mode 100644 index 0000000..f8b6d1b --- /dev/null +++ b/crates/dashboard/src/server.rs @@ -0,0 +1,858 @@ +use std::collections::HashMap; +use std::convert::Infallible; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use axum::body::Bytes; +use axum::extract::{Path, Query, State}; +use axum::http::{header, StatusCode}; +use axum::response::sse::{Event, KeepAlive, Sse}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::Router; +use tokio_stream::wrappers::ReceiverStream; +use tokio_stream::StreamExt; + +use swactor::runtime::Runtime; + +use crate::actor_detail_html::ACTOR_DETAIL_HTML; +use crate::actors_html::ACTORS_HTML; +use crate::collector::StatsCollector; +use crate::command::CommandRouter; +use crate::dashboard_html::DASHBOARD_HTML; +use crate::datastore_collector::{DatastoreFactory, DatastoreStatsProvider, ListScope}; +use crate::datastore_html::DATASTORE_HTML; +use crate::history::DashboardHistory; +use crate::layer::EventStore; +use crate::topology; +use crate::topology_html::TOPOLOGY_HTML; +use crate::warnings::{WarningConfig, WarningDetector}; + +#[cfg(feature = "distribution")] +use crate::distribution_collector::DistributionStatsProvider; +#[cfg(feature = "distribution")] +use crate::distribution_html::DISTRIBUTION_HTML; + +use crate::ci_collector::CiStatsProvider; + +use crate::trace::RuntimeTrace; + +/// Format a server-sent event. +fn format_sse(event: &str, data: &str) -> Event { + Event::default().event(event).data(data) +} + +// ── Shared application state ──────────────────────────────────────────── + +#[derive(Clone)] +pub(crate) struct AppState { + pub store: Arc, + pub runtime: Arc>>>, + pub collector: Arc>>>, + pub shutdown: Arc, + pub shutdown_notify: Arc, + pub history: Arc, + pub cmd_router: Arc, + #[cfg(feature = "distribution")] + pub distribution: Arc>>>, + pub datastore: Arc>>>, + pub datastore_factory: Arc>>>, + pub ci: Arc>>>, + pub peer_auth: Arc>>>>, + pub join_sender: Arc>>>, +} + +// ── Router builders ───────────────────────────────────────────────────── + +pub(crate) fn build_live_router(state: AppState) -> Router { + let router = Router::new() + .route("/", get(page_dashboard)) + .route("/actors", get(page_actors)) + .route("/topology", get(page_topology)) + .route("/datastore", get(page_datastore)) + .route("/events", get(handle_live_sse)) + .route("/api/stats", get(handle_stats_api)) + .route("/api/history", get(handle_history_api)) + .route("/api/topology", get(handle_topology_api)) + .route("/api/investigate", get(handle_investigate_api)) + .route("/api/datastore", get(handle_datastore_api)) + .route("/api/logs", get(handle_logs_api)) + .route("/api/datastore/list", get(handle_ds_list)) + .route("/api/datastore/get", get(handle_ds_get)) + .route("/api/datastore/data", get(handle_ds_data)) + .route("/api/datastore/status", get(handle_ds_status)) + .route("/api/datastore/put", post(handle_ds_put)) + .route("/api/datastore/delete", post(handle_ds_delete)) + .route("/api/datastore/start", post(handle_ds_start)) + .route("/api/datastore/shutdown", post(handle_ds_shutdown)) + .route("/api/peers", get(handle_peers_list)) + .route("/api/peers/add", post(handle_peers_add)) + .route("/api/peers/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) +} + +pub(crate) fn build_replay_router(state: ReplayState) -> Router { + Router::new() + .route("/", get(replay_page_dashboard)) + .route("/actors", get(replay_page_actors)) + .route("/events", get(handle_replay_sse)) + .with_state(state) +} + +// ── Server startup ────────────────────────────────────────────────────── + +pub(crate) async fn run_server(state: AppState, port: u16) { + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")) + .await + .expect("failed to bind HTTP server"); + let shutdown = state.shutdown_notify.clone(); + axum::serve(listener, build_live_router(state)) + .with_graceful_shutdown(async move { shutdown.notified().await }) + .await + .expect("HTTP server error"); +} + +pub(crate) async fn run_replay_server(state: ReplayState, port: u16) { + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")) + .await + .expect("failed to bind HTTP server"); + axum::serve(listener, build_replay_router(state)) + .await + .expect("HTTP server error"); +} + +// ── HTML page handlers ────────────────────────────────────────────────── + +fn html_response(template: &str, mode: &str) -> Response { + let html = template.replace("__DASHBOARD_MODE__", mode); + ([(header::CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response() +} + +async fn page_dashboard() -> Response { + html_response(DASHBOARD_HTML, "live") +} + +async fn page_actors() -> Response { + html_response(ACTORS_HTML, "live") +} + +async fn page_topology() -> Response { + html_response(TOPOLOGY_HTML, "live") +} + +async fn page_datastore() -> Response { + html_response(DATASTORE_HTML, "live") +} + +#[cfg(feature = "distribution")] +async fn page_distribution() -> Response { + html_response(DISTRIBUTION_HTML, "live") +} + +async fn handle_actor_detail(Path(hex_addr): Path) -> Response { + let html = ACTOR_DETAIL_HTML + .replace("__DASHBOARD_MODE__", "live") + .replace("__ACTOR_ADDR__", &hex_addr); + ([(header::CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response() +} + +// ── Live SSE handler ──────────────────────────────────────────────────── + +async fn handle_live_sse( + State(state): State, +) -> Sse>> { + let (tx, rx) = tokio::sync::mpsc::channel::(32); + + tokio::spawn(async move { + let mut cursor: u64 = 0; + let mut warning_detector = WarningDetector::new(WarningConfig::default()); + let mut tick_count: u64 = 0; + + // Send initial history snapshot so sparklines render immediately + if state.history.sample_count() > 0 { + let json = state.history.worker_history_json(); + if tx.send(format_sse("history", &json)).await.is_err() { + return; + } + } + + loop { + // Send stats if runtime is available + { + let maybe_rt = state.runtime.lock().unwrap().clone(); + if let Some(rt) = maybe_rt { + let mut stats = rt.stats(); + if let Some(col) = state.collector.lock().unwrap().as_ref() { + col.enrich(&mut stats); + } + crate::collector::enrich_names(&mut stats, &rt); + state.history.record(&stats); + + // Run warning detection + let warnings = warning_detector.check(&stats); + if !warnings.is_empty() { + if let Ok(wjson) = serde_json::to_string(&warnings) { + if tx.send(format_sse("warnings", &wjson)).await.is_err() { + return; + } + } + } + + let json = serde_json::to_string(&stats).unwrap(); + if tx.send(format_sse("stats", &json)).await.is_err() { + return; + } + + // Send topology every 5th tick (~1/sec) + tick_count += 1; + if tick_count % 5 == 0 { + let topo = topology::worker_topology(&stats); + if let Ok(tjson) = serde_json::to_string(&topo) { + if tx.send(format_sse("topology", &tjson)).await.is_err() { + return; + } + } + } + } + } + + // Send distribution snapshot if provider is attached + #[cfg(feature = "distribution")] + { + let maybe_dist = state.distribution.lock().unwrap().clone(); + if let Some(provider) = maybe_dist { + if let Some(snapshot) = provider.snapshot() { + if let Ok(json) = serde_json::to_string(&snapshot) { + if tx.send(format_sse("distribution", &json)).await.is_err() { + return; + } + } + } + } + } + + // 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 + let (batch, new_cursor) = state.store.read_from(cursor); + if !batch.is_empty() { + let json = serde_json::to_string(&batch).unwrap(); + if tx.send(format_sse("activity", &json)).await.is_err() { + return; + } + cursor = new_cursor; + } + + if state.shutdown.load(Ordering::Relaxed) { + let _ = tx.send(format_sse("done", "{}")).await; + return; + } + + tokio::time::sleep(Duration::from_millis(200)).await; + } + }); + + Sse::new(ReceiverStream::new(rx).map(Ok)) + .keep_alive(KeepAlive::default()) +} + +// ── JSON API handlers ─────────────────────────────────────────────────── + +fn json_response(json: String) -> Response { + ([(header::CONTENT_TYPE, "application/json")], json).into_response() +} + +fn json_error(status: StatusCode, msg: &str) -> Response { + let json = serde_json::json!({ "error": msg }).to_string(); + (status, [(header::CONTENT_TYPE, "application/json")], json).into_response() +} + +async fn handle_stats_api(State(state): State) -> Response { + let maybe_rt = state.runtime.lock().unwrap().clone(); + let json = match maybe_rt { + Some(rt) => { + let mut stats = rt.stats(); + if let Some(col) = state.collector.lock().unwrap().as_ref() { + col.enrich(&mut stats); + } + crate::collector::enrich_names(&mut stats, &rt); + serde_json::to_string(&stats).unwrap() + } + None => "{}".to_string(), + }; + json_response(json) +} + +async fn handle_investigate_api( + State(state): State, + Query(params): Query>, +) -> Response { + let maybe_rt = state.runtime.lock().unwrap().clone(); + let maybe_col = state.collector.lock().unwrap().clone(); + + let json = match (maybe_rt, maybe_col) { + (Some(rt), Some(col)) => { + let ctx = crate::command::CommandContext::with_enricher(rt, col); + let req = crate::command::from_query_params(¶ms); + state.cmd_router.dispatch(&req, &ctx).to_json_line() + } + (Some(rt), None) => { + let ctx = crate::command::CommandContext::new(rt); + let req = crate::command::from_query_params(¶ms); + state.cmd_router.dispatch(&req, &ctx).to_json_line() + } + _ => { + let cmd = params.get("cmd").map(|s| s.as_str()).unwrap_or("help"); + serde_json::json!({ + "ok": false, + "command": cmd, + "error": "runtime not attached yet" + }) + .to_string() + } + }; + + json_response(json) +} + +#[cfg(feature = "distribution")] +async fn handle_distribution_api(State(state): State) -> 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) -> 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, + 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) -> Response { + let maybe_rt = state.runtime.lock().unwrap().clone(); + let json = match maybe_rt { + Some(rt) => { + let mut stats = rt.stats(); + if let Some(col) = state.collector.lock().unwrap().as_ref() { + col.enrich(&mut stats); + } + let topo = topology::worker_topology(&stats); + serde_json::to_string(&topo).unwrap_or_else(|_| "{}".into()) + } + None => "{}".to_string(), + }; + json_response(json) +} + +async fn handle_logs_api( + State(state): State, + Query(params): Query>, +) -> Response { + let actor = params.get("actor").cloned().unwrap_or_default(); + let limit: usize = params + .get("limit") + .and_then(|s| s.parse().ok()) + .unwrap_or(200); + let level = params.get("level").cloned(); + + let mut events = state.store.read_for_actor(&actor, limit); + + // Filter by level if specified + if let Some(ref lvl) = level { + let lvl_upper = lvl.to_uppercase(); + events.retain(|e| e.level == lvl_upper); + } + + let json = serde_json::to_string(&events).unwrap_or_else(|_| "[]".into()); + json_response(json) +} + +async fn handle_history_api(State(state): State) -> Response { + let json = state.history.worker_history_json(); + json_response(json) +} + +// ── Datastore CRUD API handlers ───────────────────────────────────────── + +fn get_ds_provider( + datastore: &Arc>>>, +) -> Option> { + datastore.lock().unwrap().clone() +} + +async fn handle_ds_list( + State(state): State, + Query(params): Query>, +) -> Response { + let provider = match get_ds_provider(&state.datastore) { + Some(p) => p, + None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"), + }; + let scope = match params.get("scope").map(|s| s.as_str()) { + Some("local") => ListScope::Local, + _ => ListScope::Swarm, + }; + let name_filter = params.get("name").map(|s| s.as_str()); + match provider.list_objects(name_filter, scope) { + Ok(json) => json_response(json), + Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e), + } +} + +async fn handle_ds_get( + State(state): State, + Query(params): Query>, +) -> 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, + Query(params): Query>, +) -> 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) -> Response { + let provider = match get_ds_provider(&state.datastore) { + Some(p) => p, + None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"), + }; + match provider.node_status() { + Ok(json) => json_response(json), + Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e), + } +} + +async fn handle_ds_put( + State(state): State, + Query(params): Query>, + body: Bytes, +) -> Response { + let provider = match get_ds_provider(&state.datastore) { + Some(p) => p, + None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"), + }; + let name = params.get("name").cloned(); + match provider.put_data(body.to_vec(), name) { + Ok(json) => json_response(json), + Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e), + } +} + +async fn handle_ds_delete( + State(state): State, + Query(params): Query>, +) -> 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.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, + Query(params): Query>, +) -> 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) -> 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) -> 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 = list + .list_peers() + .iter() + .map(|p| { + serde_json::json!({ + "node_id": p.node_id, + "label": p.label, + }) + }) + .collect(); + let json = serde_json::json!({ + "mode": if is_open { "open" } else { "allow-list" }, + "peers": peers, + }) + .to_string(); + json_response(json) + } + None => { + let json = serde_json::json!({ + "mode": "open", + "peers": [], + }) + .to_string(); + json_response(json) + } + } +} + +async fn handle_peers_add(State(state): State, 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_remove(State(state): State, 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)", + ); + } + }; + + let node_id = distribution::types::NodeId(bytes.try_into().unwrap()); + let mut list = auth.lock().unwrap(); + list.remove_peer(&node_id); + if let Err(e) = list.save() { + eprintln!("warning: failed to persist peers.json: {e}"); + } + + json_response(r#"{"ok":true}"#.to_string()) +} + +// ── Replay server ─────────────────────────────────────────────────────── + +#[derive(Clone)] +pub(crate) struct ReplayState { + pub trace: Arc, + pub speed: f64, +} + +async fn replay_page_dashboard() -> Response { + html_response(DASHBOARD_HTML, "replay") +} + +async fn replay_page_actors() -> Response { + html_response(ACTORS_HTML, "replay") +} + +async fn handle_replay_sse( + State(state): State, +) -> Sse>> { + let (tx, rx) = tokio::sync::mpsc::channel::(32); + + tokio::spawn(async move { + let trace = &state.trace; + let speed = state.speed; + + // Send replay metadata + let meta = serde_json::json!({ + "total_events": trace.events.len(), + "total_stats": trace.stats_timeline.len(), + "speed": speed, + }); + if tx.send(format_sse("replay_meta", &meta.to_string())).await.is_err() { + return; + } + + // Find the earliest timestamp across events and stats + let base_time = trace + .events + .first() + .map(|e| e.timestamp_ms) + .into_iter() + .chain(trace.stats_timeline.first().map(|s| s.timestamp_ms)) + .min() + .unwrap_or(0); + + let playback_start = tokio::time::Instant::now(); + let mut event_idx = 0; + let mut stats_idx = 0; + + loop { + let elapsed_ms = (playback_start.elapsed().as_millis() as f64 * speed) as u64; + let virtual_time = base_time + elapsed_ms; + + // Batch events up to virtual_time + let mut batch = Vec::new(); + while event_idx < trace.events.len() + && trace.events[event_idx].timestamp_ms <= virtual_time + { + batch.push(trace.events[event_idx].clone()); + event_idx += 1; + } + if !batch.is_empty() { + let json = serde_json::to_string(&batch).unwrap(); + if tx.send(format_sse("activity", &json)).await.is_err() { + return; + } + } + + // Send stats snapshots up to virtual_time + while stats_idx < trace.stats_timeline.len() + && trace.stats_timeline[stats_idx].timestamp_ms <= virtual_time + { + let json = + serde_json::to_string(&trace.stats_timeline[stats_idx].stats).unwrap(); + if tx.send(format_sse("stats", &json)).await.is_err() { + return; + } + stats_idx += 1; + } + + // Send progress + let total = trace.events.len() + trace.stats_timeline.len(); + let done_count = event_idx + stats_idx; + let progress = if total > 0 { + done_count as f64 / total as f64 + } else { + 1.0 + }; + let progress_json = serde_json::json!({ "progress": progress }); + if tx + .send(format_sse("replay_progress", &progress_json.to_string())) + .await + .is_err() + { + return; + } + + // Check if replay is complete + if event_idx >= trace.events.len() + && stats_idx >= trace.stats_timeline.len() + { + let _ = tx.send(format_sse("done", "{}")).await; + return; + } + + tokio::time::sleep(Duration::from_millis(50)).await; + } + }); + + Sse::new(ReceiverStream::new(rx).map(Ok)) + .keep_alive(KeepAlive::default()) +} diff --git a/crates/runtime-dashboard/src/topology.rs b/crates/dashboard/src/topology.rs similarity index 100% rename from crates/runtime-dashboard/src/topology.rs rename to crates/dashboard/src/topology.rs diff --git a/crates/runtime-dashboard/src/topology_html.rs b/crates/dashboard/src/topology_html.rs similarity index 100% rename from crates/runtime-dashboard/src/topology_html.rs rename to crates/dashboard/src/topology_html.rs diff --git a/crates/runtime-dashboard/src/trace.rs b/crates/dashboard/src/trace.rs similarity index 100% rename from crates/runtime-dashboard/src/trace.rs rename to crates/dashboard/src/trace.rs diff --git a/crates/runtime-dashboard/src/tui/app.rs b/crates/dashboard/src/tui/app.rs similarity index 99% rename from crates/runtime-dashboard/src/tui/app.rs rename to crates/dashboard/src/tui/app.rs index 7406d89..844345a 100644 --- a/crates/runtime-dashboard/src/tui/app.rs +++ b/crates/dashboard/src/tui/app.rs @@ -34,6 +34,7 @@ pub struct ActorRow { pub last_msg_type: Option, pub messages_processed: u64, pub poisoned: bool, + pub name: Option, /// Per-actor mailbox sparkline (from local ring buffer). pub sparkline_mailbox: Vec, /// Per-actor message rate sparkline. @@ -191,7 +192,8 @@ impl App { let addr = format!("{}", r.address).to_lowercase(); let msg = r.last_msg_type.as_deref().unwrap_or("").to_lowercase(); let worker = format!("w{}", r.worker_id); - addr.contains(&q) || msg.contains(&q) || worker.contains(&q) + let name = r.name.as_deref().unwrap_or("").to_lowercase(); + addr.contains(&q) || msg.contains(&q) || worker.contains(&q) || name.contains(&q) }).collect() } } @@ -335,6 +337,7 @@ impl App { last_msg_type: a.last_msg_type.clone(), messages_processed: a.messages_processed, poisoned: a.poisoned, + name: a.name.clone(), sparkline_rates: rates_buf.iter().copied().collect(), sparkline_mailbox: mbox_buf.iter().copied().collect(), message_type_counts: a.message_type_counts.clone(), diff --git a/crates/runtime-dashboard/src/tui/event.rs b/crates/dashboard/src/tui/event.rs similarity index 100% rename from crates/runtime-dashboard/src/tui/event.rs rename to crates/dashboard/src/tui/event.rs diff --git a/crates/runtime-dashboard/src/tui/mod.rs b/crates/dashboard/src/tui/mod.rs similarity index 98% rename from crates/runtime-dashboard/src/tui/mod.rs rename to crates/dashboard/src/tui/mod.rs index ceb22a1..b3297d3 100644 --- a/crates/runtime-dashboard/src/tui/mod.rs +++ b/crates/dashboard/src/tui/mod.rs @@ -107,6 +107,7 @@ fn run_loop( // Initial stats poll let mut stats = runtime.stats(); collector.enrich(&mut stats); + crate::collector::enrich_names(&mut stats, &runtime); app.update(stats); loop { @@ -116,6 +117,7 @@ fn run_loop( Ok(AppEvent::Tick) => { let mut stats = runtime.stats(); collector.enrich(&mut stats); + crate::collector::enrich_names(&mut stats, &runtime); app.update(stats); #[cfg(feature = "distribution")] if let Some(ref provider) = distribution { diff --git a/crates/runtime-dashboard/src/tui/sse_client.rs b/crates/dashboard/src/tui/sse_client.rs similarity index 100% rename from crates/runtime-dashboard/src/tui/sse_client.rs rename to crates/dashboard/src/tui/sse_client.rs diff --git a/crates/runtime-dashboard/src/tui/types.rs b/crates/dashboard/src/tui/types.rs similarity index 100% rename from crates/runtime-dashboard/src/tui/types.rs rename to crates/dashboard/src/tui/types.rs diff --git a/crates/runtime-dashboard/src/tui/ui.rs b/crates/dashboard/src/tui/ui.rs similarity index 92% rename from crates/runtime-dashboard/src/tui/ui.rs rename to crates/dashboard/src/tui/ui.rs index db26d13..adfbc2a 100644 --- a/crates/runtime-dashboard/src/tui/ui.rs +++ b/crates/dashboard/src/tui/ui.rs @@ -306,21 +306,30 @@ fn draw_actor_table(f: &mut Frame, app: &App, table_state: &mut TableState, area } fn actor_row_cells(a: &super::app::ActorRow) -> Row<'static> { - let addr = format!("{}", a.address); - let addr_short = if a.poisoned { - let s = if addr.len() > 16 { - format!("!{}...", &addr[..14]) + let addr_short = if let Some(ref name) = a.name { + if a.poisoned { + format!("!{}", name) } else { - format!("!{}", addr) - }; - s - } else if addr.len() > 18 { - format!("{}...", &addr[..16]) + name.clone() + } } else { - addr + let addr = format!("{}", a.address); + if a.poisoned { + if addr.len() > 16 { + format!("!{}...", &addr[..14]) + } else { + format!("!{}", addr) + } + } else if addr.len() > 18 { + format!("{}...", &addr[..16]) + } else { + addr + } }; let addr_style = if a.poisoned { Style::default().fg(Color::Red) + } else if a.name.is_some() { + Style::default().fg(Color::Cyan) } else { Style::default() }; @@ -465,21 +474,30 @@ fn draw_focused_actor_table(f: &mut Frame, app: &App, table_state: &mut TableSta let rows: Vec = focused_rows .iter() .map(|a| { - let addr = format!("{}", a.address); - let addr_short = if a.poisoned { - let s = if addr.len() > 16 { - format!("!{}...", &addr[..14]) + let addr_short = if let Some(ref name) = a.name { + if a.poisoned { + format!("!{}", name) } else { - format!("!{}", addr) - }; - s - } else if addr.len() > 18 { - format!("{}...", &addr[..16]) + name.clone() + } } else { - addr + let addr = format!("{}", a.address); + if a.poisoned { + if addr.len() > 16 { + format!("!{}...", &addr[..14]) + } else { + format!("!{}", addr) + } + } else if addr.len() > 18 { + format!("{}...", &addr[..16]) + } else { + addr + } }; let addr_style = if a.poisoned { Style::default().fg(Color::Red) + } else if a.name.is_some() { + Style::default().fg(Color::Cyan) } else { Style::default() }; @@ -563,32 +581,40 @@ fn draw_actor_detail(f: &mut Frame, app: &App) { .map(|s| short_type_name(Some(s))) .unwrap_or_else(|| "\u{2014}".to_string()); - let info_lines = vec![ - Line::from(vec![ + let mut info_lines = Vec::new(); + if let Some(ref name) = actor.name { + info_lines.push(Line::from(vec![ + Span::styled(" Name: ", Style::default().fg(Color::DarkGray)), + Span::styled(name.clone(), Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::styled(" Address: ", Style::default().fg(Color::DarkGray)), + Span::styled(addr_str, Style::default().fg(Color::White)), + ])); + } else { + info_lines.push(Line::from(vec![ Span::styled(" Address: ", Style::default().fg(Color::DarkGray)), Span::styled(addr_str, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), - ]), - Line::from(vec![ - Span::styled(" Worker: ", Style::default().fg(Color::DarkGray)), - Span::styled(format!("W{}", actor.worker_id), Style::default().fg(Color::Cyan)), - Span::styled(" Status: ", Style::default().fg(Color::DarkGray)), - Span::styled(status, Style::default().fg(status_color).add_modifier(Modifier::BOLD)), - ]), - Line::from(vec![ - Span::styled(" Messages: ", Style::default().fg(Color::DarkGray)), - Span::styled( - format_num(actor.messages_processed), - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), - ), - Span::styled(" Mailbox: ", Style::default().fg(Color::DarkGray)), - Span::styled( - format!("{}", actor.mailbox_depth), - Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD), - ), - Span::styled(" Last Msg: ", Style::default().fg(Color::DarkGray)), - Span::styled(msg_type, Style::default().fg(Color::Green)), - ]), - ]; + ])); + } + info_lines.push(Line::from(vec![ + Span::styled(" Worker: ", Style::default().fg(Color::DarkGray)), + Span::styled(format!("W{}", actor.worker_id), Style::default().fg(Color::Cyan)), + Span::styled(" Status: ", Style::default().fg(Color::DarkGray)), + Span::styled(status, Style::default().fg(status_color).add_modifier(Modifier::BOLD)), + ])); + info_lines.push(Line::from(vec![ + Span::styled(" Messages: ", Style::default().fg(Color::DarkGray)), + Span::styled( + format_num(actor.messages_processed), + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + ), + Span::styled(" Mailbox: ", Style::default().fg(Color::DarkGray)), + Span::styled( + format!("{}", actor.mailbox_depth), + Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD), + ), + Span::styled(" Last Msg: ", Style::default().fg(Color::DarkGray)), + Span::styled(msg_type, Style::default().fg(Color::Green)), + ])); let info_block = Block::default() .borders(Borders::ALL) @@ -782,7 +808,7 @@ fn draw_dist_summary(f: &mut Frame, app: &App, area: Rect) { match &app.distribution { Some(d) => ( &d.node_id[..d.node_id.len().min(16)], - d.listen_addr.as_str(), + d.listen_addr.as_deref().unwrap_or("—"), d.alive_count, d.suspect_count, d.dead_count, @@ -863,7 +889,7 @@ fn draw_dist_members(f: &mut Frame, app: &App, table_state: &mut TableState, are Row::new(vec![ Cell::from(m.state.clone()).style(state_style), Cell::from(id_short), - Cell::from(m.addr.clone()), + Cell::from(m.addr.clone().unwrap_or_default()), Cell::from(format!("{}", m.incarnation)), ]) }) diff --git a/crates/runtime-dashboard/src/warnings.rs b/crates/dashboard/src/warnings.rs similarity index 99% rename from crates/runtime-dashboard/src/warnings.rs rename to crates/dashboard/src/warnings.rs index d1756d1..8fece1e 100644 --- a/crates/runtime-dashboard/src/warnings.rs +++ b/crates/dashboard/src/warnings.rs @@ -227,6 +227,7 @@ mod tests { fn make_actor(id: u8, depth: usize, msgs: u64, poisoned: bool) -> ActorInfo { ActorInfo { address: ActorAddress([id; 32]), + name: None, worker_id: 0, mailbox_depth: depth, last_msg_type: None, diff --git a/crates/runtime-dashboard/tests/command_api.rs b/crates/dashboard/tests/command_api.rs similarity index 98% rename from crates/runtime-dashboard/tests/command_api.rs rename to crates/dashboard/tests/command_api.rs index 3f68142..20412cf 100644 --- a/crates/runtime-dashboard/tests/command_api.rs +++ b/crates/dashboard/tests/command_api.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use swactor::actor::ActorInterface; use swactor::runtime::{Ctx, Runtime, RuntimeConfig}; -use runtime_dashboard::command::{ +use dashboard::command::{ from_query_params, parse_line, CommandContext, CommandResponse, CommandRouter, }; @@ -310,9 +310,9 @@ fn from_query_params_defaults_to_help() { #[test] fn custom_command_handler() { struct PingCommand; - impl runtime_dashboard::command::CommandHandler for PingCommand { - fn meta(&self) -> runtime_dashboard::command::CommandMeta { - runtime_dashboard::command::CommandMeta { + impl dashboard::command::CommandHandler for PingCommand { + fn meta(&self) -> dashboard::command::CommandMeta { + dashboard::command::CommandMeta { name: "ping", description: "Respond with pong", usage: "ping", diff --git a/crates/runtime-dashboard/tests/dashboard_core.rs b/crates/dashboard/tests/dashboard_core.rs similarity index 98% rename from crates/runtime-dashboard/tests/dashboard_core.rs rename to crates/dashboard/tests/dashboard_core.rs index 8bfe6c4..1beff8f 100644 --- a/crates/runtime-dashboard/tests/dashboard_core.rs +++ b/crates/dashboard/tests/dashboard_core.rs @@ -1,8 +1,8 @@ use std::sync::Arc; -use runtime_dashboard::collector::StatsCollector; -use runtime_dashboard::layer::{DashboardEvent, EventStore}; -use runtime_dashboard::trace::RuntimeTrace; +use dashboard::collector::StatsCollector; +use dashboard::layer::{DashboardEvent, EventStore}; +use dashboard::trace::RuntimeTrace; use swactor::actor::ActorAddress; use swactor::stats::{ActorSnapshot, StatsHook}; diff --git a/crates/datastore/Cargo.toml b/crates/datastore/Cargo.toml index d610dad..fcd9323 100644 --- a/crates/datastore/Cargo.toml +++ b/crates/datastore/Cargo.toml @@ -10,33 +10,23 @@ shared-types = { path = "../shared-types" } serde = { version = "1", features = ["derive"] } serde_json = "1" blake3 = "1" -tiny_http = { version = "0.12", optional = true } +tiny_http = "0.12" clap = { version = "4", features = ["derive"], optional = true } ureq = { version = "2", features = ["json"], optional = true } getrandom = { version = "0.2", optional = true } -ctrlc = { version = "3", optional = true } -runtime-dashboard = { path = "../runtime-dashboard", optional = true } -toml = { version = "0.8", optional = true } +dashboard = { path = "../dashboard" } +swactor-std = { path = "../std" } [dev-dependencies] serde_json = "1" proptest = "1" tempfile = "3" swactor = { path = "../.." } -swactor-std = { path = "../std" } ureq = { version = "2", features = ["json"] } -tiny_http = "0.12" -runtime-dashboard = { path = "../runtime-dashboard" } [features] -node = ["dep:tiny_http", "dep:clap", "dep:ctrlc", "dep:runtime-dashboard", "dep:toml"] cli = ["dep:clap", "dep:ureq", "dep:getrandom"] -[[bin]] -name = "swactor-store-node" -path = "src/bin/store_node.rs" -required-features = ["node"] - [[bin]] name = "swactor-store" path = "src/bin/store_cli.rs" diff --git a/crates/datastore/src/bin/store_node.rs b/crates/datastore/src/bin/store_node.rs deleted file mode 100644 index d43cc8a..0000000 --- a/crates/datastore/src/bin/store_node.rs +++ /dev/null @@ -1,361 +0,0 @@ -//! swactor-store-node — standalone datastore node with HTTP API. -//! -//! Starts the actor runtime, spawns datastore actors, and serves -//! a REST API for external tools (the `swactor-store` CLI). - -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::thread; -use std::time::Duration; - -use clap::Parser; -use serde::Deserialize; - -use swactor::config::RuntimeConfig; -use swactor::runtime::Runtime; - -use swactor_datastore::actors::{BlobStoreActor, DatastoreNode, GatewayActor, MetadataActor}; -use swactor_datastore::api::start_api_server; -use swactor_datastore::auth::{AccessControlList, AuthzEngine}; -use swactor_datastore::messages::{GatewayMsg, MetadataMsg}; -use swactor_datastore::metrics::DatastoreMetrics; -use swactor_datastore::storage::{FilesystemBackend, InMemoryBackend}; -use swactor_datastore::DatastoreConfig; - -use distribution::identity::{hex_encode, load_or_generate_keypair}; -use distribution::types::NodeId; - -#[derive(Parser)] -#[command(name = "swactor-store-node", about = "Swactor distributed datastore node")] -struct Args { - /// Path to a TOML config file - #[arg(long)] - config: Option, - - /// HTTP API port - #[arg(long)] - port: Option, - - /// Storage directory (omit for in-memory) - #[arg(long)] - storage_path: Option, - - /// Dashboard HTTP port (omit to disable dashboard) - #[arg(long)] - dashboard_port: Option, - - /// Chunk size in bytes - #[arg(long)] - chunk_size: Option, - - /// GC interval in ticks (each tick is ~100ms) - #[arg(long)] - gc_interval: Option, - - /// Dissemination interval in ticks - #[arg(long)] - disseminate_interval: Option, - - /// Enable auth (generates owner keypair if needed) - #[arg(long)] - auth: bool, - - /// Directory for owner.key.json + acl.json (default: "auth") - #[arg(long, default_value = "auth")] - auth_dir: String, -} - -#[derive(Deserialize, Default)] -struct NodeConfig { - port: Option, - storage_path: Option, - dashboard_port: Option, - chunk_size: Option, - gc_interval: Option, - disseminate_interval: Option, -} - -/// Resolved configuration with CLI > config file > defaults applied. -struct ResolvedConfig { - port: u16, - storage_path: Option, - dashboard_port: Option, - chunk_size: u32, - gc_interval: u64, - disseminate_interval: u64, -} - -// Key file helpers are provided by distribution::identity - -fn resolve_config(args: &Args) -> ResolvedConfig { - let file_cfg = match &args.config { - Some(path) => { - let contents = std::fs::read_to_string(path) - .unwrap_or_else(|e| panic!("failed to read config file {}: {e}", path.display())); - toml::from_str::(&contents) - .unwrap_or_else(|e| panic!("failed to parse config file {}: {e}", path.display())) - } - None => NodeConfig::default(), - }; - - ResolvedConfig { - port: args.port.or(file_cfg.port).unwrap_or(9091), - storage_path: args.storage_path.clone().or(file_cfg.storage_path), - dashboard_port: args.dashboard_port.or(file_cfg.dashboard_port), - chunk_size: args.chunk_size.or(file_cfg.chunk_size).unwrap_or(1_048_576), - gc_interval: args.gc_interval.or(file_cfg.gc_interval).unwrap_or(1000), - disseminate_interval: args.disseminate_interval.or(file_cfg.disseminate_interval).unwrap_or(50), - } -} - -fn main() { - let args = Args::parse(); - let cfg = resolve_config(&args); - let stop = Arc::new(AtomicBool::new(false)); - - // Signal handler — double Ctrl-C forces immediate exit - { - let stop = Arc::clone(&stop); - ctrlc::set_handler(move || { - if stop.load(Ordering::Relaxed) { - eprintln!("\nForced exit."); - std::process::exit(1); - } - stop.store(true, Ordering::Relaxed); - }) - .expect("failed to set signal handler"); - } - - // Optionally start dashboard - let dash = cfg.dashboard_port.map(|port| { - let d = runtime_dashboard::start_dashboard(runtime_dashboard::DashboardConfig { - port, - ..Default::default() - }); - d.install_tracing(); - d - }); - - // Create runtime - let num_threads = 2; - let collector = runtime_dashboard::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()); - - // Generate or load node identity - let (node_id, owner_keypair) = if args.auth { - let auth_dir = std::path::PathBuf::from(&args.auth_dir); - std::fs::create_dir_all(&auth_dir).expect("failed to create auth directory"); - let key_path = auth_dir.join("owner.key.json"); - let keypair = load_or_generate_keypair(&key_path); - let nid = keypair.node_id(); - eprintln!( - "Auth enabled — owner key: {}", - hex_encode(&nid.0) - ); - eprintln!("Key file: {}", key_path.display()); - (nid, Some((keypair, auth_dir))) - } else { - let node_id = { - let mut bytes = [0u8; 32]; - for (i, b) in std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - .to_le_bytes() - .iter() - .enumerate() - { - bytes[i % 32] ^= *b; - } - // Mix in process id for uniqueness - let pid = std::process::id(); - for (i, b) in pid.to_le_bytes().iter().enumerate() { - bytes[i + 16] ^= *b; - } - NodeId(bytes) - }; - (node_id, None) - }; - - // Datastore config - let config = DatastoreConfig { - chunk_size: cfg.chunk_size, - storage_path: cfg - .storage_path - .as_ref() - .map(|s| s.into()) - .unwrap_or_else(|| "datastore".into()), - gc_interval: cfg.gc_interval, - ..Default::default() - }; - - // Create storage backend - let backend: Box = match &cfg.storage_path { - Some(path) => { - let p = std::path::PathBuf::from(path); - std::fs::create_dir_all(&p).expect("failed to create storage directory"); - Box::new(FilesystemBackend::new(p)) - } - None => Box::new(InMemoryBackend::new()), - }; - - // Spawn actors before starting runtime threads - let blob_store_addr = rt - .spawn(BlobStoreActor::new(backend)) - .expect("failed to spawn BlobStoreActor"); - - let mut metadata = MetadataActor::new(node_id, &config); - metadata.set_blob_store(blob_store_addr); - let metadata_addr = rt - .spawn(metadata) - .expect("failed to spawn MetadataActor"); - - let datastore_node = DatastoreNode::new(node_id, blob_store_addr, metadata_addr, config); - let datastore_addr = rt - .spawn(datastore_node) - .expect("failed to spawn DatastoreNode"); - - // Spawn GatewayActor if auth is enabled - let gateway_addr = if let Some((_, ref auth_dir)) = owner_keypair { - let acl_path = auth_dir.join("acl.json"); - let acl = AccessControlList::load_or_create(&acl_path, node_id) - .expect("failed to load/create ACL"); - let engine = AuthzEngine::new(acl); - let gateway = GatewayActor::new(engine, datastore_addr, Some(acl_path)); - let addr = rt - .spawn(gateway) - .expect("failed to spawn GatewayActor"); - Some(addr) - } else { - None - }; - - // Start runtime - let handle = rt.run().expect("failed to start runtime"); - - // Load persisted entries from storage - { - let inbox = handle - .runtime - .new_inbox::() - .expect("failed to create inbox"); - let _ = handle.runtime.send_to( - blob_store_addr, - swactor_datastore::BlobStoreMsg::LoadAll { - reply_to: *inbox.addr(), - }, - ); - // Poll for response (up to 5 seconds) - let start = std::time::Instant::now(); - let mut loaded = false; - while start.elapsed() < Duration::from_secs(5) { - if let Some(resp) = inbox.try_recv() { - match resp { - swactor_datastore::DatastoreResponse::LoadedAll { entries } => { - let n = entries.len(); - let _ = handle.runtime.send_to( - metadata_addr, - swactor_datastore::MetadataMsg::BulkLoad { entries }, - ); - if n > 0 { - eprintln!("Loaded {n} entries from storage"); - } - loaded = true; - } - swactor_datastore::DatastoreResponse::Error { reason } => { - eprintln!("Warning: failed to load entries: {reason}"); - loaded = true; - } - _ => {} - } - break; - } - thread::sleep(Duration::from_millis(1)); - } - if !loaded { - eprintln!("Warning: timed out loading entries from storage"); - } - } - - // Create datastore metrics - let node_hex: String = node_id.0.iter().map(|b| format!("{b:02x}")).collect(); - let metrics = Arc::new(DatastoreMetrics::new()); - metrics.set_node_id(node_hex.clone()); - - if let Some(ref d) = dash { - d.set_runtime(handle.runtime.clone(), collector); - d.set_datastore(Arc::clone(&metrics) as Arc); - } - - // Start HTTP API - let (api_shutdown, _peers) = start_api_server( - handle.runtime.clone(), - datastore_addr, - metadata_addr, - blob_store_addr, - gateway_addr, - cfg.port, - Arc::clone(&metrics), - ); - - eprintln!("──────────────────────────────────────"); - eprintln!(" swactor-store node {}", &node_hex[..16]); - eprintln!(" API: http://0.0.0.0:{}", cfg.port); - if let Some(port) = cfg.dashboard_port { - eprintln!(" Dashboard: http://0.0.0.0:{port}"); - } - if cfg.storage_path.is_some() { - eprintln!(" Storage: {} (filesystem)", cfg.storage_path.as_ref().unwrap()); - } else { - eprintln!(" Storage: in-memory"); - } - if owner_keypair.is_some() { - eprintln!(" Auth: enabled (owner {})", &node_hex[..16]); - } else { - eprintln!(" Auth: disabled"); - } - eprintln!("──────────────────────────────────────"); - - // Main loop - let mut round: u64 = 0; - while !stop.load(Ordering::Relaxed) { - round += 1; - - if round % cfg.gc_interval == 0 { - let _ = handle - .runtime - .send_to(metadata_addr, MetadataMsg::GcTick); - - if let Some(gw) = gateway_addr { - let _ = handle.runtime.send_to(gw, GatewayMsg::NonceGcTick); - } - } - - if round % cfg.disseminate_interval == 0 { - let _ = handle - .runtime - .send_to(metadata_addr, MetadataMsg::DisseminateTick); - } - - thread::sleep(Duration::from_millis(100)); - } - - eprintln!("\nShutting down..."); - api_shutdown.store(true, Ordering::Relaxed); - handle.shutdown(); - if let Some(d) = dash { - d.shutdown(); - } - // Brief pause for threads to flush I/O, then exit. - // No join — cargo run already died from SIGINT so there's - // no parent waiting on us; just exit cleanly. - thread::sleep(Duration::from_millis(50)); - eprintln!("Shutdown complete."); - std::process::exit(0); -} diff --git a/crates/datastore/src/bridge.rs b/crates/datastore/src/bridge.rs index a79e757..5e77b38 100644 --- a/crates/datastore/src/bridge.rs +++ b/crates/datastore/src/bridge.rs @@ -3,21 +3,24 @@ //! and lifecycle management without depending on `swactor-datastore` types. use std::collections::BTreeMap; +use std::path::PathBuf; use std::sync::Arc; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use swactor::actor::ActorAddress; use swactor::runtime::{Inbox, Runtime}; +use swactor_std::RuntimeNaming; use distribution::types::NodeId; -use runtime_dashboard::datastore_collector::{ +use dashboard::datastore_collector::{ DatastoreFactory, DatastoreStatsProvider, ListScope, }; -use crate::actors::{BlobStoreActor, DatastoreNode, MetadataActor}; +use crate::actors::{BlobStoreActor, DatastoreNode, GatewayActor, MetadataActor}; +use crate::auth::{AccessControlList, AuthzEngine}; use crate::chunking::reassemble_blob; -use crate::messages::{DatastoreNodeMsg, DatastoreResponse, MetadataMsg}; +use crate::messages::{DatastoreNodeMsg, DatastoreResponse, GatewayMsg, MetadataMsg}; use crate::metrics::DatastoreMetrics; use crate::storage::{FilesystemBackend, InMemoryBackend}; use crate::types::{ContentHash, DatastoreConfig}; @@ -337,34 +340,71 @@ impl DatastoreFactory for DatastoreNodeFactory { storage_path: Option, ) -> Result, String> { // Generate a unique node ID - let node_id = { - 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) - }; + let node_id = generate_node_id(); + let node_hex: String = node_id.0.iter().map(|b| format!("{b:02x}")).collect(); - let config = DatastoreConfig { - chunk_size: self.default_chunk_size, - storage_path: storage_path + 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 ───────────────────────────────────────────────────────── + +/// Configuration for spawning a complete datastore actor group. +pub struct DatastoreGroupConfig { + pub node_id: NodeId, + pub node_id_hex: String, + pub chunk_size: u32, + pub storage_path: Option, + pub auth: Option, + pub gc_interval: u64, + pub disseminate_interval: u64, +} + +/// Auth configuration for the datastore gateway. +pub struct DatastoreAuthConfig { + pub auth_dir: PathBuf, +} + +/// Owns the full lifecycle of a datastore actor group: BlobStore, Metadata, +/// DatastoreNode, and optional GatewayActor. +pub struct DatastoreGroup { + metadata_addr: ActorAddress, + gateway_addr: Option, + bridge: Arc, + gc_interval: u64, + disseminate_interval: u64, + runtime: Arc, +} + +impl DatastoreGroup { + /// Spawn all datastore actors, wire them together, and register names. + pub fn spawn(runtime: Arc, config: DatastoreGroupConfig) -> Result { + let ds_config = DatastoreConfig { + chunk_size: config.chunk_size, + storage_path: config + .storage_path .as_ref() .map(|s| s.into()) .unwrap_or_else(|| "datastore".into()), ..Default::default() }; - let backend: Box = match &storage_path { + let backend: Box = match &config.storage_path { Some(path) => { - let p = std::path::PathBuf::from(path); + let p = PathBuf::from(path); std::fs::create_dir_all(&p) .map_err(|e| format!("failed to create storage directory: {e}"))?; Box::new(FilesystemBackend::new(p)) @@ -372,36 +412,108 @@ impl DatastoreFactory for DatastoreNodeFactory { None => Box::new(InMemoryBackend::new()), }; - let blob_store_addr = self - .runtime + let blob_store_addr = runtime .spawn(BlobStoreActor::new(backend)) .map_err(|e| format!("failed to spawn BlobStoreActor: {e}"))?; + let _ = runtime.register_name("BlobStore", blob_store_addr); - let mut metadata = MetadataActor::new(node_id, &config); + let mut metadata = MetadataActor::new(config.node_id, &ds_config); metadata.set_blob_store(blob_store_addr); - let metadata_addr = self - .runtime + let metadata_addr = runtime .spawn(metadata) .map_err(|e| format!("failed to spawn MetadataActor: {e}"))?; + let _ = runtime.register_name("Metadata", metadata_addr); - let datastore_node = DatastoreNode::new(node_id, blob_store_addr, metadata_addr, config); - let datastore_addr = self - .runtime + let datastore_node = + DatastoreNode::new(config.node_id, blob_store_addr, metadata_addr, ds_config); + let datastore_addr = runtime .spawn(datastore_node) .map_err(|e| format!("failed to spawn DatastoreNode: {e}"))?; + let _ = runtime.register_name("Datastore", datastore_addr); + + // Spawn GatewayActor if auth is configured + let gateway_addr = if let Some(auth_cfg) = &config.auth { + std::fs::create_dir_all(&auth_cfg.auth_dir) + .map_err(|e| format!("failed to create auth directory: {e}"))?; + let acl_path = auth_cfg.auth_dir.join("acl.json"); + let acl = AccessControlList::load_or_create(&acl_path, config.node_id) + .map_err(|e| format!("failed to load/create ACL: {e}"))?; + let engine = AuthzEngine::new(acl); + let gateway = GatewayActor::new(engine, datastore_addr, Some(acl_path)); + let addr = runtime + .spawn(gateway) + .map_err(|e| format!("failed to spawn GatewayActor: {e}"))?; + let _ = runtime.register_name("Gateway", addr); + eprintln!("Auth: enabled (owner {})", &config.node_id_hex[..16]); + Some(addr) + } else { + eprintln!("Auth: disabled"); + None + }; - let node_hex: String = node_id.0.iter().map(|b| format!("{b:02x}")).collect(); let metrics = Arc::new(DatastoreMetrics::new()); - metrics.set_node_id(node_hex); + metrics.set_node_id(config.node_id_hex.clone()); - let bridge = DatastoreBridge::new( + let bridge: Arc = Arc::new(DatastoreBridge::new( metrics, - Arc::clone(&self.runtime), + Arc::clone(&runtime), datastore_addr, metadata_addr, blob_store_addr, - ); + )); - Ok(Arc::new(bridge)) + if config.storage_path.is_some() { + eprintln!( + "Datastore: persistent ({})", + config.storage_path.as_ref().unwrap() + ); + } else { + eprintln!("Datastore: in-memory"); + } + + Ok(Self { + metadata_addr, + gateway_addr, + bridge, + gc_interval: config.gc_interval, + disseminate_interval: config.disseminate_interval, + runtime, + }) + } + + /// Send periodic ticks to the datastore actors based on the current round. + pub fn tick(&self, round: u64) { + if round % self.gc_interval == 0 { + let _ = self.runtime.send_to(self.metadata_addr, MetadataMsg::GcTick); + if let Some(gw) = self.gateway_addr { + let _ = self.runtime.send_to(gw, GatewayMsg::NonceGcTick); + } + } + if round % self.disseminate_interval == 0 { + let _ = self + .runtime + .send_to(self.metadata_addr, MetadataMsg::DisseminateTick); + } + } + + /// Access the bridge (as a trait object for the dashboard). + pub fn bridge(&self) -> &Arc { + &self.bridge } } + +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) +} diff --git a/crates/datastore/src/lib.rs b/crates/datastore/src/lib.rs index b3f928b..1578c07 100644 --- a/crates/datastore/src/lib.rs +++ b/crates/datastore/src/lib.rs @@ -6,11 +6,8 @@ pub mod actors; pub mod auth; pub mod cli; pub mod metrics; -#[cfg(feature = "node")] pub mod api; -#[cfg(feature = "node")] pub mod ui_html; -#[cfg(feature = "node")] pub mod bridge; pub use types::{ChunkRef, ContentHash, DatastoreConfig, ObjectEntry, ObjectManifest}; @@ -18,3 +15,4 @@ pub use messages::{BlobStoreMsg, DatastoreNodeMsg, DatastoreResponse, MetadataMs pub use chunking::{chunk_blob, reassemble_blob, verify_integrity, ChunkingError}; pub use storage::{StorageBackend, FilesystemBackend, InMemoryBackend}; pub use actors::{BlobStoreActor, DatastoreNode, MetadataActor, TransferActor}; +pub use bridge::{DatastoreGroup, DatastoreGroupConfig, DatastoreAuthConfig}; diff --git a/crates/datastore/src/metrics.rs b/crates/datastore/src/metrics.rs index 5e6f2b0..9db347e 100644 --- a/crates/datastore/src/metrics.rs +++ b/crates/datastore/src/metrics.rs @@ -186,10 +186,9 @@ fn now_ms() -> u64 { .as_millis() as u64 } -// ── Dashboard integration (only when runtime-dashboard is available) ──────── +// ── Dashboard integration ──────────────────────────────────────────────────── -#[cfg(feature = "node")] -impl runtime_dashboard::datastore_collector::DatastoreStatsProvider for DatastoreMetrics { +impl dashboard::datastore_collector::DatastoreStatsProvider for DatastoreMetrics { fn snapshot_json(&self) -> Option { let snap = self.snapshot(); serde_json::to_string(&snap).ok() diff --git a/crates/datastore/tests/api_integration_test.rs b/crates/datastore/tests/api_integration_test.rs index 2786728..280a7c4 100644 --- a/crates/datastore/tests/api_integration_test.rs +++ b/crates/datastore/tests/api_integration_test.rs @@ -1,5 +1,3 @@ -#![cfg(feature = "node")] - //! Integration test: spins up a real datastore node with HTTP API and exercises //! the full CRUD lifecycle over HTTP. @@ -34,7 +32,7 @@ fn http_crud_lifecycle() { let base = format!("http://127.0.0.1:{port}"); // Set up runtime with worker threads (needed for HTTP server) - let collector = runtime_dashboard::collector::StatsCollector::new(2); + let collector = dashboard::collector::StatsCollector::new(2); let mut rt = Runtime::new(RuntimeConfig { num_threads: 2, max_actors: 1024, diff --git a/crates/datastore/tests/dashboard_integration_test.rs b/crates/datastore/tests/dashboard_integration_test.rs index 8fa8def..74c7adf 100644 --- a/crates/datastore/tests/dashboard_integration_test.rs +++ b/crates/datastore/tests/dashboard_integration_test.rs @@ -1,5 +1,3 @@ -#![cfg(feature = "node")] - //! End-to-end integration test: spins up a datastore node with an HTTP API //! **and** a runtime dashboard, performs CRUD over HTTP, then verifies: //! @@ -46,12 +44,12 @@ fn dashboard_reflects_datastore_operations() { // ── Infrastructure: runtime + actors + dashboard + API ────────────── - let dash = runtime_dashboard::start_dashboard(runtime_dashboard::DashboardConfig { + let dash = dashboard::start_dashboard(dashboard::DashboardConfig { port: dash_port, ..Default::default() }); - let collector = runtime_dashboard::collector::StatsCollector::new(2); + let collector = dashboard::collector::StatsCollector::new(2); let mut rt = Runtime::new(RuntimeConfig { num_threads: 2, max_actors: 1024, @@ -86,7 +84,7 @@ fn dashboard_reflects_datastore_operations() { dash.set_runtime(handle.runtime.clone(), collector); dash.set_datastore( Arc::clone(&metrics) - as Arc, + as Arc, ); let (api_shutdown, _peers) = start_api_server( diff --git a/crates/datastore/tests/http_auth_integration.rs b/crates/datastore/tests/http_auth_integration.rs index 879de9a..aa0aba6 100644 --- a/crates/datastore/tests/http_auth_integration.rs +++ b/crates/datastore/tests/http_auth_integration.rs @@ -4,8 +4,6 @@ //! and uses ureq to prove that authorized requests succeed while unauthorized //! ones get 403 and missing-auth requests get 401. -#![cfg(feature = "node")] - use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::atomic::Ordering; use std::time::{Duration, SystemTime, UNIX_EPOCH}; diff --git a/crates/distribution/Cargo.toml b/crates/distribution/Cargo.toml index ac39d74..16fc876 100644 --- a/crates/distribution/Cargo.toml +++ b/crates/distribution/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" default = ["tcp"] tcp = [] iroh = ["dep:iroh", "dep:tokio"] +relay = ["iroh", "dep:iroh-relay"] [dependencies] swactor = { path = "../..", features = ["serde", "transport"] } @@ -17,6 +18,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" bs58 = "0.5" iroh = { version = "0.96", optional = true } +iroh-relay = { version = "0.96", features = ["server"], optional = true } tokio = { version = "1", features = ["rt-multi-thread"], optional = true } [dev-dependencies] diff --git a/crates/distribution/src/iroh_driver.rs b/crates/distribution/src/iroh_driver.rs index 5c58906..b2c7b7a 100644 --- a/crates/distribution/src/iroh_driver.rs +++ b/crates/distribution/src/iroh_driver.rs @@ -40,6 +40,16 @@ pub struct IrohDriverConfig { pub node: DistributedNodeConfig, /// Optional peer allow-list. If provided, only allowed peers can connect. pub peer_auth: Option>>, + /// If set, start an embedded relay server on this address. + /// Requires the `relay` feature. On success, the driver uses the embedded + /// relay for `RelayMode::Custom`; on failure, falls back to `relay_mode`. + #[cfg(feature = "relay")] + pub embedded_relay_bind: Option, + /// Public IP to advertise in the relay URL instead of the bind address. + /// When `Some`, the relay URL uses this IP; when `None`, falls back to the + /// bind address (which may be `0.0.0.0`). + #[cfg(feature = "relay")] + pub relay_public_ip: Option, } // ─── Pending join result ──────────────────────────────────────────────────── @@ -64,6 +74,13 @@ pub struct IrohDriver { peer_auth: Option>>, /// Collects connections from background join tasks. pending_joins: Arc>>, + /// Connections accepted by the background accept loop. + accepted_conns: Arc>>, + /// Embedded relay server (if started). + #[cfg(feature = "relay")] + relay_server: Option, + /// URL of the embedded relay server (if started). + relay_url: Option, } impl IrohDriver { @@ -71,13 +88,39 @@ impl IrohDriver { /// /// Builds a tokio runtime, creates an iroh `Endpoint`, and initializes /// the protocol-layer `DistributedNode`. + /// + /// If `embedded_relay_bind` is set (requires `relay` feature), the driver + /// starts an embedded relay server on the tokio runtime before creating + /// the endpoint. On success the endpoint uses the embedded relay; on + /// failure it falls back to `config.relay_mode`. pub fn new(config: IrohDriverConfig) -> Result> { let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build()?; + // Try to start embedded relay if configured + #[cfg(feature = "relay")] + let (relay_server, relay_url, effective_relay_mode) = match config.embedded_relay_bind { + Some(bind_addr) => { + match rt.block_on(start_embedded_relay(bind_addr, config.relay_public_ip)) { + Ok((server, url)) => { + let url_str = url.to_string(); + eprintln!("Relay: embedded relay started at {url}"); + (Some(server), Some(url_str), RelayMode::Custom(url.into())) + } + Err(e) => { + eprintln!("Relay: failed to start embedded relay: {e}, falling back"); + (None, None, config.relay_mode) + } + } + } + None => (None, None, config.relay_mode), + }; + #[cfg(not(feature = "relay"))] + let (relay_url, effective_relay_mode) = (None::, config.relay_mode); + let endpoint = rt.block_on(async { - let mut builder = Endpoint::empty_builder(config.relay_mode) + let mut builder = Endpoint::empty_builder(effective_relay_mode) .alpns(vec![ALPN.to_vec()]); if let Some(key) = config.secret_key { @@ -93,6 +136,49 @@ impl IrohDriver { let keypair = Keypair::from_bytes(&iroh_secret); let node = DistributedNode::with_keypair(keypair, config.node); + // Spawn background accept loop so incoming connections are never missed + let accepted_conns: Arc>> = + Arc::new(Mutex::new(Vec::new())); + { + let ep = endpoint.clone(); + let peer_auth = config.peer_auth.clone(); + let buf = Arc::clone(&accepted_conns); + rt.spawn(async move { + loop { + match ep.accept().await { + Some(incoming) => match incoming.await { + Ok(conn) => { + let remote_id = conn.remote_id(); + let node_id = NodeId(*remote_id.as_bytes()); + // Peer auth check + let allowed = match &peer_auth { + None => true, + Some(auth) => auth.lock().unwrap().is_allowed(&node_id), + }; + if !allowed { + eprintln!( + "iroh driver: rejected connection from unauthorized peer {}", + crate::identity::hex_encode(&node_id.0[..4]) + ); + conn.close(0u32.into(), b"unauthorized"); + continue; + } + eprintln!( + "iroh driver: accepted connection from {}", + crate::identity::hex_encode(&node_id.0[..4]) + ); + buf.lock().unwrap().push((node_id, conn)); + } + Err(e) => { + eprintln!("iroh driver: incoming connection error: {e}"); + } + }, + None => break, // endpoint closed + } + } + }); + } + Ok(Self { node, endpoint, @@ -100,9 +186,18 @@ impl IrohDriver { connections: HashMap::new(), peer_auth: config.peer_auth, pending_joins: Arc::new(Mutex::new(Vec::new())), + accepted_conns, + #[cfg(feature = "relay")] + relay_server, + relay_url, }) } + /// Get a handle to the tokio runtime owned by this driver. + pub fn tokio_handle(&self) -> tokio::runtime::Handle { + self.rt.handle().clone() + } + /// The node's identity. pub fn node_id(&self) -> NodeId { self.node.node_id() @@ -178,36 +273,71 @@ impl IrohDriver { let pending = Arc::clone(&self.pending_joins); self.rt.spawn(async move { - match endpoint.connect(seed_addr.clone(), ALPN).await { - Ok(conn) => { - // Send the join request over the new connection - let send_result: Result<(), String> = async { - let mut send = conn.open_uni().await.map_err(|e| e.to_string())?; - let tag_len = (tag.len() as u32).to_be_bytes(); - send.write_all(&tag_len).await.map_err(|e| e.to_string())?; - send.write_all(tag.as_bytes()).await.map_err(|e| e.to_string())?; - send.write_all(&payload).await.map_err(|e| e.to_string())?; - send.finish().map_err(|e| e.to_string())?; - Ok(()) - } - .await; + let mut delay = Duration::from_secs(2); + let max_delay = Duration::from_secs(30); + let max_attempts = 5; - match send_result { - Ok(()) => { - pending.lock().unwrap().push(JoinResult { - node_id: seed_node_id, - conn, - }); + for attempt in 1..=max_attempts { + if attempt > 1 { + tokio::time::sleep(delay).await; + delay = (delay * 2).min(max_delay); + } + + eprintln!("iroh driver: join attempt {attempt}/{max_attempts} connecting to {}...", seed_addr.id); + let connect_result = tokio::time::timeout( + Duration::from_secs(10), + endpoint.connect(seed_addr.clone(), ALPN), + ).await; + + match connect_result { + Ok(Ok(conn)) => { + eprintln!("iroh driver: join attempt {attempt}/{max_attempts} connected to {}, sending...", seed_addr.id); + let send_result: Result<(), String> = async { + let mut send = conn.open_uni().await.map_err(|e| e.to_string())?; + let tag_len = (tag.len() as u32).to_be_bytes(); + send.write_all(&tag_len).await.map_err(|e| e.to_string())?; + send.write_all(tag.as_bytes()).await.map_err(|e| e.to_string())?; + send.write_all(&payload).await.map_err(|e| e.to_string())?; + send.finish().map_err(|e| e.to_string())?; + Ok(()) } - Err(e) => { - eprintln!("iroh driver: join send error to {}: {e}", seed_addr.id); + .await; + + match send_result { + Ok(()) => { + eprintln!("iroh driver: join attempt {attempt}/{max_attempts} sent to {}", seed_addr.id); + pending.lock().unwrap().push(JoinResult { + node_id: seed_node_id, + conn, + }); + return; + } + Err(e) => { + eprintln!( + "iroh driver: join attempt {attempt}/{max_attempts} send error to {}: {e}", + seed_addr.id + ); + continue; + } } } - } - Err(e) => { - eprintln!("iroh driver: join connect error to {}: {e}", seed_addr.id); + Ok(Err(e)) => { + eprintln!( + "iroh driver: join attempt {attempt}/{max_attempts} connect error to {}: {e}", + seed_addr.id + ); + continue; + } + Err(_) => { + eprintln!( + "iroh driver: join attempt {attempt}/{max_attempts} connect timeout to {}", + seed_addr.id + ); + continue; + } } } + eprintln!("iroh driver: join failed after {max_attempts} attempts to {}", seed_addr.id); }); } @@ -222,6 +352,9 @@ impl IrohDriver { // Collect completed background join connections { let mut pending = self.pending_joins.lock().unwrap(); + if !pending.is_empty() { + eprintln!("iroh driver: collecting {} pending join connection(s)", pending.len()); + } for result in pending.drain(..) { self.connections.entry(result.node_id).or_insert(result.conn); } @@ -381,35 +514,17 @@ impl IrohDriver { async fn receive_pending(&self) -> (Vec<(String, Vec, PublicKey)>, Vec<(NodeId, Connection)>) { let mut messages = Vec::new(); - let mut new_connections = Vec::new(); - // Poll for incoming connections with a short timeout - loop { - let accept_fut = self.endpoint.accept(); - let result = tokio::time::timeout(Duration::from_millis(1), accept_fut).await; + // Drain connections accepted by the background accept loop + let new_connections: Vec<(NodeId, Connection)> = { + let mut buf = self.accepted_conns.lock().unwrap(); + buf.drain(..).collect() + }; - match result { - Ok(Some(incoming)) => { - if let Ok(conn) = incoming.await { - let remote_id = conn.remote_id(); - let node_id = NodeId(*remote_id.as_bytes()); - - // Peer auth check - if !self.is_peer_allowed(&node_id) { - eprintln!( - "iroh driver: rejected connection from unauthorized peer {}", - crate::identity::hex_encode(&node_id.0[..4]) - ); - conn.close(0u32.into(), b"unauthorized"); - continue; - } - - self.read_streams(&conn, remote_id, &mut messages).await; - new_connections.push((node_id, conn)); - } - } - _ => break, - } + // Read streams from newly accepted connections + for (node_id, conn) in &new_connections { + let remote_id = PublicKey::from_bytes(&node_id.0).unwrap(); + self.read_streams(conn, remote_id, &mut messages).await; } // Also read from existing cached connections @@ -424,6 +539,10 @@ impl IrohDriver { self.read_streams(&conn, remote_id, &mut messages).await; } + if !messages.is_empty() { + eprintln!("iroh driver: received {} message(s)", messages.len()); + } + (messages, new_connections) } @@ -512,14 +631,65 @@ impl IrohDriver { } } - /// Shut down the iroh endpoint. - pub fn shutdown(&self) { + /// URL of the embedded relay server, if one was started. + pub fn relay_url(&self) -> Option<&str> { + self.relay_url.as_deref() + } + + /// The endpoint's home relay URL (from RelayMode::Custom), if connected. + pub fn home_relay_url(&self) -> Option { + self.endpoint.addr().relay_urls().next().cloned() + } + + /// Shut down the driver: stop the embedded relay (if any), then close the + /// iroh endpoint. + pub fn shutdown(&mut self) { + // Shut down embedded relay first (must stop before endpoint closes) + #[cfg(feature = "relay")] + if let Some(server) = self.relay_server.take() { + self.rt.block_on(async { + let _ = server.shutdown().await; + }); + } self.rt.block_on(async { self.endpoint.close().await; }); } } +// ─── Embedded Relay ───────────────────────────────────────────────────────── + +#[cfg(feature = "relay")] +async fn start_embedded_relay( + bind_addr: std::net::SocketAddr, + public_ip: Option, +) -> Result<(iroh_relay::server::Server, iroh::RelayUrl), Box> { + let server = iroh_relay::server::Server::spawn( + iroh_relay::server::ServerConfig::<(), ()> { + relay: Some(iroh_relay::server::RelayConfig { + http_bind_addr: bind_addr, + tls: None, + limits: Default::default(), + key_cache_capacity: Some(256), + access: iroh_relay::server::AccessConfig::Everyone, + }), + quic: None, + metrics_addr: None, + }, + ) + .await?; + + let url: iroh::RelayUrl = match server.http_addr() { + Some(addr) => { + let host = public_ip.unwrap_or_else(|| addr.ip()); + format!("http://{}:{}/", host, addr.port()).parse()? + } + None => return Err("relay server has no HTTP address".into()), + }; + + Ok((server, url)) +} + // ─── Wire Framing Over QUIC Streams ───────────────────────────────────────── /// Write a tagged message to a QUIC send stream. diff --git a/crates/distribution/src/lib.rs b/crates/distribution/src/lib.rs index dd87799..eb62498 100644 --- a/crates/distribution/src/lib.rs +++ b/crates/distribution/src/lib.rs @@ -11,6 +11,7 @@ pub mod kademlia; pub mod cache; pub mod node; pub mod registry; +pub mod node_metadata; pub mod snapshot; #[cfg(feature = "tcp")] pub mod driver; diff --git a/crates/distribution/src/node.rs b/crates/distribution/src/node.rs index d7e4690..50302ab 100644 --- a/crates/distribution/src/node.rs +++ b/crates/distribution/src/node.rs @@ -10,6 +10,7 @@ use crate::crypto::Keypair; use crate::kademlia::directory::{actor_addr_as_node_id, DirectoryShard}; use crate::kademlia::repair::{RepairQueue, RepublishTracker}; use crate::kademlia::routing_table::RoutingTable; +use crate::node_metadata::{NodeMetadataDisseminator, NodeMetadataEntry}; use crate::registry::{ pack_combined_piggyback, unpack_combined_piggyback, ClusterRegistry, RegistryConfig, RegistryEntry, RegistryEvent, @@ -25,6 +26,8 @@ pub struct DistributedNodeConfig { pub cache_capacity: usize, pub republish_interval: u64, pub registry: RegistryConfig, + /// Dissemination multiplier for node metadata (default: 3). + pub metadata_lambda: usize, } impl Default for DistributedNodeConfig { @@ -34,6 +37,7 @@ impl Default for DistributedNodeConfig { cache_capacity: 10_000, republish_interval: 1000, registry: RegistryConfig::default(), + metadata_lambda: 3, } } } @@ -51,6 +55,7 @@ pub struct DistributedNode { repair_queue: RepairQueue, republish: RepublishTracker, registry: ClusterRegistry, + metadata: NodeMetadataDisseminator, tick_count: u64, } @@ -72,6 +77,7 @@ impl DistributedNode { repair_queue: RepairQueue::new(), republish: RepublishTracker::new(config.republish_interval), registry: ClusterRegistry::new(config.registry), + metadata: NodeMetadataDisseminator::new(config.metadata_lambda), tick_count: 0, keypair, } @@ -138,35 +144,41 @@ impl DistributedNode { // Registry GC self.registry.gc_tick(); - // Wrap outgoing piggyback with registry entries - self.inject_registry_piggyback(actions) + // Wrap outgoing piggyback with registry + metadata entries + self.inject_piggyback(actions) } // ─── SWIM message handling (delegate to SwimNode) ─────────────────── pub fn handle_ping(&mut self, from: NodeId, sequence: u64, piggyback: &[u8]) -> Vec { - let (membership_bytes, registry_entries) = unpack_combined_piggyback(piggyback); + let (membership_bytes, registry_entries, metadata_entries) = + unpack_combined_piggyback(piggyback); let actions = self.swim.handle_ping(from, sequence, &membership_bytes); self.process_membership_changes(&actions); self.merge_registry_entries(registry_entries); + self.merge_metadata_entries(metadata_entries); self.maybe_update_routing_table(from); - self.inject_registry_piggyback(actions) + self.inject_piggyback(actions) } pub fn handle_ack(&mut self, from: NodeId, sequence: u64, piggyback: &[u8]) -> Vec { - let (membership_bytes, registry_entries) = unpack_combined_piggyback(piggyback); + let (membership_bytes, registry_entries, metadata_entries) = + unpack_combined_piggyback(piggyback); let actions = self.swim.handle_ack(from, sequence, &membership_bytes); self.process_membership_changes(&actions); self.merge_registry_entries(registry_entries); - self.inject_registry_piggyback(actions) + self.merge_metadata_entries(metadata_entries); + self.inject_piggyback(actions) } pub fn handle_ping_req(&mut self, from: NodeId, target: NodeId, sequence: u64, piggyback: &[u8]) -> Vec { - let (membership_bytes, registry_entries) = unpack_combined_piggyback(piggyback); + let (membership_bytes, registry_entries, metadata_entries) = + unpack_combined_piggyback(piggyback); let actions = self.swim.handle_ping_req(from, target, sequence, &membership_bytes); self.process_membership_changes(&actions); self.merge_registry_entries(registry_entries); - self.inject_registry_piggyback(actions) + self.merge_metadata_entries(metadata_entries); + self.inject_piggyback(actions) } pub fn handle_join_request(&mut self, from: NodeId) -> Vec { @@ -261,6 +273,24 @@ impl DistributedNode { &self.registry } + // ─── Node metadata (relay URL) ───────────────────────────────────── + + /// Set this node's relay URL and begin gossiping it to the cluster. + pub fn set_relay_url(&mut self, url: Option) { + self.metadata + .set_local(self.node_id(), url, self.cluster_size()); + } + + /// Look up a node's relay URL. + pub fn relay_url(&self, node_id: &NodeId) -> Option<&str> { + self.metadata.relay_url(node_id) + } + + /// Read-only access to the metadata disseminator. + pub fn metadata(&self) -> &NodeMetadataDisseminator { + &self.metadata + } + // ─── Accessors ────────────────────────────────────────────────────── pub fn routing_table(&self) -> &RoutingTable { @@ -306,15 +336,18 @@ impl DistributedNode { match state { MemberState::Alive => { self.routing_table.insert(node_id); - // Re-disseminate registry entries so the recovering node - // catches up on state accumulated during the partition. - self.registry.re_disseminate_all(self.cluster_size()); + // Re-disseminate registry + metadata entries so the recovering + // node catches up on state accumulated during the partition. + let size = self.cluster_size(); + self.registry.re_disseminate_all(size); + self.metadata.re_disseminate_all(size); } MemberState::Dead => { self.routing_table.remove(&node_id); self.cache.invalidate_node(&node_id); self.repair_queue.on_node_death(&node_id, &mut self.directory); self.registry.tombstone_node(node_id, self.cluster_size()); + self.metadata.remove_node(&node_id); } MemberState::Suspect => { // Keep in routing table but could downprioritize @@ -326,24 +359,27 @@ impl DistributedNode { self.swim.members().alive_count() + 1 // +1 for self } - /// Post-process outgoing actions: wrap each piggyback with registry entries. - fn inject_registry_piggyback(&mut self, actions: Vec) -> Vec { + /// Post-process outgoing actions: wrap each piggyback with registry + metadata entries. + fn inject_piggyback(&mut self, actions: Vec) -> Vec { actions .into_iter() .map(|action| match action { NodeAction::SendPing { to, sequence, piggyback } => { let registry_entries = self.registry.take_pending(8); - let combined = pack_combined_piggyback(piggyback, registry_entries); + let metadata_entries = self.metadata.take_pending(4); + let combined = pack_combined_piggyback(piggyback, registry_entries, metadata_entries); NodeAction::SendPing { to, sequence, piggyback: combined } } NodeAction::SendAck { to, sequence, piggyback } => { let registry_entries = self.registry.take_pending(8); - let combined = pack_combined_piggyback(piggyback, registry_entries); + let metadata_entries = self.metadata.take_pending(4); + let combined = pack_combined_piggyback(piggyback, registry_entries, metadata_entries); NodeAction::SendAck { to, sequence, piggyback: combined } } NodeAction::SendPingReq { relay, target, sequence, piggyback } => { let registry_entries = self.registry.take_pending(8); - let combined = pack_combined_piggyback(piggyback, registry_entries); + let metadata_entries = self.metadata.take_pending(4); + let combined = pack_combined_piggyback(piggyback, registry_entries, metadata_entries); NodeAction::SendPingReq { relay, target, sequence, piggyback: combined } } other => other, @@ -357,6 +393,13 @@ impl DistributedNode { self.registry.merge_batch(entries, self.cluster_size()); } } + + /// Merge metadata entries received from a piggyback payload. + fn merge_metadata_entries(&mut self, entries: Vec) { + if !entries.is_empty() { + self.metadata.apply_incoming(entries, self.cluster_size()); + } + } } /// Result of resolving an actor's location. diff --git a/crates/distribution/src/node_metadata.rs b/crates/distribution/src/node_metadata.rs new file mode 100644 index 0000000..e4b4dbb --- /dev/null +++ b/crates/distribution/src/node_metadata.rs @@ -0,0 +1,129 @@ +//! Node metadata dissemination — gossip-propagated per-node metadata. +//! +//! Each node may have metadata (currently: relay URL) that should be visible +//! cluster-wide. Uses higher-generation-wins semantics and SWIM-style +//! dissemination budgets (Λ * log₂(n)). + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::types::NodeId; + +/// A single metadata entry for one node. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct NodeMetadataEntry { + pub node_id: NodeId, + pub relay_url: Option, + pub generation: u64, +} + +/// Dissemination state for one entry. +#[derive(Debug, Clone)] +struct PendingEntry { + entry: NodeMetadataEntry, + remaining: usize, +} + +/// Per-node metadata store with gossip dissemination. +pub struct NodeMetadataDisseminator { + store: HashMap, + pending: Vec, + lambda: usize, + local_generation: u64, +} + +impl NodeMetadataDisseminator { + pub fn new(lambda: usize) -> Self { + Self { + store: HashMap::new(), + pending: Vec::new(), + lambda, + local_generation: 0, + } + } + + /// Set this node's relay URL and enqueue for dissemination. + pub fn set_local(&mut self, node_id: NodeId, relay_url: Option, cluster_size: usize) { + self.local_generation += 1; + let entry = NodeMetadataEntry { + node_id, + relay_url, + generation: self.local_generation, + }; + self.store.insert(node_id, entry.clone()); + self.enqueue(entry, cluster_size); + } + + /// Merge incoming entries from gossip. Re-enqueues changed entries for forwarding. + pub fn apply_incoming(&mut self, entries: Vec, cluster_size: usize) { + for entry in entries { + let dominated = match self.store.get(&entry.node_id) { + Some(existing) => entry.generation <= existing.generation, + None => false, + }; + if dominated { + continue; + } + self.store.insert(entry.node_id, entry.clone()); + self.enqueue(entry, cluster_size); + } + } + + /// Take pending entries for piggyback, up to `max_count`. + pub fn take_pending(&mut self, max_count: usize) -> Vec { + let count = max_count.min(self.pending.len()); + let mut result = Vec::with_capacity(count); + + for entry in self.pending.iter_mut().take(count) { + result.push(entry.entry.clone()); + entry.remaining = entry.remaining.saturating_sub(1); + } + + self.pending.retain(|e| e.remaining > 0); + result + } + + /// Look up a node's relay URL. + pub fn relay_url(&self, node_id: &NodeId) -> Option<&str> { + self.store + .get(node_id) + .and_then(|e| e.relay_url.as_deref()) + } + + /// Remove metadata for a dead node. + pub fn remove_node(&mut self, node_id: &NodeId) { + self.store.remove(node_id); + self.pending.retain(|e| &e.entry.node_id != node_id); + } + + /// Re-enqueue all entries for dissemination (anti-entropy on membership recovery). + pub fn re_disseminate_all(&mut self, cluster_size: usize) { + for entry in self.store.values().cloned().collect::>() { + self.enqueue(entry, cluster_size); + } + } + + fn transmit_budget(&self, cluster_size: usize) -> usize { + let n = cluster_size.max(2) as f64; + let log_n = n.log2().ceil() as usize; + self.lambda * log_n.max(1) + } + + fn enqueue(&mut self, entry: NodeMetadataEntry, cluster_size: usize) { + let budget = self.transmit_budget(cluster_size); + + // Replace existing pending entry for same node if present. + if let Some(existing) = self.pending.iter_mut().find(|e| e.entry.node_id == entry.node_id) + { + existing.entry = entry; + existing.remaining = budget; + return; + } + + self.pending.push(PendingEntry { + entry, + remaining: budget, + }); + } +} diff --git a/crates/distribution/src/registry.rs b/crates/distribution/src/registry.rs index e132709..711321d 100644 --- a/crates/distribution/src/registry.rs +++ b/crates/distribution/src/registry.rs @@ -9,6 +9,7 @@ use std::collections::{HashMap, VecDeque}; use serde::{Deserialize, Serialize}; use swactor::actor::ActorAddress; +use crate::node_metadata::NodeMetadataEntry; use crate::types::NodeId; // ─── Configuration ────────────────────────────────────────────────────────── @@ -53,13 +54,16 @@ pub struct RegistryEntry { pub tombstone: bool, } -/// Combined piggyback payload: membership bytes + registry entries. +/// Combined piggyback payload: membership bytes + registry entries + node metadata. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PiggybackPayload { /// Raw SWIM membership piggyback bytes (opaque to registry). pub membership: Vec, /// Registry entries to disseminate. pub registry: Vec, + /// Node metadata entries (relay URLs, etc.) to disseminate. + #[serde(default)] + pub node_metadata: Vec, } // ─── Events ───────────────────────────────────────────────────────────────── @@ -367,20 +371,30 @@ fn lww_wins(incoming: &RegistryEntry, existing: &RegistryEntry) -> bool { // ─── Piggyback pack/unpack ────────────────────────────────────────────────── -/// Combine membership piggyback bytes and registry entries into a single payload. -pub fn pack_combined_piggyback(membership: Vec, registry: Vec) -> Vec { - let payload = PiggybackPayload { membership, registry }; +/// Combine membership piggyback bytes, registry entries, and node metadata into a single payload. +pub fn pack_combined_piggyback( + membership: Vec, + registry: Vec, + node_metadata: Vec, +) -> Vec { + let payload = PiggybackPayload { + membership, + registry, + node_metadata, + }; serde_json::to_vec(&payload).unwrap_or_default() } -/// Split a combined piggyback payload into membership bytes and registry entries. +/// Split a combined piggyback payload into membership bytes, registry entries, and node metadata. /// If deserialization fails, treats the entire blob as membership bytes (backwards compat). -pub fn unpack_combined_piggyback(bytes: &[u8]) -> (Vec, Vec) { +pub fn unpack_combined_piggyback( + bytes: &[u8], +) -> (Vec, Vec, Vec) { if bytes.is_empty() { - return (Vec::new(), Vec::new()); + return (Vec::new(), Vec::new(), Vec::new()); } match serde_json::from_slice::(bytes) { - Ok(payload) => (payload.membership, payload.registry), - Err(_) => (bytes.to_vec(), Vec::new()), + Ok(payload) => (payload.membership, payload.registry, payload.node_metadata), + Err(_) => (bytes.to_vec(), Vec::new(), Vec::new()), } } diff --git a/crates/distribution/src/snapshot.rs b/crates/distribution/src/snapshot.rs index f238ac3..257a90d 100644 --- a/crates/distribution/src/snapshot.rs +++ b/crates/distribution/src/snapshot.rs @@ -19,6 +19,8 @@ pub struct MemberInfo { pub is_authorized: Option, #[serde(skip_serializing_if = "Option::is_none")] pub label: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub relay_url: Option, } /// Snapshot of a node in the Kademlia routing table. @@ -109,6 +111,10 @@ pub struct DistributionNodeSnapshot { /// Base58-encoded invite code for this node. #[serde(skip_serializing_if = "Option::is_none", default)] pub invite_code: Option, + + /// This node's relay URL, if running an embedded relay server. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub relay_url: Option, } fn node_id_hex(id: &NodeId) -> String { @@ -139,6 +145,7 @@ impl DistributedNode { incarnation: m.incarnation, is_authorized: None, label: None, + relay_url: self.metadata().relay_url(&m.node_id).map(String::from), }) .collect(); @@ -205,6 +212,7 @@ impl DistributedNode { authorized_peer_count: None, node_name: None, invite_code: None, + relay_url: self.metadata().relay_url(&self.node_id()).map(String::from), } } } diff --git a/crates/distribution/tests/common/iroh.rs b/crates/distribution/tests/common/iroh.rs index 973131b..f4e18f5 100644 --- a/crates/distribution/tests/common/iroh.rs +++ b/crates/distribution/tests/common/iroh.rs @@ -22,6 +22,10 @@ pub fn make_driver() -> IrohDriver { relay_mode: RelayMode::Disabled, node: test_config(), peer_auth: None, + #[cfg(feature = "relay")] + embedded_relay_bind: None, + #[cfg(feature = "relay")] + relay_public_ip: None, }) .expect("failed to create iroh driver") } @@ -32,6 +36,10 @@ pub fn make_driver_with_auth(auth: Arc>) -> IrohDriver { relay_mode: RelayMode::Disabled, node: test_config(), peer_auth: Some(auth), + #[cfg(feature = "relay")] + embedded_relay_bind: None, + #[cfg(feature = "relay")] + relay_public_ip: None, }) .expect("failed to create iroh driver") } @@ -42,6 +50,10 @@ pub fn make_driver_with_relay(relay_url: iroh::RelayUrl) -> IrohDriver { relay_mode: RelayMode::Custom(relay_url.into()), node: test_config(), peer_auth: None, + #[cfg(feature = "relay")] + embedded_relay_bind: None, + #[cfg(feature = "relay")] + relay_public_ip: None, }) .expect("failed to create iroh driver with relay") } @@ -197,8 +209,8 @@ impl IrohTestCluster { } /// Shut down all drivers. - pub fn shutdown(&self) { - for d in &self.drivers { + pub fn shutdown(&mut self) { + for d in &mut self.drivers { d.shutdown(); } } diff --git a/crates/distribution/tests/common/mod.rs b/crates/distribution/tests/common/mod.rs index cd8860b..d9b4c5c 100644 --- a/crates/distribution/tests/common/mod.rs +++ b/crates/distribution/tests/common/mod.rs @@ -25,6 +25,7 @@ pub fn test_config() -> DistributedNodeConfig { cache_capacity: 100, republish_interval: 50, registry: RegistryConfig::default(), + metadata_lambda: 3, } } diff --git a/crates/distribution/tests/iroh_driver.rs b/crates/distribution/tests/iroh_driver.rs index e84d810..dc3c24d 100644 --- a/crates/distribution/tests/iroh_driver.rs +++ b/crates/distribution/tests/iroh_driver.rs @@ -23,8 +23,8 @@ use iroh::PublicKey; #[test] fn iroh_driver_creates_with_unique_identity() { - let d1 = make_driver(); - let d2 = make_driver(); + let mut d1 = make_driver(); + let mut d2 = make_driver(); assert_ne!(d1.node_id(), d2.node_id()); d1.shutdown(); d2.shutdown(); @@ -32,7 +32,7 @@ fn iroh_driver_creates_with_unique_identity() { #[test] fn iroh_driver_snapshot_contains_node_id() { - let driver = make_driver(); + let mut driver = make_driver(); let snap = driver.snapshot(); assert!(!snap.node_id.is_empty()); assert_eq!(snap.members.len(), 0); @@ -41,7 +41,7 @@ fn iroh_driver_snapshot_contains_node_id() { #[test] fn iroh_driver_identity_matches_iroh_endpoint() { - let driver = make_driver(); + let mut driver = make_driver(); let node_id = driver.node_id(); let snap = driver.snapshot(); let expected_hex: String = node_id.0.iter().map(|b| format!("{:02x}", b)).collect(); diff --git a/crates/local-runner/Cargo.toml b/crates/local-runner/Cargo.toml deleted file mode 100644 index 5164e74..0000000 --- a/crates/local-runner/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "local-runner" -version = "0.1.0" -edition = "2024" - -[[bin]] -name = "local-runner" -path = "src/main.rs" - -[dependencies] -swactor = { path = "../..", features = ["serde"] } -swactor-ci = { path = "../ci", features = ["local"] } -runtime-dashboard = { path = "../runtime-dashboard", features = ["ci"] } -clap = { version = "4", features = ["derive"] } -ctrlc = "3" -iroh = "0.96" -tokio = { version = "1", features = ["rt-multi-thread"] } -serde_json = "1" diff --git a/crates/runtime-dashboard/src/server.rs b/crates/runtime-dashboard/src/server.rs deleted file mode 100644 index 199c73e..0000000 --- a/crates/runtime-dashboard/src/server.rs +++ /dev/null @@ -1,1216 +0,0 @@ -use std::io::{self, Read as IoRead}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{mpsc, Arc, Mutex}; -use std::thread; -use std::time::{Duration, Instant}; - -use std::collections::HashMap; - -use swactor::runtime::Runtime; - -use crate::actor_detail_html::ACTOR_DETAIL_HTML; -use crate::actors_html::ACTORS_HTML; -use crate::collector::StatsCollector; -use crate::dashboard_html::DASHBOARD_HTML; -use crate::history::DashboardHistory; -use crate::layer::EventStore; -use crate::topology; -use crate::topology_html::TOPOLOGY_HTML; -use crate::trace::RuntimeTrace; -use crate::warnings::{WarningConfig, WarningDetector}; - -#[cfg(feature = "distribution")] -use crate::distribution_collector::DistributionStatsProvider; -#[cfg(feature = "distribution")] -use crate::distribution_html::DISTRIBUTION_HTML; - -use crate::datastore_collector::{DatastoreFactory, DatastoreStatsProvider, ListScope}; -use crate::datastore_html::DATASTORE_HTML; - -#[cfg(feature = "ci")] -use crate::ci_collector::CiStatsProvider; - -/// Format a server-sent event. -fn format_sse(event: &str, data: &str) -> Vec { - format!("event: {event}\ndata: {data}\n\n").into_bytes() -} - -/// Adapts an `mpsc::Receiver>` to `std::io::Read` for tiny_http streaming. -struct ChannelReader { - rx: mpsc::Receiver>, - buf: Vec, - pos: usize, -} - -impl ChannelReader { - fn new(rx: mpsc::Receiver>) -> Self { - Self { - rx, - buf: Vec::new(), - pos: 0, - } - } -} - -impl IoRead for ChannelReader { - fn read(&mut self, out: &mut [u8]) -> io::Result { - // Drain current buffer first. - if self.pos < self.buf.len() { - let n = std::cmp::min(out.len(), self.buf.len() - self.pos); - out[..n].copy_from_slice(&self.buf[self.pos..self.pos + n]); - self.pos += n; - return Ok(n); - } - - // Wait for next chunk. - match self.rx.recv() { - Ok(data) => { - if data.is_empty() { - return Ok(0); // EOF signal - } - let n = std::cmp::min(out.len(), data.len()); - out[..n].copy_from_slice(&data[..n]); - if n < data.len() { - self.buf = data; - self.pos = n; - } else { - self.buf.clear(); - self.pos = 0; - } - Ok(n) - } - Err(_) => Ok(0), // channel closed - } - } -} - -fn make_sse_response( - rx: mpsc::Receiver>, -) -> tiny_http::Response> { - let reader = ChannelReader::new(rx); - tiny_http::Response::new( - tiny_http::StatusCode(200), - vec![ - "Content-Type: text/event-stream" - .parse::() - .unwrap(), - "Cache-Control: no-cache" - .parse::() - .unwrap(), - "Connection: keep-alive" - .parse::() - .unwrap(), - ], - Box::new(reader) as Box, - None, - None, - ) -} - -fn respond_html(request: tiny_http::Request, html_template: &str, mode: &str) { - let html = html_template.replace("__DASHBOARD_MODE__", mode); - let response = tiny_http::Response::from_string(html).with_header( - "Content-Type: text/html; charset=utf-8" - .parse::() - .unwrap(), - ); - let _ = request.respond(response); -} - -fn respond_404(request: tiny_http::Request) { - let response = tiny_http::Response::from_string("Not Found").with_status_code(404); - let _ = request.respond(response); -} - -// ── Live server ───────────────────────────────────────────────────────── - -/// Start the live HTTP server with a pool of handler threads. -pub(crate) fn spawn_http_server( - store: Arc, - runtime: Arc>>>, - collector: Arc>>>, - shutdown: Arc, - history: Arc, - port: u16, - #[cfg(feature = "distribution")] - distribution: Arc>>>, - datastore: Arc>>>, - datastore_factory: Arc>>>, - #[cfg(feature = "ci")] - ci: Arc>>>, - dashboard_token: Arc>>, - peer_auth: Arc>>>>, - join_sender: Arc>>>, -) { - let addr = format!("0.0.0.0:{port}"); - let server = tiny_http::Server::http(&addr).expect("failed to bind HTTP server"); - let server = Arc::new(server); - let cmd_router = Arc::new(crate::command::CommandRouter::with_builtins()); - - for _ in 0..4 { - let server = Arc::clone(&server); - let store = Arc::clone(&store); - let runtime = Arc::clone(&runtime); - let collector = Arc::clone(&collector); - let shutdown = Arc::clone(&shutdown); - let history = Arc::clone(&history); - let cmd_router = Arc::clone(&cmd_router); - #[cfg(feature = "distribution")] - let distribution = Arc::clone(&distribution); - let datastore = Arc::clone(&datastore); - let datastore_factory = Arc::clone(&datastore_factory); - #[cfg(feature = "ci")] - let ci = Arc::clone(&ci); - let dashboard_token = Arc::clone(&dashboard_token); - let peer_auth = Arc::clone(&peer_auth); - let join_sender = Arc::clone(&join_sender); - thread::spawn(move || { - loop { - let request = match server.recv() { - Ok(r) => r, - Err(_) => break, - }; - - let url = request.url().to_string(); - let path = url.split('?').next().unwrap_or(&url); - let method = request.method().as_str(); - match (method, path) { - (_, "/") => respond_html(request, DASHBOARD_HTML, "live"), - (_, "/actors") => respond_html(request, ACTORS_HTML, "live"), - (_, "/topology") => respond_html(request, TOPOLOGY_HTML, "live"), - #[cfg(feature = "distribution")] - (_, "/distribution") => respond_html(request, DISTRIBUTION_HTML, "live"), - (_, "/datastore") => respond_html(request, DATASTORE_HTML, "live"), - (_, "/events") => { - handle_live_sse( - request, - Arc::clone(&store), - Arc::clone(&runtime), - Arc::clone(&collector), - Arc::clone(&shutdown), - Arc::clone(&history), - #[cfg(feature = "distribution")] - Arc::clone(&distribution), - Arc::clone(&datastore), - #[cfg(feature = "ci")] - Arc::clone(&ci), - ); - } - (_, "/api/stats") => { - handle_stats_api( - request, - Arc::clone(&runtime), - Arc::clone(&collector), - ); - } - (_, "/api/history") => { - handle_history_api(request, Arc::clone(&history)); - } - (_, "/api/topology") => { - handle_topology_api( - request, - Arc::clone(&runtime), - Arc::clone(&collector), - ); - } - (_, "/api/investigate") => { - handle_investigate_api( - request, - &url, - Arc::clone(&runtime), - Arc::clone(&collector), - Arc::clone(&cmd_router), - ); - } - #[cfg(feature = "distribution")] - (_, "/api/distribution") => { - handle_distribution_api( - request, - Arc::clone(&distribution), - ); - } - (_, "/api/datastore") => { - handle_datastore_api( - request, - Arc::clone(&datastore), - ); - } - (_, "/api/logs") => { - handle_logs_api(request, &url, Arc::clone(&store)); - } - ("GET", "/api/datastore/list") => { - handle_ds_list(request, &url, &datastore); - } - ("GET", "/api/datastore/get") => { - handle_ds_get(request, &url, &datastore); - } - ("GET", "/api/datastore/data") => { - handle_ds_data(request, &url, &datastore); - } - ("GET", "/api/datastore/status") => { - handle_ds_status(request, &datastore); - } - ("POST", "/api/datastore/put") => { - if !check_token(&request, &dashboard_token) { - respond_auth_error(request, &dashboard_token); - } else { - handle_ds_put(request, &url, &datastore); - } - } - ("POST", "/api/datastore/delete") => { - if !check_token(&request, &dashboard_token) { - respond_auth_error(request, &dashboard_token); - } else { - handle_ds_delete(request, &url, &datastore); - } - } - ("POST", "/api/datastore/start") => { - if !check_token(&request, &dashboard_token) { - respond_auth_error(request, &dashboard_token); - } else { - handle_ds_start(request, &url, &datastore, &datastore_factory); - } - } - ("POST", "/api/datastore/shutdown") => { - if !check_token(&request, &dashboard_token) { - respond_auth_error(request, &dashboard_token); - } else { - handle_ds_shutdown(request, &datastore); - } - } - ("GET", "/api/peers") => { - handle_peers_list(request, &peer_auth); - } - ("POST", "/api/peers/add") => { - if !check_token(&request, &dashboard_token) { - respond_auth_error(request, &dashboard_token); - } else { - handle_peers_add(request, &peer_auth, &join_sender); - } - } - ("POST", "/api/peers/remove") => { - if !check_token(&request, &dashboard_token) { - respond_auth_error(request, &dashboard_token); - } else { - handle_peers_remove(request, &peer_auth); - } - } - #[cfg(feature = "ci")] - _ if path.starts_with("/api/ci/") => { - handle_ci_api(request, path, Arc::clone(&ci)); - } - _ if path.starts_with("/actor/") => { - let hex = &path[7..]; // strip "/actor/" - respond_actor_detail(request, hex); - } - _ => respond_404(request), - } - } - }); - } -} - -fn respond_actor_detail(request: tiny_http::Request, hex_addr: &str) { - let html = ACTOR_DETAIL_HTML - .replace("__DASHBOARD_MODE__", "live") - .replace("__ACTOR_ADDR__", hex_addr); - let response = tiny_http::Response::from_string(html).with_header( - "Content-Type: text/html; charset=utf-8" - .parse::() - .unwrap(), - ); - let _ = request.respond(response); -} - -fn handle_live_sse( - request: tiny_http::Request, - store: Arc, - runtime: Arc>>>, - collector: Arc>>>, - shutdown: Arc, - history: Arc, - #[cfg(feature = "distribution")] - distribution: Arc>>>, - datastore: Arc>>>, - #[cfg(feature = "ci")] - ci: Arc>>>, -) { - let (tx, rx) = mpsc::channel::>(); - let response = make_sse_response(rx); - - // Spawn producer thread - thread::spawn(move || { - let mut cursor: u64 = 0; - let mut warning_detector = WarningDetector::new(WarningConfig::default()); - let mut tick_count: u64 = 0; - - // Send initial history snapshot so sparklines render immediately - if history.sample_count() > 0 { - let json = history.worker_history_json(); - let _ = tx.send(format_sse("history", &json)); - } - - loop { - // Send stats if runtime is available - { - let maybe_rt = runtime.lock().unwrap().clone(); - if let Some(rt) = maybe_rt { - let mut stats = rt.stats(); - if let Some(col) = collector.lock().unwrap().as_ref() { - col.enrich(&mut stats); - } - history.record(&stats); - - // Run warning detection - let warnings = warning_detector.check(&stats); - if !warnings.is_empty() { - if let Ok(wjson) = serde_json::to_string(&warnings) { - if tx.send(format_sse("warnings", &wjson)).is_err() { - return; - } - } - } - - let json = serde_json::to_string(&stats).unwrap(); - if tx.send(format_sse("stats", &json)).is_err() { - return; - } - - // Send topology every 5th tick (~1/sec) - tick_count += 1; - if tick_count % 5 == 0 { - let topo = topology::worker_topology(&stats); - if let Ok(tjson) = serde_json::to_string(&topo) { - if tx.send(format_sse("topology", &tjson)).is_err() { - return; - } - } - } - } - } - - // Send distribution snapshot if provider is attached - #[cfg(feature = "distribution")] - { - let maybe_dist = distribution.lock().unwrap().clone(); - if let Some(provider) = maybe_dist { - if let Some(snapshot) = provider.snapshot() { - if let Ok(json) = serde_json::to_string(&snapshot) { - if tx.send(format_sse("distribution", &json)).is_err() { - return; - } - } - } - } - } - - // Send datastore snapshot if provider is attached - { - let maybe_ds = 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)).is_err() { - return; - } - } - None => { - let envelope = r#"{"is_running":false,"snapshot":null}"#; - if tx.send(format_sse("datastore", envelope)).is_err() { - return; - } - } - } - } - - // Send CI snapshot if provider is attached - #[cfg(feature = "ci")] - { - let maybe_ci = 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)).is_err() { - return; - } - } - } - } - - // Send new activity events - let (batch, new_cursor) = store.read_from(cursor); - if !batch.is_empty() { - let json = serde_json::to_string(&batch).unwrap(); - if tx.send(format_sse("activity", &json)).is_err() { - return; - } - cursor = new_cursor; - } - - if shutdown.load(Ordering::Relaxed) { - let _ = tx.send(format_sse("done", "{}")); - let _ = tx.send(Vec::new()); // EOF - return; - } - - thread::sleep(Duration::from_millis(200)); - } - }); - - // Blocks until connection closes - let _ = request.respond(response); -} - -fn handle_stats_api( - request: tiny_http::Request, - runtime: Arc>>>, - collector: Arc>>>, -) { - let maybe_rt = runtime.lock().unwrap().clone(); - let json = match maybe_rt { - Some(rt) => { - let mut stats = rt.stats(); - if let Some(col) = collector.lock().unwrap().as_ref() { - col.enrich(&mut stats); - } - serde_json::to_string(&stats).unwrap() - } - None => "{}".to_string(), - }; - let response = tiny_http::Response::from_string(json).with_header( - "Content-Type: application/json" - .parse::() - .unwrap(), - ); - let _ = request.respond(response); -} - -fn handle_investigate_api( - request: tiny_http::Request, - url: &str, - runtime: Arc>>>, - collector: Arc>>>, - cmd_router: Arc, -) { - let params = parse_query_string(url); - - let maybe_rt = runtime.lock().unwrap().clone(); - let maybe_col = collector.lock().unwrap().clone(); - - let json = match (maybe_rt, maybe_col) { - (Some(rt), Some(col)) => { - let ctx = crate::command::CommandContext::with_enricher(rt, col); - let req = crate::command::from_query_params(¶ms); - cmd_router.dispatch(&req, &ctx).to_json_line() - } - (Some(rt), None) => { - let ctx = crate::command::CommandContext::new(rt); - let req = crate::command::from_query_params(¶ms); - cmd_router.dispatch(&req, &ctx).to_json_line() - } - _ => { - let cmd = params.get("cmd").map(|s| s.as_str()).unwrap_or("help"); - serde_json::json!({ - "ok": false, - "command": cmd, - "error": "runtime not attached yet" - }) - .to_string() - } - }; - - let response = tiny_http::Response::from_string(json).with_header( - "Content-Type: application/json" - .parse::() - .unwrap(), - ); - let _ = request.respond(response); -} - -#[cfg(feature = "distribution")] -fn handle_distribution_api( - request: tiny_http::Request, - distribution: Arc>>>, -) { - let json = match 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(), - }; - - let response = tiny_http::Response::from_string(json).with_header( - "Content-Type: application/json" - .parse::() - .unwrap(), - ); - let _ = request.respond(response); -} - -fn handle_datastore_api( - request: tiny_http::Request, - datastore: Arc>>>, -) { - let json = match 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(), - }; - - let response = tiny_http::Response::from_string(json).with_header( - "Content-Type: application/json" - .parse::() - .unwrap(), - ); - let _ = request.respond(response); -} - -#[cfg(feature = "ci")] -fn handle_ci_api( - request: tiny_http::Request, - path: &str, - ci: Arc>>>, -) { - use crate::ci_collector; - - let route = ci_collector::parse_route(path); - let json = match 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(), - }; - - let response = tiny_http::Response::from_string(json).with_header( - "Content-Type: application/json" - .parse::() - .unwrap(), - ); - let _ = request.respond(response); -} - -fn handle_topology_api( - request: tiny_http::Request, - runtime: Arc>>>, - collector: Arc>>>, -) { - let maybe_rt = runtime.lock().unwrap().clone(); - let json = match maybe_rt { - Some(rt) => { - let mut stats = rt.stats(); - if let Some(col) = collector.lock().unwrap().as_ref() { - col.enrich(&mut stats); - } - let topo = topology::worker_topology(&stats); - serde_json::to_string(&topo).unwrap_or_else(|_| "{}".into()) - } - None => "{}".to_string(), - }; - let response = tiny_http::Response::from_string(json).with_header( - "Content-Type: application/json" - .parse::() - .unwrap(), - ); - let _ = request.respond(response); -} - -fn handle_logs_api(request: tiny_http::Request, url: &str, store: Arc) { - let params = parse_query_string(url); - let actor = params.get("actor").cloned().unwrap_or_default(); - let limit: usize = params - .get("limit") - .and_then(|s| s.parse().ok()) - .unwrap_or(200); - let level = params.get("level").cloned(); - - let mut events = store.read_for_actor(&actor, limit); - - // Filter by level if specified - if let Some(ref lvl) = level { - let lvl_upper = lvl.to_uppercase(); - events.retain(|e| e.level == lvl_upper); - } - - let json = serde_json::to_string(&events).unwrap_or_else(|_| "[]".into()); - let response = tiny_http::Response::from_string(json).with_header( - "Content-Type: application/json" - .parse::() - .unwrap(), - ); - let _ = request.respond(response); -} - -fn handle_history_api(request: tiny_http::Request, history: Arc) { - let json = history.worker_history_json(); - let response = tiny_http::Response::from_string(json).with_header( - "Content-Type: application/json" - .parse::() - .unwrap(), - ); - let _ = request.respond(response); -} - -fn parse_query_string(url: &str) -> HashMap { - let mut params = HashMap::new(); - if let Some(qs) = url.split('?').nth(1) { - for pair in qs.split('&') { - let mut kv = pair.splitn(2, '='); - if let (Some(k), Some(v)) = (kv.next(), kv.next()) { - params.insert(k.to_string(), v.to_string()); - } - } - } - params -} - -// ── Datastore CRUD API handlers ────────────────────────────────────────── - -fn ds_respond_json(request: tiny_http::Request, json: &str) { - let response = tiny_http::Response::from_string(json).with_header( - "Content-Type: application/json" - .parse::() - .unwrap(), - ); - let _ = request.respond(response); -} - -fn ds_respond_bytes(request: tiny_http::Request, data: &[u8]) { - let response = tiny_http::Response::from_data(data.to_vec()).with_header( - "Content-Type: application/octet-stream" - .parse::() - .unwrap(), - ); - let _ = request.respond(response); -} - -fn ds_respond_error(request: tiny_http::Request, status: u16, msg: &str) { - let json = serde_json::json!({ "error": msg }).to_string(); - let response = tiny_http::Response::from_string(json) - .with_status_code(status) - .with_header( - "Content-Type: application/json" - .parse::() - .unwrap(), - ); - let _ = request.respond(response); -} - -fn get_ds_provider( - datastore: &Arc>>>, -) -> Option> { - datastore.lock().unwrap().clone() -} - -fn handle_ds_list( - request: tiny_http::Request, - url: &str, - datastore: &Arc>>>, -) { - let provider = match get_ds_provider(datastore) { - Some(p) => p, - None => { ds_respond_error(request, 503, "datastore not running"); return; } - }; - let params = parse_query_string(url); - let scope = match params.get("scope").map(|s| s.as_str()) { - Some("local") => ListScope::Local, - _ => ListScope::Swarm, - }; - let name_filter = params.get("name").map(|s| s.as_str()); - match provider.list_objects(name_filter, scope) { - Ok(json) => ds_respond_json(request, &json), - Err(e) => ds_respond_error(request, 500, &e), - } -} - -fn handle_ds_get( - request: tiny_http::Request, - url: &str, - datastore: &Arc>>>, -) { - let provider = match get_ds_provider(datastore) { - Some(p) => p, - None => { ds_respond_error(request, 503, "datastore not running"); return; } - }; - let params = parse_query_string(url); - let hash = match params.get("hash") { - Some(h) => h.as_str(), - None => { ds_respond_error(request, 400, "missing ?hash= parameter"); return; } - }; - match provider.get_object(hash) { - Ok(json) => ds_respond_json(request, &json), - Err(e) if e.contains("not found") => ds_respond_error(request, 404, &e), - Err(e) => ds_respond_error(request, 500, &e), - } -} - -fn handle_ds_data( - request: tiny_http::Request, - url: &str, - datastore: &Arc>>>, -) { - let provider = match get_ds_provider(datastore) { - Some(p) => p, - None => { ds_respond_error(request, 503, "datastore not running"); return; } - }; - let params = parse_query_string(url); - let hash = match params.get("hash") { - Some(h) => h.as_str(), - None => { ds_respond_error(request, 400, "missing ?hash= parameter"); return; } - }; - match provider.get_data(hash) { - Ok(data) => ds_respond_bytes(request, &data), - Err(e) if e.contains("not found") => ds_respond_error(request, 404, &e), - Err(e) => ds_respond_error(request, 500, &e), - } -} - -fn handle_ds_status( - request: tiny_http::Request, - datastore: &Arc>>>, -) { - let provider = match get_ds_provider(datastore) { - Some(p) => p, - None => { ds_respond_error(request, 503, "datastore not running"); return; } - }; - match provider.node_status() { - Ok(json) => ds_respond_json(request, &json), - Err(e) => ds_respond_error(request, 500, &e), - } -} - -fn handle_ds_put( - mut request: tiny_http::Request, - url: &str, - datastore: &Arc>>>, -) { - let provider = match get_ds_provider(datastore) { - Some(p) => p, - None => { ds_respond_error(request, 503, "datastore not running"); return; } - }; - let params = parse_query_string(url); - let name = params.get("name").cloned(); - - let mut body = Vec::new(); - if request.as_reader().read_to_end(&mut body).is_err() { - return; - } - - match provider.put_data(body, name) { - Ok(json) => ds_respond_json(request, &json), - Err(e) => ds_respond_error(request, 500, &e), - } -} - -fn handle_ds_delete( - request: tiny_http::Request, - url: &str, - datastore: &Arc>>>, -) { - let provider = match get_ds_provider(datastore) { - Some(p) => p, - None => { ds_respond_error(request, 503, "datastore not running"); return; } - }; - let params = parse_query_string(url); - let hash = match params.get("hash") { - Some(h) => h.as_str(), - None => { ds_respond_error(request, 400, "missing ?hash= parameter"); return; } - }; - match provider.delete_object(hash) { - Ok(json) => ds_respond_json(request, &json), - Err(e) if e.contains("not found") => ds_respond_error(request, 404, &e), - Err(e) => ds_respond_error(request, 500, &e), - } -} - -fn handle_ds_start( - request: tiny_http::Request, - url: &str, - datastore: &Arc>>>, - factory: &Arc>>>, -) { - // Check if already running - { - let ds = datastore.lock().unwrap(); - if ds.is_some() { - ds_respond_error(request, 409, "datastore already running"); - return; - } - } - - let fac = match factory.lock().unwrap().clone() { - Some(f) => f, - None => { ds_respond_error(request, 501, "no datastore factory configured"); return; } - }; - - let params = parse_query_string(url); - let storage_path = params.get("storage_path").cloned(); - - match fac.start_datastore(storage_path) { - Ok(provider) => { - *datastore.lock().unwrap() = Some(provider); - ds_respond_json(request, r#"{"ok":true}"#); - } - Err(e) => ds_respond_error(request, 500, &e), - } -} - -fn handle_ds_shutdown( - request: tiny_http::Request, - datastore: &Arc>>>, -) { - let provider = match get_ds_provider(datastore) { - Some(p) => p, - None => { ds_respond_error(request, 503, "datastore not running"); return; } - }; - - match provider.shutdown_datastore() { - Ok(()) => { - *datastore.lock().unwrap() = None; - ds_respond_json(request, r#"{"ok":true}"#); - } - Err(e) => ds_respond_error(request, 500, &e), - } -} - -// ── Dashboard Token Auth ───────────────────────────────────────────────── - -/// Check if the request has a valid Bearer token matching the dashboard token. -/// Returns true if no token is configured or if the token matches. -fn check_token( - request: &tiny_http::Request, - token: &Arc>>, -) -> bool { - let expected = token.lock().unwrap().clone(); - let expected = match expected { - Some(t) => t, - None => return true, // No token configured = open - }; - - for header in request.headers() { - if header.field.as_str().as_str().eq_ignore_ascii_case("authorization") { - let val = header.value.as_str(); - if let Some(bearer) = val.strip_prefix("Bearer ") { - return bearer.trim() == expected; - } - } - } - false -} - -fn respond_auth_error(request: tiny_http::Request, token: &Arc>>) { - let has_token = token.lock().unwrap().is_some(); - let (status, msg) = if has_token { - (403, "forbidden: invalid or missing dashboard token") - } else { - (401, "unauthorized") - }; - ds_respond_error(request, status, msg); -} - -// ── Peer Management API ───────────────────────────────────────────────── - -fn handle_peers_list( - request: tiny_http::Request, - peer_auth: &Arc>>>>, -) { - let maybe_auth = peer_auth.lock().unwrap().clone(); - match maybe_auth { - Some(auth) => { - let list = auth.lock().unwrap(); - let is_open = list.is_open(); - let peers: Vec = 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(); - ds_respond_json(request, &json); - } - None => { - let json = serde_json::json!({ - "mode": "open", - "peers": [], - }) - .to_string(); - ds_respond_json(request, &json); - } - } -} - -fn handle_peers_add( - mut request: tiny_http::Request, - peer_auth: &Arc>>>>, - join_sender: &Arc>>>, -) { - - let maybe_auth = peer_auth.lock().unwrap().clone(); - let auth = match maybe_auth { - Some(a) => a, - None => { - ds_respond_error(request, 400, "peer auth not configured"); - return; - } - }; - - let mut body = String::new(); - if request.as_reader().read_to_string(&mut body).is_err() { - ds_respond_error(request, 400, "failed to read body"); - return; - } - - let parsed: serde_json::Value = match serde_json::from_str(&body) { - Ok(v) => v, - Err(e) => { - ds_respond_error(request, 400, &format!("invalid JSON: {e}")); - return; - } - }; - - let node_id_str = match parsed.get("node_id").and_then(|v| v.as_str()) { - Some(s) => s, - None => { - ds_respond_error(request, 400, "missing node_id field"); - return; - } - }; - 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(_) => { - ds_respond_error(request, 400, "invalid node_id (hex decoded to wrong length)"); - return; - } - } - } else if let Some(arr) = distribution::identity::base58_decode(node_id_str) { - arr - } else { - ds_respond_error(request, 400, "invalid node_id (expected 64-char hex or base58)"); - return; - }; - - 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) = join_sender.lock().unwrap().as_ref() { - let _ = tx.send(bytes); - } - - ds_respond_json(request, r#"{"ok":true}"#); -} - -fn handle_peers_remove( - mut request: tiny_http::Request, - peer_auth: &Arc>>>>, -) { - - let maybe_auth = peer_auth.lock().unwrap().clone(); - let auth = match maybe_auth { - Some(a) => a, - None => { - ds_respond_error(request, 400, "peer auth not configured"); - return; - } - }; - - let mut body = String::new(); - if request.as_reader().read_to_string(&mut body).is_err() { - ds_respond_error(request, 400, "failed to read body"); - return; - } - - let parsed: serde_json::Value = match serde_json::from_str(&body) { - Ok(v) => v, - Err(e) => { - ds_respond_error(request, 400, &format!("invalid JSON: {e}")); - return; - } - }; - - let node_id_hex = match parsed.get("node_id").and_then(|v| v.as_str()) { - Some(s) => s, - None => { - ds_respond_error(request, 400, "missing node_id field"); - return; - } - }; - - let bytes = match distribution::identity::hex_decode(node_id_hex) { - Some(b) if b.len() == 32 => b, - _ => { - ds_respond_error(request, 400, "invalid node_id hex (must be 64 hex chars)"); - return; - } - }; - - let node_id = distribution::types::NodeId(bytes.try_into().unwrap()); - let mut list = auth.lock().unwrap(); - list.remove_peer(&node_id); - if let Err(e) = list.save() { - eprintln!("warning: failed to persist peers.json: {e}"); - } - - ds_respond_json(request, r#"{"ok":true}"#); -} - -// ── Replay server ─────────────────────────────────────────────────────── - -/// Start a replay HTTP server that serves a pre-recorded trace. -pub(crate) fn spawn_replay_server(trace: Arc, port: u16, speed: f64) { - let addr = format!("0.0.0.0:{port}"); - let server = tiny_http::Server::http(&addr).expect("failed to bind HTTP server"); - let server = Arc::new(server); - - for _ in 0..4 { - let server = Arc::clone(&server); - let trace = Arc::clone(&trace); - thread::spawn(move || { - loop { - let request = match server.recv() { - Ok(r) => r, - Err(_) => break, - }; - - let url = request.url().to_string(); - match url.as_str() { - "/" => respond_html(request, DASHBOARD_HTML, "replay"), - "/actors" => respond_html(request, ACTORS_HTML, "replay"), - "/events" => { - handle_replay_sse(request, Arc::clone(&trace), speed); - } - _ => respond_404(request), - } - } - }); - } -} - -fn handle_replay_sse(request: tiny_http::Request, trace: Arc, speed: f64) { - let (tx, rx) = mpsc::channel::>(); - let response = make_sse_response(rx); - - thread::spawn(move || { - // Send replay metadata - let meta = serde_json::json!({ - "total_events": trace.events.len(), - "total_stats": trace.stats_timeline.len(), - "speed": speed, - }); - if tx.send(format_sse("replay_meta", &meta.to_string())).is_err() { - return; - } - - // Find the earliest timestamp across events and stats - let base_time = trace - .events - .first() - .map(|e| e.timestamp_ms) - .into_iter() - .chain(trace.stats_timeline.first().map(|s| s.timestamp_ms)) - .min() - .unwrap_or(0); - - let playback_start = Instant::now(); - let mut event_idx = 0; - let mut stats_idx = 0; - - loop { - let elapsed_ms = (playback_start.elapsed().as_millis() as f64 * speed) as u64; - let virtual_time = base_time + elapsed_ms; - - // Batch events up to virtual_time - let mut batch = Vec::new(); - while event_idx < trace.events.len() - && trace.events[event_idx].timestamp_ms <= virtual_time - { - batch.push(trace.events[event_idx].clone()); - event_idx += 1; - } - if !batch.is_empty() { - let json = serde_json::to_string(&batch).unwrap(); - if tx.send(format_sse("activity", &json)).is_err() { - return; - } - } - - // Send stats snapshots up to virtual_time - while stats_idx < trace.stats_timeline.len() - && trace.stats_timeline[stats_idx].timestamp_ms <= virtual_time - { - let json = - serde_json::to_string(&trace.stats_timeline[stats_idx].stats).unwrap(); - if tx.send(format_sse("stats", &json)).is_err() { - return; - } - stats_idx += 1; - } - - // Send progress - let total = trace.events.len() + trace.stats_timeline.len(); - let done_count = event_idx + stats_idx; - let progress = if total > 0 { - done_count as f64 / total as f64 - } else { - 1.0 - }; - let progress_json = serde_json::json!({ "progress": progress }); - if tx - .send(format_sse("replay_progress", &progress_json.to_string())) - .is_err() - { - return; - } - - // Check if replay is complete - if event_idx >= trace.events.len() - && stats_idx >= trace.stats_timeline.len() - { - let _ = tx.send(format_sse("done", "{}")); - let _ = tx.send(Vec::new()); // EOF - return; - } - - thread::sleep(Duration::from_millis(50)); - } - }); - - // Blocks until connection closes - let _ = request.respond(response); -} diff --git a/crates/std/src/extension.rs b/crates/std/src/extension.rs index 713495e..91e3126 100644 --- a/crates/std/src/extension.rs +++ b/crates/std/src/extension.rs @@ -28,6 +28,11 @@ impl StdExtension { group_registry: GroupRegistry::new(), } } + + /// Resolve a human-readable name for an actor address (reverse lookup). + pub fn resolve_name(&self, addr: &ActorAddress) -> Option { + self.name_registry.lookup_by_addr(addr) + } } impl Default for StdExtension { diff --git a/crates/std/src/name_registry.rs b/crates/std/src/name_registry.rs index 3b1cc0f..97cce0d 100644 --- a/crates/std/src/name_registry.rs +++ b/crates/std/src/name_registry.rs @@ -52,6 +52,11 @@ impl NameRegistry { } } + /// Look up the name bound to an actor address (reverse lookup). + pub fn lookup_by_addr(&self, addr: &ActorAddress) -> Option { + self.reverse.read().unwrap().get(addr).cloned() + } + /// Return all registered names. pub fn registered_names(&self) -> Vec { self.names.read().unwrap().keys().cloned().collect() diff --git a/crates/swactor-node/Cargo.toml b/crates/swactor-node/Cargo.toml index 7eb01f6..1ebd0a7 100644 --- a/crates/swactor-node/Cargo.toml +++ b/crates/swactor-node/Cargo.toml @@ -5,22 +5,22 @@ edition = "2024" [dependencies] swactor = { path = "../..", features = ["serde", "tracing", "transport"] } -runtime-dashboard = { path = "../runtime-dashboard", features = ["distribution"] } -swactor-datastore = { path = "../datastore", features = ["node"] } +swactor-std = { path = "../std" } +dashboard = { path = "../dashboard", features = ["distribution"] } +swactor-datastore = { path = "../datastore" } distribution = { path = "../distribution" } clap = { version = "4", features = ["derive"] } ctrlc = "3" iroh = { version = "0.96", optional = true } -mdns-sd = { version = "0.11", optional = true } toml = "0.8" serde = { version = "1", features = ["derive"] } libc = "0.2" [features] -default = ["iroh", "mdns"] +default = ["iroh", "relay"] tcp = ["distribution/tcp"] iroh = ["distribution/iroh", "dep:iroh"] -mdns = ["dep:mdns-sd"] +relay = ["iroh", "distribution/relay"] [[bin]] name = "swactor" diff --git a/crates/swactor-node/src/config.rs b/crates/swactor-node/src/config.rs index 2a4e561..3fa02e8 100644 --- a/crates/swactor-node/src/config.rs +++ b/crates/swactor-node/src/config.rs @@ -10,7 +10,6 @@ use serde::Deserialize; pub struct SwactorNodeConfig { pub transport: Option, pub dashboard_port: Option, - pub dashboard_token: Option, pub storage_path: Option, pub identity_dir: Option, pub peers_file: Option, @@ -25,7 +24,10 @@ pub struct SwactorNodeConfig { pub disseminate_interval: Option, pub no_datastore: Option, pub node_name: Option, - pub mdns: Option, + pub relay: Option, + pub relay_port: Option, + pub relay_bind: Option, + pub relay_hosts: Option>, } impl SwactorNodeConfig { diff --git a/crates/swactor-node/src/main.rs b/crates/swactor-node/src/main.rs index 48d2cf6..643d00a 100644 --- a/crates/swactor-node/src/main.rs +++ b/crates/swactor-node/src/main.rs @@ -14,7 +14,6 @@ use clap::{Parser, Subcommand}; use swactor::actor::{ActorInterface, Ctx}; use swactor::config::RuntimeConfig; use swactor::runtime::Runtime; - use distribution::crypto::Keypair; use distribution::identity::{base58_encode, base58_decode, hex_encode, load_or_generate_keypair}; use distribution::node::DistributedNodeConfig; @@ -23,23 +22,18 @@ use distribution::snapshot::DistributionNodeSnapshot; use distribution::swim::probe::SwimConfig; use distribution::types::NodeId; -use runtime_dashboard::collector::StatsCollector; -use runtime_dashboard::distribution_collector::DistributionStatsProvider; -use runtime_dashboard::{start_dashboard, DashboardConfig}; +use dashboard::collector::StatsCollector; +use dashboard::distribution_collector::DistributionStatsProvider; +use dashboard::{start_dashboard, DashboardConfig}; -use swactor_datastore::actors::{BlobStoreActor, DatastoreNode, GatewayActor, MetadataActor}; -use swactor_datastore::auth::{AccessControlList, AuthzEngine}; -use swactor_datastore::bridge::{DatastoreBridge, DatastoreNodeFactory}; -use swactor_datastore::messages::{GatewayMsg, MetadataMsg}; -use swactor_datastore::metrics::DatastoreMetrics; -use swactor_datastore::storage::{FilesystemBackend, InMemoryBackend}; -use swactor_datastore::DatastoreConfig; +use swactor_datastore::bridge::DatastoreNodeFactory; +use swactor_datastore::{DatastoreAuthConfig, DatastoreGroup, DatastoreGroupConfig}; mod config; mod install; -#[cfg(feature = "mdns")] -mod mdns; mod names; +#[cfg(feature = "relay")] +mod relay; // ── CLI ────────────────────────────────────────────────────────────────── @@ -143,17 +137,21 @@ struct Args { #[arg(long, default_value = "./auth")] auth_dir: String, - /// Dashboard API token (omit to auto-generate from keypair) - #[arg(long)] - dashboard_token: Option, - /// Human-readable node name (e.g. "swift-falcon") #[arg(long)] node_name: Option, - /// Disable mDNS LAN discovery + /// Disable embedded relay server #[arg(long)] - no_mdns: bool, + no_relay: bool, + + /// Port for embedded relay server + #[arg(long, default_value = "3340")] + relay_port: u16, + + /// Bind address for embedded relay server + #[arg(long, default_value = "0.0.0.0")] + relay_bind: String, } // ── Dummy actor ────────────────────────────────────────────────────────── @@ -215,6 +213,8 @@ identity_dir = "{dir}/identity" peers_file = "{dir}/peers.json" auth = true auth_dir = "{dir}/auth" +relay = true +relay_port = 3340 "#, dir = config_dir.display(), ); @@ -314,7 +314,6 @@ fn main() { #[cfg(feature = "tcp")] let seed = args.seed.clone().or(cfg.seed); let auth_enabled = args.auth || cfg.auth.unwrap_or(false); - let dashboard_token = args.dashboard_token.clone().or(cfg.dashboard_token); let actors = if args.actors != 0 { args.actors } else { @@ -336,7 +335,18 @@ fn main() { cfg.disseminate_interval.unwrap_or(args.disseminate_interval) }; let no_datastore = args.no_datastore || cfg.no_datastore.unwrap_or(false); - let mdns_enabled = !args.no_mdns && cfg.mdns.unwrap_or(true); + let relay_enabled = !args.no_relay && cfg.relay.unwrap_or(true); + let relay_port = if args.relay_port != 3340 { + args.relay_port + } else { + cfg.relay_port.unwrap_or(args.relay_port) + }; + let relay_bind = if args.relay_bind != "0.0.0.0" { + args.relay_bind.clone() + } else { + cfg.relay_bind.unwrap_or_else(|| args.relay_bind.clone()) + }; + let relay_hosts = cfg.relay_hosts.unwrap_or_default(); #[cfg(feature = "tcp")] let listen = args.listen.or_else(|| { cfg.listen.as_ref().and_then(|s| s.parse().ok()) @@ -468,21 +478,12 @@ fn main() { }; let peer_auth = Arc::new(Mutex::new(peer_auth)); - // Dashboard token - let dashboard_token = dashboard_token.unwrap_or_else(|| { - // Derive a token from the first 16 bytes of the node's secret key hash - let secret = keypair.secret_bytes(); - hex_encode(&secret[..16]) - }); - eprintln!("Dashboard token: {dashboard_token}"); - // Start dashboard let dash = start_dashboard(DashboardConfig { port: dashboard_port, ..Default::default() }); dash.install_tracing(); - dash.set_dashboard_token(dashboard_token); dash.set_peer_auth(Arc::clone(&peer_auth)); // Create actor runtime @@ -493,102 +494,40 @@ fn main() { max_actors: 1024, channel_buffer_size: 2000, ..Default::default() - }); + }) + .with_extension(Arc::new(swactor_std::StdExtension::new())); rt.set_stats_hook(collector.clone()); let handle = rt.run().expect("failed to start runtime"); dash.set_runtime(handle.runtime.clone(), collector); // Datastore setup - let mut ds_metadata_addr = None; - let mut ds_gateway_addr = None; - let ds_gc_interval = gc_interval; - let ds_disseminate_interval = disseminate_interval; - - if !no_datastore { - let config = DatastoreConfig { - chunk_size, - storage_path: storage_path - .as_ref() - .map(|s| s.into()) - .unwrap_or_else(|| "datastore".into()), - ..Default::default() - }; - - let backend: Box = match &storage_path { - Some(path) => { - let p = std::path::PathBuf::from(path); - std::fs::create_dir_all(&p).expect("failed to create storage directory"); - Box::new(FilesystemBackend::new(p)) - } - None => Box::new(InMemoryBackend::new()), - }; - - let blob_store_addr = handle - .runtime - .spawn(BlobStoreActor::new(backend)) - .expect("failed to spawn BlobStoreActor"); - - let mut metadata = MetadataActor::new(node_id, &config); - metadata.set_blob_store(blob_store_addr); - let metadata_addr = handle - .runtime - .spawn(metadata) - .expect("failed to spawn MetadataActor"); - - let datastore_node = - DatastoreNode::new(node_id, blob_store_addr, metadata_addr, config); - let datastore_addr = handle - .runtime - .spawn(datastore_node) - .expect("failed to spawn DatastoreNode"); - - // Spawn GatewayActor if auth is enabled - let gateway_addr = if auth_enabled { - let auth_dir = std::path::PathBuf::from(&auth_dir_str); - std::fs::create_dir_all(&auth_dir).expect("failed to create auth directory"); - let acl_path = auth_dir.join("acl.json"); - let acl = AccessControlList::load_or_create(&acl_path, node_id) - .expect("failed to load/create ACL"); - let engine = AuthzEngine::new(acl); - let gateway = GatewayActor::new(engine, datastore_addr, Some(acl_path)); - let addr = handle - .runtime - .spawn(gateway) - .expect("failed to spawn GatewayActor"); - eprintln!("Auth: enabled (owner {})", &node_hex[..16]); - Some(addr) - } else { - eprintln!("Auth: disabled"); - None - }; - - let metrics = Arc::new(DatastoreMetrics::new()); - metrics.set_node_id(node_hex.clone()); - - let bridge = DatastoreBridge::new( - Arc::clone(&metrics), - Arc::clone(&handle.runtime), - datastore_addr, - metadata_addr, - blob_store_addr, - ); - - dash.set_datastore(Arc::new(bridge)); - ds_metadata_addr = Some(metadata_addr); - ds_gateway_addr = gateway_addr; - - if storage_path.is_some() { - eprintln!( - "Datastore: persistent ({})", - storage_path.as_ref().unwrap() - ); - } else { - eprintln!("Datastore: in-memory"); - } + let ds_group = if !no_datastore { + let group = DatastoreGroup::spawn( + handle.runtime.clone(), + DatastoreGroupConfig { + node_id, + node_id_hex: node_hex.clone(), + chunk_size, + storage_path: storage_path.clone(), + auth: if auth_enabled { + Some(DatastoreAuthConfig { + auth_dir: auth_dir_str.into(), + }) + } else { + None + }, + gc_interval, + disseminate_interval, + }, + ) + .expect("failed to start datastore"); + dash.set_datastore(group.bridge().clone()); + Some(group) } else { eprintln!("Datastore: disabled"); - } + None + }; // Always wire up the factory so the UI can start/stop datastore let factory = DatastoreNodeFactory::new(Arc::clone(&handle.runtime), chunk_size); @@ -609,30 +548,9 @@ fn main() { ..Default::default() }; - // Channel for triggering SWIM joins (fed by mDNS and dashboard "Add Peer") - let (join_tx, join_rx) = std::sync::mpsc::channel::<[u8; 32]>(); - dash.set_join_sender(join_tx.clone()); - - // Start mDNS discovery (if enabled) - #[cfg(feature = "mdns")] - let _mdns_handle = if mdns_enabled { - eprintln!("mDNS: enabled (LAN discovery)"); - Some(mdns::start_mdns( - &invite_code, - &node_name, - Arc::clone(&peer_auth), - join_tx, - )) - } else { - drop(join_tx); - eprintln!("mDNS: disabled"); - None - }; - #[cfg(not(feature = "mdns"))] - { - let _ = mdns_enabled; - drop(join_tx); - } + // Channel for triggering SWIM joins (fed by dashboard "Add Peer") + let (join_tx, join_rx) = std::sync::mpsc::channel::(); + dash.set_join_sender(join_tx); match transport.as_str() { #[cfg(feature = "iroh")] @@ -646,13 +564,14 @@ fn main() { &handle, &dash, &stop, - ds_metadata_addr, - ds_gateway_addr, - ds_gc_interval, - ds_disseminate_interval, + &ds_group, node_name, invite_code, join_rx, + relay_enabled, + &relay_bind, + relay_port, + relay_hosts, ), #[cfg(feature = "tcp")] "tcp" => run_tcp( @@ -666,10 +585,7 @@ fn main() { &handle, &dash, &stop, - ds_metadata_addr, - ds_gateway_addr, - ds_gc_interval, - ds_disseminate_interval, + &ds_group, node_name, invite_code, join_rx, @@ -686,10 +602,6 @@ fn main() { } eprintln!("\nShutting down..."); - #[cfg(feature = "mdns")] - if let Some(h) = _mdns_handle { - h.shutdown(); - } handle.shutdown(); dash.shutdown(); handle.join(); @@ -707,15 +619,12 @@ fn run_tcp( keypair: Keypair, peer_auth: Arc>, handle: &swactor::runtime::RuntimeHandle, - dash: &runtime_dashboard::DashboardHandle, + dash: &dashboard::DashboardHandle, stop: &Arc, - ds_metadata_addr: Option, - ds_gateway_addr: Option, - ds_gc_interval: u64, - ds_disseminate_interval: u64, + ds_group: &Option, node_name: String, invite_code: String, - _join_rx: std::sync::mpsc::Receiver<[u8; 32]>, + _join_rx: std::sync::mpsc::Receiver, ) { use distribution::driver::NodeDriver; @@ -750,6 +659,8 @@ fn run_tcp( }; dash.set_distribution(Arc::new(provider)); + // Start dashboard HTTP on a standalone tokio runtime (no iroh runtime in TCP mode) + dash.start_http_standalone(); eprintln!("Dashboard at http://0.0.0.0:{dashboard_port}"); // Main loop @@ -770,18 +681,8 @@ fn run_tcp( *cached_snapshot.lock().unwrap() = Some(snap); // Datastore ticks - if let Some(metadata_addr) = ds_metadata_addr { - if round % ds_gc_interval == 0 { - let _ = handle.runtime.send_to(metadata_addr, MetadataMsg::GcTick); - if let Some(gw) = ds_gateway_addr { - let _ = handle.runtime.send_to(gw, GatewayMsg::NonceGcTick); - } - } - if round % ds_disseminate_interval == 0 { - let _ = handle - .runtime - .send_to(metadata_addr, MetadataMsg::DisseminateTick); - } + if let Some(group) = ds_group { + group.tick(round); } thread::sleep(Duration::from_millis(100)); @@ -799,24 +700,85 @@ fn run_iroh( keypair: Keypair, peer_auth: Arc>, handle: &swactor::runtime::RuntimeHandle, - dash: &runtime_dashboard::DashboardHandle, + dash: &dashboard::DashboardHandle, stop: &Arc, - ds_metadata_addr: Option, - ds_gateway_addr: Option, - ds_gc_interval: u64, - ds_disseminate_interval: u64, + ds_group: &Option, node_name: String, invite_code: String, - join_rx: std::sync::mpsc::Receiver<[u8; 32]>, + join_rx: std::sync::mpsc::Receiver, + relay_enabled: bool, + relay_bind: &str, + relay_port: u16, + relay_hosts: Vec, ) { use distribution::iroh_driver::{IrohDriver, IrohDriverConfig}; use iroh::{RelayMode, SecretKey}; + // Evaluate relay candidacy and determine embedded relay bind address + #[cfg(feature = "relay")] + let (embedded_relay_bind, relay_public_ip) = if relay_enabled { + let bind_addr: std::net::SocketAddr = format!("{relay_bind}:{relay_port}") + .parse() + .unwrap_or_else(|e| { + eprintln!("Invalid relay bind address: {e}"); + std::process::exit(1); + }); + + let rules: Vec> = vec![ + Box::new(relay::PublicIpRule), + Box::new(relay::PortBindRule::new(bind_addr)), + ]; + match relay::evaluate_candidacy(&rules) { + Ok(()) => { + let public_ip = relay::PublicIpRule::outbound_ip(); + if let Some(ip) = &public_ip { + eprintln!("Relay: candidacy passed, will start on {bind_addr} (public IP: {ip})"); + } else { + eprintln!("Relay: candidacy passed, will start on {bind_addr} (no public IP detected)"); + } + (Some(bind_addr), public_ip) + } + Err(reason) => { + eprintln!("Relay: not eligible — {reason}"); + (None, None) + } + } + } else { + eprintln!("Relay: disabled"); + (None, None) + }; + + #[cfg(not(feature = "relay"))] + let _ = (relay_enabled, relay_bind, relay_port); + + // Compute relay mode from known relay hosts (if any) + let relay_mode = { + let urls: Vec = relay_hosts.iter() + .filter_map(|host| { + let url_str = format!("http://{host}:{relay_port}/"); + match url_str.parse::() { + Ok(u) => Some(u), + Err(e) => { eprintln!("Relay: bad host {host}: {e}"); None } + } + }) + .collect(); + if urls.is_empty() { + RelayMode::Disabled + } else { + eprintln!("Relay: using {} known relay(s)", urls.len()); + RelayMode::Custom(urls.into_iter().collect::()) + } + }; + let iroh_config = IrohDriverConfig { secret_key: Some(SecretKey::from_bytes(&keypair.secret_bytes())), - relay_mode: RelayMode::Default, + relay_mode, node: node_config, peer_auth: Some(peer_auth.clone()), + #[cfg(feature = "relay")] + embedded_relay_bind, + #[cfg(feature = "relay")] + relay_public_ip, }; let mut driver = IrohDriver::new(iroh_config).expect("failed to create iroh driver"); @@ -834,6 +796,14 @@ fn run_iroh( // Spawn and register actors let actor_addrs = spawn_actors(actors, handle, driver.node_mut()); + // Announce relay URL to cluster gossip + let mut home_relay_set = if let Some(url) = driver.relay_url().map(|u| u.to_string()) { + driver.node_mut().set_relay_url(Some(url)); + true + } else { + false + }; + // Wire distribution snapshot to dashboard let mut snap = driver.snapshot(); snap.node_name = Some(node_name.clone()); @@ -845,6 +815,8 @@ fn run_iroh( }; dash.set_distribution(Arc::new(provider)); + // Start dashboard HTTP on IrohDriver's tokio runtime + dash.start_http(driver.tokio_handle()); eprintln!("Dashboard at http://0.0.0.0:{dashboard_port}"); // Main loop @@ -855,19 +827,32 @@ fn run_iroh( driver.recv(); driver.tick(); - // Drain discovered peers (mDNS + dashboard "Add Peer") and auto-join them + // Drain discovered peers (dashboard "Add Peer") and auto-join them { let mut new_peers = Vec::new(); - while let Ok(bytes) = join_rx.try_recv() { - new_peers.push(bytes); + while let Ok(info) = join_rx.try_recv() { + new_peers.push(info); } if !new_peers.is_empty() { let addrs: Vec = new_peers .iter() - .filter_map(|b| { - iroh::PublicKey::from_bytes(b).ok().map(|k| { - eprintln!("Auto-joining peer {}", base58_encode(b)); - iroh::EndpointAddr::from(k) + .filter_map(|(bytes, relay_url)| { + iroh::PublicKey::from_bytes(bytes).ok().map(|k| { + let mut addr = iroh::EndpointAddr::from(k); + if let Some(url_str) = relay_url { + match url_str.parse::() { + Ok(url) => { + eprintln!("Auto-joining peer {} via relay {}", base58_encode(bytes), url); + addr = addr.with_relay_url(url); + } + Err(e) => { + eprintln!("Auto-joining peer {} (bad relay URL {}: {e})", base58_encode(bytes), url_str); + } + } + } else { + eprintln!("Auto-joining peer {} (no relay URL)", base58_encode(bytes)); + } + addr }) }) .collect(); @@ -877,6 +862,15 @@ fn run_iroh( } } + // Lazily pick up home relay URL once the endpoint connects + if !home_relay_set { + if let Some(url) = driver.home_relay_url() { + eprintln!("Relay URL (home): {url}"); + driver.node_mut().set_relay_url(Some(url.to_string())); + home_relay_set = true; + } + } + for addr in &actor_addrs { let _ = handle.runtime.send_to(*addr, Heartbeat); } @@ -887,23 +881,14 @@ fn run_iroh( *cached_snapshot.lock().unwrap() = Some(snap); // Datastore ticks - if let Some(metadata_addr) = ds_metadata_addr { - if round % ds_gc_interval == 0 { - let _ = handle.runtime.send_to(metadata_addr, MetadataMsg::GcTick); - if let Some(gw) = ds_gateway_addr { - let _ = handle.runtime.send_to(gw, GatewayMsg::NonceGcTick); - } - } - if round % ds_disseminate_interval == 0 { - let _ = handle - .runtime - .send_to(metadata_addr, MetadataMsg::DisseminateTick); - } + if let Some(group) = ds_group { + group.tick(round); } thread::sleep(Duration::from_millis(100)); } + // Driver shutdown handles embedded relay cleanup automatically driver.shutdown(); } diff --git a/crates/swactor-node/src/mdns.rs b/crates/swactor-node/src/mdns.rs deleted file mode 100644 index a70c618..0000000 --- a/crates/swactor-node/src/mdns.rs +++ /dev/null @@ -1,143 +0,0 @@ -//! mDNS LAN discovery for swactor nodes. -//! -//! Advertises this node on the local network and discovers peers -//! automatically. Discovered peers are added to the peer allow-list. - -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc::Sender; -use std::sync::{Arc, Mutex}; -use std::thread; -use std::time::Duration; - -use distribution::identity::{base58_decode, hex_encode}; -use distribution::peer_auth::PeerAllowList; -use distribution::types::NodeId; - -const SERVICE_TYPE: &str = "_swactor._udp.local."; - -/// Handle to the background mDNS thread. -pub struct MdnsHandle { - stop: Arc, - thread: Option>, -} - -impl MdnsHandle { - pub fn shutdown(mut self) { - self.stop.store(true, Ordering::Relaxed); - if let Some(t) = self.thread.take() { - let _ = t.join(); - } - } -} - -/// Start mDNS advertisement and discovery in a background thread. -/// -/// Discovered peer node IDs are sent through `discovery_tx` so the main -/// loop can call `driver.join()` on them. -pub fn start_mdns( - invite_code: &str, - node_name: &str, - peer_auth: Arc>, - discovery_tx: Sender<[u8; 32]>, -) -> MdnsHandle { - let stop = Arc::new(AtomicBool::new(false)); - let stop2 = Arc::clone(&stop); - let invite_code = invite_code.to_string(); - let node_name = node_name.to_string(); - - let thread = thread::spawn(move || { - run_mdns(stop2, &invite_code, &node_name, peer_auth, discovery_tx); - }); - - MdnsHandle { - stop, - thread: Some(thread), - } -} - -fn run_mdns( - stop: Arc, - invite_code: &str, - node_name: &str, - peer_auth: Arc>, - discovery_tx: Sender<[u8; 32]>, -) { - use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo}; - - let mdns = match ServiceDaemon::new() { - Ok(d) => d, - Err(e) => { - eprintln!("mDNS: failed to start daemon: {e}"); - return; - } - }; - - // Register our service - let instance_name = node_name.replace(' ', "-"); - let hostname = format!("{}.local.", instance_name); - let properties = [("id", invite_code)]; - match ServiceInfo::new(SERVICE_TYPE, &instance_name, &hostname, (), 0, &properties[..]) { - Ok(info) => { - if let Err(e) = mdns.register(info) { - eprintln!("mDNS: failed to register service: {e}"); - } - } - Err(e) => { - eprintln!("mDNS: failed to create service info: {e}"); - } - } - - // Browse for peers - let receiver = match mdns.browse(SERVICE_TYPE) { - Ok(r) => r, - Err(e) => { - eprintln!("mDNS: failed to browse: {e}"); - return; - } - }; - - while !stop.load(Ordering::Relaxed) { - match receiver.recv_timeout(Duration::from_secs(1)) { - Ok(event) => { - if let ServiceEvent::ServiceResolved(info) = event { - if let Some(peer_code) = info.get_property_val_str("id") { - // Skip ourselves - if peer_code == invite_code { - continue; - } - if let Some(peer_bytes) = base58_decode(peer_code) { - let peer_id = NodeId(peer_bytes); - let peer_hex = hex_encode(&peer_bytes); - let label = info.get_fullname().split('.').next() - .unwrap_or("mdns-peer") - .to_string(); - - let mut auth = peer_auth.lock().unwrap(); - if !auth.is_allowed(&peer_id) { - eprintln!( - "mDNS: discovered peer {} ({})", - label, &peer_hex[..8] - ); - auth.add_peer(peer_id, label); - if let Err(e) = auth.save() { - eprintln!("mDNS: failed to save peers: {e}"); - } - } - // Always notify the main loop so it can join() the - // peer — even if already in the allow-list (e.g. added - // offline via `swactor join`). The main loop is - // idempotent on re-joining known members. - let _ = discovery_tx.send(peer_bytes); - } - } - } - } - Err(e) => { - if format!("{e:?}").contains("Disconnected") { break; } - // Timeout — continue loop - } - } - } - - let _ = mdns.shutdown(); -} diff --git a/crates/swactor-node/src/relay.rs b/crates/swactor-node/src/relay.rs new file mode 100644 index 0000000..f1406ee --- /dev/null +++ b/crates/swactor-node/src/relay.rs @@ -0,0 +1,86 @@ +//! Relay candidacy evaluation. +//! +//! Determines whether this node is eligible to run an embedded relay server +//! by checking a set of extensible rules (public IP, port availability, etc.). + +use std::net::{IpAddr, SocketAddr, UdpSocket}; + +// ─── Candidacy ────────────────────────────────────────────────────────── + +/// Result of evaluating a single candidacy rule. +pub enum CandidacyResult { + Eligible, + Ineligible(String), +} + +/// Extensible rule for relay candidacy evaluation. +pub trait CandidacyRule: Send { + fn evaluate(&self) -> CandidacyResult; +} + +/// Checks whether the node's outbound IP is public (non-RFC1918, non-loopback). +pub struct PublicIpRule; + +impl PublicIpRule { + pub fn outbound_ip() -> Option { + let sock = UdpSocket::bind("0.0.0.0:0").ok()?; + sock.connect("192.0.2.1:80").ok()?; // RFC 5737 TEST-NET-1 (non-routable) + Some(sock.local_addr().ok()?.ip()) + } + + fn is_public(ip: &IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + !v4.is_loopback() + && !v4.is_private() + && !v4.is_link_local() + && !v4.is_unspecified() + } + IpAddr::V6(v6) => !v6.is_loopback() && !v6.is_unspecified(), + } + } +} + +impl CandidacyRule for PublicIpRule { + fn evaluate(&self) -> CandidacyResult { + match Self::outbound_ip() { + Some(ip) if Self::is_public(&ip) => CandidacyResult::Eligible, + Some(ip) => CandidacyResult::Ineligible(format!("outbound IP {ip} is private")), + None => CandidacyResult::Ineligible("could not determine outbound IP".into()), + } + } +} + +/// Checks whether the desired relay port is available for binding. +pub struct PortBindRule { + addr: SocketAddr, +} + +impl PortBindRule { + pub fn new(addr: SocketAddr) -> Self { + Self { addr } + } +} + +impl CandidacyRule for PortBindRule { + fn evaluate(&self) -> CandidacyResult { + match std::net::TcpListener::bind(self.addr) { + Ok(_) => CandidacyResult::Eligible, + Err(e) => CandidacyResult::Ineligible(format!( + "cannot bind {}: {e}", + self.addr + )), + } + } +} + +/// Evaluate all candidacy rules. Returns `Ok(())` if all pass, or +/// `Err(reason)` with the first failure reason. +pub fn evaluate_candidacy(rules: &[Box]) -> Result<(), String> { + for rule in rules { + if let CandidacyResult::Ineligible(reason) = rule.evaluate() { + return Err(reason); + } + } + Ok(()) +} diff --git a/crates/wasm/Cargo.toml b/crates/wasm/Cargo.toml deleted file mode 100644 index 20787f6..0000000 --- a/crates/wasm/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "wasm" -version = "0.1.0" -edition = "2024" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -swactor = { path = "../..", default-features = false, features = ["wasm"] } -swactor-std = { path = "../std", default-features = false, features = ["wasm"] } -wasm-bindgen = "0.2" diff --git a/crates/crypto-wasm/Cargo.toml b/crates/wasm/crypto/Cargo.toml similarity index 83% rename from crates/crypto-wasm/Cargo.toml rename to crates/wasm/crypto/Cargo.toml index 962ab6e..5e31df5 100644 --- a/crates/crypto-wasm/Cargo.toml +++ b/crates/wasm/crypto/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "swactor-crypto-wasm" +name = "wasm-crypto" version = "0.1.0" edition = "2021" diff --git a/crates/crypto-wasm/src/lib.rs b/crates/wasm/crypto/src/lib.rs similarity index 100% rename from crates/crypto-wasm/src/lib.rs rename to crates/wasm/crypto/src/lib.rs diff --git a/crates/wasm/runtime/Cargo.toml b/crates/wasm/runtime/Cargo.toml new file mode 100644 index 0000000..156c268 --- /dev/null +++ b/crates/wasm/runtime/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "wasm-runtime" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +swactor = { path = "../../..", default-features = false, features = ["wasm"] } +swactor-std = { path = "../../std", default-features = false, features = ["wasm"] } +wasm-bindgen = "0.2" diff --git a/crates/wasm/demo.html b/crates/wasm/runtime/demo.html similarity index 100% rename from crates/wasm/demo.html rename to crates/wasm/runtime/demo.html diff --git a/crates/wasm/src/lib.rs b/crates/wasm/runtime/src/lib.rs similarity index 100% rename from crates/wasm/src/lib.rs rename to crates/wasm/runtime/src/lib.rs diff --git a/crates/wasm/test.mjs b/crates/wasm/runtime/test.mjs similarity index 100% rename from crates/wasm/test.mjs rename to crates/wasm/runtime/test.mjs diff --git a/scripts/sim-dashboard.sh b/scripts/sim-dashboard.sh deleted file mode 100755 index 41f981a..0000000 --- a/scripts/sim-dashboard.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -DIR="$(cd "$(dirname "${1:-.}")" && pwd)/$(basename "${1:-traces}")" -PORT="${2:-8080}" - -rm -rf "$DIR" -mkdir -p "$DIR" - -echo "Running distribution sim tests with trace export..." -SWACTOR_TRACE_DIR="$DIR" cargo test -p simulation \ - --test distribution_registry \ - --test distribution_lifecycle \ - --test distribution_properties || echo "WARNING: some tests failed (traces from passing tests are still available)" - -COUNT=$(find "$DIR" -name '*.trace.json' 2>/dev/null | wc -l) -echo "$COUNT traces in $DIR/" -echo "Dashboard at http://localhost:$PORT" -cargo run -p simulation --features dashboard --example replay -- "$DIR" "$PORT" diff --git a/src/stats.rs b/src/stats.rs index d899b06..3f3b5fa 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -152,6 +152,9 @@ pub struct ActorInfo { /// Whether the actor has panicked and is no longer processing messages. #[cfg_attr(feature = "serde", serde(default))] pub poisoned: bool, + /// Human-readable name from the name registry (if registered). + #[cfg_attr(feature = "serde", serde(default))] + pub name: Option, /// Per-message-type counts, sorted descending by count. Top 32 types. #[cfg_attr(feature = "serde", serde(default))] pub message_type_counts: Vec<(String, u64)>, diff --git a/tests/docker/src/lib.rs b/tests/docker/src/lib.rs index e5563cf..674fa0d 100644 --- a/tests/docker/src/lib.rs +++ b/tests/docker/src/lib.rs @@ -391,6 +391,22 @@ pub fn wait_for_lan_convergence( } } +// ── Deploy simulation helpers ──────────────────────────────────────────────── + +/// Restart a specific container with fresh flags (simulates deploy lifecycle). +pub fn redeploy_node(service: &str) { + let status = Command::new("docker") + .args(["compose", "-f", &compose_file(), "up", "-d", "--force-recreate", service]) + .status() + .expect("failed to redeploy node"); + assert!(status.success(), "docker compose force-recreate {service} failed"); +} + +/// Fetch the node_id from a node's distribution snapshot. +pub fn get_node_id(port: u16) -> Option { + poll_distribution(port).map(|snap| snap.node_id) +} + fn lan_hpz_compose_path() -> String { let mut p = PathBuf::from(COMPOSE_DIR); p.push("docker-compose.lan-hpz.yml"); diff --git a/tests/docker/tests/deploy.rs b/tests/docker/tests/deploy.rs new file mode 100644 index 0000000..91aab03 --- /dev/null +++ b/tests/docker/tests/deploy.rs @@ -0,0 +1,55 @@ +//! Deploy lifecycle simulation test. +//! +//! Exercises the same verification flow that `cargo xtask deploy` runs +//! (health check, node_id collection, rolling redeploy, convergence) +//! against the local docker-compose cluster — no real SSH needed. +//! +//! Run with: `cargo test -p docker-tests -- --ignored deploy_lifecycle` + +use std::collections::HashSet; +use std::time::Duration; + +use docker_tests::*; + +#[test] +#[ignore] +fn deploy_lifecycle_simulation() { + // Phase 1: Start cluster (simulates: build + deploy containers) + let mut cluster = ClusterHandle::start(); + + // Phase 2: Health check — all nodes respond to /api/distribution + for &port in &DASHBOARD_PORTS { + let snap = poll_distribution(port); + assert!(snap.is_some(), "node on port {port} not responding"); + } + + // Phase 3: Convergence — all nodes see 4 peers alive + wait_for_convergence(&DASHBOARD_PORTS, 4, Duration::from_secs(30)) + .expect("initial convergence failed"); + + // Phase 4: Collect node IDs (deploy script does this via GET /api/distribution) + let node_ids: Vec = DASHBOARD_PORTS + .iter() + .map(|&port| get_node_id(port).expect("missing node_id")) + .collect(); + let unique: HashSet<&String> = node_ids.iter().collect(); + assert_eq!(unique.len(), 5, "expected 5 unique node IDs, got {}", unique.len()); + + // Phase 5: Simulate rolling redeploy — recreate one node, verify it rejoins + redeploy_node("node-3"); + wait_for_convergence(&DASHBOARD_PORTS, 4, Duration::from_secs(45)) + .expect("convergence after redeploy failed"); + + // Phase 6: Final status report — all metrics healthy + for &port in &DASHBOARD_PORTS { + let snap = poll_distribution(port).unwrap(); + assert!(snap.alive_count >= 4, + "port {port}: expected alive_count >= 4, got {}", snap.alive_count); + assert!(snap.routing_table_size >= 3, + "port {port}: expected routing_table_size >= 3, got {}", snap.routing_table_size); + assert!(snap.directory_entry_count >= 2, + "port {port}: expected directory_entry_count >= 2, got {}", snap.directory_entry_count); + } + + cluster.stop(); +} diff --git a/xtask/src/deploy.rs b/xtask/src/deploy.rs new file mode 100644 index 0000000..84f4abe --- /dev/null +++ b/xtask/src/deploy.rs @@ -0,0 +1,696 @@ +use std::path::Path; +use std::process::Command; +use std::thread; +use std::time::{Duration, Instant}; + +use serde::Deserialize; + +// ── Config structs ────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct DeployConfig { + pub defaults: DeployDefaults, + pub machines: Vec, +} + +#[derive(Deserialize, Clone)] +pub struct DeployDefaults { + pub image: String, + pub container: String, + pub dashboard_port: u16, + #[serde(default = "default_relay_port")] + pub relay_port: u16, + #[serde(default)] + pub swactor_flags: Vec, + #[serde(default)] + pub relay_hosts: Vec, + #[serde(default = "default_true")] + pub introduce_peers: bool, + #[serde(default = "default_health_timeout")] + pub health_timeout_secs: u64, + #[serde(default = "default_convergence_timeout")] + pub convergence_timeout_secs: u64, +} + +fn default_true() -> bool { true } +fn default_relay_port() -> u16 { 3340 } +fn default_health_timeout() -> u64 { 30 } +fn default_convergence_timeout() -> u64 { 60 } + +#[derive(Deserialize, Clone)] +pub struct MachineConfig { + pub name: String, + /// SSH destination — an alias from ~/.ssh/config (e.g. "thinkpad") or a hostname/IP. + /// Ignored when `local = true`. + #[serde(default)] + pub ssh: String, + /// When true, run docker commands directly instead of over SSH. + #[serde(default)] + pub local: bool, + pub dashboard_port: Option, + pub relay_port: Option, + pub container: Option, + pub swactor_flags: Option>, +} + +impl MachineConfig { + fn effective_port(&self, defaults: &DeployDefaults) -> u16 { + self.dashboard_port.unwrap_or(defaults.dashboard_port) + } + + fn effective_relay_port(&self, defaults: &DeployDefaults) -> u16 { + self.relay_port.unwrap_or(defaults.relay_port) + } + + fn effective_container(&self, defaults: &DeployDefaults) -> String { + self.container.clone().unwrap_or_else(|| defaults.container.clone()) + } + + fn effective_flags(&self, defaults: &DeployDefaults) -> Vec { + self.swactor_flags.clone().unwrap_or_else(|| defaults.swactor_flags.clone()) + } +} + +// ── SSH helpers ───────────────────────────────────────────────────────────── + +fn ssh_cmd(machine: &MachineConfig, remote_cmd: &str) -> Command { + let mut cmd = Command::new("ssh"); + cmd.arg(&machine.ssh); + cmd.arg(remote_cmd); + cmd +} + +fn transfer_image(machine: &MachineConfig, archive: &Path) -> Result<(), String> { + if machine.local { + println!(" Loading image locally on {}...", machine.name); + let pipe_cmd = format!("gunzip < {} | docker load", archive.display()); + let status = Command::new("sh") + .arg("-c") + .arg(&pipe_cmd) + .status() + .map_err(|e| format!("failed to run docker load: {e}"))?; + if !status.success() { + return Err(format!("local docker load on {} failed", machine.name)); + } + } else { + println!(" Copying image to {}...", machine.name); + let status = Command::new("scp") + .arg(archive.as_os_str()) + .arg(format!("{}:/tmp/swactor-deploy.tar.gz", machine.ssh)) + .status() + .map_err(|e| format!("scp failed: {e}"))?; + if !status.success() { + return Err(format!("scp to {} failed", machine.name)); + } + + println!(" Loading image on {}...", machine.name); + let status = Command::new("ssh") + .arg(&machine.ssh) + .arg("gunzip < /tmp/swactor-deploy.tar.gz | docker load") + .status() + .map_err(|e| format!("docker load on {} failed: {e}", machine.name))?; + if !status.success() { + return Err(format!("docker load on {} failed", machine.name)); + } + } + println!(" Image loaded on {}", machine.name); + Ok(()) +} + +// ── HTTP helpers (via curl, optionally over SSH) ──────────────────────────── + +/// Build a curl GET url, running locally or over SSH depending on the machine. +fn machine_curl_get(machine: &MachineConfig, defaults: &DeployDefaults, path: &str) -> Option { + let port = machine.effective_port(defaults); + let url = format!("http://localhost:{port}{path}"); + + let output = if machine.local { + Command::new("curl") + .args(["-s", "--max-time", "3", &url]) + .output() + .ok()? + } else { + let curl_cmd = format!("curl -s --max-time 3 '{url}'"); + Command::new("ssh") + .arg(&machine.ssh) + .arg(&curl_cmd) + .output() + .ok()? + }; + + if !output.status.success() { + return None; + } + let body = String::from_utf8_lossy(&output.stdout).to_string(); + if body.is_empty() || body == "{}" { + return None; + } + Some(body) +} + +fn machine_curl_post(machine: &MachineConfig, defaults: &DeployDefaults, path: &str, json_body: &str) -> Result { + let port = machine.effective_port(defaults); + let url = format!("http://localhost:{port}{path}"); + + let output = if machine.local { + let mut cmd = Command::new("curl"); + cmd.args(["-s", "--max-time", "5", "-X", "POST"]); + cmd.args(["-H", "Content-Type: application/json"]); + cmd.arg("-d").arg(json_body); + cmd.arg(&url); + cmd.output().map_err(|e| format!("curl failed: {e}"))? + } else { + // Escape single quotes in json_body for the shell + let escaped = json_body.replace('\'', "'\\''"); + let curl_cmd = format!( + "curl -s --max-time 5 -X POST -H 'Content-Type: application/json' -d '{escaped}' '{url}'" + ); + Command::new("ssh") + .arg(&machine.ssh) + .arg(&curl_cmd) + .output() + .map_err(|e| format!("ssh curl failed: {e}"))? + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("POST {url} failed: {stderr}")); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +// ── Deploy orchestration ──────────────────────────────────────────────────── + +pub fn run_deploy( + root: &Path, + config_path: &str, + skip_build: bool, + skip_verify: bool, + skip_peers: bool, +) { + // Phase 1: Load config + println!("=== Phase 1: Loading config ===\n"); + let config = load_deploy_config(root, config_path); + println!(" Loaded {} machine(s): {}", config.machines.len(), + config.machines.iter().map(|m| m.name.as_str()).collect::>().join(", ")); + + println!(); + + // Phase 2: Build image + let archive = std::env::temp_dir().join("swactor-deploy.tar.gz"); + if !skip_build { + println!("=== Phase 2: Building Docker image ===\n"); + build_image(root, &config.defaults.image, &archive); + println!(); + } else { + println!("=== Phase 2: Skipping build ===\n"); + if !archive.exists() { + eprintln!("Warning: --skip-build but {} does not exist", archive.display()); + eprintln!(" Run without --skip-build first, or ensure the archive exists.\n"); + } + } + + // Phase 3: Deploy to each machine + println!("=== Phase 3: Deploying to machines ===\n"); + for machine in &config.machines { + deploy_to_machine(machine, &config.defaults, &archive); + } + println!(); + + // Phase 4: Health check + if !skip_verify { + println!("=== Phase 4: Health check ===\n"); + for machine in &config.machines { + health_check(machine, &config.defaults); + } + println!(); + } + + // Phase 4b: Inject relay_hosts into container configs + if !config.defaults.relay_hosts.is_empty() { + println!("=== Phase 4b: Injecting relay_hosts config ===\n"); + inject_relay_hosts(&config.machines, &config.defaults); + println!(); + } + + // Phase 5: Peer introduction + convergence + if !skip_verify && !skip_peers && config.defaults.introduce_peers { + println!("=== Phase 5: Peer introduction ===\n"); + introduce_peers(&config.machines, &config.defaults); + + println!("\n=== Phase 5b: Waiting for convergence ===\n"); + wait_for_cluster_convergence(&config.machines, &config.defaults); + println!(); + } + + // Phase 6: Report + println!("=== Phase 6: Cluster status ===\n"); + print_cluster_status(&config.machines, &config.defaults); +} + +fn load_deploy_config(root: &Path, config_path: &str) -> DeployConfig { + let path = if Path::new(config_path).is_absolute() { + config_path.to_string() + } else { + root.join(config_path).to_string_lossy().to_string() + }; + + let content = std::fs::read_to_string(&path).unwrap_or_else(|e| { + eprintln!("Failed to read config '{}': {e}", path); + eprintln!("Create a deploy.toml in the workspace root. See the plan for the format."); + std::process::exit(1); + }); + + let config: DeployConfig = toml::from_str(&content).unwrap_or_else(|e| { + eprintln!("Failed to parse config '{}': {e}", path); + std::process::exit(1); + }); + + if config.machines.is_empty() { + eprintln!("Error: deploy config has no [[machines]] entries"); + std::process::exit(1); + } + + config +} + +fn build_image(root: &Path, image: &str, archive: &Path) { + println!(" Building swactor-node (musl, static, release)..."); + let status = Command::new("cargo") + .args(["build", "--release", "-p", "swactor-node", "--target", "x86_64-unknown-linux-musl"]) + .current_dir(root) + .status() + .unwrap_or_else(|e| { + eprintln!("Failed to run cargo build: {e}"); + std::process::exit(1); + }); + if !status.success() { + eprintln!("cargo build failed"); + std::process::exit(1); + } + + println!(" Packaging Docker image '{image}'..."); + let status = Command::new("docker") + .args(["build", "-t", image, "."]) + .current_dir(root) + .status() + .unwrap_or_else(|e| { + eprintln!("Failed to run docker build: {e}"); + std::process::exit(1); + }); + if !status.success() { + eprintln!("docker build failed"); + std::process::exit(1); + } + + println!(" Saving image to {}...", archive.display()); + let pipe_cmd = format!( + "docker save {} | gzip > {}", + image, + archive.display() + ); + let status = Command::new("sh") + .arg("-c") + .arg(&pipe_cmd) + .status() + .unwrap_or_else(|e| { + eprintln!("Failed to save docker image: {e}"); + std::process::exit(1); + }); + if !status.success() { + eprintln!("docker save | gzip failed"); + std::process::exit(1); + } + + let size = std::fs::metadata(archive).map(|m| m.len()).unwrap_or(0); + println!(" Image archive: {:.1} MB", size as f64 / 1_048_576.0); +} + +fn deploy_to_machine(machine: &MachineConfig, defaults: &DeployDefaults, archive: &Path) { + let container = machine.effective_container(defaults); + let port = machine.effective_port(defaults); + let relay_port = machine.effective_relay_port(defaults); + let flags = machine.effective_flags(defaults); + let image = &defaults.image; + + println!(" Deploying to {}...", machine.name); + + // Step 1: Transfer image + if let Err(e) = transfer_image(machine, archive) { + eprintln!(" Error: {e}"); + std::process::exit(1); + } + + // Step 2: Stop + remove existing containers + // Kill the named container and any other containers from the same image + // so stale instances don't hold ports (e.g. relay port 3340). + let stop_cmd = format!( + concat!( + "docker kill {container} 2>/dev/null; docker rm {container} 2>/dev/null; ", + "for cid in $(docker ps -q --filter ancestor={image} 2>/dev/null); do ", + "docker kill $cid 2>/dev/null; docker rm $cid 2>/dev/null; ", + "done; sleep 1; true", + ), + container = container, + image = image, + ); + if machine.local { + let _ = Command::new("sh").arg("-c").arg(&stop_cmd).status(); + } else { + let status = ssh_cmd(machine, &stop_cmd).status(); + if let Err(e) = status { + eprintln!(" Warning: failed to stop/rm old container: {e}"); + } + } + + // Step 3: Start new container + let mut run_parts = vec![ + "docker".to_string(), "run".to_string(), "-d".to_string(), + "--name".to_string(), container.clone(), + "--network".to_string(), "host".to_string(), + "--restart".to_string(), "unless-stopped".to_string(), + image.clone(), + "--dashboard-port".to_string(), port.to_string(), + "--relay-port".to_string(), relay_port.to_string(), + ]; + run_parts.extend(flags); + + let status = if machine.local { + Command::new(&run_parts[0]) + .args(&run_parts[1..]) + .status() + .unwrap_or_else(|e| { + eprintln!(" Failed to start container on {}: {e}", machine.name); + std::process::exit(1); + }) + } else { + let run_cmd = run_parts.iter() + .map(|a| if a.contains(' ') { format!("'{a}'") } else { a.clone() }) + .collect::>() + .join(" "); + ssh_cmd(machine, &run_cmd) + .status() + .unwrap_or_else(|e| { + eprintln!(" Failed to start container on {}: {e}", machine.name); + std::process::exit(1); + }) + }; + if !status.success() { + eprintln!(" Container start failed on {}", machine.name); + std::process::exit(1); + } + + println!(" {} deployed (container: {container}, dashboard: {port}, relay: {relay_port})", machine.name); +} + +fn health_check(machine: &MachineConfig, defaults: &DeployDefaults) { + let timeout = Duration::from_secs(defaults.health_timeout_secs); + let start = Instant::now(); + + print!(" Checking {}...", machine.name); + + loop { + if start.elapsed() > timeout { + println!(" TIMEOUT"); + eprintln!(" Health check failed for {} ({}s timeout)", machine.name, defaults.health_timeout_secs); + std::process::exit(1); + } + + if let Some(body) = machine_curl_get(machine, defaults, "/api/stats") { + if serde_json::from_str::(&body).is_ok() { + println!(" OK"); + return; + } + } + + thread::sleep(Duration::from_secs(2)); + } +} + +fn inject_relay_hosts(machines: &[MachineConfig], defaults: &DeployDefaults) { + // Format the TOML line to append + let hosts_toml = format!( + "relay_hosts = [{}]\n", + defaults.relay_hosts.iter() + .map(|h| format!("\"{h}\"")) + .collect::>() + .join(", ") + ); + + // The container has no shell, so we use docker cp to modify the config: + // 1. docker cp container:/.swactor/node.toml /tmp/... + // 2. append relay_hosts line + // 3. docker cp /tmp/... container:/.swactor/node.toml + for machine in machines { + let container = machine.effective_container(defaults); + let tmp_file = format!("/tmp/swactor-relay-inject-{}.toml", machine.name); + + let result = if machine.local { + inject_relay_hosts_local(&container, &tmp_file, &hosts_toml) + } else { + inject_relay_hosts_remote(machine, &container, &hosts_toml) + }; + + match result { + Ok(()) => println!(" {} relay_hosts injected", machine.name), + Err(e) => eprintln!(" Warning: inject on {} failed: {e}", machine.name), + } + } + + // Restart containers to pick up the new config + println!(" Restarting containers..."); + for machine in machines { + let container = machine.effective_container(defaults); + let restart_cmd = format!("docker restart {container}"); + + let status = if machine.local { + Command::new("sh").arg("-c").arg(&restart_cmd).status() + } else { + ssh_cmd(machine, &restart_cmd).status() + }; + + match status { + Ok(s) if s.success() => println!(" {} restarted", machine.name), + Ok(s) => eprintln!(" Warning: restart {} exited {}", machine.name, s), + Err(e) => eprintln!(" Warning: restart {} failed: {e}", machine.name), + } + } + + // Brief pause for containers to come back up + println!(" Waiting for containers to restart..."); + thread::sleep(Duration::from_secs(5)); + + // Re-run health checks after restart + for machine in machines { + health_check(machine, defaults); + } +} + +fn inject_relay_hosts_local(container: &str, tmp_file: &str, hosts_toml: &str) -> Result<(), String> { + // Copy config out of container + let cp_out = format!("docker cp {container}:/.swactor/node.toml {tmp_file}"); + let status = Command::new("sh").arg("-c").arg(&cp_out) + .status().map_err(|e| format!("docker cp out: {e}"))?; + if !status.success() { + return Err("docker cp out failed".into()); + } + + // Replace or append relay_hosts + let contents = std::fs::read_to_string(tmp_file) + .map_err(|e| format!("read tmp: {e}"))?; + let updated = if contents.contains("relay_hosts") { + contents.lines() + .map(|line| { + if line.trim_start().starts_with("relay_hosts") { + hosts_toml.trim_end() + } else { + line + } + }) + .collect::>() + .join("\n") + "\n" + } else { + let mut s = contents; + if !s.ends_with('\n') && !s.is_empty() { s.push('\n'); } + s.push_str(hosts_toml); + s + }; + std::fs::write(tmp_file, &updated) + .map_err(|e| format!("write tmp: {e}"))?; + + // Copy config back into container + let cp_in = format!("docker cp {tmp_file} {container}:/.swactor/node.toml"); + let status = Command::new("sh").arg("-c").arg(&cp_in) + .status().map_err(|e| format!("docker cp in: {e}"))?; + if !status.success() { + return Err("docker cp in failed".into()); + } + + let _ = std::fs::remove_file(tmp_file); + Ok(()) +} + +fn inject_relay_hosts_remote(machine: &MachineConfig, container: &str, hosts_toml: &str) -> Result<(), String> { + // Write the desired line to a local temp file, scp it, then use a simple + // shell script on the remote to merge it into the container's config. + let local_tmp = format!("/tmp/swactor-relay-line-{}.txt", machine.name); + std::fs::write(&local_tmp, hosts_toml) + .map_err(|e| format!("write local tmp: {e}"))?; + + // scp the line file to the remote + let scp_status = Command::new("scp") + .args([&local_tmp, &format!("{}:/tmp/swactor-relay-line.txt", machine.ssh)]) + .status().map_err(|e| format!("scp: {e}"))?; + if !scp_status.success() { + let _ = std::fs::remove_file(&local_tmp); + return Err("scp relay line failed".into()); + } + let _ = std::fs::remove_file(&local_tmp); + + // On the remote: docker cp out, filter+append, docker cp back + let remote_cmd = format!( + concat!( + "docker cp {container}:/.swactor/node.toml /tmp/swactor-inject.toml && ", + "grep -v '^relay_hosts' /tmp/swactor-inject.toml > /tmp/swactor-inject2.toml && ", + "cat /tmp/swactor-relay-line.txt >> /tmp/swactor-inject2.toml && ", + "docker cp /tmp/swactor-inject2.toml {container}:/.swactor/node.toml && ", + "rm -f /tmp/swactor-inject.toml /tmp/swactor-inject2.toml /tmp/swactor-relay-line.txt" + ), + container = container, + ); + let status = ssh_cmd(machine, &remote_cmd) + .status().map_err(|e| format!("ssh: {e}"))?; + if !status.success() { + return Err("remote inject failed".into()); + } + Ok(()) +} + +fn introduce_peers(machines: &[MachineConfig], defaults: &DeployDefaults) { + // Step 1: Collect node_id and relay_url from each machine + let mut node_info: Vec<(String, String, Option)> = Vec::new(); // (name, node_id, relay_url) + + for machine in machines { + let body = machine_curl_get(machine, defaults, "/api/distribution").unwrap_or_else(|| { + eprintln!(" Failed to get node_id from {}", machine.name); + std::process::exit(1); + }); + + let snap: serde_json::Value = serde_json::from_str(&body).unwrap_or_else(|e| { + eprintln!(" Invalid JSON from {}: {e}", machine.name); + std::process::exit(1); + }); + + let node_id = snap.get("node_id") + .and_then(|v| v.as_str()) + .unwrap_or_else(|| { + eprintln!(" No node_id in response from {}", machine.name); + std::process::exit(1); + }) + .to_string(); + + let relay_url = snap.get("relay_url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + println!(" {} node_id: {}...{}{}", machine.name, &node_id[..8], &node_id[node_id.len()-8..], + relay_url.as_ref().map(|u| format!(" relay: {u}")).unwrap_or_default()); + node_info.push((machine.name.clone(), node_id, relay_url)); + } + + // Step 2: For each pair, POST /api/peers/add + println!(); + for (i, machine) in machines.iter().enumerate() { + for (j, (peer_name, peer_node_id, peer_relay_url)) in node_info.iter().enumerate() { + if i == j { continue; } + + let mut payload = serde_json::json!({ + "node_id": peer_node_id, + "label": peer_name, + }); + if let Some(url) = peer_relay_url { + payload["relay_url"] = serde_json::json!(url); + } + let body = payload.to_string(); + + match machine_curl_post(machine, defaults, "/api/peers/add", &body) { + Ok(_) => println!(" {} <- added peer {}", machine.name, peer_name), + Err(e) => { + eprintln!(" Warning: failed to add peer {} to {}: {e}", peer_name, machine.name); + } + } + } + } +} + +fn wait_for_cluster_convergence(machines: &[MachineConfig], defaults: &DeployDefaults) { + let expected_alive = machines.len() - 1; + let timeout = Duration::from_secs(defaults.convergence_timeout_secs); + let start = Instant::now(); + + println!(" Waiting for all nodes to see >= {expected_alive} alive peers..."); + + loop { + if start.elapsed() > timeout { + println!(" TIMEOUT after {}s", defaults.convergence_timeout_secs); + for machine in machines { + match machine_curl_get(machine, defaults, "/api/distribution") { + Some(body) => { + if let Ok(snap) = serde_json::from_str::(&body) { + let alive = snap.get("alive_count") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + eprintln!(" {}: alive_count={}", machine.name, alive); + } + } + None => eprintln!(" {}: unreachable", machine.name), + } + } + eprintln!("\n Convergence failed. Nodes may still be joining."); + std::process::exit(1); + } + + let all_converged = machines.iter().all(|m| { + machine_curl_get(m, defaults, "/api/distribution") + .and_then(|body| serde_json::from_str::(&body).ok()) + .and_then(|snap| snap.get("alive_count").and_then(|v| v.as_u64())) + .map(|alive| alive as usize >= expected_alive) + .unwrap_or(false) + }); + + if all_converged { + println!(" Converged! All nodes see >= {expected_alive} alive peers."); + return; + } + + thread::sleep(Duration::from_secs(3)); + } +} + +fn print_cluster_status(machines: &[MachineConfig], defaults: &DeployDefaults) { + println!(" {:<12} {:>5} {:>7} {:>5} {:>5}", + "NAME", "ALIVE", "ROUTING", "DIR", "CACHE"); + println!(" {}", "-".repeat(45)); + + for machine in machines { + match machine_curl_get(machine, defaults, "/api/distribution") { + Some(body) => { + if let Ok(snap) = serde_json::from_str::(&body) { + let alive = snap.get("alive_count").and_then(|v| v.as_u64()).unwrap_or(0); + let routing = snap.get("routing_table_size").and_then(|v| v.as_u64()).unwrap_or(0); + let dir = snap.get("directory_entry_count").and_then(|v| v.as_u64()).unwrap_or(0); + let cache = snap.get("cache_size").and_then(|v| v.as_u64()).unwrap_or(0); + + println!(" {:<12} {:>5} {:>7} {:>5} {:>5}", + machine.name, alive, routing, dir, cache); + } else { + println!(" {:<12} -- invalid JSON --", machine.name); + } + } + None => { + println!(" {:<12} -- unreachable --", machine.name); + } + } + } + println!(); +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 0906603..2fb8363 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,3 +1,5 @@ +mod deploy; + use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Instant; @@ -39,26 +41,22 @@ enum Cmd { list: bool, }, - /// Start a datastore node + /// Start a local swactor node (full features, no cluster) #[command(trailing_var_arg = true)] Node { - /// Port for the node + /// Dashboard HTTP port #[arg(long)] port: Option, - /// Storage path + /// Storage path for persistent datastore (omit for in-memory) #[arg(long)] storage_path: Option, - /// Enable auth (bare --auth → true, --auth=false → false) - #[arg(long, num_args = 0..=1, default_missing_value = "true")] - auth: Option, - - /// Auth directory + /// Build in release mode #[arg(long)] - auth_dir: Option, + release: bool, - /// Extra arguments forwarded to the underlying binary + /// Extra arguments forwarded to the swactor binary #[arg(allow_hyphen_values = true)] extra: Vec, }, @@ -108,26 +106,39 @@ enum Cmd { /// Identity directories to include dirs: Vec, }, + + /// Deploy swactor to remote machines + Deploy { + /// Deploy via Docker over SSH (build image, push, run containers) + #[arg(long)] + docker: bool, + + /// Path to deploy config file + #[arg(long, default_value = ".deploy/deploy.toml")] + config: String, + + /// Skip Docker image build (use existing archive) + #[arg(long)] + skip_build: bool, + + /// Skip health check and convergence verification + #[arg(long)] + skip_verify: bool, + + /// Skip peer introduction (deploy only) + #[arg(long)] + skip_peers: bool, + }, } // ── Config file ───────────────────────────────────────────────────── #[derive(Deserialize, Default)] struct Config { - #[serde(default)] - node: NodeConfig, #[serde(default)] cli: CliConfig, } -#[derive(Deserialize, Default)] -struct NodeConfig { - port: Option, - storage_path: Option, - auth: Option, - auth_dir: Option, -} - #[derive(Deserialize, Default)] struct CliConfig { url: Option, @@ -210,13 +221,11 @@ const INTEGRATED: Group = Group { description: "HTTP API + dashboard end-to-end tests", steps: &[ TestStep { - label: "datastore integration (node features)", + label: "datastore integration", args: &[ "test", "-p", "swactor-datastore", - "--features", - "node", "--test", "api_integration_test", "--test", @@ -224,8 +233,8 @@ const INTEGRATED: Group = Group { ], }, TestStep { - label: "runtime dashboard", - args: &["test", "-p", "runtime-dashboard"], + label: "dashboard", + args: &["test", "-p", "dashboard"], }, ], }; @@ -267,7 +276,9 @@ USAGE: cargo xtask COMMANDS: test Run a test group - dev-node [OPTS] Launch a dev node (distribution + dashboard + datastore) + node [OPTS] Start a local swactor node (full features, no cluster) + dev-node [OPTS] Launch a dev node (legacy) + build Build the swactor binary (release) TEST GROUPS: core Actor runtime, message delivery, property tests @@ -280,14 +291,11 @@ TEST GROUPS: TEST FLAGS: --list Show all groups and the cargo commands they run -DEV OPTIONS: - --port PORT Dashboard port (default: 9090) - --actors N Dummy heartbeat actors (default: 3) - --storage PATH Persistent storage dir (omit for in-memory) - --no-datastore Disable datastore entirely - --tcp Use TCP transport instead of iroh (requires --listen) - --listen ADDR TCP listen address (e.g. 127.0.0.1:7000) - --release Build in release mode" +NODE OPTIONS: + --port PORT Dashboard port (default: 9091) + --storage-path PATH Persistent storage dir (omit for in-memory) + --release Build in release mode + -- [EXTRA...] Extra args forwarded to swactor binary" ); } @@ -518,29 +526,21 @@ fn run_dev(extra_args: Vec) { fn run_node( port: Option, storage_path: Option, - auth: Option, - auth_dir: Option, + release: bool, extra: Vec, - cfg: &NodeConfig, ) { ignore_sigint(); - let port = port.or(cfg.port).unwrap_or(9091); - let storage_path = storage_path - .or_else(|| cfg.storage_path.clone()) - .unwrap_or_else(|| "./datastore".into()); - let auth_enabled = auth.or(cfg.auth).unwrap_or(true); - let auth_dir = auth_dir - .or_else(|| cfg.auth_dir.clone()) - .unwrap_or_else(|| "./auth".into()); - // Build first, then run the binary directly (not via `cargo run`). - // This avoids cargo sitting in the middle of the process chain and - // dying from SIGINT before the node finishes its shutdown. + // Build the full swactor binary (same one produced by `cargo xtask build`). + let mut build_args = vec![ + "build", "-p", "swactor-node", + ]; + if release { + build_args.push("--release"); + } + let build_status = Command::new("cargo") - .args([ - "build", "-p", "swactor-datastore", "--features", "node", - "--bin", "swactor-store-node", - ]) + .args(&build_args) .status(); match build_status { Ok(s) if !s.success() => std::process::exit(s.code().unwrap_or(1)), @@ -553,27 +553,50 @@ fn run_node( // Locate the built binary let root = workspace_root(); - let binary = root.join("target/debug/swactor-store-node"); + let profile = if release { "release" } else { "debug" }; + let binary = root.join(format!("target/{profile}/swactor")); if !binary.exists() { eprintln!("Binary not found at {}", binary.display()); std::process::exit(1); } + // Use a local working directory so the node doesn't write into ~/.swactor + let work_dir = root.join(".dev-node"); + std::fs::create_dir_all(&work_dir).expect("failed to create .dev-node directory"); + + let identity_dir = work_dir.join("identity"); + let auth_dir = work_dir.join("auth"); + let default_storage = work_dir.join("datastore"); + std::fs::create_dir_all(&identity_dir).expect("failed to create identity dir"); + std::fs::create_dir_all(&auth_dir).expect("failed to create auth dir"); + + // Write a minimal config so the swactor binary doesn't auto-create ~/.swactor + let config_path = work_dir.join("node.toml"); + let storage = storage_path.unwrap_or_else(|| default_storage.to_string_lossy().into_owned()); + let port = port.unwrap_or(9091); + let config_content = format!( + r#"transport = "iroh" +dashboard_port = {port} +storage_path = "{storage}" +identity_dir = "{identity}" +auth = true +auth_dir = "{auth}" +"#, + identity = identity_dir.display(), + auth = auth_dir.display(), + ); + std::fs::write(&config_path, &config_content).expect("failed to write dev config"); + let mut bin_args: Vec = vec![ - "--port".into(), - port.to_string(), - "--storage-path".into(), - storage_path, + "--config".into(), + config_path.to_string_lossy().into_owned(), ]; - if auth_enabled { - bin_args.push("--auth".into()); - bin_args.push("--auth-dir".into()); - bin_args.push(auth_dir); - } - bin_args.extend(extra); + println!(" {} {}", binary.display(), bin_args.join(" ")); + println!(); + let status = Command::new(&binary).args(&bin_args).status(); match status { Ok(s) if !s.success() => std::process::exit(s.code().unwrap_or(1)), @@ -656,7 +679,7 @@ fn run_wasm() { "build", "--target", "wasm32-unknown-unknown", "--release", - "-p", "swactor-crypto-wasm", + "-p", "wasm-crypto", ]) .status(); @@ -672,7 +695,7 @@ fn run_wasm() { _ => {} } - let src = root.join("target/wasm32-unknown-unknown/release/swactor_crypto_wasm.wasm"); + let src = root.join("target/wasm32-unknown-unknown/release/wasm_crypto.wasm"); let dst = root.join("crates/datastore/src/crypto_wasm.wasm"); std::fs::copy(&src, &dst).unwrap_or_else(|e| { @@ -896,10 +919,9 @@ fn main() { Cmd::Node { port, storage_path, - auth, - auth_dir, + release, extra, - } => run_node(port, storage_path, auth, auth_dir, extra, &config.node), + } => run_node(port, storage_path, release, extra), Cmd::DevNode { extra } => run_dev(extra), Cmd::Cli { url, @@ -908,5 +930,15 @@ fn main() { } => run_cli(url, key, extra, &config.cli), Cmd::InitNode { role, dir } => run_init_node(&role, dir.as_deref()), Cmd::GenPeers { dirs } => run_gen_peers(&dirs), + Cmd::Deploy { docker, config, skip_build, skip_verify, skip_peers } => { + if docker { + deploy::run_deploy(&root, &config, skip_build, skip_verify, skip_peers); + } else { + eprintln!("Unimplemented. In the future `cargo xtask deploy` will take a \ + `deploy.toml` config, and attempt to deploy/update the nodes provided \ + in the config."); + std::process::exit(1); + } + } } }