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:
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};