feat: less crates, swactor distribution node
This commit is contained in:
parent
eb4bf65c20
commit
c954e95d4f
87 changed files with 3172 additions and 2523 deletions
2
.dockerignore
Normal file
2
.dockerignore
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!target/x86_64-unknown-linux-musl/release/swactor
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -16,4 +16,11 @@ docs/architecture.html
|
|||
crates/simulation/traces
|
||||
|
||||
# xtask personal config
|
||||
xtask/config.toml
|
||||
xtask/config.toml
|
||||
|
||||
# Deploy config (machine-specific)
|
||||
deploy.toml
|
||||
.deploy/
|
||||
|
||||
# Local dev node state
|
||||
.dev-node/
|
||||
288
Cargo.lock
generated
288
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
11
Dockerfile
11
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"]
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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"
|
||||
|
|
@ -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));
|
||||
|
|
@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -135,8 +135,11 @@ pub const ACTOR_DETAIL_HTML: &str = r##"<!DOCTYPE html>
|
|||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<div class="info-row" id="nameRow" style="display:none;">
|
||||
<div><div class="info-label">Name</div><div class="info-value" id="addrName" style="color:#00bcd4;">—</div></div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div><div class="info-label">Address</div><div class="info-value" id="addrFull">—</div></div>
|
||||
<div><div class="info-label">Address</div><div class="info-value" id="addrFull" style="font-size:12px;color:#aaa;font-weight:400;">—</div></div>
|
||||
<div><div class="info-label">Worker</div><div class="info-value" id="addrWorker">—</div></div>
|
||||
<div><div class="info-label">Status</div><div class="info-value" id="addrStatus">—</div></div>
|
||||
</div>
|
||||
|
|
@ -237,7 +240,14 @@ pub const ACTOR_DETAIL_HTML: &str = r##"<!DOCTYPE html>
|
|||
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;
|
||||
|
||||
|
|
@ -478,7 +478,8 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
|
|||
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##"<!DOCTYPE html>
|
|||
tr.className = 'clickable' + (fullHex === focusedAddrHex ? ' focused' : '');
|
||||
if (a.poisoned) tr.style.opacity = '0.6';
|
||||
tr.setAttribute('data-addr', fullHex);
|
||||
var addrCell = a.name
|
||||
? '<td><span style="color:#00bcd4;font-weight:600;">' + escapeHtml(a.name) + '</span> <span style="color:#555;font-size:10px;">' + escapeHtml(hex) + '</span>' + (a.poisoned ? ' <span style="color:#f44336;font-size:9px;">DEAD</span>' : '') + '</td>'
|
||||
: '<td style="color:#aaa;font-size:11px;">' + escapeHtml(hex) + (a.poisoned ? ' <span style="color:#f44336;font-size:9px;">DEAD</span>' : '') + '</td>';
|
||||
tr.innerHTML =
|
||||
'<td style="color:#aaa;font-size:11px;">' + escapeHtml(hex) + (a.poisoned ? ' <span style="color:#f44336;font-size:9px;">DEAD</span>' : '') + '</td>' +
|
||||
addrCell +
|
||||
'<td>W' + a.worker_id + '</td>' +
|
||||
'<td>' + a.mailbox_depth + '</td>' +
|
||||
'<td>' + (a.messages_processed || 0).toLocaleString() + '</td>' +
|
||||
|
|
@ -593,8 +597,14 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
|
|||
return;
|
||||
}
|
||||
|
||||
document.getElementById('detailAddr').innerHTML = '<a href="/actor/' + focusedAddrHex + '" style="color:#aaa;text-decoration:none;">' + escapeHtml(addrToHex(actor.address)) + '</a>';
|
||||
document.getElementById('detailFullAddr').innerHTML = '<a href="/actor/' + focusedAddrHex + '" style="color:#fff;text-decoration:none;">' + escapeHtml(focusedAddrHex) + '</a>';
|
||||
var detailAddrText = actor.name
|
||||
? '<a href="/actor/' + focusedAddrHex + '" style="color:#00bcd4;text-decoration:none;font-weight:600;">' + escapeHtml(actor.name) + '</a>'
|
||||
: '<a href="/actor/' + focusedAddrHex + '" style="color:#aaa;text-decoration:none;">' + escapeHtml(addrToHex(actor.address)) + '</a>';
|
||||
document.getElementById('detailAddr').innerHTML = detailAddrText;
|
||||
var detailFullText = actor.name
|
||||
? '<span style="color:#00bcd4;font-weight:600;">' + escapeHtml(actor.name) + '</span> <span style="color:#555;font-size:9px;">' + escapeHtml(focusedAddrHex) + '</span>'
|
||||
: '<a href="/actor/' + focusedAddrHex + '" style="color:#fff;text-decoration:none;">' + escapeHtml(focusedAddrHex) + '</a>';
|
||||
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();
|
||||
|
|
@ -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<AtomicBool>,
|
||||
) {
|
||||
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<AtomicBool>,
|
||||
) {
|
||||
use distribution::iroh_driver::{IrohDriver, IrohDriverConfig};
|
||||
|
|
@ -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()
|
||||
|
|
@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -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::<swactor_std::StdExtension>() {
|
||||
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(),
|
||||
}));
|
||||
}
|
||||
|
|
@ -356,6 +356,14 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
|
|||
|
||||
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##"<!DOCTYPE html>
|
|||
hex += '\u2026';
|
||||
}
|
||||
var tr = document.createElement('tr');
|
||||
tr.innerHTML = '<td style="color:#aaa;font-size:11px;"><a href="/actor/' + hex + '" style="color:#aaa;text-decoration:none;">' + hex + '</a></td><td>W' + wid + '</td>';
|
||||
var addrCell;
|
||||
if (nameMap[hex]) {
|
||||
addrCell = '<td><a href="/actor/' + hex + '" style="text-decoration:none;"><span style="color:#00bcd4;font-weight:600;">' + escapeHtml(nameMap[hex]) + '</span> <span style="color:#555;font-size:10px;">' + hex + '</span></a></td>';
|
||||
} else {
|
||||
addrCell = '<td style="color:#aaa;font-size:11px;"><a href="/actor/' + hex + '" style="color:#aaa;text-decoration:none;">' + hex + '</a></td>';
|
||||
}
|
||||
tr.innerHTML = addrCell + '<td>W' + wid + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
if (data.actors.length > 200) {
|
||||
|
|
@ -493,7 +507,13 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
|
|||
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 += '<tr><td style="color:#aaa;"><a href="/actor/' + hex + '" style="color:#aaa;text-decoration:none;">' + hex + '</a></td>' +
|
||||
var addrHtml;
|
||||
if (a.name) {
|
||||
addrHtml = '<td><a href="/actor/' + hex + '" style="text-decoration:none;"><span style="color:#00bcd4;font-weight:600;">' + escapeHtml(a.name) + '</span> <span style="color:#555;font-size:10px;">' + hex + '</span></a></td>';
|
||||
} else {
|
||||
addrHtml = '<td style="color:#aaa;"><a href="/actor/' + hex + '" style="color:#aaa;text-decoration:none;">' + hex + '</a></td>';
|
||||
}
|
||||
rows += '<tr>' + addrHtml +
|
||||
'<td>' + a.mailbox_depth + '</td>' +
|
||||
'<td class="' + msgClass + '"' + title + '>' + escapeHtml(msgShort) + '</td></tr>';
|
||||
});
|
||||
|
|
@ -94,6 +94,19 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
|
|||
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##"<!DOCTYPE html>
|
|||
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##"<!DOCTYPE html>
|
|||
var tr = document.createElement('tr');
|
||||
var idCell = m.node_id.substring(0, 16) + '\u2026';
|
||||
if (m.label) idCell = m.label + ' <span style="color:#555;">(' + m.node_id.substring(0, 8) + ')</span>';
|
||||
var relayBadge = m.relay_url
|
||||
? ' <span class="relay-badge" title="' + m.relay_url + '">RELAY</span>'
|
||||
: '';
|
||||
tr.innerHTML =
|
||||
'<td class="' + cls + '">' + m.state + '</td>' +
|
||||
'<td style="color:#aaa;font-size:10px;">' + idCell + '</td>' +
|
||||
'<td style="color:#aaa;font-size:10px;">' + idCell + relayBadge + '</td>' +
|
||||
'<td>' + m.addr + '</td>' +
|
||||
'<td>' + m.incarnation + '</td>';
|
||||
body.appendChild(tr);
|
||||
|
|
@ -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,
|
||||
|
|
@ -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<String>);
|
||||
|
||||
/// Configuration for the runtime dashboard.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DashboardConfig {
|
||||
|
|
@ -92,18 +94,19 @@ pub struct DashboardHandle {
|
|||
runtime: Arc<Mutex<Option<Arc<Runtime>>>>,
|
||||
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
shutdown_notify: Arc<tokio::sync::Notify>,
|
||||
stats_timeline: Arc<ArrayQueue<TimestampedStats>>,
|
||||
history: Arc<DashboardHistory>,
|
||||
recording: bool,
|
||||
port: u16,
|
||||
#[cfg(feature = "distribution")]
|
||||
distribution: Arc<Mutex<Option<Arc<dyn distribution_collector::DistributionStatsProvider>>>>,
|
||||
datastore: Arc<Mutex<Option<Arc<dyn datastore_collector::DatastoreStatsProvider>>>>,
|
||||
datastore_factory: Arc<Mutex<Option<Arc<dyn datastore_collector::DatastoreFactory>>>>,
|
||||
#[cfg(feature = "ci")]
|
||||
ci: Arc<Mutex<Option<Arc<dyn ci_collector::CiStatsProvider>>>>,
|
||||
dashboard_token: Arc<Mutex<Option<String>>>,
|
||||
peer_auth: Arc<Mutex<Option<Arc<Mutex<distribution::peer_auth::PeerAllowList>>>>>,
|
||||
join_sender: Arc<Mutex<Option<std::sync::mpsc::Sender<[u8; 32]>>>>,
|
||||
join_sender: Arc<Mutex<Option<std::sync::mpsc::Sender<JoinPeerInfo>>>>,
|
||||
standalone_rt: Mutex<Option<tokio::runtime::Runtime>>,
|
||||
}
|
||||
|
||||
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<dyn ci_collector::CiStatsProvider>) {
|
||||
*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<Mutex<distribution::peer_auth::PeerAllowList>>) {
|
||||
*self.peer_auth.lock().unwrap() = Some(auth);
|
||||
}
|
||||
|
||||
/// Set a sender that triggers `driver.join()` when a peer is added via the dashboard.
|
||||
pub fn set_join_sender(&self, tx: std::sync::mpsc::Sender<[u8; 32]>) {
|
||||
pub fn set_join_sender(&self, tx: std::sync::mpsc::Sender<JoinPeerInfo>) {
|
||||
*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<Mutex<Option<Arc<Runtime>>>> = Arc::new(Mutex::new(None));
|
||||
let collector: Arc<Mutex<Option<Arc<StatsCollector>>>> = 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<Mutex<Option<Arc<dyn datastore_collector::DatastoreFactory>>>> =
|
||||
Arc::new(Mutex::new(None));
|
||||
|
||||
#[cfg(feature = "ci")]
|
||||
let ci: Arc<Mutex<Option<Arc<dyn ci_collector::CiStatsProvider>>>> =
|
||||
let ci: Arc<Mutex<Option<Arc<dyn ci_collector::CiStatsProvider>>>> =
|
||||
Arc::new(Mutex::new(None));
|
||||
|
||||
let dashboard_token: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
|
||||
let peer_auth: Arc<Mutex<Option<Arc<Mutex<distribution::peer_auth::PeerAllowList>>>>> =
|
||||
Arc::new(Mutex::new(None));
|
||||
|
||||
let join_sender: Arc<Mutex<Option<std::sync::mpsc::Sender<[u8; 32]>>>> =
|
||||
let join_sender: Arc<Mutex<Option<std::sync::mpsc::Sender<JoinPeerInfo>>>> =
|
||||
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");
|
||||
858
crates/dashboard/src/server.rs
Normal file
858
crates/dashboard/src/server.rs
Normal file
|
|
@ -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<EventStore>,
|
||||
pub runtime: Arc<Mutex<Option<Arc<Runtime>>>>,
|
||||
pub collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
|
||||
pub shutdown: Arc<AtomicBool>,
|
||||
pub shutdown_notify: Arc<tokio::sync::Notify>,
|
||||
pub history: Arc<DashboardHistory>,
|
||||
pub cmd_router: Arc<CommandRouter>,
|
||||
#[cfg(feature = "distribution")]
|
||||
pub distribution: Arc<Mutex<Option<Arc<dyn DistributionStatsProvider>>>>,
|
||||
pub datastore: Arc<Mutex<Option<Arc<dyn DatastoreStatsProvider>>>>,
|
||||
pub datastore_factory: Arc<Mutex<Option<Arc<dyn DatastoreFactory>>>>,
|
||||
pub ci: Arc<Mutex<Option<Arc<dyn CiStatsProvider>>>>,
|
||||
pub peer_auth: Arc<Mutex<Option<Arc<Mutex<distribution::peer_auth::PeerAllowList>>>>>,
|
||||
pub join_sender: Arc<Mutex<Option<std::sync::mpsc::Sender<crate::JoinPeerInfo>>>>,
|
||||
}
|
||||
|
||||
// ── 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<String>) -> 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<AppState>,
|
||||
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<Event>(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<AppState>) -> 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<AppState>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> 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<AppState>) -> Response {
|
||||
let json = match state.distribution.lock().unwrap().as_ref() {
|
||||
Some(provider) => match provider.snapshot() {
|
||||
Some(snapshot) => serde_json::to_string(&snapshot).unwrap_or_else(|_| "{}".into()),
|
||||
None => "{}".to_string(),
|
||||
},
|
||||
None => serde_json::json!({
|
||||
"error": "distribution provider not attached"
|
||||
})
|
||||
.to_string(),
|
||||
};
|
||||
json_response(json)
|
||||
}
|
||||
|
||||
async fn handle_datastore_api(State(state): State<AppState>) -> Response {
|
||||
let json = match state.datastore.lock().unwrap().as_ref() {
|
||||
Some(provider) => provider.snapshot_json().unwrap_or_else(|| "{}".into()),
|
||||
None => serde_json::json!({
|
||||
"error": "datastore provider not attached"
|
||||
})
|
||||
.to_string(),
|
||||
};
|
||||
json_response(json)
|
||||
}
|
||||
|
||||
async fn handle_ci_api(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
|
||||
) -> Response {
|
||||
use crate::ci_collector;
|
||||
|
||||
let path = uri.path();
|
||||
let route = ci_collector::parse_route(path);
|
||||
let json = match state.ci.lock().unwrap().as_ref() {
|
||||
Some(provider) => {
|
||||
let snapshot = provider.snapshot();
|
||||
ci_collector::handle_route(&route, &snapshot)
|
||||
.unwrap_or_else(|| r#"{"error":"not found"}"#.to_string())
|
||||
}
|
||||
None => serde_json::json!({
|
||||
"error": "CI provider not attached"
|
||||
})
|
||||
.to_string(),
|
||||
};
|
||||
json_response(json)
|
||||
}
|
||||
|
||||
async fn handle_topology_api(State(state): State<AppState>) -> Response {
|
||||
let maybe_rt = state.runtime.lock().unwrap().clone();
|
||||
let json = match maybe_rt {
|
||||
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<AppState>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> 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<AppState>) -> Response {
|
||||
let json = state.history.worker_history_json();
|
||||
json_response(json)
|
||||
}
|
||||
|
||||
// ── Datastore CRUD API handlers ─────────────────────────────────────────
|
||||
|
||||
fn get_ds_provider(
|
||||
datastore: &Arc<Mutex<Option<Arc<dyn DatastoreStatsProvider>>>>,
|
||||
) -> Option<Arc<dyn DatastoreStatsProvider>> {
|
||||
datastore.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
async fn handle_ds_list(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let provider = match get_ds_provider(&state.datastore) {
|
||||
Some(p) => p,
|
||||
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
|
||||
};
|
||||
let scope = match params.get("scope").map(|s| s.as_str()) {
|
||||
Some("local") => ListScope::Local,
|
||||
_ => ListScope::Swarm,
|
||||
};
|
||||
let name_filter = params.get("name").map(|s| s.as_str());
|
||||
match provider.list_objects(name_filter, scope) {
|
||||
Ok(json) => json_response(json),
|
||||
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ds_get(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let provider = match get_ds_provider(&state.datastore) {
|
||||
Some(p) => p,
|
||||
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
|
||||
};
|
||||
let hash = match params.get("hash") {
|
||||
Some(h) => h.as_str(),
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "missing ?hash= parameter"),
|
||||
};
|
||||
match provider.get_object(hash) {
|
||||
Ok(json) => json_response(json),
|
||||
Err(e) if e.contains("not found") => json_error(StatusCode::NOT_FOUND, &e),
|
||||
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ds_data(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let provider = match get_ds_provider(&state.datastore) {
|
||||
Some(p) => p,
|
||||
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
|
||||
};
|
||||
let hash = match params.get("hash") {
|
||||
Some(h) => h.as_str(),
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "missing ?hash= parameter"),
|
||||
};
|
||||
match provider.get_data(hash) {
|
||||
Ok(data) => {
|
||||
([(header::CONTENT_TYPE, "application/octet-stream")], data).into_response()
|
||||
}
|
||||
Err(e) if e.contains("not found") => json_error(StatusCode::NOT_FOUND, &e),
|
||||
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ds_status(State(state): State<AppState>) -> Response {
|
||||
let provider = match get_ds_provider(&state.datastore) {
|
||||
Some(p) => p,
|
||||
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
|
||||
};
|
||||
match provider.node_status() {
|
||||
Ok(json) => json_response(json),
|
||||
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ds_put(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
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<AppState>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let provider = match get_ds_provider(&state.datastore) {
|
||||
Some(p) => p,
|
||||
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
|
||||
};
|
||||
let hash = match params.get("hash") {
|
||||
Some(h) => h.as_str(),
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "missing ?hash= parameter"),
|
||||
};
|
||||
match provider.delete_object(hash) {
|
||||
Ok(json) => json_response(json),
|
||||
Err(e) if e.contains("not found") => json_error(StatusCode::NOT_FOUND, &e),
|
||||
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ds_start(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
// Check if already running
|
||||
{
|
||||
let ds = state.datastore.lock().unwrap();
|
||||
if ds.is_some() {
|
||||
return json_error(StatusCode::CONFLICT, "datastore already running");
|
||||
}
|
||||
}
|
||||
|
||||
let fac = match state.datastore_factory.lock().unwrap().clone() {
|
||||
Some(f) => f,
|
||||
None => return json_error(StatusCode::NOT_IMPLEMENTED, "no datastore factory configured"),
|
||||
};
|
||||
|
||||
let storage_path = params.get("storage_path").cloned();
|
||||
|
||||
match fac.start_datastore(storage_path) {
|
||||
Ok(provider) => {
|
||||
*state.datastore.lock().unwrap() = Some(provider);
|
||||
json_response(r#"{"ok":true}"#.to_string())
|
||||
}
|
||||
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ds_shutdown(State(state): State<AppState>) -> Response {
|
||||
let provider = match get_ds_provider(&state.datastore) {
|
||||
Some(p) => p,
|
||||
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
|
||||
};
|
||||
|
||||
match provider.shutdown_datastore() {
|
||||
Ok(()) => {
|
||||
*state.datastore.lock().unwrap() = None;
|
||||
json_response(r#"{"ok":true}"#.to_string())
|
||||
}
|
||||
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Peer Management API ─────────────────────────────────────────────────
|
||||
|
||||
async fn handle_peers_list(State(state): State<AppState>) -> Response {
|
||||
let maybe_auth = state.peer_auth.lock().unwrap().clone();
|
||||
match maybe_auth {
|
||||
Some(auth) => {
|
||||
let list = auth.lock().unwrap();
|
||||
let is_open = list.is_open();
|
||||
let peers: Vec<serde_json::Value> = list
|
||||
.list_peers()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
serde_json::json!({
|
||||
"node_id": p.node_id,
|
||||
"label": p.label,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let json = serde_json::json!({
|
||||
"mode": if is_open { "open" } else { "allow-list" },
|
||||
"peers": peers,
|
||||
})
|
||||
.to_string();
|
||||
json_response(json)
|
||||
}
|
||||
None => {
|
||||
let json = serde_json::json!({
|
||||
"mode": "open",
|
||||
"peers": [],
|
||||
})
|
||||
.to_string();
|
||||
json_response(json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_peers_add(State(state): State<AppState>, body: String) -> Response {
|
||||
let maybe_auth = state.peer_auth.lock().unwrap().clone();
|
||||
let auth = match maybe_auth {
|
||||
Some(a) => a,
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "peer auth not configured"),
|
||||
};
|
||||
|
||||
let parsed: serde_json::Value = match serde_json::from_str(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, &format!("invalid JSON: {e}")),
|
||||
};
|
||||
|
||||
let node_id_str = match parsed.get("node_id").and_then(|v| v.as_str()) {
|
||||
Some(s) => s,
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "missing node_id field"),
|
||||
};
|
||||
let label = parsed
|
||||
.get("label")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// Accept hex (64 chars) or base58 (~44 chars)
|
||||
let bytes: [u8; 32] = if let Some(b) = distribution::identity::hex_decode(node_id_str) {
|
||||
match b.try_into() {
|
||||
Ok(arr) => arr,
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid node_id (hex decoded to wrong length)",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if let Some(arr) = distribution::identity::base58_decode(node_id_str) {
|
||||
arr
|
||||
} else {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid node_id (expected 64-char hex or base58)",
|
||||
);
|
||||
};
|
||||
|
||||
let relay_url = parsed
|
||||
.get("relay_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let node_id = distribution::types::NodeId(bytes);
|
||||
let mut list = auth.lock().unwrap();
|
||||
list.add_peer(node_id, label);
|
||||
if let Err(e) = list.save() {
|
||||
eprintln!("warning: failed to persist peers.json: {e}");
|
||||
}
|
||||
drop(list);
|
||||
|
||||
// Trigger a SWIM join for the newly added peer
|
||||
if let Some(tx) = state.join_sender.lock().unwrap().as_ref() {
|
||||
let _ = tx.send((bytes, relay_url));
|
||||
}
|
||||
|
||||
json_response(r#"{"ok":true}"#.to_string())
|
||||
}
|
||||
|
||||
async fn handle_peers_remove(State(state): State<AppState>, body: String) -> Response {
|
||||
let maybe_auth = state.peer_auth.lock().unwrap().clone();
|
||||
let auth = match maybe_auth {
|
||||
Some(a) => a,
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "peer auth not configured"),
|
||||
};
|
||||
|
||||
let parsed: serde_json::Value = match serde_json::from_str(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, &format!("invalid JSON: {e}")),
|
||||
};
|
||||
|
||||
let node_id_hex = match parsed.get("node_id").and_then(|v| v.as_str()) {
|
||||
Some(s) => s,
|
||||
None => return json_error(StatusCode::BAD_REQUEST, "missing node_id field"),
|
||||
};
|
||||
|
||||
let bytes = match distribution::identity::hex_decode(node_id_hex) {
|
||||
Some(b) if b.len() == 32 => b,
|
||||
_ => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid node_id hex (must be 64 hex chars)",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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<RuntimeTrace>,
|
||||
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<ReplayState>,
|
||||
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<Event>(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())
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ pub struct ActorRow {
|
|||
pub last_msg_type: Option<String>,
|
||||
pub messages_processed: u64,
|
||||
pub poisoned: bool,
|
||||
pub name: Option<String>,
|
||||
/// Per-actor mailbox sparkline (from local ring buffer).
|
||||
pub sparkline_mailbox: Vec<u64>,
|
||||
/// 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(),
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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<Row> = 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)),
|
||||
])
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
@ -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",
|
||||
|
|
@ -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};
|
||||
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<std::path::PathBuf>,
|
||||
|
||||
/// HTTP API port
|
||||
#[arg(long)]
|
||||
port: Option<u16>,
|
||||
|
||||
/// Storage directory (omit for in-memory)
|
||||
#[arg(long)]
|
||||
storage_path: Option<String>,
|
||||
|
||||
/// Dashboard HTTP port (omit to disable dashboard)
|
||||
#[arg(long)]
|
||||
dashboard_port: Option<u16>,
|
||||
|
||||
/// Chunk size in bytes
|
||||
#[arg(long)]
|
||||
chunk_size: Option<u32>,
|
||||
|
||||
/// GC interval in ticks (each tick is ~100ms)
|
||||
#[arg(long)]
|
||||
gc_interval: Option<u64>,
|
||||
|
||||
/// Dissemination interval in ticks
|
||||
#[arg(long)]
|
||||
disseminate_interval: Option<u64>,
|
||||
|
||||
/// 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<u16>,
|
||||
storage_path: Option<String>,
|
||||
dashboard_port: Option<u16>,
|
||||
chunk_size: Option<u32>,
|
||||
gc_interval: Option<u64>,
|
||||
disseminate_interval: Option<u64>,
|
||||
}
|
||||
|
||||
/// Resolved configuration with CLI > config file > defaults applied.
|
||||
struct ResolvedConfig {
|
||||
port: u16,
|
||||
storage_path: Option<String>,
|
||||
dashboard_port: Option<u16>,
|
||||
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::<NodeConfig>(&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<dyn swactor_datastore::StorageBackend> = 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::<swactor_datastore::DatastoreResponse>()
|
||||
.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<dyn runtime_dashboard::datastore_collector::DatastoreStatsProvider>);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
|
@ -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<String>,
|
||||
) -> Result<Arc<dyn DatastoreStatsProvider>, 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<String>,
|
||||
pub auth: Option<DatastoreAuthConfig>,
|
||||
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<ActorAddress>,
|
||||
bridge: Arc<dyn DatastoreStatsProvider>,
|
||||
gc_interval: u64,
|
||||
disseminate_interval: u64,
|
||||
runtime: Arc<Runtime>,
|
||||
}
|
||||
|
||||
impl DatastoreGroup {
|
||||
/// Spawn all datastore actors, wire them together, and register names.
|
||||
pub fn spawn(runtime: Arc<Runtime>, config: DatastoreGroupConfig) -> Result<Self, String> {
|
||||
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<dyn crate::StorageBackend> = match &storage_path {
|
||||
let backend: Box<dyn crate::StorageBackend> = 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<dyn DatastoreStatsProvider> = 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<dyn DatastoreStatsProvider> {
|
||||
&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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
let snap = self.snapshot();
|
||||
serde_json::to_string(&snap).ok()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<dyn runtime_dashboard::datastore_collector::DatastoreStatsProvider>,
|
||||
as Arc<dyn dashboard::datastore_collector::DatastoreStatsProvider>,
|
||||
);
|
||||
|
||||
let (api_shutdown, _peers) = start_api_server(
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<Arc<Mutex<PeerAllowList>>>,
|
||||
/// 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<std::net::SocketAddr>,
|
||||
/// 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<std::net::IpAddr>,
|
||||
}
|
||||
|
||||
// ─── Pending join result ────────────────────────────────────────────────────
|
||||
|
|
@ -64,6 +74,13 @@ pub struct IrohDriver {
|
|||
peer_auth: Option<Arc<Mutex<PeerAllowList>>>,
|
||||
/// Collects connections from background join tasks.
|
||||
pending_joins: Arc<Mutex<Vec<JoinResult>>>,
|
||||
/// Connections accepted by the background accept loop.
|
||||
accepted_conns: Arc<Mutex<Vec<(NodeId, Connection)>>>,
|
||||
/// Embedded relay server (if started).
|
||||
#[cfg(feature = "relay")]
|
||||
relay_server: Option<iroh_relay::server::Server>,
|
||||
/// URL of the embedded relay server (if started).
|
||||
relay_url: Option<String>,
|
||||
}
|
||||
|
||||
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<Self, Box<dyn std::error::Error>> {
|
||||
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::<String>, 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<Mutex<Vec<(NodeId, Connection)>>> =
|
||||
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<u8>, 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<iroh::RelayUrl> {
|
||||
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<std::net::IpAddr>,
|
||||
) -> Result<(iroh_relay::server::Server, iroh::RelayUrl), Box<dyn std::error::Error>> {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<NodeAction> {
|
||||
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<NodeAction> {
|
||||
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<NodeAction> {
|
||||
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<NodeAction> {
|
||||
|
|
@ -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<String>) {
|
||||
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<NodeAction>) -> Vec<NodeAction> {
|
||||
/// Post-process outgoing actions: wrap each piggyback with registry + metadata entries.
|
||||
fn inject_piggyback(&mut self, actions: Vec<NodeAction>) -> Vec<NodeAction> {
|
||||
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<NodeMetadataEntry>) {
|
||||
if !entries.is_empty() {
|
||||
self.metadata.apply_incoming(entries, self.cluster_size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of resolving an actor's location.
|
||||
|
|
|
|||
129
crates/distribution/src/node_metadata.rs
Normal file
129
crates/distribution/src/node_metadata.rs
Normal file
|
|
@ -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<String>,
|
||||
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<NodeId, NodeMetadataEntry>,
|
||||
pending: Vec<PendingEntry>,
|
||||
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<String>, 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<NodeMetadataEntry>, 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<NodeMetadataEntry> {
|
||||
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::<Vec<_>>() {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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<u8>,
|
||||
/// Registry entries to disseminate.
|
||||
pub registry: Vec<RegistryEntry>,
|
||||
/// Node metadata entries (relay URLs, etc.) to disseminate.
|
||||
#[serde(default)]
|
||||
pub node_metadata: Vec<NodeMetadataEntry>,
|
||||
}
|
||||
|
||||
// ─── 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<u8>, registry: Vec<RegistryEntry>) -> Vec<u8> {
|
||||
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<u8>,
|
||||
registry: Vec<RegistryEntry>,
|
||||
node_metadata: Vec<NodeMetadataEntry>,
|
||||
) -> Vec<u8> {
|
||||
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<u8>, Vec<RegistryEntry>) {
|
||||
pub fn unpack_combined_piggyback(
|
||||
bytes: &[u8],
|
||||
) -> (Vec<u8>, Vec<RegistryEntry>, Vec<NodeMetadataEntry>) {
|
||||
if bytes.is_empty() {
|
||||
return (Vec::new(), Vec::new());
|
||||
return (Vec::new(), Vec::new(), Vec::new());
|
||||
}
|
||||
match serde_json::from_slice::<PiggybackPayload>(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()),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ pub struct MemberInfo {
|
|||
pub is_authorized: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub relay_url: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// This node's relay URL, if running an embedded relay server.
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub relay_url: Option<String>,
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Mutex<PeerAllowList>>) -> 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ pub fn test_config() -> DistributedNodeConfig {
|
|||
cache_capacity: 100,
|
||||
republish_interval: 50,
|
||||
registry: RegistryConfig::default(),
|
||||
metadata_lambda: 3,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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<String> {
|
||||
self.name_registry.lookup_by_addr(addr)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StdExtension {
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
self.reverse.read().unwrap().get(addr).cloned()
|
||||
}
|
||||
|
||||
/// Return all registered names.
|
||||
pub fn registered_names(&self) -> Vec<String> {
|
||||
self.names.read().unwrap().keys().cloned().collect()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ use serde::Deserialize;
|
|||
pub struct SwactorNodeConfig {
|
||||
pub transport: Option<String>,
|
||||
pub dashboard_port: Option<u16>,
|
||||
pub dashboard_token: Option<String>,
|
||||
pub storage_path: Option<String>,
|
||||
pub identity_dir: Option<String>,
|
||||
pub peers_file: Option<String>,
|
||||
|
|
@ -25,7 +24,10 @@ pub struct SwactorNodeConfig {
|
|||
pub disseminate_interval: Option<u64>,
|
||||
pub no_datastore: Option<bool>,
|
||||
pub node_name: Option<String>,
|
||||
pub mdns: Option<bool>,
|
||||
pub relay: Option<bool>,
|
||||
pub relay_port: Option<u16>,
|
||||
pub relay_bind: Option<String>,
|
||||
pub relay_hosts: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl SwactorNodeConfig {
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
|
||||
/// Human-readable node name (e.g. "swift-falcon")
|
||||
#[arg(long)]
|
||||
node_name: Option<String>,
|
||||
|
||||
/// 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<dyn swactor_datastore::StorageBackend> = 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::<dashboard::JoinPeerInfo>();
|
||||
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<Mutex<PeerAllowList>>,
|
||||
handle: &swactor::runtime::RuntimeHandle,
|
||||
dash: &runtime_dashboard::DashboardHandle,
|
||||
dash: &dashboard::DashboardHandle,
|
||||
stop: &Arc<AtomicBool>,
|
||||
ds_metadata_addr: Option<swactor::actor::ActorAddress>,
|
||||
ds_gateway_addr: Option<swactor::actor::ActorAddress>,
|
||||
ds_gc_interval: u64,
|
||||
ds_disseminate_interval: u64,
|
||||
ds_group: &Option<DatastoreGroup>,
|
||||
node_name: String,
|
||||
invite_code: String,
|
||||
_join_rx: std::sync::mpsc::Receiver<[u8; 32]>,
|
||||
_join_rx: std::sync::mpsc::Receiver<dashboard::JoinPeerInfo>,
|
||||
) {
|
||||
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<Mutex<PeerAllowList>>,
|
||||
handle: &swactor::runtime::RuntimeHandle,
|
||||
dash: &runtime_dashboard::DashboardHandle,
|
||||
dash: &dashboard::DashboardHandle,
|
||||
stop: &Arc<AtomicBool>,
|
||||
ds_metadata_addr: Option<swactor::actor::ActorAddress>,
|
||||
ds_gateway_addr: Option<swactor::actor::ActorAddress>,
|
||||
ds_gc_interval: u64,
|
||||
ds_disseminate_interval: u64,
|
||||
ds_group: &Option<DatastoreGroup>,
|
||||
node_name: String,
|
||||
invite_code: String,
|
||||
join_rx: std::sync::mpsc::Receiver<[u8; 32]>,
|
||||
join_rx: std::sync::mpsc::Receiver<dashboard::JoinPeerInfo>,
|
||||
relay_enabled: bool,
|
||||
relay_bind: &str,
|
||||
relay_port: u16,
|
||||
relay_hosts: Vec<String>,
|
||||
) {
|
||||
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<Box<dyn relay::CandidacyRule>> = 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<iroh::RelayUrl> = relay_hosts.iter()
|
||||
.filter_map(|host| {
|
||||
let url_str = format!("http://{host}:{relay_port}/");
|
||||
match url_str.parse::<iroh::RelayUrl>() {
|
||||
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::<iroh::RelayMap>())
|
||||
}
|
||||
};
|
||||
|
||||
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<iroh::EndpointAddr> = 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::<iroh::RelayUrl>() {
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AtomicBool>,
|
||||
thread: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
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<Mutex<PeerAllowList>>,
|
||||
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<AtomicBool>,
|
||||
invite_code: &str,
|
||||
node_name: &str,
|
||||
peer_auth: Arc<Mutex<PeerAllowList>>,
|
||||
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();
|
||||
}
|
||||
86
crates/swactor-node/src/relay.rs
Normal file
86
crates/swactor-node/src/relay.rs
Normal file
|
|
@ -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<IpAddr> {
|
||||
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<dyn CandidacyRule>]) -> Result<(), String> {
|
||||
for rule in rules {
|
||||
if let CandidacyResult::Ineligible(reason) = rule.evaluate() {
|
||||
return Err(reason);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
[package]
|
||||
name = "swactor-crypto-wasm"
|
||||
name = "wasm-crypto"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
12
crates/wasm/runtime/Cargo.toml
Normal file
12
crates/wasm/runtime/Cargo.toml
Normal file
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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<String>,
|
||||
/// Per-message-type counts, sorted descending by count. Top 32 types.
|
||||
#[cfg_attr(feature = "serde", serde(default))]
|
||||
pub message_type_counts: Vec<(String, u64)>,
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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");
|
||||
|
|
|
|||
55
tests/docker/tests/deploy.rs
Normal file
55
tests/docker/tests/deploy.rs
Normal file
|
|
@ -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<String> = 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();
|
||||
}
|
||||
696
xtask/src/deploy.rs
Normal file
696
xtask/src/deploy.rs
Normal file
|
|
@ -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<MachineConfig>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(default)]
|
||||
pub relay_hosts: Vec<String>,
|
||||
#[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<u16>,
|
||||
pub relay_port: Option<u16>,
|
||||
pub container: Option<String>,
|
||||
pub swactor_flags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String, String> {
|
||||
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::<Vec<_>>().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::<Vec<_>>()
|
||||
.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::<serde_json::Value>(&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::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.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<String>)> = 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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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!();
|
||||
}
|
||||
|
|
@ -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<u16>,
|
||||
|
||||
/// Storage path
|
||||
/// Storage path for persistent datastore (omit for in-memory)
|
||||
#[arg(long)]
|
||||
storage_path: Option<String>,
|
||||
|
||||
/// Enable auth (bare --auth → true, --auth=false → false)
|
||||
#[arg(long, num_args = 0..=1, default_missing_value = "true")]
|
||||
auth: Option<bool>,
|
||||
|
||||
/// Auth directory
|
||||
/// Build in release mode
|
||||
#[arg(long)]
|
||||
auth_dir: Option<String>,
|
||||
release: bool,
|
||||
|
||||
/// Extra arguments forwarded to the underlying binary
|
||||
/// Extra arguments forwarded to the swactor binary
|
||||
#[arg(allow_hyphen_values = true)]
|
||||
extra: Vec<String>,
|
||||
},
|
||||
|
|
@ -108,26 +106,39 @@ enum Cmd {
|
|||
/// Identity directories to include
|
||||
dirs: Vec<String>,
|
||||
},
|
||||
|
||||
/// 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<u16>,
|
||||
storage_path: Option<String>,
|
||||
auth: Option<bool>,
|
||||
auth_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct CliConfig {
|
||||
url: Option<String>,
|
||||
|
|
@ -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 <COMMAND>
|
|||
|
||||
COMMANDS:
|
||||
test <GROUP> 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<String>) {
|
|||
fn run_node(
|
||||
port: Option<u16>,
|
||||
storage_path: Option<String>,
|
||||
auth: Option<bool>,
|
||||
auth_dir: Option<String>,
|
||||
release: bool,
|
||||
extra: Vec<String>,
|
||||
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<String> = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue