1use std::collections::BTreeSet;
21use std::path::Path;
22
23use schemars::JsonSchema;
24use serde::{Deserialize, Serialize};
25
26use crate::model::Object;
27
28pub(crate) mod observed;
29pub mod render;
30
31pub const KIND: &str = "deployment";
33
34pub const SCHEMA_VERSION: &str = "deployment/v1";
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
39pub struct Refusal {
40 pub file: String,
42 pub key: String,
44 pub found: String,
46 pub problem: String,
48 pub correction: String,
50}
51
52impl std::fmt::Display for Refusal {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 write!(
55 f,
56 "{}: {} is {}: {}; {}",
57 self.file, self.key, self.found, self.problem, self.correction
58 )
59 }
60}
61
62impl Refusal {
63 fn new(
64 file: &str,
65 key: &str,
66 found: impl std::fmt::Display,
67 problem: &str,
68 correction: &str,
69 ) -> Self {
70 Refusal {
71 file: file.into(),
72 key: key.into(),
73 found: found.to_string(),
74 problem: problem.into(),
75 correction: correction.into(),
76 }
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, JsonSchema)]
88#[serde(transparent)]
89pub struct Port(u16);
90
91impl Port {
92 pub const LOWEST: u32 = 1024;
94
95 pub fn new(n: u32) -> Result<Self, String> {
104 if !(Self::LOWEST..=65535).contains(&n) {
105 return Err(format!(
106 "{n} is outside {}-65535; the deployed process runs unprivileged and cannot bind a lower port",
107 Self::LOWEST
108 ));
109 }
110 Ok(Port(n as u16))
111 }
112
113 pub fn get(self) -> u16 {
115 self.0
116 }
117}
118
119impl std::fmt::Display for Port {
120 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 write!(f, "{}", self.0)
122 }
123}
124
125impl<'de> Deserialize<'de> for Port {
126 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
127 Port::new(u32::deserialize(d)?).map_err(serde::de::Error::custom)
128 }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, JsonSchema)]
134#[serde(transparent)]
135pub struct Positive(u32);
136
137impl Positive {
138 pub fn new(n: u32) -> Result<Self, String> {
146 if n == 0 {
147 return Err("0, and a deployment cannot run on none of it".into());
148 }
149 Ok(Positive(n))
150 }
151
152 pub fn get(self) -> u32 {
154 self.0
155 }
156}
157
158impl std::fmt::Display for Positive {
159 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160 write!(f, "{}", self.0)
161 }
162}
163
164impl<'de> Deserialize<'de> for Positive {
165 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
166 Positive::new(u32::deserialize(d)?).map_err(serde::de::Error::custom)
167 }
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, JsonSchema)]
173#[serde(transparent)]
174pub struct Route(String);
175
176impl Route {
177 pub fn new(s: &str) -> Result<Self, String> {
185 if !s.starts_with('/') {
186 return Err(format!(
187 "'{s}', which is not a path beginning with /; a health route names a path on this service, not a URL"
188 ));
189 }
190 if s.contains("//") || s.contains('?') || s.contains('#') || s.contains(' ') {
191 return Err(format!(
192 "'{s}', which carries an empty segment, a query, a fragment or a space"
193 ));
194 }
195 Ok(Route(s.into()))
196 }
197
198 pub fn as_str(&self) -> &str {
200 &self.0
201 }
202}
203
204impl std::fmt::Display for Route {
205 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206 f.write_str(&self.0)
207 }
208}
209
210impl<'de> Deserialize<'de> for Route {
211 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
212 Route::new(&String::deserialize(d)?).map_err(serde::de::Error::custom)
213 }
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
218#[serde(rename_all = "snake_case")]
219pub enum Interface {
220 Loopback,
223 All,
226}
227
228impl Interface {
229 pub fn host(self) -> &'static str {
237 match self {
238 Interface::Loopback => "127.0.0.1",
239 Interface::All => "0.0.0.0",
240 }
241 }
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
246#[serde(rename_all = "snake_case")]
247pub enum CpuKind {
248 Shared,
250 Performance,
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
259#[serde(rename_all = "snake_case")]
260#[schemars(rename = "DeploymentStatus")]
261pub enum Status {
262 Declared,
264 Active,
266 Retired,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
274#[serde(deny_unknown_fields)]
275pub struct Build {
276 pub package: String,
278 pub binary: String,
280 pub profile: String,
282 pub inputs: Vec<String>,
284 #[serde(default)]
287 pub site: Option<String>,
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
292#[serde(deny_unknown_fields)]
293pub struct Listen {
294 pub port: Port,
296 pub interface: Interface,
298}
299
300impl Listen {
301 pub fn address(&self) -> String {
309 format!("{}:{}", self.interface.host(), self.port)
310 }
311}
312
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
315#[serde(deny_unknown_fields)]
316pub struct HealthRoutes {
317 pub liveness: Route,
319 pub readiness: Route,
321 #[serde(default)]
323 pub grace_seconds: Option<u32>,
324 #[serde(default)]
326 pub interval_seconds: Option<Positive>,
327 #[serde(default)]
329 pub timeout_seconds: Option<Positive>,
330}
331
332#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
334#[serde(deny_unknown_fields)]
335pub struct Resources {
336 pub cpu_kind: CpuKind,
338 pub cpus: Positive,
340 pub memory_mb: Positive,
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
346#[serde(deny_unknown_fields)]
347pub struct Machines {
348 pub count: Positive,
350 pub min_running: u32,
352 pub autostart: bool,
354 pub autostop: bool,
356}
357
358#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
361#[serde(deny_unknown_fields)]
362pub struct Budgets {
363 #[serde(default)]
365 pub image_bytes: Option<Positive>,
366 #[serde(default)]
368 pub binary_bytes: Option<Positive>,
369 #[serde(default)]
371 pub build_context_bytes: Option<Positive>,
372 #[serde(default)]
374 pub cold_start_ms: Option<Positive>,
375 #[serde(default)]
377 pub resident_memory_mb: Option<Positive>,
378 #[serde(default)]
380 pub blocking_check_ms: Option<Positive>,
381 #[serde(default)]
383 pub request_p99_ms: Option<Positive>,
384}
385
386impl crate::order::Ordered for Deployment {
389 fn order_key(&self) -> crate::order::OrderKey<'_> {
390 crate::order::OrderKey::plain(&self.id, &self.id)
391 }
392}
393
394#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
396#[serde(deny_unknown_fields)]
397pub struct Deployment {
398 pub schema: String,
400 pub kind: String,
402 pub id: String,
404 pub title: String,
406 #[serde(default)]
408 pub description: Option<String>,
409 #[serde(default)]
411 pub status: Option<Status>,
412 pub application: String,
414 pub build: Build,
416 pub listen: Listen,
418 pub health: HealthRoutes,
420 pub resources: Resources,
422 pub machines: Machines,
424 pub region: String,
426 #[serde(default)]
428 pub budgets: Budgets,
429 #[serde(default)]
431 pub created_at: Option<String>,
432 #[serde(default)]
434 pub updated_at: Option<String>,
435}
436
437pub struct Workspace<'a> {
441 pub root: &'a Path,
443 pub routes: BTreeSet<String>,
445 pub packages: Vec<(String, Vec<String>)>,
447}
448
449impl<'a> Workspace<'a> {
450 pub fn read(root: &'a Path, routes: BTreeSet<String>) -> Self {
454 Workspace {
455 root,
456 routes,
457 packages: packages(root),
458 }
459 }
460}
461
462fn packages(root: &Path) -> Vec<(String, Vec<String>)> {
466 let mut out = Vec::new();
467 let Ok(entries) = std::fs::read_dir(root.join("apps")) else {
468 return out;
469 };
470 let mut dirs: Vec<_> = entries.flatten().map(|e| e.path()).collect();
471 dirs.sort();
472 for dir in dirs {
473 let Ok(text) = std::fs::read_to_string(dir.join("Cargo.toml")) else {
474 continue;
475 };
476 let mut package = None;
477 let mut bins = Vec::new();
478 let mut section = "";
479 for line in text.lines() {
480 let line = line.trim();
481 if line.starts_with('[') {
482 section = if line == "[package]" {
483 "package"
484 } else if line == "[[bin]]" {
485 "bin"
486 } else {
487 ""
488 };
489 continue;
490 }
491 let Some(value) = line
492 .strip_prefix("name")
493 .and_then(|r| r.trim().strip_prefix('='))
494 else {
495 continue;
496 };
497 let value = value.trim().trim_matches('"').to_string();
498 match section {
499 "package" if package.is_none() => package = Some(value),
500 "bin" => bins.push(value),
501 _ => {}
502 }
503 }
504 if let Some(package) = package {
505 if bins.is_empty() {
506 bins.push(package.clone());
507 }
508 out.push((package, bins));
509 }
510 }
511 out
512}
513
514impl Deployment {
515 pub fn parse(object: &Object) -> Result<Deployment, Refusal> {
519 let file = object.provenance.path.clone();
520 let parsed: Deployment = serde_json::from_value(object.metadata.clone()).map_err(|e| {
521 Refusal::new(
522 &file,
523 "(the object)",
524 e,
525 "not a deployment this executable can read",
526 "compare it with share/schemas/majordomus/deployment/deployment.v1.schema.json, or run: majordomus doctor",
527 )
528 })?;
529 if parsed.schema != SCHEMA_VERSION {
530 return Err(Refusal::new(
531 &file,
532 "schema",
533 &parsed.schema,
534 &format!(
535 "a format version this executable does not read; it reads {SCHEMA_VERSION}"
536 ),
537 "write the version this executable reads, or upgrade the executable",
538 ));
539 }
540 if parsed.kind != KIND {
541 return Err(Refusal::new(
542 &file,
543 "kind",
544 &parsed.kind,
545 &format!("not {KIND}"),
546 &format!("write `kind: {KIND}`"),
547 ));
548 }
549 Ok(parsed)
550 }
551
552 pub fn check(&self, file: &str, ws: &Workspace<'_>) -> Vec<Refusal> {
556 let mut out = Vec::new();
557
558 for (key, route) in [
560 ("health.liveness", &self.health.liveness),
561 ("health.readiness", &self.health.readiness),
562 ] {
563 if !ws.routes.contains(route.as_str()) {
564 out.push(Refusal::new(
565 file,
566 key,
567 route,
568 "a route no capability registers; the platform would poll a 404 and stop the machine",
569 "name a registered route (majordomus capabilities list --format json), or declare the capability that serves it",
570 ));
571 }
572 }
573
574 match ws.packages.iter().find(|(p, _)| *p == self.build.package) {
576 None => out.push(Refusal::new(
577 file,
578 "build.package",
579 &self.build.package,
580 "a package this workspace does not contain",
581 &format!(
582 "name one of: {}",
583 ws.packages
584 .iter()
585 .map(|(p, _)| p.as_str())
586 .collect::<Vec<_>>()
587 .join(", ")
588 ),
589 )),
590 Some((_, bins)) if !bins.contains(&self.build.binary) => out.push(Refusal::new(
591 file,
592 "build.binary",
593 &self.build.binary,
594 &format!("not a binary {} produces", self.build.package),
595 &format!("name one of: {}", bins.join(", ")),
596 )),
597 Some(_) => {}
598 }
599
600 for (n, input) in self.build.inputs.iter().enumerate() {
602 if input.starts_with('/') || input.split('/').any(|s| s == "..") {
603 out.push(Refusal::new(
604 file,
605 &format!("build.inputs.{n}"),
606 input,
607 "not a path inside the repository",
608 "give a repository-relative path with no leading slash and no ..",
609 ));
610 } else if !ws.root.join(input).exists() {
611 out.push(Refusal::new(
612 file,
613 &format!("build.inputs.{n}"),
614 input,
615 "a path this repository does not have",
616 "correct the path, or remove it from the build context",
617 ));
618 }
619 }
620 if let Some(site) = &self.build.site {
621 if site.starts_with('/') || site.split('/').any(|s| s == "..") {
622 out.push(Refusal::new(
623 file,
624 "build.site",
625 site,
626 "not a path inside the repository",
627 "give a repository-relative path with no leading slash and no ..",
628 ));
629 }
630 }
631
632 if self.machines.min_running > self.machines.count.get() {
634 out.push(Refusal::new(
635 file,
636 "machines.min_running",
637 self.machines.min_running,
638 &format!(
639 "above machines.count ({}); the platform cannot keep more running than exist",
640 self.machines.count
641 ),
642 "lower min_running, or raise count",
643 ));
644 }
645
646 if self.listen.interface == Interface::Loopback {
648 out.push(Refusal::new(
649 file,
650 "listen.interface",
651 "loopback",
652 "unreachable from outside the machine it runs in",
653 "state `interface: all`, which is what a hosted process means",
654 ));
655 }
656
657 out
658 }
659}
660#[cfg(test)]
661mod tests {
662 use super::*;
663
664 fn object_text() -> serde_json::Value {
666 serde_json::json!({
667 "schema": "deployment/v1",
668 "kind": "deployment",
669 "id": "example",
670 "title": "The example deployment",
671 "application": "example",
672 "build": {
673 "package": "majordomus-cli",
674 "binary": "majordomus",
675 "profile": "release",
676 "inputs": ["apps/majordomus-cli"],
677 },
678 "listen": { "port": 8080, "interface": "all" },
679 "health": { "liveness": "/api/v1/live", "readiness": "/api/v1/ready" },
680 "resources": { "cpu_kind": "shared", "cpus": 1, "memory_mb": 256 },
681 "machines": { "count": 1, "min_running": 0, "autostart": true, "autostop": true },
682 "region": "fra",
683 })
684 }
685
686 fn parse(v: serde_json::Value) -> Result<Deployment, String> {
687 serde_json::from_value::<Deployment>(v).map_err(|e| e.to_string())
688 }
689
690 #[test]
691 fn the_shipped_shape_parses() {
692 let d = parse(object_text()).expect("the example object parses");
693 assert_eq!(d.listen.port.get(), 8080);
694 assert_eq!(d.listen.address(), "0.0.0.0:8080");
695 assert_eq!(d.resources.memory_mb.get(), 256);
696 }
697
698 #[test]
699 fn an_unknown_key_is_refused() {
700 let mut v = object_text();
701 v["fly_api_token"] = serde_json::json!("x");
702 assert!(parse(v).unwrap_err().contains("fly_api_token"));
703 }
704
705 #[test]
706 fn a_privileged_port_cannot_be_constructed() {
707 let mut v = object_text();
708 v["listen"]["port"] = serde_json::json!(80);
709 assert!(parse(v).unwrap_err().contains("unprivileged"));
710 }
711
712 #[test]
713 fn zero_memory_cannot_be_constructed() {
714 let mut v = object_text();
715 v["resources"]["memory_mb"] = serde_json::json!(0);
716 assert!(parse(v).unwrap_err().contains("cannot run on none of it"));
717 }
718
719 #[test]
720 fn a_health_route_that_is_a_url_cannot_be_constructed() {
721 let mut v = object_text();
722 v["health"]["liveness"] = serde_json::json!("https://example.invalid/live");
723 assert!(parse(v)
724 .unwrap_err()
725 .contains("not a path beginning with /"));
726 }
727
728 fn object(metadata: serde_json::Value) -> crate::model::Object {
730 crate::model::Object {
731 kind: KIND.into(),
732 identity: "example".into(),
733 uri: "majordomus://deployment/example".into(),
734 title: None,
735 description: None,
736 metadata,
737 body: String::new(),
738 content: String::new(),
739 media_type: "application/yaml",
740 provenance: crate::model::Provenance {
741 path: ".ai/repo/deployments/example.yaml".into(),
742 directory: ".ai/repo/deployments".into(),
743 source_class: "deployment".into(),
744 section: Some("deployments".into()),
745 bytes: 0,
746 member: None,
747 },
748 }
749 }
750
751 #[test]
752 fn an_indexed_object_parses_and_names_its_own_file() {
753 let d = Deployment::parse(&object(object_text())).expect("parses");
754 assert_eq!(d.id, "example");
755 }
756
757 #[test]
758 fn a_format_version_this_executable_does_not_read_is_refused() {
759 let mut v = object_text();
760 v["schema"] = serde_json::json!("deployment/v2");
761 let r = Deployment::parse(&object(v)).expect_err("refused");
762 assert_eq!(r.file, ".ai/repo/deployments/example.yaml");
763 assert_eq!(r.key, "schema");
764 assert_eq!(r.found, "deployment/v2");
765 assert!(r.problem.contains("does not read"));
766 assert!(!r.correction.is_empty());
767 }
768
769 #[test]
770 fn a_malformed_object_is_refused_naming_its_file() {
771 let mut v = object_text();
772 v["listen"]["port"] = serde_json::json!(80);
773 let r = Deployment::parse(&object(v)).expect_err("refused");
774 assert_eq!(r.file, ".ai/repo/deployments/example.yaml");
775 assert!(r.found.contains("unprivileged"));
776 }
777
778 #[test]
779 fn an_unknown_interface_cannot_be_constructed() {
780 let mut v = object_text();
781 v["listen"]["interface"] = serde_json::json!("public");
782 assert!(parse(v).is_err());
783 }
784
785 mod checks {
786 use super::super::*;
787 use std::collections::BTreeSet;
788
789 fn workspace(root: &std::path::Path) -> Workspace<'_> {
790 Workspace {
791 root,
792 routes: ["/api/v1/live", "/api/v1/ready"]
793 .into_iter()
794 .map(String::from)
795 .collect::<BTreeSet<_>>(),
796 packages: vec![("majordomus-cli".to_string(), vec!["majordomus".to_string()])],
797 }
798 }
799
800 fn deployment(v: serde_json::Value) -> Deployment {
801 serde_json::from_value(v).expect("parses")
802 }
803
804 fn tree() -> tempfile::TempDir {
805 let d = tempfile::tempdir().expect("a temporary root");
806 std::fs::create_dir_all(d.path().join("apps/majordomus-cli")).expect("a package dir");
807 d
808 }
809
810 #[test]
811 fn a_valid_deployment_earns_no_refusal() {
812 let d = tree();
813 let dep = deployment(super::object_text());
814 assert_eq!(dep.check("x.yaml", &workspace(d.path())), Vec::new());
815 }
816
817 #[test]
818 fn a_route_no_capability_registers_is_refused_with_the_correction() {
819 let d = tree();
820 let mut v = super::object_text();
821 v["health"]["readiness"] = serde_json::json!("/nope");
822 let out = deployment(v).check("x.yaml", &workspace(d.path()));
823 assert_eq!(out.len(), 1);
824 assert_eq!(out[0].key, "health.readiness");
825 assert_eq!(out[0].found, "/nope");
826 assert!(out[0].problem.contains("no capability registers"));
827 assert!(!out[0].correction.is_empty());
828 }
829
830 #[test]
831 fn a_package_the_workspace_does_not_have_is_refused() {
832 let d = tree();
833 let mut v = super::object_text();
834 v["build"]["package"] = serde_json::json!("nothing-cli");
835 let out = deployment(v).check("x.yaml", &workspace(d.path()));
836 assert_eq!(out[0].key, "build.package");
837 assert!(out[0].correction.contains("majordomus-cli"));
838 }
839
840 #[test]
841 fn a_binary_the_package_does_not_produce_is_refused() {
842 let d = tree();
843 let mut v = super::object_text();
844 v["build"]["binary"] = serde_json::json!("not-a-binary");
845 let out = deployment(v).check("x.yaml", &workspace(d.path()));
846 assert_eq!(out[0].key, "build.binary");
847 }
848
849 #[test]
850 fn a_build_input_that_does_not_resolve_is_refused() {
851 let d = tree();
852 let mut v = super::object_text();
853 v["build"]["inputs"] = serde_json::json!(["apps/majordomus-cli", "not/here"]);
854 let out = deployment(v).check("x.yaml", &workspace(d.path()));
855 assert_eq!(out[0].key, "build.inputs.1");
856 assert!(out[0].problem.contains("does not have"));
857 }
858
859 #[test]
860 fn a_build_input_that_leaves_the_repository_is_refused() {
861 let d = tree();
862 let mut v = super::object_text();
863 v["build"]["inputs"] = serde_json::json!(["../elsewhere"]);
864 let out = deployment(v).check("x.yaml", &workspace(d.path()));
865 assert!(out[0].problem.contains("inside the repository"));
866 }
867
868 #[test]
869 fn more_running_than_exist_is_refused() {
870 let d = tree();
871 let mut v = super::object_text();
872 v["machines"]["min_running"] = serde_json::json!(2);
873 let out = deployment(v).check("x.yaml", &workspace(d.path()));
874 assert_eq!(out[0].key, "machines.min_running");
875 assert!(out[0].problem.contains("above machines.count"));
876 }
877
878 #[test]
879 fn a_hosted_deployment_that_binds_loopback_is_refused() {
880 let d = tree();
881 let mut v = super::object_text();
882 v["listen"]["interface"] = serde_json::json!("loopback");
883 let out = deployment(v).check("x.yaml", &workspace(d.path()));
884 assert_eq!(out[0].key, "listen.interface");
885 assert!(out[0].correction.contains("interface: all"));
886 }
887
888 #[test]
889 fn every_refusal_names_the_file_the_key_the_value_and_the_correction() {
890 let d = tree();
891 let mut v = super::object_text();
892 v["health"]["liveness"] = serde_json::json!("/gone");
893 v["build"]["package"] = serde_json::json!("nothing-cli");
894 v["machines"]["min_running"] = serde_json::json!(9);
895 v["listen"]["interface"] = serde_json::json!("loopback");
896 let out = deployment(v).check("the-file.yaml", &workspace(d.path()));
897 assert!(out.len() >= 4);
898 for r in &out {
899 assert_eq!(r.file, "the-file.yaml");
900 assert!(!r.key.is_empty(), "a refusal with no key: {r}");
901 assert!(!r.found.is_empty(), "a refusal with no value: {r}");
902 assert!(!r.problem.is_empty(), "a refusal with no problem: {r}");
903 assert!(
904 !r.correction.is_empty(),
905 "a refusal with no correction: {r}"
906 );
907 }
908 }
909 }
910
911 mod workspace {
912 use super::super::*;
913
914 #[test]
918 fn the_packages_are_read_from_the_manifests() {
919 let d = tempfile::tempdir().expect("a temporary root");
920 let a = d.path().join("apps/one");
921 let b = d.path().join("apps/two");
922 std::fs::create_dir_all(&a).expect("a package dir");
923 std::fs::create_dir_all(&b).expect("a package dir");
924 std::fs::write(
925 a.join("Cargo.toml"),
926 "[package]\nname = \"one-cli\"\nversion = \"0.1.0\"\n\n[[bin]]\nname = \"one\"\npath = \"src/main.rs\"\n\n[dependencies]\nserde = \"1\"\n",
927 )
928 .expect("a manifest");
929 std::fs::write(
931 b.join("Cargo.toml"),
932 "[package]\nname = \"two\"\nversion = \"0.1.0\"\n",
933 )
934 .expect("a manifest");
935
936 let ws = Workspace::read(d.path(), Default::default());
937 assert_eq!(
938 ws.packages,
939 vec![
940 ("one-cli".to_string(), vec!["one".to_string()]),
941 ("two".to_string(), vec!["two".to_string()]),
942 ]
943 );
944 }
945
946 #[test]
950 fn a_tree_with_no_packages_reads_as_none() {
951 let d = tempfile::tempdir().expect("a temporary root");
952 assert!(Workspace::read(d.path(), Default::default())
953 .packages
954 .is_empty());
955 }
956
957 #[test]
960 fn a_refusal_renders_as_one_line() {
961 let d = tempfile::tempdir().expect("a temporary root");
962 let mut v = super::object_text();
963 v["machines"]["min_running"] = serde_json::json!(4);
964 let dep: Deployment = serde_json::from_value(v).expect("parses");
965 let out = dep.check(
966 "the-file.yaml",
967 &Workspace::read(d.path(), Default::default()),
968 );
969 let line = out
970 .iter()
971 .find(|r| r.key == "machines.min_running")
972 .expect("the refusal")
973 .to_string();
974 assert!(
975 line.starts_with("the-file.yaml: machines.min_running is 4:"),
976 "{line}"
977 );
978 assert!(line.contains("lower min_running"), "{line}");
979 }
980 }
981
982 mod invariants {
983 use super::super::*;
984 use proptest::prelude::*;
985
986 proptest! {
987 #[test]
990 fn a_port_is_accepted_exactly_when_it_is_unprivileged(n in 0u32..70000) {
991 let expected = (1024..=65535).contains(&n);
992 prop_assert_eq!(Port::new(n).is_ok(), expected);
993 }
994
995 #[test]
997 fn a_route_is_accepted_exactly_when_it_is_a_clean_path(s in "[/a-z0-9?# ]{1,12}") {
998 let ok = s.starts_with('/')
999 && !s.contains("//") && !s.contains('?') && !s.contains('#') && !s.contains(' ');
1000 prop_assert_eq!(Route::new(&s).is_ok(), ok);
1001 }
1002
1003 #[test]
1005 fn a_positive_is_accepted_exactly_when_it_is_not_zero(n in 0u32..1000) {
1006 prop_assert_eq!(Positive::new(n).is_ok(), n != 0);
1007 }
1008
1009 #[test]
1013 fn the_bound_address_is_the_declared_one(port in 1024u32..65535, all in any::<bool>()) {
1014 let listen = Listen {
1015 port: Port::new(port).unwrap(),
1016 interface: if all { Interface::All } else { Interface::Loopback },
1017 };
1018 prop_assert_eq!(
1019 listen.address(),
1020 format!("{}:{}", if all { "0.0.0.0" } else { "127.0.0.1" }, port)
1021 );
1022 }
1023 }
1024 }
1025}