Skip to main content

max / makenotwork

14.1 KB · 426 lines History Blame Raw
1 //! Domain types — the vocabulary every other module speaks.
2 //!
3 //! These newtypes replace string-typed fields across the daemon, schema,
4 //! WS payloads, and TUI. Construction is the boundary parse: a `Version`
5 //! exists because some byte sequence at the edge of the process passed
6 //! semver validation; downstream code is freed from re-validating it.
7 //!
8 //! All types implement `Display`, `FromStr`, `Serialize`, `Deserialize`,
9 //! and `sqlx::Type<Sqlite>` so they round-trip through events, JSON
10 //! responses, and SQLite columns without per-site conversion.
11 //!
12 //! See `plans/observability.md` for the architecture this is the first
13 //! step of.
14
15 // Step 1 is pure addition: nothing else in the crate uses these yet.
16 // Steps 2-7 thread the types through call sites; remove the allow then.
17 #![allow(dead_code)]
18
19 use serde::{Deserialize, Serialize};
20 use sqlx::Sqlite;
21 use std::fmt;
22 use std::str::FromStr;
23
24 // ---------------------------------------------------------------------
25 // String-backed identifiers
26 // ---------------------------------------------------------------------
27
28 /// A tier in the deploy topology (e.g. "host", "a", "b").
29 ///
30 /// Construction does no cross-validation against the loaded `Topology` —
31 /// that is the responsibility of `Topology::load`, which mints the
32 /// canonical `TierId` set. Use `TierId::new` only at boundaries (config
33 /// load, deserialization of inbound requests).
34 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
35 #[sqlx(transparent)]
36 #[serde(transparent)]
37 pub struct TierId(String);
38
39 impl TierId {
40 pub fn new(s: impl Into<String>) -> Self { Self(s.into()) }
41 pub fn as_str(&self) -> &str { &self.0 }
42 }
43
44 impl fmt::Display for TierId {
45 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) }
46 }
47
48 impl FromStr for TierId {
49 type Err = std::convert::Infallible;
50 fn from_str(s: &str) -> Result<Self, Self::Err> { Ok(Self(s.to_owned())) }
51 }
52
53 impl From<&str> for TierId {
54 fn from(s: &str) -> Self { Self(s.to_owned()) }
55 }
56
57 impl From<String> for TierId {
58 fn from(s: String) -> Self { Self(s) }
59 }
60
61 /// A node name within a tier (e.g. "alpha-west-1").
62 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
63 #[sqlx(transparent)]
64 #[serde(transparent)]
65 pub struct NodeId(String);
66
67 impl NodeId {
68 pub fn new(s: impl Into<String>) -> Self { Self(s.into()) }
69 pub fn as_str(&self) -> &str { &self.0 }
70 }
71
72 impl fmt::Display for NodeId {
73 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) }
74 }
75
76 impl FromStr for NodeId {
77 type Err = std::convert::Infallible;
78 fn from_str(s: &str) -> Result<Self, Self::Err> { Ok(Self(s.to_owned())) }
79 }
80
81 impl From<&str> for NodeId {
82 fn from(s: &str) -> Self { Self(s.to_owned()) }
83 }
84
85 impl From<String> for NodeId {
86 fn from(s: String) -> Self { Self(s) }
87 }
88
89 // ---------------------------------------------------------------------
90 // Version (semver)
91 // ---------------------------------------------------------------------
92
93 /// Server semver (e.g. `0.9.6`). Parsed once at the build step; stored
94 /// as TEXT in the schema.
95 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
96 #[serde(try_from = "String", into = "String")]
97 pub struct Version(semver::Version);
98
99 #[derive(Debug, thiserror::Error)]
100 #[error("invalid semver `{input}`: {source}")]
101 pub struct VersionParseError {
102 pub input: String,
103 #[source]
104 pub source: semver::Error,
105 }
106
107 impl Version {
108 pub fn parse(s: &str) -> Result<Self, VersionParseError> {
109 semver::Version::parse(s)
110 .map(Self)
111 .map_err(|e| VersionParseError { input: s.to_owned(), source: e })
112 }
113 pub fn as_inner(&self) -> &semver::Version { &self.0 }
114 }
115
116 impl fmt::Display for Version {
117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) }
118 }
119
120 impl FromStr for Version {
121 type Err = VersionParseError;
122 fn from_str(s: &str) -> Result<Self, Self::Err> { Self::parse(s) }
123 }
124
125 impl TryFrom<String> for Version {
126 type Error = VersionParseError;
127 fn try_from(s: String) -> Result<Self, Self::Error> { Self::parse(&s) }
128 }
129
130 impl From<Version> for String {
131 fn from(v: Version) -> Self { v.0.to_string() }
132 }
133
134 impl sqlx::Type<Sqlite> for Version {
135 fn type_info() -> <Sqlite as sqlx::Database>::TypeInfo { <String as sqlx::Type<Sqlite>>::type_info() }
136 fn compatible(ty: &<Sqlite as sqlx::Database>::TypeInfo) -> bool { <String as sqlx::Type<Sqlite>>::compatible(ty) }
137 }
138
139 impl<'q> sqlx::Encode<'q, Sqlite> for Version {
140 fn encode_by_ref(
141 &self,
142 buf: &mut <Sqlite as sqlx::Database>::ArgumentBuffer<'q>,
143 ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
144 <String as sqlx::Encode<Sqlite>>::encode(self.0.to_string(), buf)
145 }
146 }
147
148 impl<'r> sqlx::Decode<'r, Sqlite> for Version {
149 fn decode(
150 value: <Sqlite as sqlx::Database>::ValueRef<'r>,
151 ) -> Result<Self, sqlx::error::BoxDynError> {
152 let s = <String as sqlx::Decode<Sqlite>>::decode(value)?;
153 Ok(Version::parse(&s)?)
154 }
155 }
156
157 // ---------------------------------------------------------------------
158 // Git sha
159 // ---------------------------------------------------------------------
160
161 /// A git commit sha. Always stored in its full 40-hex-character form;
162 /// short forms entering at the edge are accepted only if the topology
163 /// resolves them unambiguously (resolution happens at the call site,
164 /// not in this type — this type only enforces shape).
165 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
166 #[serde(try_from = "String", into = "String")]
167 pub struct GitSha(String);
168
169 #[derive(Debug, thiserror::Error)]
170 pub enum GitShaParseError {
171 #[error("git sha `{0}` is not 7-40 hex chars")]
172 BadShape(String),
173 }
174
175 impl GitSha {
176 pub fn parse(s: &str) -> Result<Self, GitShaParseError> {
177 let len = s.len();
178 let ok = (7..=40).contains(&len) && s.bytes().all(|b| b.is_ascii_hexdigit());
179 if ok { Ok(Self(s.to_ascii_lowercase())) } else { Err(GitShaParseError::BadShape(s.to_owned())) }
180 }
181 pub fn as_str(&self) -> &str { &self.0 }
182 /// Best-effort 7-char prefix for display.
183 pub fn short(&self) -> &str { &self.0[..self.0.len().min(7)] }
184 }
185
186 impl fmt::Display for GitSha {
187 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) }
188 }
189
190 impl FromStr for GitSha {
191 type Err = GitShaParseError;
192 fn from_str(s: &str) -> Result<Self, Self::Err> { Self::parse(s) }
193 }
194
195 impl TryFrom<String> for GitSha {
196 type Error = GitShaParseError;
197 fn try_from(s: String) -> Result<Self, Self::Error> { Self::parse(&s) }
198 }
199
200 impl From<GitSha> for String {
201 fn from(g: GitSha) -> Self { g.0 }
202 }
203
204 impl sqlx::Type<Sqlite> for GitSha {
205 fn type_info() -> <Sqlite as sqlx::Database>::TypeInfo { <String as sqlx::Type<Sqlite>>::type_info() }
206 fn compatible(ty: &<Sqlite as sqlx::Database>::TypeInfo) -> bool { <String as sqlx::Type<Sqlite>>::compatible(ty) }
207 }
208
209 impl<'q> sqlx::Encode<'q, Sqlite> for GitSha {
210 fn encode_by_ref(
211 &self,
212 buf: &mut <Sqlite as sqlx::Database>::ArgumentBuffer<'q>,
213 ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
214 <String as sqlx::Encode<Sqlite>>::encode(self.0.clone(), buf)
215 }
216 }
217
218 impl<'r> sqlx::Decode<'r, Sqlite> for GitSha {
219 fn decode(
220 value: <Sqlite as sqlx::Database>::ValueRef<'r>,
221 ) -> Result<Self, sqlx::error::BoxDynError> {
222 let s = <String as sqlx::Decode<Sqlite>>::decode(value)?;
223 Ok(GitSha::parse(&s)?)
224 }
225 }
226
227 // ---------------------------------------------------------------------
228 // Gate kind
229 // ---------------------------------------------------------------------
230
231 /// The discriminant of `topology::Gate`. `Gate` carries gate parameters
232 /// (e.g. `BurnIn { hours }`); `GateKind` is the identifier we use in
233 /// events, schema columns, and the TUI. They were the same type before;
234 /// splitting them is what lets a gate's parameters evolve without
235 /// touching the wire/schema vocabulary.
236 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
237 #[serde(rename_all = "snake_case")]
238 pub enum GateKind {
239 CargoTest,
240 MigrationDryRun,
241 BootSmoke,
242 BurnIn,
243 ManualConfirm,
244 }
245
246 impl GateKind {
247 pub fn as_str(self) -> &'static str {
248 match self {
249 GateKind::CargoTest => "cargo_test",
250 GateKind::MigrationDryRun => "migration_dry_run",
251 GateKind::BootSmoke => "boot_smoke",
252 GateKind::BurnIn => "burn_in",
253 GateKind::ManualConfirm => "manual_confirm",
254 }
255 }
256 }
257
258 #[derive(Debug, thiserror::Error)]
259 #[error("unknown gate kind `{0}`")]
260 pub struct GateKindParseError(pub String);
261
262 impl FromStr for GateKind {
263 type Err = GateKindParseError;
264 fn from_str(s: &str) -> Result<Self, Self::Err> {
265 match s {
266 "cargo_test" => Ok(GateKind::CargoTest),
267 "migration_dry_run" => Ok(GateKind::MigrationDryRun),
268 "boot_smoke" => Ok(GateKind::BootSmoke),
269 "burn_in" => Ok(GateKind::BurnIn),
270 "manual_confirm" => Ok(GateKind::ManualConfirm),
271 other => Err(GateKindParseError(other.to_owned())),
272 }
273 }
274 }
275
276 impl fmt::Display for GateKind {
277 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) }
278 }
279
280 impl sqlx::Type<Sqlite> for GateKind {
281 fn type_info() -> <Sqlite as sqlx::Database>::TypeInfo { <String as sqlx::Type<Sqlite>>::type_info() }
282 fn compatible(ty: &<Sqlite as sqlx::Database>::TypeInfo) -> bool { <String as sqlx::Type<Sqlite>>::compatible(ty) }
283 }
284
285 impl<'q> sqlx::Encode<'q, Sqlite> for GateKind {
286 fn encode_by_ref(
287 &self,
288 buf: &mut <Sqlite as sqlx::Database>::ArgumentBuffer<'q>,
289 ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
290 <String as sqlx::Encode<Sqlite>>::encode(self.as_str().to_owned(), buf)
291 }
292 }
293
294 impl<'r> sqlx::Decode<'r, Sqlite> for GateKind {
295 fn decode(
296 value: <Sqlite as sqlx::Database>::ValueRef<'r>,
297 ) -> Result<Self, sqlx::error::BoxDynError> {
298 let s = <String as sqlx::Decode<Sqlite>>::decode(value)?;
299 Ok(GateKind::from_str(&s)?)
300 }
301 }
302
303 // ---------------------------------------------------------------------
304 // Row ids
305 // ---------------------------------------------------------------------
306
307 /// Primary key of `gate_runs`. Carried through `GateStart` → `GateLogChunk`
308 /// → `GateDone` so client-side correlation is trivial. `Ord` is monotonic
309 /// in insertion order (sqlite `INTEGER PRIMARY KEY AUTOINCREMENT`), which
310 /// the TUI's run-buffer map relies on for chronological iteration.
311 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, sqlx::Type)]
312 #[sqlx(transparent)]
313 #[serde(transparent)]
314 pub struct GateRunId(pub i64);
315
316 impl fmt::Display for GateRunId {
317 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) }
318 }
319
320 /// Primary key of `deploys`.
321 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
322 #[sqlx(transparent)]
323 #[serde(transparent)]
324 pub struct DeployId(pub i64);
325
326 impl fmt::Display for DeployId {
327 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) }
328 }
329
330 #[cfg(test)]
331 mod tests {
332 use super::*;
333
334 #[test]
335 fn tier_id_round_trips_through_json() {
336 let t = TierId::new("host");
337 let s = serde_json::to_string(&t).unwrap();
338 assert_eq!(s, "\"host\"");
339 let back: TierId = serde_json::from_str(&s).unwrap();
340 assert_eq!(t, back);
341 }
342
343 #[test]
344 fn version_parses_and_displays() {
345 let v: Version = "0.9.6".parse().unwrap();
346 assert_eq!(v.to_string(), "0.9.6");
347 assert!("not-a-version".parse::<Version>().is_err());
348 }
349
350 #[test]
351 fn version_json_is_string_form() {
352 let v: Version = "1.2.3-rc.1".parse().unwrap();
353 let s = serde_json::to_string(&v).unwrap();
354 assert_eq!(s, "\"1.2.3-rc.1\"");
355 let back: Version = serde_json::from_str(&s).unwrap();
356 assert_eq!(v, back);
357 }
358
359 #[test]
360 fn git_sha_accepts_short_and_full() {
361 assert!(GitSha::parse("abc1234").is_ok());
362 assert!(GitSha::parse("0123456789abcdef0123456789abcdef01234567").is_ok());
363 // length out of range
364 assert!(GitSha::parse("abc").is_err());
365 assert!(GitSha::parse(&"a".repeat(41)).is_err());
366 // non-hex
367 assert!(GitSha::parse("zzzzzzz").is_err());
368 }
369
370 #[test]
371 fn git_sha_short_truncates_safely() {
372 let s = GitSha::parse("abc1234").unwrap();
373 assert_eq!(s.short(), "abc1234");
374 let long = GitSha::parse("0123456789abcdef0123456789abcdef01234567").unwrap();
375 assert_eq!(long.short(), "0123456");
376 }
377
378 #[test]
379 fn git_sha_normalizes_to_lowercase() {
380 let s = GitSha::parse("ABCdef1").unwrap();
381 assert_eq!(s.as_str(), "abcdef1");
382 }
383
384 #[test]
385 fn gate_kind_round_trips_through_json() {
386 // serde_json uses #[serde(rename_all = "snake_case")] — verify the
387 // shape the TUI's `format_event` already consumes is preserved.
388 let k = GateKind::MigrationDryRun;
389 let s = serde_json::to_string(&k).unwrap();
390 assert_eq!(s, "\"migration_dry_run\"");
391 let back: GateKind = serde_json::from_str(&s).unwrap();
392 assert_eq!(k, back);
393 }
394
395 #[test]
396 fn gate_kind_as_str_matches_serde_form() {
397 // The legacy `gates::kind_str` helper produced strings the TUI
398 // matched on. Locking in that our serde form matches those exactly
399 // so step 3 (events use the types) doesn't change the wire shape.
400 for k in [
401 GateKind::CargoTest,
402 GateKind::MigrationDryRun,
403 GateKind::BootSmoke,
404 GateKind::BurnIn,
405 GateKind::ManualConfirm,
406 ] {
407 let via_serde: String = serde_json::from_str::<String>(
408 &serde_json::to_string(&k).unwrap(),
409 )
410 .unwrap();
411 assert_eq!(via_serde, k.as_str());
412 }
413 }
414
415 #[test]
416 fn gate_kind_from_str_rejects_unknown() {
417 assert!("not_a_gate".parse::<GateKind>().is_err());
418 }
419
420 #[test]
421 fn gate_run_id_serializes_as_number() {
422 let id = GateRunId(42);
423 assert_eq!(serde_json::to_string(&id).unwrap(), "42");
424 }
425 }
426