tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit c14fc50442100c9592bdd8d8343c6a8088e35a7a
parent aa4fea1c236bed5ee7575fb236359ef07e6c4825
Author: triesap <tyson@radroots.org>
Date:   Wed, 17 Jun 2026 15:23:45 -0700

cli: add host config validation commands

- load tenant configs from the host config directory
- validate duplicate tenant identity and store boundaries
- add redacted config inspect and tenant list commands
- run the relay from the new host config surface

Diffstat:
Mconfig/tangle.host.example.json | 2+-
Mcrates/tangle/Cargo.toml | 1+
Mcrates/tangle/src/lib.rs | 236++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/tangle/src/main.rs | 46++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/tangle/tests/version.rs | 49+++++++++++++++++++++++++++++++++++++------------
Mcrates/tangle_runtime/src/config.rs | 283+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/tangle_runtime/src/lib.rs | 81++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
7 files changed, 670 insertions(+), 28 deletions(-)

diff --git a/config/tangle.host.example.json b/config/tangle.host.example.json @@ -1,6 +1,6 @@ { "listen_addr": "0.0.0.0:7000", - "tenant_config_dir": "config/tenants", + "tenant_config_dir": "tenants", "limits": { "max_total_connections": 10000, "max_total_subscriptions": 25000, diff --git a/crates/tangle/Cargo.toml b/crates/tangle/Cargo.toml @@ -9,6 +9,7 @@ description = "The tangle Nostr relay runtime" readme = "../../README" [dependencies] +serde_json = "1" tangle_runtime = { path = "../tangle_runtime" } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/crates/tangle/src/lib.rs b/crates/tangle/src/lib.rs @@ -7,13 +7,19 @@ pub const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION"); pub const USAGE: &str = "\ usage: tangle [--version] - tangle run --config PATH"; + tangle run --config PATH + tangle config validate --config PATH + tangle config inspect --config PATH --redacted + tangle tenant list --config PATH"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TangleCommand { Version, Help, Run, + ConfigValidate, + ConfigInspect, + TenantList, } impl TangleCommand { @@ -22,6 +28,9 @@ impl TangleCommand { Self::Version => "version", Self::Help => "help", Self::Run => "run", + Self::ConfigValidate => "config validate", + Self::ConfigInspect => "config inspect", + Self::TenantList => "tenant list", } } } @@ -30,13 +39,23 @@ impl TangleCommand { pub struct TangleInvocation { command: TangleCommand, config_path: Option<String>, + redacted: bool, } impl TangleInvocation { pub fn new(command: TangleCommand, config_path: Option<String>) -> Self { + Self::new_with_options(command, config_path, false) + } + + pub fn new_with_options( + command: TangleCommand, + config_path: Option<String>, + redacted: bool, + ) -> Self { Self { command, config_path, + redacted, } } @@ -47,6 +66,10 @@ impl TangleInvocation { pub fn config_path(&self) -> Option<&str> { self.config_path.as_deref() } + + pub fn redacted(&self) -> bool { + self.redacted + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -108,9 +131,25 @@ where "--version" | "-V" => TangleCommand::Version, "--help" | "-h" | "help" => TangleCommand::Help, "run" => TangleCommand::Run, + "config" => match args.next().as_deref() { + Some("validate") => TangleCommand::ConfigValidate, + Some("inspect") => TangleCommand::ConfigInspect, + Some(command) => { + return Err(TangleCliError::UnknownCommand(format!("config {command}"))); + } + None => return Err(TangleCliError::UnknownCommand("config".to_owned())), + }, + "tenant" => match args.next().as_deref() { + Some("list") => TangleCommand::TenantList, + Some(command) => { + return Err(TangleCliError::UnknownCommand(format!("tenant {command}"))); + } + None => return Err(TangleCliError::UnknownCommand("tenant".to_owned())), + }, _ => return Err(TangleCliError::UnknownCommand(first)), }; let mut config_path = None; + let mut redacted = false; while let Some(argument) = args.next() { match argument.as_str() { "--config" => { @@ -122,6 +161,9 @@ where }; config_path = Some(path); } + "--redacted" if command == TangleCommand::ConfigInspect => { + redacted = true; + } _ => { return Err(TangleCliError::UnexpectedArgument { command: command.as_str().to_owned(), @@ -130,13 +172,17 @@ where } } } - if config_path.is_some() && command != TangleCommand::Run { + if config_path.is_some() && !command_accepts_config(command) { return Err(TangleCliError::UnexpectedArgument { command: command.as_str().to_owned(), argument: "--config".to_owned(), }); } - Ok(TangleInvocation::new(command, config_path)) + Ok(TangleInvocation::new_with_options( + command, + config_path, + redacted, + )) } pub fn require_config_path(invocation: &TangleInvocation) -> Result<&str, TangleCliError> { @@ -148,7 +194,10 @@ pub fn require_config_path(invocation: &TangleInvocation) -> Result<&str, Tangle pub async fn run_with_config( config_path: &str, ) -> Result<tangle_runtime::server::TangleServeReport, String> { - let config = tangle_runtime::load_base_relay_runtime_config(config_path) + let config_set = tangle_runtime::load_tangle_host_runtime_config(config_path) + .map_err(|error| error.to_string())?; + let config = config_set + .single_active_runtime_config() .map_err(|error| error.to_string())?; tangle_runtime::logging::init_tangle_tracing(config.tracing()) .map_err(|error| error.to_string())?; @@ -160,11 +209,107 @@ pub async fn run_with_config( .map_err(|error| error.to_string()) } +pub fn validate_config(config_path: &str) -> Result<(), String> { + tangle_runtime::load_tangle_host_runtime_config(config_path) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +pub fn inspect_config(config_path: &str, redacted: bool) -> Result<String, String> { + if !redacted { + return Err("--redacted is required".to_owned()); + } + let config = tangle_runtime::load_tangle_host_runtime_config(config_path) + .map_err(|error| error.to_string())?; + let tenants = config + .tenants() + .iter() + .map(|tenant| { + serde_json::json!({ + "tenant_id": tenant.tenant_id().as_str(), + "tenant_schema": tenant.tenant_schema().as_str(), + "host": tenant.host().as_str(), + "relay_url": tenant.relay_url().as_str(), + "inactive": tenant.inactive(), + "info": { + "name": tenant.info().name(), + "description": tenant.info().description(), + "contact": tenant.info().contact(), + "icon": tenant.info().icon() + }, + "pocket": { + "data_directory": tenant.pocket_config().data_directory().display().to_string(), + "sync_policy": format!("{:?}", tenant.pocket_config().sync_policy()) + }, + "groups": { + "enabled": tenant.groups().enabled(), + "relay_secret": "<redacted>", + "relay_self": tenant.relay_self_pubkey().ok().flatten().map(|pubkey| pubkey.as_str().to_owned()) + }, + "backup_export": { + "backup_enabled": tenant.backup_export().backup_enabled(), + "export_enabled": tenant.backup_export().export_enabled() + } + }) + }) + .collect::<Vec<_>>(); + serde_json::to_string_pretty(&serde_json::json!({ + "listen_addr": config.host().listen_addr().to_string(), + "tenant_config_dir": config.host().tenant_config_dir().display().to_string(), + "limits": { + "max_total_connections": config.host().limits().max_total_connections(), + "max_total_subscriptions": config.host().limits().max_total_subscriptions(), + "tenant_startup_concurrency": config.host().limits().tenant_startup_concurrency() + }, + "ops": { + "enabled": config.host().ops().enabled(), + "expose_tenant_inventory": config.host().ops().expose_tenant_inventory() + }, + "trusted_proxy": { + "enabled": config.host().trusted_proxy().enabled(), + "trusted_peers": config.host().trusted_proxy().trusted_peers() + }, + "tenants": tenants + })) + .map_err(|error| error.to_string()) +} + +pub fn list_tenants(config_path: &str) -> Result<String, String> { + let config = tangle_runtime::load_tangle_host_runtime_config(config_path) + .map_err(|error| error.to_string())?; + let mut lines = vec!["tenant_id\thost\tstatus\ttenant_schema".to_owned()]; + for tenant in config.tenants() { + lines.push(format!( + "{}\t{}\t{}\t{}", + tenant.tenant_id().as_str(), + tenant.host().as_str(), + if tenant.inactive() { + "inactive" + } else { + "active" + }, + tenant.tenant_schema().as_str() + )); + } + Ok(lines.join("\n")) +} + +fn command_accepts_config(command: TangleCommand) -> bool { + matches!( + command, + TangleCommand::Run + | TangleCommand::ConfigValidate + | TangleCommand::ConfigInspect + | TangleCommand::TenantList + ) +} + #[cfg(test)] mod tests { use super::{ PACKAGE_NAME, PACKAGE_VERSION, TangleCliError, TangleCommand, TangleInvocation, - parse_tangle_invocation, require_config_path, usage_output, version_output, + inspect_config, list_tenants, parse_tangle_invocation, require_config_path, usage_output, + validate_config, version_output, }; #[test] @@ -178,7 +323,7 @@ mod tests { fn usage_lists_only_v2_command_surface() { assert_eq!( usage_output(), - "usage:\n tangle [--version]\n tangle run --config PATH" + "usage:\n tangle [--version]\n tangle run --config PATH\n tangle config validate --config PATH\n tangle config inspect --config PATH --redacted\n tangle tenant list --config PATH" ); } @@ -193,11 +338,52 @@ mod tests { TangleInvocation::new(TangleCommand::Version, None) ); assert_eq!( - parse_tangle_invocation(["run", "--config", "config/tangle.example.json"]) + parse_tangle_invocation(["run", "--config", "config/tangle.host.example.json"]) .expect("run"), TangleInvocation::new( TangleCommand::Run, - Some("config/tangle.example.json".to_owned()) + Some("config/tangle.host.example.json".to_owned()) + ) + ); + assert_eq!( + parse_tangle_invocation([ + "config", + "validate", + "--config", + "config/tangle.host.example.json" + ]) + .expect("validate"), + TangleInvocation::new( + TangleCommand::ConfigValidate, + Some("config/tangle.host.example.json".to_owned()) + ) + ); + assert_eq!( + parse_tangle_invocation([ + "config", + "inspect", + "--config", + "config/tangle.host.example.json", + "--redacted" + ]) + .expect("inspect"), + TangleInvocation::new_with_options( + TangleCommand::ConfigInspect, + Some("config/tangle.host.example.json".to_owned()), + true + ) + ); + assert_eq!( + parse_tangle_invocation([ + "tenant", + "list", + "--config", + "config/tangle.host.example.json" + ]) + .expect("tenant list"), + TangleInvocation::new( + TangleCommand::TenantList, + Some("config/tangle.host.example.json".to_owned()) ) ); } @@ -211,6 +397,8 @@ mod tests { vec!["projection", "rebuild"], vec!["ops", "backup"], vec!["ops", "restore"], + vec!["config", "migrate"], + vec!["tenant", "create"], ] { assert!(matches!( parse_tangle_invocation(args).expect_err("removed"), @@ -236,6 +424,13 @@ mod tests { argument: "--config".to_owned(), } ); + assert_eq!( + parse_tangle_invocation(["run", "--redacted"]).expect_err("redacted"), + TangleCliError::UnexpectedArgument { + command: "run".to_owned(), + argument: "--redacted".to_owned(), + } + ); } #[test] @@ -246,4 +441,29 @@ mod tests { TangleCliError::MissingOptionValue("--config") ); } + + #[test] + fn config_commands_use_new_host_config_surface() { + let config_path = workspace_file("config/tangle.host.example.json"); + validate_config(&config_path).expect("validate"); + let inspect = inspect_config(&config_path, true).expect("inspect redacted"); + assert!(inspect.contains("\"tenant_id\": \"farmers-market\"")); + assert!(inspect.contains("\"relay_secret\": \"<redacted>\"")); + assert!( + !inspect.contains("7777777777777777777777777777777777777777777777777777777777777777") + ); + let tenants = list_tenants(&config_path).expect("tenant list"); + assert!(tenants.contains("tenant_id\thost\tstatus\ttenant_schema")); + assert!(tenants.contains("farmers-market\trelay.radroots.test\tactive\tfarmers_market")); + } + + fn workspace_file(path: &str) -> String { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(std::path::Path::parent) + .expect("workspace root") + .join(path) + .to_string_lossy() + .into_owned() + } } diff --git a/crates/tangle/src/main.rs b/crates/tangle/src/main.rs @@ -29,12 +29,54 @@ async fn main() -> ExitCode { ExitCode::from(2) } }, + tangle::TangleCommand::ConfigValidate => { + match config_path_or_error(&invocation).and_then(|path| tangle::validate_config(&path)) + { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error}"); + ExitCode::from(2) + } + } + } + tangle::TangleCommand::ConfigInspect => { + match config_path_or_error(&invocation) + .and_then(|path| tangle::inspect_config(&path, invocation.redacted())) + { + Ok(output) => { + println!("{output}"); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("{error}"); + ExitCode::from(2) + } + } + } + tangle::TangleCommand::TenantList => { + match config_path_or_error(&invocation).and_then(|path| tangle::list_tenants(&path)) { + Ok(output) => { + println!("{output}"); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("{error}"); + ExitCode::from(2) + } + } + } } } async fn run_server(invocation: &tangle::TangleInvocation) -> Result<(), String> { - let config_path = tangle::require_config_path(invocation).map_err(|error| error.to_string())?; - tangle::run_with_config(config_path).await.map(|_| ()) + let config_path = config_path_or_error(invocation)?; + tangle::run_with_config(&config_path).await.map(|_| ()) +} + +fn config_path_or_error(invocation: &tangle::TangleInvocation) -> Result<String, String> { + tangle::require_config_path(invocation) + .map(str::to_owned) + .map_err(|error| error.to_string()) } #[cfg(test)] diff --git a/crates/tangle/tests/version.rs b/crates/tangle/tests/version.rs @@ -29,7 +29,7 @@ fn tangle_without_args_reports_usage() { assert!(output.status.success()); assert_eq!( String::from_utf8_lossy(&output.stdout), - "usage:\n tangle [--version]\n tangle run --config PATH\n" + "usage:\n tangle [--version]\n tangle run --config PATH\n tangle config validate --config PATH\n tangle config inspect --config PATH --redacted\n tangle tenant list --config PATH\n" ); assert!(output.stderr.is_empty()); } @@ -45,7 +45,7 @@ fn tangle_unknown_arg_reports_usage_error() { assert!(output.stdout.is_empty()); assert_eq!( String::from_utf8_lossy(&output.stderr), - "unknown command: --unknown\nusage:\n tangle [--version]\n tangle run --config PATH\n" + "unknown command: --unknown\nusage:\n tangle [--version]\n tangle run --config PATH\n tangle config validate --config PATH\n tangle config inspect --config PATH --redacted\n tangle tenant list --config PATH\n" ); } @@ -91,23 +91,44 @@ fn tangle_run_starts_server_and_stays_alive_until_shutdown() { let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).expect("runtime root"); let data_dir = root.join("pocket"); - let config_path = root.join("runtime.json"); + let tenant_dir = root.join("tenants"); + std::fs::create_dir_all(&tenant_dir).expect("tenant dir"); + let config_path = root.join("host.json"); + let tenant_config_path = tenant_dir.join("farmers_market.json"); let listen_addr = reserve_loopback_addr(); std::fs::write( &config_path, serde_json::json!({ - "server": { - "listen_addr": listen_addr.to_string(), - "relay_url": "wss://relay.radroots.test" + "listen_addr": listen_addr.to_string(), + "tenant_config_dir": "tenants", + "limits": { + "max_total_connections": 10000, + "max_total_subscriptions": 25000, + "tenant_startup_concurrency": 4 + } + }) + .to_string(), + ) + .expect("write host config"); + std::fs::write( + &tenant_config_path, + serde_json::json!({ + "tenant_id": "farmers-market", + "tenant_schema": "farmers_market", + "host": "relay.radroots.test", + "relay_url": "wss://relay.radroots.test", + "inactive": false, + "info": { + "name": "Radroots Farmers Market" }, "pocket": { "data_directory": data_dir, - "sync_policy": "flush_on_shutdown", - "query": { - "allow_scraping": false, - "allow_scrape_if_limited_to": 100, - "allow_scrape_if_max_seconds": 3600 - } + "sync_policy": "flush_on_shutdown" + }, + "pocket_query": { + "allow_scraping": false, + "allow_scrape_if_limited_to": 100, + "allow_scrape_if_max_seconds": 3600 }, "groups": { "enabled": true, @@ -123,6 +144,10 @@ fn tangle_run_starts_server_and_stays_alive_until_shutdown() { "max_outbox_replay_batch": 1000 } }, + "backup_export": { + "backup_enabled": true, + "export_enabled": true + }, "auth": { "challenge_ttl_seconds": 300, "created_at_skew_seconds": 600 diff --git a/crates/tangle_runtime/src/config.rs b/crates/tangle_runtime/src/config.rs @@ -13,7 +13,11 @@ use crate::{ tenant::{CanonicalHost, TenantId, TenantRelayUrl, TenantSchema}, }; use serde::Deserialize; -use std::{net::SocketAddr, path::PathBuf}; +use std::{ + collections::BTreeSet, + net::SocketAddr, + path::{Component, Path, PathBuf}, +}; use tangle_crypto::RelaySigner; use tangle_groups::GroupRuntimeConfig; use tangle_protocol::{PublicKeyHex, SubscriptionId}; @@ -31,6 +35,50 @@ pub struct TangleHostRuntimeConfig { tracing: BaseRelayTracingConfig, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TangleHostRuntimeConfigSet { + host: TangleHostRuntimeConfig, + tenants: Vec<TenantRuntimeConfig>, +} + +impl TangleHostRuntimeConfigSet { + pub fn new( + host: TangleHostRuntimeConfig, + tenants: Vec<TenantRuntimeConfig>, + ) -> Result<Self, BaseRelayError> { + validate_tenant_config_set(&tenants)?; + Ok(Self { host, tenants }) + } + + pub fn host(&self) -> &TangleHostRuntimeConfig { + &self.host + } + + pub fn tenants(&self) -> &[TenantRuntimeConfig] { + &self.tenants + } + + pub fn active_tenants(&self) -> impl Iterator<Item = &TenantRuntimeConfig> { + self.tenants.iter().filter(|tenant| !tenant.inactive()) + } + + pub fn single_active_runtime_config(&self) -> Result<BaseRelayRuntimeConfig, BaseRelayError> { + let mut active = self.active_tenants(); + let Some(tenant) = active.next() else { + return Err(BaseRelayError::invalid( + "at least one active tenant is required", + )); + }; + if active.next().is_some() { + return Err(BaseRelayError::invalid( + "multi-tenant host runtime startup is not implemented yet", + )); + } + Ok(tenant + .to_base_relay_runtime_config(self.host.listen_addr(), self.host.tracing().clone())) + } +} + impl TangleHostRuntimeConfig { pub fn listen_addr(&self) -> SocketAddr { self.listen_addr @@ -325,6 +373,25 @@ impl TenantRuntimeConfig { pub fn backup_export(&self) -> TenantBackupExportConfig { self.backup_export } + + pub fn to_base_relay_runtime_config( + &self, + listen_addr: SocketAddr, + tracing: BaseRelayTracingConfig, + ) -> BaseRelayRuntimeConfig { + BaseRelayRuntimeConfig { + listen_addr, + relay_url: self.relay_url.as_str().to_owned(), + pocket: self.pocket.clone(), + pocket_query: self.pocket_query, + groups: self.groups.clone(), + auth_ttl_seconds: self.auth_ttl_seconds, + auth_created_at_skew_seconds: self.auth_created_at_skew_seconds, + limits: self.limits, + rate_limits: self.rate_limits, + tracing, + } + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1208,12 +1275,78 @@ fn validate_optional_text( } } +fn validate_tenant_config_set(tenants: &[TenantRuntimeConfig]) -> Result<(), BaseRelayError> { + if tenants.iter().all(TenantRuntimeConfig::inactive) { + return Err(BaseRelayError::invalid( + "at least one active tenant is required", + )); + } + let mut tenant_ids = BTreeSet::new(); + let mut tenant_schemas = BTreeSet::new(); + let mut hosts = BTreeSet::new(); + let mut relay_urls = BTreeSet::new(); + let mut relay_self_pubkeys = BTreeSet::new(); + let mut store_paths = BTreeSet::new(); + for tenant in tenants { + insert_unique("tenant_id", tenant.tenant_id().as_str(), &mut tenant_ids)?; + insert_unique( + "tenant_schema", + tenant.tenant_schema().as_str(), + &mut tenant_schemas, + )?; + insert_unique("host", tenant.host().as_str(), &mut hosts)?; + insert_unique("relay_url", tenant.relay_url().as_str(), &mut relay_urls)?; + if let Some(pubkey) = tenant.relay_self_pubkey()? { + insert_unique( + "relay self pubkey", + pubkey.as_str(), + &mut relay_self_pubkeys, + )?; + } + let store_path = canonical_path_key(tenant.pocket_config().data_directory()); + insert_unique("pocket data directory", &store_path, &mut store_paths)?; + } + Ok(()) +} + +fn insert_unique( + field: &str, + value: impl Into<String>, + values: &mut BTreeSet<String>, +) -> Result<(), BaseRelayError> { + let value = value.into(); + if values.insert(value.clone()) { + Ok(()) + } else { + Err(BaseRelayError::invalid(format!( + "duplicate tenant {field}: {value}" + ))) + } +} + +fn canonical_path_key(path: &Path) -> String { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::Normal(part) => normalized.push(part), + Component::RootDir | Component::Prefix(_) => normalized.push(component.as_os_str()), + } + } + normalized.to_string_lossy().into_owned() +} + #[cfg(test)] mod tests { use super::{ - BaseRelayTracingFormat, parse_base_relay_runtime_config_json, - parse_tangle_host_runtime_config_json, parse_tenant_runtime_config_json, + BaseRelayTracingFormat, TangleHostRuntimeConfigSet, TenantRuntimeConfig, + parse_base_relay_runtime_config_json, parse_tangle_host_runtime_config_json, + parse_tenant_runtime_config_json, }; + use serde_json::{Value, json}; use std::path::Path; use tangle_store_pocket::PocketSyncPolicy; @@ -1225,7 +1358,7 @@ mod tests { .expect("host config"); assert_eq!(config.listen_addr().to_string(), "0.0.0.0:7000"); - assert_eq!(config.tenant_config_dir(), Path::new("config/tenants")); + assert_eq!(config.tenant_config_dir(), Path::new("tenants")); assert_eq!(config.limits().max_total_connections(), 10_000); assert_eq!(config.limits().max_total_subscriptions(), 25_000); assert_eq!(config.limits().tenant_startup_concurrency(), 4); @@ -1291,6 +1424,148 @@ mod tests { } #[test] + fn tangle_host_runtime_config_set_rejects_invalid_tenant_sets() { + let host = parse_tangle_host_runtime_config_json(include_str!( + "../../../config/tangle.host.example.json" + )) + .expect("host config"); + let first = tenant_from_value(first_tenant_value()); + let second = tenant_from_value(second_tenant_value()); + TangleHostRuntimeConfigSet::new(host.clone(), vec![first.clone(), second.clone()]) + .expect("unique tenants"); + + assert!( + TangleHostRuntimeConfigSet::new( + host.clone(), + vec![first.clone(), mutate_second("tenant_id", "farmers-market")] + ) + .expect_err("tenant id") + .prefixed_message() + .contains("duplicate tenant tenant_id") + ); + assert!( + TangleHostRuntimeConfigSet::new( + host.clone(), + vec![ + first.clone(), + mutate_second("tenant_schema", "farmers_market") + ] + ) + .expect_err("schema") + .prefixed_message() + .contains("duplicate tenant tenant_schema") + ); + assert!( + TangleHostRuntimeConfigSet::new( + host.clone(), + vec![first.clone(), mutate_second("host", "relay.radroots.test")] + ) + .expect_err("host") + .prefixed_message() + .contains("duplicate tenant host") + ); + assert!( + TangleHostRuntimeConfigSet::new( + host.clone(), + vec![ + first.clone(), + mutate_second("relay_url", "wss://relay.radroots.test") + ] + ) + .expect_err("relay url") + .prefixed_message() + .contains("duplicate tenant relay_url") + ); + assert!( + TangleHostRuntimeConfigSet::new( + host.clone(), + vec![first.clone(), second_with_group_secret("7")] + ) + .expect_err("relay self") + .prefixed_message() + .contains("duplicate tenant relay self pubkey") + ); + assert!( + TangleHostRuntimeConfigSet::new( + host.clone(), + vec![ + first.clone(), + second_with_store_path("./runtime/tenants/farmers_market/pocket") + ] + ) + .expect_err("store") + .prefixed_message() + .contains("duplicate tenant pocket data directory") + ); + assert_eq!( + TangleHostRuntimeConfigSet::new( + host, + vec![inactive_first_tenant(), inactive_second_tenant()] + ) + .expect_err("active tenants") + .prefixed_message(), + "invalid: at least one active tenant is required" + ); + } + + fn first_tenant_value() -> Value { + serde_json::from_str(include_str!( + "../../../config/tenants/farmers_market.example.json" + )) + .expect("tenant json") + } + + fn second_tenant_value() -> Value { + let mut value = first_tenant_value(); + value["tenant_id"] = json!("seed-coop"); + value["tenant_schema"] = json!("seed_coop"); + value["host"] = json!("seed.relay.radroots.test"); + value["relay_url"] = json!("wss://seed.relay.radroots.test"); + value["pocket"]["data_directory"] = json!("runtime/tenants/seed_coop/pocket"); + value["groups"]["canonical_relay_url"] = json!("wss://seed.relay.radroots.test"); + value["groups"]["relay_secret"] = + json!("8888888888888888888888888888888888888888888888888888888888888888"); + value + } + + fn tenant_from_value(value: Value) -> TenantRuntimeConfig { + parse_tenant_runtime_config_json(&value.to_string()).expect("tenant") + } + + fn mutate_second(field: &str, field_value: &str) -> TenantRuntimeConfig { + let mut value = second_tenant_value(); + value[field] = json!(field_value); + if field == "relay_url" { + value["groups"]["canonical_relay_url"] = json!(field_value); + } + tenant_from_value(value) + } + + fn second_with_group_secret(nibble: &str) -> TenantRuntimeConfig { + let mut value = second_tenant_value(); + value["groups"]["relay_secret"] = json!(nibble.repeat(64)); + tenant_from_value(value) + } + + fn second_with_store_path(path: &str) -> TenantRuntimeConfig { + let mut value = second_tenant_value(); + value["pocket"]["data_directory"] = json!(path); + tenant_from_value(value) + } + + fn inactive_first_tenant() -> TenantRuntimeConfig { + let mut value = first_tenant_value(); + value["inactive"] = json!(true); + tenant_from_value(value) + } + + fn inactive_second_tenant() -> TenantRuntimeConfig { + let mut value = second_tenant_value(); + value["inactive"] = json!(true); + tenant_from_value(value) + } + + #[test] fn base_relay_runtime_config_parses_v2_production_example() { let config = parse_base_relay_runtime_config_json(include_str!( "../../../config/tangle.example.json" diff --git a/crates/tangle_runtime/src/lib.rs b/crates/tangle_runtime/src/lib.rs @@ -19,7 +19,10 @@ pub mod tenant; use std::{fmt, fs, path::Path, path::PathBuf}; -use config::{BaseRelayRuntimeConfig, parse_base_relay_runtime_config_json}; +use config::{ + BaseRelayRuntimeConfig, TangleHostRuntimeConfigSet, parse_base_relay_runtime_config_json, + parse_tangle_host_runtime_config_json, parse_tenant_runtime_config_json, +}; use errors::BaseRelayError; use runtime::TangleRuntime; @@ -32,6 +35,14 @@ pub enum TangleRuntimeLoadError { path: PathBuf, source: std::io::Error, }, + ReadTenantConfigDir { + path: PathBuf, + source: std::io::Error, + }, + ReadTenantConfig { + path: PathBuf, + source: std::io::Error, + }, ParseConfig(BaseRelayError), OpenRelay(BaseRelayError), } @@ -46,6 +57,20 @@ impl fmt::Display for TangleRuntimeLoadError { path.display() ) } + Self::ReadTenantConfigDir { path, source } => { + write!( + formatter, + "failed to read tangle tenant config directory `{}`: {source}", + path.display() + ) + } + Self::ReadTenantConfig { path, source } => { + write!( + formatter, + "failed to read tangle tenant config `{}`: {source}", + path.display() + ) + } Self::ParseConfig(error) => write!(formatter, "{error}"), Self::OpenRelay(error) => write!(formatter, "{error}"), } @@ -65,6 +90,50 @@ pub fn load_base_relay_runtime_config( parse_base_relay_runtime_config_json(&raw).map_err(TangleRuntimeLoadError::ParseConfig) } +pub fn load_tangle_host_runtime_config( + path: impl AsRef<Path>, +) -> Result<TangleHostRuntimeConfigSet, TangleRuntimeLoadError> { + let path = path.as_ref(); + let raw = fs::read_to_string(path).map_err(|source| TangleRuntimeLoadError::ReadConfig { + path: path.to_path_buf(), + source, + })?; + let host = + parse_tangle_host_runtime_config_json(&raw).map_err(TangleRuntimeLoadError::ParseConfig)?; + let config_dir = resolve_config_path(path.parent(), host.tenant_config_dir()); + let mut tenant_paths = fs::read_dir(&config_dir) + .map_err(|source| TangleRuntimeLoadError::ReadTenantConfigDir { + path: config_dir.clone(), + source, + })? + .map(|entry| entry.map(|entry| entry.path())) + .collect::<Result<Vec<_>, _>>() + .map_err(|source| TangleRuntimeLoadError::ReadTenantConfigDir { + path: config_dir.clone(), + source, + })?; + tenant_paths.retain(|path| { + path.is_file() + && path + .extension() + .is_some_and(|extension| extension == "json") + }); + tenant_paths.sort(); + let mut tenants = Vec::with_capacity(tenant_paths.len()); + for tenant_path in tenant_paths { + let raw = fs::read_to_string(&tenant_path).map_err(|source| { + TangleRuntimeLoadError::ReadTenantConfig { + path: tenant_path.clone(), + source, + } + })?; + let tenant = + parse_tenant_runtime_config_json(&raw).map_err(TangleRuntimeLoadError::ParseConfig)?; + tenants.push(tenant); + } + TangleHostRuntimeConfigSet::new(host, tenants).map_err(TangleRuntimeLoadError::ParseConfig) +} + pub fn open_tangle_runtime_from_config_path( path: impl AsRef<Path>, ) -> Result<TangleRuntime, TangleRuntimeLoadError> { @@ -72,6 +141,16 @@ pub fn open_tangle_runtime_from_config_path( TangleRuntime::open(config).map_err(TangleRuntimeLoadError::OpenRelay) } +fn resolve_config_path(base: Option<&Path>, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else if let Some(base) = base { + base.join(path) + } else { + path.to_path_buf() + } +} + #[cfg(test)] mod tests { use crate::pocket_conversion::{pocket_event_to_tangle, tangle_event_to_pocket};