//! The three tables, and how a directory finds its way to a URL. use std::collections::BTreeMap; use std::fmt; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use crate::defaults; use crate::validate::Problems; /// What a service is. /// /// The set is closed because each variant means something to the validator: /// which upstreams it may name, and which role in a profile it may fill. A /// kind nothing can check would be a string. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum ServiceKind { /// The personal data server: accounts, repositories, records. Pds, /// Follows a personal data server and holds what it finds. Index, /// Mirrors an index and answers a browser. Query, /// The canvas. Web, } impl ServiceKind { /// The name this kind is written as in the configuration file. pub fn as_str(self) -> &'static str { match self { Self::Pds => "pds", Self::Index => "index", Self::Query => "query", Self::Web => "web", } } } impl fmt::Display for ServiceKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } /// One process: what it is, where it listens, and what it points at. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Service { /// What kind of service this is. pub kind: ServiceKind, /// The port it listens on. pub port: u16, /// The interface it listens on. Loopback unless something says otherwise. #[serde(default = "defaults::host")] pub host: String, /// The personal data server this service reads or writes, by service name. /// /// Meaningful to a record host and to an index. Named rather than /// derived, so that a profile's roles can be checked against each other /// instead of assumed to agree. #[serde(default, skip_serializing_if = "Option::is_none")] pub pds: Option, /// The index this service mirrors, by service name. Query services only. #[serde(default, skip_serializing_if = "Option::is_none")] pub index: Option, /// The query service this service asks, by service name. The canvas only. #[serde(default, skip_serializing_if = "Option::is_none")] pub query: Option, /// The DNS zone agent hostnames are minted under. Personal data servers. #[serde(default, skip_serializing_if = "Option::is_none")] pub zone: Option, /// Where the write-ahead log and blobs live, if this one keeps its state. #[serde(default, skip_serializing_if = "Option::is_none")] pub data: Option, /// The name specification agents are named from. Personal data servers. #[serde(default, skip_serializing_if = "Option::is_none")] pub names: Option, } impl Service { /// The base URL this service answers on. pub fn url(&self) -> String { format!("http://{}:{}", self.host, self.port) } /// The `host:port` a listener binds. pub fn listen(&self) -> String { format!("{}:{}", self.host, self.port) } } /// One service for each role. /// /// Every field is optional but `pds`, because a profile that names no personal /// data server cannot say where an account would go, and that is the one /// question every consumer of this crate asks. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Profile { /// The personal data server accounts are minted on. pub pds: String, /// The index following the personal data server. #[serde(default, skip_serializing_if = "Option::is_none")] pub index: Option, /// The query service the canvas asks. #[serde(default, skip_serializing_if = "Option::is_none")] pub query: Option, /// The canvas. #[serde(default, skip_serializing_if = "Option::is_none")] pub web: Option, } /// A directory and the profile it uses. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Binding<'a> { /// The directory the binding was written for. pub dir: &'a Path, /// The profile's name. pub profile: &'a str, } /// Everything this machine has been told about its services. #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Config { /// Every service instance, by name. #[serde(default, rename = "service")] pub services: BTreeMap, /// Every profile, by name. #[serde(default, rename = "profile")] pub profiles: BTreeMap, /// Directory to profile name. /// /// A `BTreeMap` over strings rather than paths, because a TOML table's /// keys are strings and writing them back out has to round-trip exactly. #[serde(default)] pub bind: BTreeMap, /// Whether every component should log what it is doing in detail. /// /// One switch rather than five, and it lives here rather than in each /// process's environment because the question "why did that record not /// appear" is asked of the whole stack at once. `didbot-setup env` /// turns it into a `RUST_LOG` the development scripts export, and the hook /// reads it directly, since nothing exports an environment variable into a /// hook. /// /// It is deliberately not a level. A level invites choosing one per /// component, which is how a developer ends up with the detail turned up /// on the process that was working. #[serde(default)] pub debug: bool, } /// What can go wrong reading a configuration file. #[derive(Debug, thiserror::Error)] pub enum ConfigError { /// The file could not be read. #[error("cannot read {path}: {source}")] Read { /// The file that could not be read. path: PathBuf, /// Why not. source: std::io::Error, }, /// The file is not the shape this crate expects. #[error("{path} is not usable: {source}")] Parse { /// The file that would not parse. path: PathBuf, /// Why not. source: Box, }, } impl Config { /// Reads a configuration file, or returns [`Config::builtin`] if there is /// none. /// /// A missing file is not an error and never will be: a machine that has /// never run `didbot-setup` still has one of everything on the /// ports the development scripts use, and everything here must answer for /// it. A file that exists and will not parse *is* an error, because the /// alternative is silently ignoring what somebody wrote. pub fn load(path: &Path) -> Result { let raw = match std::fs::read_to_string(path) { Ok(raw) => raw, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Self::builtin()), Err(source) => { return Err(ConfigError::Read { path: path.to_owned(), source, }) } }; toml::from_str(&raw).map_err(|source| ConfigError::Parse { path: path.to_owned(), source: Box::new(source), }) } /// Reads the configuration from its usual place. pub fn load_default() -> Result { Self::load(&defaults::default_config_path()) } /// One of everything, on the ports the development scripts use. /// /// This is not a placeholder for a real configuration: it is the /// configuration, for every machine that has not needed a second of /// anything. Keeping it in code rather than writing a file at install time /// means a checkout with no setup step behaves identically to one with a /// file that says the same thing. pub fn builtin() -> Self { defaults::builtin() } /// The profile bound to `dir`, or to the nearest directory above it. /// /// Longest prefix wins, so a binding on a subdirectory overrides one on /// its parent. A directory nobody has bound falls back to the profile /// named [`DEFAULT_PROFILE`](crate::DEFAULT_PROFILE), which is why a /// worktree created five minutes ago talks to the same server as /// everything else without anyone having registered it. pub fn profile_name_for(&self, dir: &Path) -> &str { self.bind .iter() .filter(|(bound, _)| dir.starts_with(Path::new(bound))) .max_by_key(|(bound, _)| bound.len()) .map(|(_, profile)| profile.as_str()) .unwrap_or(defaults::DEFAULT_PROFILE) } /// The profile bound to `dir`, if it exists. pub fn profile_for(&self, dir: &Path) -> Option<&Profile> { self.profiles.get(self.profile_name_for(dir)) } /// The personal data server a directory's agents are provisioned on. /// /// The one question the hook asks. `None` when the directory resolves to a /// profile that does not exist, or to one whose personal data server does /// not — both of which [`Config::problems`] reports by name. pub fn pds_url_for(&self, dir: &Path) -> Option { let profile = self.profile_for(dir)?; Some(self.services.get(&profile.pds)?.url()) } /// Every binding, in the order the file writes them. pub fn bindings(&self) -> impl Iterator> { self.bind.iter().map(|(dir, profile)| Binding { dir: Path::new(dir), profile: profile.as_str(), }) } /// Every service of one kind, by name. pub fn services_of(&self, kind: ServiceKind) -> impl Iterator { self.services .iter() .filter(move |(_, service)| service.kind == kind) .map(|(name, service)| (name.as_str(), service)) } /// The first free port at or above whichever of `anchors` sits highest. /// /// The anchors are the upstreams a new service was given, so a record /// host added against a personal data server on 3500 lands on 3501 and a /// second stack reads as a block. With no anchor it starts at the first /// port this project uses, which keeps a service added on its own next to /// the rest rather than a thousand above them. pub fn next_free_port(&self, anchors: &[Option<&str>]) -> u16 { /// The lowest port this project hands out. const FIRST: u16 = 3000; let anchor = anchors .iter() .flatten() .filter_map(|name| self.services.get(*name)) .map(|service| service.port) .max() .map_or(FIRST, |port| port.saturating_add(1)); let taken: std::collections::BTreeSet = self.services.values().map(|service| service.port).collect(); (anchor..u16::MAX) .find(|port| !taken.contains(port)) .unwrap_or(anchor) } /// Everything wrong with this configuration. pub fn problems(&self) -> Problems { crate::validate::check(self) } /// Renders the configuration back to TOML. pub fn to_toml(&self) -> String { toml::to_string_pretty(self).expect("the configuration types always serialize") } /// Writes the configuration to `path`, creating its directory. /// /// Written beside the target and renamed over it, so a process reading the /// file while it is being written sees the old one or the new one and /// never half of either. pub fn save(&self, path: &Path) -> std::io::Result<()> { let parent = path.parent().unwrap_or_else(|| Path::new(".")); std::fs::create_dir_all(parent)?; let temp = parent.join(format!(".stack.toml.{}.tmp", std::process::id())); let outcome = std::fs::write(&temp, self.to_toml().as_bytes()) .and_then(|()| std::fs::rename(&temp, path)); if outcome.is_err() { let _ = std::fs::remove_file(&temp); } outcome } /// The log filter every component runs under, given this configuration. /// /// The quiet form is what the server already defaults to; the loud form /// turns this project's crates up and leaves the HTTP stack's internals /// down, because `hyper` narrates every frame at DEBUG and would bury the /// thing somebody turned this on to see. pub fn log_filter(&self) -> &'static str { if self.debug { crate::DEBUG_LOG_FILTER } else { crate::QUIET_LOG_FILTER } } } #[cfg(test)] mod tests { use super::*; use crate::defaults::DEFAULT_PROFILE; fn parse(raw: &str) -> Config { toml::from_str(raw).expect("parses") } #[test] fn a_machine_with_no_file_still_has_a_stack() { let config = Config::builtin(); assert_eq!( config.pds_url_for(Path::new("/anywhere/at/all")).as_deref(), Some("http://127.0.0.1:3000"), "the ports the development scripts already use" ); assert!(config.problems().is_empty(), "{:?}", config.problems()); } #[test] fn the_longest_bound_prefix_wins() { let config = parse( r#" [service.pds-main] kind = "pds" port = 3000 [service.pds-beta] kind = "pds" port = 3100 [profile.default] pds = "pds-main" [profile.beta] pds = "pds-beta" [bind] "/home/dev" = "default" "/home/dev/beta" = "beta" "#, ); assert_eq!(config.profile_name_for(Path::new("/home/dev/x")), "default"); assert_eq!( config.profile_name_for(Path::new("/home/dev/beta/deep/inside")), "beta" ); } #[test] fn an_unbound_directory_falls_back_to_the_default_profile() { // The case that matters: a worktree created a moment ago, that nobody // has registered, and that must still record. let config = parse( r#" [service.pds-main] kind = "pds" port = 3000 [profile.default] pds = "pds-main" [bind] "/home/dev/beta" = "default" "#, ); assert_eq!( config.profile_name_for(Path::new("/somewhere/else")), DEFAULT_PROFILE ); assert_eq!( config.pds_url_for(Path::new("/somewhere/else")).as_deref(), Some("http://127.0.0.1:3000") ); } #[test] fn a_prefix_that_is_not_a_path_boundary_does_not_match() { // "/home/dev" must not capture "/home/development". let config = parse( r#" [service.pds-main] kind = "pds" port = 3000 [service.pds-beta] kind = "pds" port = 3100 [profile.default] pds = "pds-main" [profile.beta] pds = "pds-beta" [bind] "/home/dev" = "beta" "#, ); assert_eq!( config.profile_name_for(Path::new("/home/development/repo")), DEFAULT_PROFILE ); } #[test] fn two_profiles_can_share_one_personal_data_server() { // The composition a whole-stack model cannot express, and the reason // roles are named individually. let config = parse( r#" [service.pds-main] kind = "pds" port = 3000 [service.index-a] kind = "index" port = 3001 pds = "pds-main" [service.index-b] kind = "index" port = 3011 pds = "pds-main" [profile.default] pds = "pds-main" index = "index-a" [profile.a] pds = "pds-main" index = "index-a" [profile.b] pds = "pds-main" index = "index-b" [bind] "/repo/a" = "a" "/repo/b" = "b" "#, ); assert!(config.problems().is_empty(), "{:?}", config.problems()); assert_eq!( config.pds_url_for(Path::new("/repo/a")), config.pds_url_for(Path::new("/repo/b")), "one server" ); assert_eq!( config .profile_for(Path::new("/repo/a")) .unwrap() .index .as_deref(), Some("index-a") ); assert_eq!( config .profile_for(Path::new("/repo/b")) .unwrap() .index .as_deref(), Some("index-b") ); } #[test] fn a_new_service_lands_next_to_its_upstream() { // So that a second stack reads as a block rather than as five numbers // scattered above whatever happened to be highest. let config = parse( r#" [service.pds-beta] kind = "pds" port = 3500 [service.web] kind = "web" port = 8137 [profile.default] pds = "pds-beta" "#, ); assert_eq!(config.next_free_port(&[Some("pds-beta")]), 3501); } #[test] fn a_service_with_no_upstream_starts_at_the_first_port() { let config = parse( r#" [service.web] kind = "web" port = 8137 [profile.default] pds = "web" "#, ); assert_eq!(config.next_free_port(&[None]), 3000); } #[test] fn a_port_already_taken_is_skipped() { let config = parse( r#" [service.pds] kind = "pds" port = 3000 [service.index] kind = "index" port = 3001 pds = "pds" [profile.default] pds = "pds" "#, ); assert_eq!(config.next_free_port(&[Some("pds")]), 3002); assert_eq!(config.next_free_port(&[None]), 3002); } #[test] fn a_written_configuration_reads_back_the_same() { let config = Config::builtin(); let round_tripped: Config = toml::from_str(&config.to_toml()).expect("round trip"); assert_eq!(config, round_tripped); } #[test] fn an_unknown_key_is_refused_rather_than_ignored() { // A typo in a table name would otherwise be a setting that silently // does nothing, which is the worst outcome for a file nobody reads // twice. let err = toml::from_str::("[servcie.pds]\nkind = \"pds\"\nport = 3000\n") .expect_err("a misspelled table is refused"); assert!(err.to_string().contains("servcie"), "{err}"); } }