Skip to main content

max / makenotwork

17.9 KB · 571 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 {
41 Self(s.into())
42 }
43 pub fn as_str(&self) -> &str {
44 &self.0
45 }
46 }
47
48 impl fmt::Display for TierId {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50 self.0.fmt(f)
51 }
52 }
53
54 impl FromStr for TierId {
55 type Err = std::convert::Infallible;
56 fn from_str(s: &str) -> Result<Self, Self::Err> {
57 Ok(Self(s.to_owned()))
58 }
59 }
60
61 impl From<&str> for TierId {
62 fn from(s: &str) -> Self {
63 Self(s.to_owned())
64 }
65 }
66
67 impl From<String> for TierId {
68 fn from(s: String) -> Self {
69 Self(s)
70 }
71 }
72
73 /// A node name within a tier (e.g. "alpha-west-1").
74 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
75 #[sqlx(transparent)]
76 #[serde(transparent)]
77 pub struct NodeId(String);
78
79 impl NodeId {
80 pub fn new(s: impl Into<String>) -> Self {
81 Self(s.into())
82 }
83 pub fn as_str(&self) -> &str {
84 &self.0
85 }
86 }
87
88 impl fmt::Display for NodeId {
89 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90 self.0.fmt(f)
91 }
92 }
93
94 impl FromStr for NodeId {
95 type Err = std::convert::Infallible;
96 fn from_str(s: &str) -> Result<Self, Self::Err> {
97 Ok(Self(s.to_owned()))
98 }
99 }
100
101 impl From<&str> for NodeId {
102 fn from(s: &str) -> Self {
103 Self(s.to_owned())
104 }
105 }
106
107 impl From<String> for NodeId {
108 fn from(s: String) -> Self {
109 Self(s)
110 }
111 }
112
113 // ---------------------------------------------------------------------
114 // Version (semver)
115 // ---------------------------------------------------------------------
116
117 /// Server semver (e.g. `0.9.6`). Parsed once at the build step; stored
118 /// as TEXT in the schema.
119 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
120 #[serde(try_from = "String", into = "String")]
121 pub struct Version(semver::Version);
122
123 #[derive(Debug, thiserror::Error)]
124 #[error("invalid semver `{input}`: {source}")]
125 pub struct VersionParseError {
126 pub input: String,
127 #[source]
128 pub source: semver::Error,
129 }
130
131 impl Version {
132 pub fn parse(s: &str) -> Result<Self, VersionParseError> {
133 semver::Version::parse(s)
134 .map(Self)
135 .map_err(|e| VersionParseError {
136 input: s.to_owned(),
137 source: e,
138 })
139 }
140 pub fn as_inner(&self) -> &semver::Version {
141 &self.0
142 }
143 }
144
145 impl fmt::Display for Version {
146 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147 self.0.fmt(f)
148 }
149 }
150
151 impl FromStr for Version {
152 type Err = VersionParseError;
153 fn from_str(s: &str) -> Result<Self, Self::Err> {
154 Self::parse(s)
155 }
156 }
157
158 impl TryFrom<String> for Version {
159 type Error = VersionParseError;
160 fn try_from(s: String) -> Result<Self, Self::Error> {
161 Self::parse(&s)
162 }
163 }
164
165 impl From<Version> for String {
166 fn from(v: Version) -> Self {
167 v.0.to_string()
168 }
169 }
170
171 impl sqlx::Type<Sqlite> for Version {
172 fn type_info() -> <Sqlite as sqlx::Database>::TypeInfo {
173 <String as sqlx::Type<Sqlite>>::type_info()
174 }
175 fn compatible(ty: &<Sqlite as sqlx::Database>::TypeInfo) -> bool {
176 <String as sqlx::Type<Sqlite>>::compatible(ty)
177 }
178 }
179
180 impl sqlx::Encode<'_, Sqlite> for Version {
181 fn encode_by_ref(
182 &self,
183 buf: &mut <Sqlite as sqlx::Database>::ArgumentBuffer,
184 ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
185 <String as sqlx::Encode<Sqlite>>::encode(self.0.to_string(), buf)
186 }
187 }
188
189 impl<'r> sqlx::Decode<'r, Sqlite> for Version {
190 fn decode(
191 value: <Sqlite as sqlx::Database>::ValueRef<'r>,
192 ) -> Result<Self, sqlx::error::BoxDynError> {
193 let s = <String as sqlx::Decode<Sqlite>>::decode(value)?;
194 Ok(Version::parse(&s)?)
195 }
196 }
197
198 // ---------------------------------------------------------------------
199 // Git sha
200 // ---------------------------------------------------------------------
201
202 /// A git commit sha. Always stored in its full 40-hex-character form;
203 /// short forms entering at the edge are accepted only if the topology
204 /// resolves them unambiguously (resolution happens at the call site,
205 /// not in this type — this type only enforces shape).
206 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
207 #[serde(try_from = "String", into = "String")]
208 pub struct GitSha(String);
209
210 #[derive(Debug, thiserror::Error)]
211 pub enum GitShaParseError {
212 #[error("git sha `{0}` is not 7-40 hex chars")]
213 BadShape(String),
214 }
215
216 impl GitSha {
217 pub fn parse(s: &str) -> Result<Self, GitShaParseError> {
218 let len = s.len();
219 let ok = (7..=40).contains(&len) && s.bytes().all(|b| b.is_ascii_hexdigit());
220 if ok {
221 Ok(Self(s.to_ascii_lowercase()))
222 } else {
223 Err(GitShaParseError::BadShape(s.to_owned()))
224 }
225 }
226 pub fn as_str(&self) -> &str {
227 &self.0
228 }
229 /// Best-effort 7-char prefix for display.
230 pub fn short(&self) -> &str {
231 &self.0[..self.0.len().min(7)]
232 }
233 }
234
235 impl fmt::Display for GitSha {
236 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237 self.0.fmt(f)
238 }
239 }
240
241 impl FromStr for GitSha {
242 type Err = GitShaParseError;
243 fn from_str(s: &str) -> Result<Self, Self::Err> {
244 Self::parse(s)
245 }
246 }
247
248 impl TryFrom<String> for GitSha {
249 type Error = GitShaParseError;
250 fn try_from(s: String) -> Result<Self, Self::Error> {
251 Self::parse(&s)
252 }
253 }
254
255 impl From<GitSha> for String {
256 fn from(g: GitSha) -> Self {
257 g.0
258 }
259 }
260
261 impl sqlx::Type<Sqlite> for GitSha {
262 fn type_info() -> <Sqlite as sqlx::Database>::TypeInfo {
263 <String as sqlx::Type<Sqlite>>::type_info()
264 }
265 fn compatible(ty: &<Sqlite as sqlx::Database>::TypeInfo) -> bool {
266 <String as sqlx::Type<Sqlite>>::compatible(ty)
267 }
268 }
269
270 impl sqlx::Encode<'_, Sqlite> for GitSha {
271 fn encode_by_ref(
272 &self,
273 buf: &mut <Sqlite as sqlx::Database>::ArgumentBuffer,
274 ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
275 <String as sqlx::Encode<Sqlite>>::encode(self.0.clone(), buf)
276 }
277 }
278
279 impl<'r> sqlx::Decode<'r, Sqlite> for GitSha {
280 fn decode(
281 value: <Sqlite as sqlx::Database>::ValueRef<'r>,
282 ) -> Result<Self, sqlx::error::BoxDynError> {
283 let s = <String as sqlx::Decode<Sqlite>>::decode(value)?;
284 Ok(GitSha::parse(&s)?)
285 }
286 }
287
288 // ---------------------------------------------------------------------
289 // Gate kind
290 // ---------------------------------------------------------------------
291
292 /// The discriminant of `topology::Gate`. `Gate` carries gate parameters
293 /// (e.g. `BurnIn { hours }`); `GateKind` is the identifier we use in
294 /// events, schema columns, and the TUI. They were the same type before;
295 /// splitting them is what lets a gate's parameters evolve without
296 /// touching the wire/schema vocabulary.
297 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
298 #[serde(rename_all = "snake_case")]
299 pub enum GateKind {
300 CargoTest,
301 /// The tests `cargo_test` structurally cannot run: `--features fast-tests`
302 /// relaxes the auth/sandbox rate limits and argon2 cost so the signup-heavy
303 /// workflow suite finishes, and the rate-limiting tests are
304 /// `#[cfg_attr(feature = "fast-tests", ignore)]`d because a bucket that
305 /// refills at 100/sec never depletes under parallel test threads. This gate
306 /// runs that set WITHOUT the feature, against the production constants, so a
307 /// rate-limiter regression cannot reach a node unblocked.
308 HardeningTest,
309 /// `cargo clippy --all-targets -- -D warnings` over every configured
310 /// `test_target`. Before this existed, `-D warnings` was enforced in exactly
311 /// one place — `server/deploy/run-ci.sh`, which died with the astra pipeline
312 /// — so lint drift had no gate at all.
313 Clippy,
314 /// `cargo fmt --check` over every configured `test_target`.
315 Fmt,
316 /// `cargo audit` over the crates that carry a triaged `.cargo/audit.toml`.
317 CargoAudit,
318 /// `cargo deny check` over the crates that carry a `deny.toml`.
319 CargoDeny,
320 MigrationDryRun,
321 /// Boots the freshly-built binary against a throwaway *empty* DB it
322 /// migrates from scratch, seeds the example catalog into, and probes
323 /// `GET /health` on — a fast, infra-light "is the code sound" check that
324 /// runs first, so a later `cargo_test`/`migration_dry_run` red reads as an
325 /// environment problem, not a code one. Distinct from `BootSmoke`, which
326 /// boots the staged artifact in a minimal no-DB mode.
327 CodeSmoke,
328 BootSmoke,
329 /// Post-deploy readiness of the *deployed nodes* (distinct from `BootSmoke`,
330 /// which boots the staged artifact on the build host). Probes each node's
331 /// service over its executor — the gate that actually proves a tier's nodes
332 /// are serving before the next promote.
333 NodeHealth,
334 BurnIn,
335 ManualConfirm,
336 }
337
338 impl GateKind {
339 pub fn as_str(self) -> &'static str {
340 match self {
341 GateKind::CargoTest => "cargo_test",
342 GateKind::HardeningTest => "hardening_test",
343 GateKind::Clippy => "clippy",
344 GateKind::Fmt => "fmt",
345 GateKind::CargoAudit => "cargo_audit",
346 GateKind::CargoDeny => "cargo_deny",
347 GateKind::MigrationDryRun => "migration_dry_run",
348 GateKind::CodeSmoke => "code_smoke",
349 GateKind::BootSmoke => "boot_smoke",
350 GateKind::NodeHealth => "node_health",
351 GateKind::BurnIn => "burn_in",
352 GateKind::ManualConfirm => "manual_confirm",
353 }
354 }
355 }
356
357 #[derive(Debug, thiserror::Error)]
358 #[error("unknown gate kind `{0}`")]
359 pub struct GateKindParseError(pub String);
360
361 impl FromStr for GateKind {
362 type Err = GateKindParseError;
363 fn from_str(s: &str) -> Result<Self, Self::Err> {
364 match s {
365 "cargo_test" => Ok(GateKind::CargoTest),
366 "hardening_test" => Ok(GateKind::HardeningTest),
367 "clippy" => Ok(GateKind::Clippy),
368 "fmt" => Ok(GateKind::Fmt),
369 "cargo_audit" => Ok(GateKind::CargoAudit),
370 "cargo_deny" => Ok(GateKind::CargoDeny),
371 "migration_dry_run" => Ok(GateKind::MigrationDryRun),
372 "code_smoke" => Ok(GateKind::CodeSmoke),
373 "boot_smoke" => Ok(GateKind::BootSmoke),
374 "node_health" => Ok(GateKind::NodeHealth),
375 "burn_in" => Ok(GateKind::BurnIn),
376 "manual_confirm" => Ok(GateKind::ManualConfirm),
377 other => Err(GateKindParseError(other.to_owned())),
378 }
379 }
380 }
381
382 impl fmt::Display for GateKind {
383 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
384 f.write_str(self.as_str())
385 }
386 }
387
388 impl sqlx::Type<Sqlite> for GateKind {
389 fn type_info() -> <Sqlite as sqlx::Database>::TypeInfo {
390 <String as sqlx::Type<Sqlite>>::type_info()
391 }
392 fn compatible(ty: &<Sqlite as sqlx::Database>::TypeInfo) -> bool {
393 <String as sqlx::Type<Sqlite>>::compatible(ty)
394 }
395 }
396
397 impl sqlx::Encode<'_, Sqlite> for GateKind {
398 fn encode_by_ref(
399 &self,
400 buf: &mut <Sqlite as sqlx::Database>::ArgumentBuffer,
401 ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
402 <String as sqlx::Encode<Sqlite>>::encode(self.as_str().to_owned(), buf)
403 }
404 }
405
406 impl<'r> sqlx::Decode<'r, Sqlite> for GateKind {
407 fn decode(
408 value: <Sqlite as sqlx::Database>::ValueRef<'r>,
409 ) -> Result<Self, sqlx::error::BoxDynError> {
410 let s = <String as sqlx::Decode<Sqlite>>::decode(value)?;
411 Ok(GateKind::from_str(&s)?)
412 }
413 }
414
415 // ---------------------------------------------------------------------
416 // Row ids
417 // ---------------------------------------------------------------------
418
419 /// Primary key of `gate_runs`. Carried through `GateStart` → `GateLogChunk`
420 /// → `GateDone` so client-side correlation is trivial. `Ord` is monotonic
421 /// in insertion order (sqlite `INTEGER PRIMARY KEY AUTOINCREMENT`), which
422 /// the TUI's run-buffer map relies on for chronological iteration.
423 #[derive(
424 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, sqlx::Type,
425 )]
426 #[sqlx(transparent)]
427 #[serde(transparent)]
428 pub struct GateRunId(pub i64);
429
430 impl fmt::Display for GateRunId {
431 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
432 self.0.fmt(f)
433 }
434 }
435
436 /// Primary key of `deploys`.
437 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
438 #[sqlx(transparent)]
439 #[serde(transparent)]
440 pub struct DeployId(pub i64);
441
442 impl fmt::Display for DeployId {
443 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
444 self.0.fmt(f)
445 }
446 }
447
448 /// Primary key of `build_runs` — the resource a `/rebuild` returns and a
449 /// non-TUI driver polls via `GET /runs/{id}`. Distinct from `GateRunId`
450 /// (one build run drives many gate runs).
451 #[derive(
452 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, sqlx::Type,
453 )]
454 #[sqlx(transparent)]
455 #[serde(transparent)]
456 pub struct RunId(pub i64);
457
458 impl fmt::Display for RunId {
459 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
460 self.0.fmt(f)
461 }
462 }
463
464 #[cfg(test)]
465 mod tests {
466 use super::*;
467
468 #[test]
469 fn tier_id_round_trips_through_json() {
470 let t = TierId::new("host");
471 let s = serde_json::to_string(&t).unwrap();
472 assert_eq!(s, "\"host\"");
473 let back: TierId = serde_json::from_str(&s).unwrap();
474 assert_eq!(t, back);
475 }
476
477 #[test]
478 fn version_parses_and_displays() {
479 let v: Version = "0.9.6".parse().unwrap();
480 assert_eq!(v.to_string(), "0.9.6");
481 assert!("not-a-version".parse::<Version>().is_err());
482 }
483
484 #[test]
485 fn version_json_is_string_form() {
486 let v: Version = "1.2.3-rc.1".parse().unwrap();
487 let s = serde_json::to_string(&v).unwrap();
488 assert_eq!(s, "\"1.2.3-rc.1\"");
489 let back: Version = serde_json::from_str(&s).unwrap();
490 assert_eq!(v, back);
491 }
492
493 #[test]
494 fn git_sha_accepts_short_and_full() {
495 assert!(GitSha::parse("abc1234").is_ok());
496 assert!(GitSha::parse("0123456789abcdef0123456789abcdef01234567").is_ok());
497 // length out of range
498 assert!(GitSha::parse("abc").is_err());
499 assert!(GitSha::parse(&"a".repeat(41)).is_err());
500 // non-hex
501 assert!(GitSha::parse("zzzzzzz").is_err());
502 }
503
504 #[test]
505 fn git_sha_short_truncates_safely() {
506 let s = GitSha::parse("abc1234").unwrap();
507 assert_eq!(s.short(), "abc1234");
508 let long = GitSha::parse("0123456789abcdef0123456789abcdef01234567").unwrap();
509 assert_eq!(long.short(), "0123456");
510 }
511
512 #[test]
513 fn git_sha_normalizes_to_lowercase() {
514 let s = GitSha::parse("ABCdef1").unwrap();
515 assert_eq!(s.as_str(), "abcdef1");
516 }
517
518 #[test]
519 fn gate_kind_round_trips_through_json() {
520 // serde_json uses #[serde(rename_all = "snake_case")] — verify the
521 // shape the TUI's `format_event` already consumes is preserved.
522 let k = GateKind::MigrationDryRun;
523 let s = serde_json::to_string(&k).unwrap();
524 assert_eq!(s, "\"migration_dry_run\"");
525 let back: GateKind = serde_json::from_str(&s).unwrap();
526 assert_eq!(k, back);
527 }
528
529 #[test]
530 fn gate_kind_as_str_matches_serde_form() {
531 // The legacy `gates::kind_str` helper produced strings the TUI
532 // matched on. Locking in that our serde form matches those exactly
533 // so step 3 (events use the types) doesn't change the wire shape.
534 for k in [
535 GateKind::CargoTest,
536 GateKind::HardeningTest,
537 GateKind::MigrationDryRun,
538 GateKind::CodeSmoke,
539 GateKind::BootSmoke,
540 GateKind::BurnIn,
541 GateKind::ManualConfirm,
542 ] {
543 let via_serde: String =
544 serde_json::from_str::<String>(&serde_json::to_string(&k).unwrap()).unwrap();
545 assert_eq!(via_serde, k.as_str());
546 }
547 }
548
549 #[test]
550 fn gate_kind_hardening_test_round_trips() {
551 // It is written into gate_runs.gate_kind and read back by
552 // unsatisfied_gates, so the two directions must agree exactly.
553 assert_eq!(GateKind::HardeningTest.as_str(), "hardening_test");
554 assert_eq!(
555 "hardening_test".parse::<GateKind>().unwrap(),
556 GateKind::HardeningTest
557 );
558 }
559
560 #[test]
561 fn gate_kind_from_str_rejects_unknown() {
562 assert!("not_a_gate".parse::<GateKind>().is_err());
563 }
564
565 #[test]
566 fn gate_run_id_serializes_as_number() {
567 let id = GateRunId(42);
568 assert_eq!(serde_json::to_string(&id).unwrap(), "42");
569 }
570 }
571