Skip to main content

majordomus_cli/deploy/
mod.rs

1//! The deployment objects of the layer, typed: `.ai/repo/deployments/*.yaml` parsed into
2//! values that cannot hold nonsense, and validated against what this repository actually
3//! contains before anything is built from them.
4//!
5//! Nothing here reaches the network, spawns a process or talks to a hosting provider. The
6//! question this module answers is the local one — would this deployment work — and it is
7//! answered while a test is cheap rather than after a rollout is not. The projections (the
8//! image definition and its ignore file) read the values this module produces; they never
9//! re-read the file.
10//!
11//! The object names no hosting provider. Fly.io was the one it named, and it is no target
12//! (owner order, 2026-10-12): what ships is the release binaries, GitHub Pages and the
13//! container image published to GHCR, and none of the three reads a provider block. A
14//! field nothing reads is removed rather than kept for later (`docs/SCHEMAS.md`), so the
15//! block, its Fly facts and the `fly.toml` they were rendered into went together.
16//!
17//! Every refusal names four things: the file, the key, the value observed and the
18//! correction. A message that names fewer sends the reader looking.
19
20use 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
31/// The kind a deployment object is discovered as.
32pub const KIND: &str = "deployment";
33
34/// The only format version this executable reads.
35pub const SCHEMA_VERSION: &str = "deployment/v1";
36
37/// Why a deployment object is refused: what was read, where, and what to do about it.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
39pub struct Refusal {
40    /// The repository-relative file the value came from.
41    pub file: String,
42    /// The key path within it, as the contract names it.
43    pub key: String,
44    /// The value observed, rendered as it was read.
45    pub found: String,
46    /// What is wrong with it, in one line.
47    pub problem: String,
48    /// What to do instead.
49    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// ---------------------------------------------------------------- the scalars
81//
82// Each of these refuses its own nonsense at construction, so no later stage has to ask
83// whether the number it holds is a number the deployment could survive.
84
85/// A TCP port a deployed process may bind. Privileged ports are not among them: the
86/// process runs as a non-root user and could not bind one.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, JsonSchema)]
88#[serde(transparent)]
89pub struct Port(u16);
90
91impl Port {
92    /// The lowest port an unprivileged process may bind.
93    pub const LOWEST: u32 = 1024;
94
95    /// The port, or why this number is not one.
96    ///
97    /// ```
98    /// use majordomus_cli::deploy::Port;
99    /// assert!(Port::new(8080).is_ok());
100    /// assert!(Port::new(80).is_err());
101    /// assert!(Port::new(70000).is_err());
102    /// ```
103    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    /// The number.
114    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/// A count that is at least one: memory in megabytes, CPUs, machines. Zero is refused
132/// wherever zero would mean "a deployment that cannot run".
133#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, JsonSchema)]
134#[serde(transparent)]
135pub struct Positive(u32);
136
137impl Positive {
138    /// The value, or why zero is not one.
139    ///
140    /// ```
141    /// use majordomus_cli::deploy::Positive;
142    /// assert_eq!(Positive::new(256).unwrap().get(), 256);
143    /// assert!(Positive::new(0).is_err());
144    /// ```
145    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    /// The number.
153    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/// A route a platform polls: an absolute path on this service, never a URL. The host is
171/// the deployment's to know and not the object's to state.
172#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, JsonSchema)]
173#[serde(transparent)]
174pub struct Route(String);
175
176impl Route {
177    /// The route, or why this string is not one.
178    ///
179    /// ```
180    /// use majordomus_cli::deploy::Route;
181    /// assert!(Route::new("/api/v1/live").is_ok());
182    /// assert!(Route::new("https://example.invalid/live").is_err());
183    /// ```
184    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    /// The path.
199    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/// Which interface a process listens on.
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
218#[serde(rename_all = "snake_case")]
219pub enum Interface {
220    /// Reachable from this host alone: the local default, and what every local invocation
221    /// keeps.
222    Loopback,
223    /// Every interface. A hosted process needs it and a local one never does; stating it
224    /// is the intent that replaces suppressing the bind warning.
225    All,
226}
227
228impl Interface {
229    /// The address a process binds for this interface.
230    ///
231    /// ```
232    /// use majordomus_cli::deploy::Interface;
233    /// assert_eq!(Interface::Loopback.host(), "127.0.0.1");
234    /// assert_eq!(Interface::All.host(), "0.0.0.0");
235    /// ```
236    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/// What a machine's CPU is.
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
246#[serde(rename_all = "snake_case")]
247pub enum CpuKind {
248    /// A fraction of a core: the cheap profile.
249    Shared,
250    /// A dedicated one.
251    Performance,
252}
253
254/// Where a deployment stands.
255///
256/// `Status` is the right name inside this module; the schema component namespace is flat,
257/// and `distribution::Status` answers a different question, so each says which it is.
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
259#[serde(rename_all = "snake_case")]
260#[schemars(rename = "DeploymentStatus")]
261pub enum Status {
262    /// The object exists and nothing is deployed from it yet.
263    Declared,
264    /// It is deployed.
265    Active,
266    /// It was, and the object is kept for the record.
267    Retired,
268}
269
270// ---------------------------------------------------------------- the object
271
272/// What is shipped and what it is built from.
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
274#[serde(deny_unknown_fields)]
275pub struct Build {
276    /// The Cargo package that is built.
277    pub package: String,
278    /// The binary target the image runs.
279    pub binary: String,
280    /// The Cargo profile the image is built under.
281    pub profile: String,
282    /// The repository-relative paths the build context carries, and the only ones.
283    pub inputs: Vec<String>,
284    /// The directory the canonical site pipeline writes; absent means the image serves no
285    /// site.
286    #[serde(default)]
287    pub site: Option<String>,
288}
289
290/// The address the process listens on.
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
292#[serde(deny_unknown_fields)]
293pub struct Listen {
294    /// The port, stated once for the process and the image.
295    pub port: Port,
296    /// Which interface.
297    pub interface: Interface,
298}
299
300impl Listen {
301    /// `host:port` as the deployed process binds it.
302    ///
303    /// ```
304    /// use majordomus_cli::deploy::{Interface, Listen, Port};
305    /// let l = Listen { port: Port::new(8080).unwrap(), interface: Interface::All };
306    /// assert_eq!(l.address(), "0.0.0.0:8080");
307    /// ```
308    pub fn address(&self) -> String {
309        format!("{}:{}", self.interface.host(), self.port)
310    }
311}
312
313/// The routes a platform polls, and how often.
314#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
315#[serde(deny_unknown_fields)]
316pub struct HealthRoutes {
317    /// Is this process alive.
318    pub liveness: Route,
319    /// Can this process serve traffic.
320    pub readiness: Route,
321    /// How long the platform waits before the first check counts.
322    #[serde(default)]
323    pub grace_seconds: Option<u32>,
324    /// How often the platform polls liveness.
325    #[serde(default)]
326    pub interval_seconds: Option<Positive>,
327    /// How long one check may take before it counts as failed.
328    #[serde(default)]
329    pub timeout_seconds: Option<Positive>,
330}
331
332/// What one machine is granted.
333#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
334#[serde(deny_unknown_fields)]
335pub struct Resources {
336    /// Shared or dedicated.
337    pub cpu_kind: CpuKind,
338    /// How many CPUs one machine has.
339    pub cpus: Positive,
340    /// Memory per machine, in megabytes.
341    pub memory_mb: Positive,
342}
343
344/// How many machines run, and whether the platform may stop them.
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
346#[serde(deny_unknown_fields)]
347pub struct Machines {
348    /// How many machines the application has.
349    pub count: Positive,
350    /// How many stay running when idle.
351    pub min_running: u32,
352    /// Whether a request to a stopped machine starts it.
353    pub autostart: bool,
354    /// Whether an idle machine is stopped.
355    pub autostop: bool,
356}
357
358/// The measured figures this deployment is held to. Each is written by the run that
359/// measured it; an absent one has not been measured yet and is not a licence.
360#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
361#[serde(deny_unknown_fields)]
362pub struct Budgets {
363    /// Largest accepted compressed image size.
364    #[serde(default)]
365    pub image_bytes: Option<Positive>,
366    /// Largest accepted stripped binary size.
367    #[serde(default)]
368    pub binary_bytes: Option<Positive>,
369    /// Largest accepted build context.
370    #[serde(default)]
371    pub build_context_bytes: Option<Positive>,
372    /// Longest accepted time from a stopped machine to a served response.
373    #[serde(default)]
374    pub cold_start_ms: Option<Positive>,
375    /// Largest accepted resident set under load.
376    #[serde(default)]
377    pub resident_memory_mb: Option<Positive>,
378    /// Longest accepted time for one liveness or readiness check.
379    #[serde(default)]
380    pub blocking_check_ms: Option<Positive>,
381    /// Longest accepted p99 for a served request.
382    #[serde(default)]
383    pub request_p99_ms: Option<Positive>,
384}
385
386/// A deployment is presented by its id, which is its identity within the repository; the
387/// title is prose and two deployments may well be given the same one.
388impl 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/// One deployment of this repository's executable, as the canonical object states it.
395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
396#[serde(deny_unknown_fields)]
397pub struct Deployment {
398    /// The format version; a version this executable does not read is refused.
399    pub schema: String,
400    /// Always `deployment`.
401    pub kind: String,
402    /// The identity within the repository.
403    pub id: String,
404    /// One line naming it.
405    pub title: String,
406    /// One line: what it serves and to whom.
407    #[serde(default)]
408    pub description: Option<String>,
409    /// Where it stands.
410    #[serde(default)]
411    pub status: Option<Status>,
412    /// The application's name: what the deployment is known by in a listing.
413    pub application: String,
414    /// What is shipped and what it is built from.
415    pub build: Build,
416    /// The address the process listens on.
417    pub listen: Listen,
418    /// The routes a platform polls.
419    pub health: HealthRoutes,
420    /// What one machine is granted.
421    pub resources: Resources,
422    /// How many machines run.
423    pub machines: Machines,
424    /// The region they run in.
425    pub region: String,
426    /// The measured figures it is held to.
427    #[serde(default)]
428    pub budgets: Budgets,
429    /// When the object was written.
430    #[serde(default)]
431    pub created_at: Option<String>,
432    /// When it was last changed.
433    #[serde(default)]
434    pub updated_at: Option<String>,
435}
436
437// ---------------------------------------------------------------- reading and checking
438
439/// What the local checks need to know about the repository they are deciding against.
440pub struct Workspace<'a> {
441    /// The repository root.
442    pub root: &'a Path,
443    /// Every HTTP path the capability registry serves.
444    pub routes: BTreeSet<String>,
445    /// Every Cargo package in the workspace, with the binaries it produces.
446    pub packages: Vec<(String, Vec<String>)>,
447}
448
449impl<'a> Workspace<'a> {
450    /// The workspace as this process sees it: the routes from the registry it built, the
451    /// packages from the manifests the repository tracks. Reading the registry that
452    /// already exists is the point; a second list of routes would be the defect.
453    pub fn read(root: &'a Path, routes: BTreeSet<String>) -> Self {
454        Workspace {
455            root,
456            routes,
457            packages: packages(root),
458        }
459    }
460}
461
462/// The Cargo packages under `apps/`, each with the binaries it declares. Read with the
463/// same subset discipline the rest of the executable reads YAML with: the two keys that
464/// matter, taken literally, with no TOML dependency for the sake of two of them.
465fn 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    /// Parse one indexed object into a deployment, or say why it is not one. The object's
516    /// metadata is the whole YAML file, so this is where an unknown key, a missing field
517    /// and a value outside its type are all refused at once.
518    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    /// Every local refusal this deployment earns, in the order the keys appear. An empty
553    /// list is the whole verdict this module can give: what remains is decided where the
554    /// image runs, and by then a refusal costs a rollout.
555    pub fn check(&self, file: &str, ws: &Workspace<'_>) -> Vec<Refusal> {
556        let mut out = Vec::new();
557
558        // --- the routes a platform will poll must be routes something answers
559        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        // --- the package and the binary are workspace facts, not aspirations
575        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        // --- a build input that does not resolve makes an image out of nothing
601        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        // --- more machines running than the application has is a bill nobody agreed to
633        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        // --- a hosted deployment that binds loopback is unreachable inside its machine
647        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    /// The object this repository ships, as the parser sees it, with one key replaced.
665    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    /// One indexed object carrying `metadata`, which is all `parse` reads of it.
729    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        /// The workspace reads the packages it actually has, with the binaries each
915        /// declares — the two facts a build specification can be wrong about, taken from
916        /// the manifests rather than assumed.
917        #[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            // no [[bin]]: the package's own name is the binary it produces
930            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        /// A repository with no packages is read as one, not as a failure: the refusal
947        /// then names what the workspace has, which is nothing, rather than crashing on
948        /// the way to saying so.
949        #[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        /// A refusal renders as one line naming all four things, so a log or a terminal
958        /// carries the whole of it.
959        #[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            /// Whatever a port is constructed from, an accepted one is bindable by an
988            /// unprivileged process and a refused one is not.
989            #[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            /// A route is accepted exactly when it is a path on this service.
996            #[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            /// Zero is the only refused count.
1004            #[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            /// Any accepted deployment binds an address its own interface and port describe,
1010            /// whatever the two are — the process and the image read one fact and cannot
1011            /// disagree about it.
1012            #[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}