//! Domain types — the vocabulary every other module speaks. //! //! These newtypes replace string-typed fields across the daemon, schema, //! WS payloads, and TUI. Construction is the boundary parse: a `Version` //! exists because some byte sequence at the edge of the process passed //! semver validation; downstream code is freed from re-validating it. //! //! All types implement `Display`, `FromStr`, `Serialize`, `Deserialize`, //! and `sqlx::Type` so they round-trip through events, JSON //! responses, and SQLite columns without per-site conversion. //! //! See `plans/observability.md` for the architecture this is the first //! step of. // Step 1 is pure addition: nothing else in the crate uses these yet. // Steps 2-7 thread the types through call sites; remove the allow then. #![allow(dead_code)] use serde::{Deserialize, Serialize}; use sqlx::Sqlite; use std::fmt; use std::str::FromStr; // --------------------------------------------------------------------- // String-backed identifiers // --------------------------------------------------------------------- /// A product Sando ships (e.g. "mnw", "pom"). /// /// Sando was single-product for its whole life, so every keyed table, every /// tier, and every release root was implicitly about the MNW server. Nothing /// said so, which is why nothing had to be changed when a second product /// arrived and everything had to be changed at once. This is that "which /// product", made explicit and threaded rather than assumed. /// /// [`DEFAULT_APP`] is what a pre-multi-app config and every pre-multi-app row /// mean, so an existing deployment keeps working unedited. #[derive( Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, sqlx::Type, )] #[sqlx(transparent)] #[serde(transparent)] pub struct AppId(String); /// The app a config with no `[app.*]` tables describes, and the value migration /// 011 backfills every pre-existing row to. pub const DEFAULT_APP: &str = "mnw"; impl AppId { pub fn new(s: impl Into) -> Self { Self(s.into()) } pub fn as_str(&self) -> &str { &self.0 } } impl Default for AppId { fn default() -> Self { Self(DEFAULT_APP.to_owned()) } } impl fmt::Display for AppId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } impl FromStr for AppId { type Err = std::convert::Infallible; fn from_str(s: &str) -> Result { Ok(Self(s.to_owned())) } } impl From<&str> for AppId { fn from(s: &str) -> Self { Self(s.to_owned()) } } impl From for AppId { fn from(s: String) -> Self { Self(s) } } /// A tier in the deploy topology (e.g. "host", "a", "b"). /// /// Construction does no cross-validation against the loaded `Topology` — /// that is the responsibility of `Topology::load`, which mints the /// canonical `TierId` set. Use `TierId::new` only at boundaries (config /// load, deserialization of inbound requests). #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] #[sqlx(transparent)] #[serde(transparent)] pub struct TierId(String); impl TierId { pub fn new(s: impl Into) -> Self { Self(s.into()) } pub fn as_str(&self) -> &str { &self.0 } } impl fmt::Display for TierId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } impl FromStr for TierId { type Err = std::convert::Infallible; fn from_str(s: &str) -> Result { Ok(Self(s.to_owned())) } } impl From<&str> for TierId { fn from(s: &str) -> Self { Self(s.to_owned()) } } impl From for TierId { fn from(s: String) -> Self { Self(s) } } /// A node name within a tier (e.g. "alpha-west-1"). #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] #[sqlx(transparent)] #[serde(transparent)] pub struct NodeId(String); impl NodeId { pub fn new(s: impl Into) -> Self { Self(s.into()) } pub fn as_str(&self) -> &str { &self.0 } } impl fmt::Display for NodeId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } impl FromStr for NodeId { type Err = std::convert::Infallible; fn from_str(s: &str) -> Result { Ok(Self(s.to_owned())) } } impl From<&str> for NodeId { fn from(s: &str) -> Self { Self(s.to_owned()) } } impl From for NodeId { fn from(s: String) -> Self { Self(s) } } // --------------------------------------------------------------------- // Version (semver) // --------------------------------------------------------------------- /// Server semver (e.g. `0.9.6`). Parsed once at the build step; stored /// as TEXT in the schema. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(try_from = "String", into = "String")] pub struct Version(semver::Version); #[derive(Debug, thiserror::Error)] #[error("invalid semver `{input}`: {source}")] pub struct VersionParseError { pub input: String, #[source] pub source: semver::Error, } impl Version { pub fn parse(s: &str) -> Result { semver::Version::parse(s) .map(Self) .map_err(|e| VersionParseError { input: s.to_owned(), source: e, }) } pub fn as_inner(&self) -> &semver::Version { &self.0 } } impl fmt::Display for Version { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } impl FromStr for Version { type Err = VersionParseError; fn from_str(s: &str) -> Result { Self::parse(s) } } impl TryFrom for Version { type Error = VersionParseError; fn try_from(s: String) -> Result { Self::parse(&s) } } impl From for String { fn from(v: Version) -> Self { v.0.to_string() } } impl sqlx::Type for Version { fn type_info() -> ::TypeInfo { >::type_info() } fn compatible(ty: &::TypeInfo) -> bool { >::compatible(ty) } } impl sqlx::Encode<'_, Sqlite> for Version { fn encode_by_ref( &self, buf: &mut ::ArgumentBuffer, ) -> Result { >::encode(self.0.to_string(), buf) } } impl<'r> sqlx::Decode<'r, Sqlite> for Version { fn decode( value: ::ValueRef<'r>, ) -> Result { let s = >::decode(value)?; Ok(Version::parse(&s)?) } } // --------------------------------------------------------------------- // Git sha // --------------------------------------------------------------------- /// A git commit sha. Always stored in its full 40-hex-character form; /// short forms entering at the edge are accepted only if the topology /// resolves them unambiguously (resolution happens at the call site, /// not in this type — this type only enforces shape). #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(try_from = "String", into = "String")] pub struct GitSha(String); #[derive(Debug, thiserror::Error)] pub enum GitShaParseError { #[error("git sha `{0}` is not 7-40 hex chars")] BadShape(String), } impl GitSha { pub fn parse(s: &str) -> Result { let len = s.len(); let ok = (7..=40).contains(&len) && s.bytes().all(|b| b.is_ascii_hexdigit()); if ok { Ok(Self(s.to_ascii_lowercase())) } else { Err(GitShaParseError::BadShape(s.to_owned())) } } pub fn as_str(&self) -> &str { &self.0 } /// Best-effort 7-char prefix for display. pub fn short(&self) -> &str { &self.0[..self.0.len().min(7)] } } impl fmt::Display for GitSha { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } impl FromStr for GitSha { type Err = GitShaParseError; fn from_str(s: &str) -> Result { Self::parse(s) } } impl TryFrom for GitSha { type Error = GitShaParseError; fn try_from(s: String) -> Result { Self::parse(&s) } } impl From for String { fn from(g: GitSha) -> Self { g.0 } } // --------------------------------------------------------------------- // Platform // --------------------------------------------------------------------- /// What a bundle was built for, and what a node can run: `os/arch`. /// /// Sando was single-platform for its whole life — one `build_host`, one /// architecture, one bundle per version — so nothing ever had to say which /// machine a set of bytes was for. pom breaks that: astra is aarch64 and /// hetzner is x86_64, so one pom version is two bundles with two digests, and /// "which of these goes to which box" becomes a question the system has to be /// able to answer. /// /// Parsed rather than stringly so the answer is a comparison of two values and /// not of two spellings. `ArtifactRecord.provenance.target` already carries this /// in `linux/aarch64` form; this is the type it parses into. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(try_from = "String", into = "String")] pub struct Platform { os: String, arch: String, } #[derive(Debug, thiserror::Error)] pub enum PlatformParseError { #[error("platform `{0}` is not `os/arch` (e.g. `linux/aarch64`)")] BadShape(String), } impl Platform { pub fn parse(s: &str) -> Result { let bad = || PlatformParseError::BadShape(s.to_owned()); let (os, arch) = s.split_once('/').ok_or_else(bad)?; let part_ok = |p: &str| { !p.is_empty() && p.bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.') }; if !part_ok(os) || !part_ok(arch) { return Err(bad()); } Ok(Self { os: os.to_ascii_lowercase(), arch: arch.to_ascii_lowercase(), }) } pub fn os(&self) -> &str { &self.os } pub fn arch(&self) -> &str { &self.arch } } impl fmt::Display for Platform { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}/{}", self.os, self.arch) } } impl FromStr for Platform { type Err = PlatformParseError; fn from_str(s: &str) -> Result { Self::parse(s) } } impl TryFrom for Platform { type Error = PlatformParseError; fn try_from(s: String) -> Result { Self::parse(&s) } } impl From for String { fn from(p: Platform) -> Self { p.to_string() } } impl sqlx::Type for GitSha { fn type_info() -> ::TypeInfo { >::type_info() } fn compatible(ty: &::TypeInfo) -> bool { >::compatible(ty) } } impl sqlx::Encode<'_, Sqlite> for GitSha { fn encode_by_ref( &self, buf: &mut ::ArgumentBuffer, ) -> Result { >::encode(self.0.clone(), buf) } } impl<'r> sqlx::Decode<'r, Sqlite> for GitSha { fn decode( value: ::ValueRef<'r>, ) -> Result { let s = >::decode(value)?; Ok(GitSha::parse(&s)?) } } // --------------------------------------------------------------------- // Gate kind // --------------------------------------------------------------------- /// The discriminant of `topology::Gate`. `Gate` carries gate parameters /// (e.g. `BurnIn { hours }`); `GateKind` is the identifier we use in /// events, schema columns, and the TUI. They were the same type before; /// splitting them is what lets a gate's parameters evolve without /// touching the wire/schema vocabulary. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum GateKind { CargoTest, /// The tests `cargo_test` structurally cannot run: `--features fast-tests` /// relaxes the auth/sandbox rate limits and argon2 cost so the signup-heavy /// workflow suite finishes, and the rate-limiting tests are /// `#[cfg_attr(feature = "fast-tests", ignore)]`d because a bucket that /// refills at 100/sec never depletes under parallel test threads. This gate /// runs that set WITHOUT the feature, against the production constants, so a /// rate-limiter regression cannot reach a node unblocked. HardeningTest, /// `cargo clippy --all-targets -- -D warnings` over every configured /// `test_target`. The only gate on lint drift. Clippy, /// `cargo fmt --check` over every configured `test_target`. Fmt, /// `cargo audit` over the crates that carry a triaged `.cargo/audit.toml`. CargoAudit, /// `cargo deny check` over the crates that carry a `deny.toml`. CargoDeny, MigrationDryRun, /// Boots the freshly-built binary against a throwaway *empty* DB it /// migrates from scratch, seeds the example catalog into, and probes /// `GET /health` on: a fast, infra-light "is the code sound" check that /// runs first, so a later `cargo_test`/`migration_dry_run` red reads as an /// environment problem, not a code one. Distinct from `BootSmoke`, which /// boots the staged artifact in a minimal no-DB mode. CodeSmoke, BootSmoke, /// Post-deploy readiness of the *deployed nodes* (distinct from `BootSmoke`, /// which boots the staged artifact on the build host). Probes each node's /// service over its executor, and is the gate that proves a tier's nodes are /// serving before the next promote. NodeHealth, /// Post-deploy, and the only gate that reads the site the way a visitor /// receives it: a real browser, over the tier's **public hostname**, with /// the CDN in front. /// /// Every other gate here is build-host or origin-side. `BootSmoke` runs on /// fw13 and proves nothing about a node; `NodeHealth` reaches the node over /// its executor and so never crosses the edge. Nothing else watches the /// composition at the edge: a stale bundle held at the CDN can fail to link /// against a freshly deployed one while every artifact is individually /// correct, and since `core/index.ts` side-effect-imports every common /// island, one bad link kills all of them plus the page scripts that read /// the globals they publish. /// /// Progressive enhancement is what makes this a gate rather than a nicety. /// Every island enhances server-rendered markup that stands on its own, so /// a dead bundle renders the unenhanced page, a state the design /// deliberately supports. "Broken" and "working as intended" are the same /// screenshot, and the difference is only visible as behaviour. /// /// Runs `scripts/page-smoke.mjs` with `BASE` set to the tier's public URL. /// Red on any uncaught exception, any console error, any island that did /// not run, or any failed page expectation. PageSmoke, BurnIn, ManualConfirm, } impl GateKind { pub fn as_str(self) -> &'static str { match self { GateKind::CargoTest => "cargo_test", GateKind::HardeningTest => "hardening_test", GateKind::Clippy => "clippy", GateKind::Fmt => "fmt", GateKind::CargoAudit => "cargo_audit", GateKind::CargoDeny => "cargo_deny", GateKind::MigrationDryRun => "migration_dry_run", GateKind::CodeSmoke => "code_smoke", GateKind::BootSmoke => "boot_smoke", GateKind::NodeHealth => "node_health", GateKind::PageSmoke => "page_smoke", GateKind::BurnIn => "burn_in", GateKind::ManualConfirm => "manual_confirm", } } } #[derive(Debug, thiserror::Error)] #[error("unknown gate kind `{0}`")] pub struct GateKindParseError(pub String); impl FromStr for GateKind { type Err = GateKindParseError; fn from_str(s: &str) -> Result { match s { "cargo_test" => Ok(GateKind::CargoTest), "hardening_test" => Ok(GateKind::HardeningTest), "clippy" => Ok(GateKind::Clippy), "fmt" => Ok(GateKind::Fmt), "cargo_audit" => Ok(GateKind::CargoAudit), "cargo_deny" => Ok(GateKind::CargoDeny), "migration_dry_run" => Ok(GateKind::MigrationDryRun), "code_smoke" => Ok(GateKind::CodeSmoke), "boot_smoke" => Ok(GateKind::BootSmoke), "node_health" => Ok(GateKind::NodeHealth), "page_smoke" => Ok(GateKind::PageSmoke), "burn_in" => Ok(GateKind::BurnIn), "manual_confirm" => Ok(GateKind::ManualConfirm), other => Err(GateKindParseError(other.to_owned())), } } } impl fmt::Display for GateKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } impl sqlx::Type for GateKind { fn type_info() -> ::TypeInfo { >::type_info() } fn compatible(ty: &::TypeInfo) -> bool { >::compatible(ty) } } impl sqlx::Encode<'_, Sqlite> for GateKind { fn encode_by_ref( &self, buf: &mut ::ArgumentBuffer, ) -> Result { >::encode(self.as_str().to_owned(), buf) } } impl<'r> sqlx::Decode<'r, Sqlite> for GateKind { fn decode( value: ::ValueRef<'r>, ) -> Result { let s = >::decode(value)?; Ok(GateKind::from_str(&s)?) } } // --------------------------------------------------------------------- // Row ids // --------------------------------------------------------------------- /// Primary key of `gate_runs`. Carried through `GateStart` → `GateLogChunk` /// → `GateDone` so client-side correlation is trivial. `Ord` is monotonic /// in insertion order (sqlite `INTEGER PRIMARY KEY AUTOINCREMENT`), which /// the TUI's run-buffer map relies on for chronological iteration. #[derive( Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, sqlx::Type, )] #[sqlx(transparent)] #[serde(transparent)] pub struct GateRunId(pub i64); impl fmt::Display for GateRunId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } /// Primary key of `deploys`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] #[sqlx(transparent)] #[serde(transparent)] pub struct DeployId(pub i64); impl fmt::Display for DeployId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } /// Primary key of `build_runs` — the resource a `/rebuild` returns and a /// non-TUI driver polls via `GET /runs/{id}`. Distinct from `GateRunId` /// (one build run drives many gate runs). #[derive( Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, sqlx::Type, )] #[sqlx(transparent)] #[serde(transparent)] pub struct RunId(pub i64); impl fmt::Display for RunId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } #[cfg(test)] mod tests { use super::*; #[test] fn tier_id_round_trips_through_json() { let t = TierId::new("host"); let s = serde_json::to_string(&t).unwrap(); assert_eq!(s, "\"host\""); let back: TierId = serde_json::from_str(&s).unwrap(); assert_eq!(t, back); } #[test] fn version_parses_and_displays() { let v: Version = "0.9.6".parse().unwrap(); assert_eq!(v.to_string(), "0.9.6"); assert!("not-a-version".parse::().is_err()); } #[test] fn version_json_is_string_form() { let v: Version = "1.2.3-rc.1".parse().unwrap(); let s = serde_json::to_string(&v).unwrap(); assert_eq!(s, "\"1.2.3-rc.1\""); let back: Version = serde_json::from_str(&s).unwrap(); assert_eq!(v, back); } #[test] fn git_sha_accepts_short_and_full() { assert!(GitSha::parse("abc1234").is_ok()); assert!(GitSha::parse("0123456789abcdef0123456789abcdef01234567").is_ok()); // length out of range assert!(GitSha::parse("abc").is_err()); assert!(GitSha::parse(&"a".repeat(41)).is_err()); // non-hex assert!(GitSha::parse("zzzzzzz").is_err()); } #[test] fn git_sha_short_truncates_safely() { let s = GitSha::parse("abc1234").unwrap(); assert_eq!(s.short(), "abc1234"); let long = GitSha::parse("0123456789abcdef0123456789abcdef01234567").unwrap(); assert_eq!(long.short(), "0123456"); } #[test] fn git_sha_normalizes_to_lowercase() { let s = GitSha::parse("ABCdef1").unwrap(); assert_eq!(s.as_str(), "abcdef1"); } #[test] fn gate_kind_round_trips_through_json() { // serde_json uses #[serde(rename_all = "snake_case")] — verify the // shape the TUI's `format_event` already consumes is preserved. let k = GateKind::MigrationDryRun; let s = serde_json::to_string(&k).unwrap(); assert_eq!(s, "\"migration_dry_run\""); let back: GateKind = serde_json::from_str(&s).unwrap(); assert_eq!(k, back); } #[test] fn gate_kind_as_str_matches_serde_form() { // The legacy `gates::kind_str` helper produced strings the TUI // matched on. Locking in that our serde form matches those exactly // so step 3 (events use the types) doesn't change the wire shape. for k in [ GateKind::CargoTest, GateKind::HardeningTest, GateKind::MigrationDryRun, GateKind::CodeSmoke, GateKind::BootSmoke, GateKind::BurnIn, GateKind::ManualConfirm, ] { let via_serde: String = serde_json::from_str::(&serde_json::to_string(&k).unwrap()).unwrap(); assert_eq!(via_serde, k.as_str()); } } #[test] fn gate_kind_hardening_test_round_trips() { // It is written into gate_runs.gate_kind and read back by // unsatisfied_gates, so the two directions must agree exactly. assert_eq!(GateKind::HardeningTest.as_str(), "hardening_test"); assert_eq!( "hardening_test".parse::().unwrap(), GateKind::HardeningTest ); } #[test] fn gate_kind_from_str_rejects_unknown() { assert!("not_a_gate".parse::().is_err()); } #[test] fn gate_run_id_serializes_as_number() { let id = GateRunId(42); assert_eq!(serde_json::to_string(&id).unwrap(), "42"); } #[test] fn platform_components_take_the_punctuation_target_names_use() { // Real target names carry `_`, `-` and `.` inside a component, and the // charset here is the only thing that lets them through. A narrower // predicate rejects `unknown-linux-gnu` or `armv7.hf` while still // parsing `linux/aarch64`, which is why the shape test above cannot // see the difference. for good in [ "linux_gnu/x86_64", "unknown-linux/aarch64", "linux/armv7.hf", "linux/x86_64", ] { assert!(Platform::parse(good).is_ok(), "{good:?} should parse"); } for bad in [ "linux+gnu/x86_64", "linux/x86 64", "linux/x86:64", "li nux/x", ] { assert!(Platform::parse(bad).is_err(), "{bad:?} should not parse"); } } #[test] fn platform_halves_read_back_lowercased() { // `os()` and `arch()` are what a caller compares and what the SQL // lookup binds, so they have to be the normalized halves rather than // the spelling the config used. let p = Platform::parse("Linux/AArch64").unwrap(); assert_eq!(p.os(), "linux"); assert_eq!(p.arch(), "aarch64"); assert_eq!(p.to_string(), "linux/aarch64"); } }