Skip to main content

majordomus_cli/capability/builtin/
deploy.rs

1//! The `deploy` module: what this repository's deployments are, and whether they would
2//! work — read-only, from the canonical objects the index already holds.
3//!
4//! Every operation here is a read. A deployment is mutated by the trusted command line and
5//! by CI, never by an HTTP request and never by an MCP client: an operation that could
6//! start or stop a machine has no business behind a surface a browser can reach.
7//!
8//! No capability here contacts the provider. Live provider state costs a network call, and
9//! a network call on a read that a client may poll is not an ordinary read; it belongs to
10//! an explicit operation with its own cost, not behind `deploy.list`.
11//!
12//! `deploy.observed` keeps to that: it answers from the record `majordomus served forge` wrote
13//! when it last asked the forge ([`crate::deploy::observed`]) — every deployment to every
14//! environment and every release — and says so when this checkout has never asked.
15
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18
19use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
20use crate::capability::handler::{CapabilityError, Context};
21use crate::capability::model::{Exposure, McpExposure, McpResource, Stability};
22use crate::capability::module::ModuleDescriptor;
23use crate::capability::CachePolicy;
24use crate::deploy::{Deployment, Refusal, Workspace, KIND};
25use crate::{capability, module};
26
27use super::{get, mcp, Empty};
28
29/// The URI under which the deployments are read as one MCP resource.
30pub const DEPLOYMENTS_URI: &str = "majordomus://deployments";
31
32/// One deployment as this process reads it, with where it came from.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
34pub struct DeploymentView {
35    /// The repository-relative file the object lives in.
36    pub file: String,
37    /// The object, typed.
38    #[serde(flatten)]
39    pub deployment: Deployment,
40}
41
42/// Every deployment the layer declares.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
44pub struct DeploymentList {
45    /// How many.
46    pub count: usize,
47    /// Each, in identity order.
48    pub deployments: Vec<DeploymentView>,
49    /// Objects of the deployment kind this executable could not read, with why. A
50    /// malformed object is reported here rather than hidden by being skipped.
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub unreadable: Vec<Refusal>,
53}
54
55/// The input of `deploy.get`: which deployment.
56#[derive(Debug, Serialize, Deserialize, JsonSchema)]
57#[serde(deny_unknown_fields)]
58pub struct GetDeploymentInput {
59    /// The deployment's `id`.
60    pub id: String,
61}
62
63/// Whether the deployments this repository declares would work, decided locally: against
64/// the capability registry this process built and the workspace it sits in.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
66pub struct DeploymentCheck {
67    /// True when no deployment earned a refusal.
68    pub ok: bool,
69    /// How many deployments were decided.
70    pub decided: usize,
71    /// Every refusal, in the order the objects and their keys appear.
72    pub refusals: Vec<Refusal>,
73}
74
75/// The view orders as the deployment it wraps: the file path it adds is provenance, not a
76/// second opinion about the sequence.
77impl crate::order::Ordered for DeploymentView {
78    fn order_key(&self) -> crate::order::OrderKey<'_> {
79        self.deployment.order_key()
80    }
81}
82
83fn views(ctx: &Context) -> (Vec<DeploymentView>, Vec<Refusal>) {
84    let mut out = Vec::new();
85    let mut bad = Vec::new();
86    for object in ctx.index.objects.iter().filter(|o| o.kind == KIND) {
87        match Deployment::parse(object) {
88            Ok(deployment) => out.push(DeploymentView {
89                file: object.provenance.path.clone(),
90                deployment,
91            }),
92            Err(refusal) => bad.push(refusal),
93        }
94    }
95    crate::order::canonical(&mut out);
96    (out, bad)
97}
98
99fn deploy_list(ctx: &Context, _: Empty) -> Result<DeploymentList, CapabilityError> {
100    let (deployments, unreadable) = views(ctx);
101    Ok(DeploymentList {
102        count: deployments.len(),
103        deployments,
104        unreadable,
105    })
106}
107
108fn deploy_observed(
109    ctx: &Context,
110    _: Empty,
111) -> Result<crate::deploy::observed::ObservedDeployments, CapabilityError> {
112    let root = std::path::PathBuf::from(&ctx.index.repository.root);
113    Ok(crate::deploy::observed::report(&root))
114}
115
116fn deploy_get(ctx: &Context, input: GetDeploymentInput) -> Result<DeploymentView, CapabilityError> {
117    let (deployments, _) = views(ctx);
118    deployments
119        .into_iter()
120        .find(|d| d.deployment.id == input.id)
121        .ok_or_else(|| {
122            CapabilityError::NotFound(format!(
123                "no deployment '{}' (the deployments are read from the layer's deployments section)",
124                input.id
125            ))
126        })
127}
128
129fn deploy_check(ctx: &Context, _: Empty) -> Result<DeploymentCheck, CapabilityError> {
130    let (deployments, unreadable) = views(ctx);
131    let routes = ctx
132        .registry
133        .iter()
134        .filter_map(|c| c.exposure.http.as_ref().map(|h| h.path.clone()))
135        .collect();
136    let ws = Workspace::read(std::path::Path::new(&ctx.index.repository.root), routes);
137    let mut refusals = unreadable;
138    for view in &deployments {
139        refusals.extend(view.deployment.check(&view.file, &ws));
140    }
141    Ok(DeploymentCheck {
142        ok: refusals.is_empty(),
143        decided: deployments.len(),
144        refusals,
145    })
146}
147
148impl BenchmarkCases for GetDeploymentInput {
149    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
150        ctx.index
151            .objects
152            .iter()
153            .find(|o| o.kind == KIND)
154            .map(|o| {
155                vec![NamedCase::new(
156                    "first-deployment",
157                    GetDeploymentInput {
158                        id: o.identity.clone(),
159                    },
160                )]
161            })
162            .unwrap_or_else(|| {
163                vec![NamedCase::new(
164                    "absent",
165                    GetDeploymentInput {
166                        id: "absent".into(),
167                    },
168                )]
169            })
170    }
171}
172
173/// The module.
174pub fn module() -> ModuleDescriptor {
175    module! {
176        id: "deploy",
177        title: "Deployment",
178        description: "The deployments this repository declares, read from the canonical objects the index holds, and whether they would work — decided against the capability registry this process built and the workspace it sits in. Every operation is a read: a deployment is changed by the trusted command line and by CI, never over HTTP and never by an MCP client.",
179        stability: Stability::BehaviorallyVerified,
180        capabilities: [
181            capability! {
182                id: "deploy.list",
183                title: "Deployments",
184                description: "Every deployment the layer declares, typed: the application, the package and binary shipped, the address the process listens on, the routes a platform polls, the resources, the machine count, the region, the build inputs and the measured budgets. An object of the kind this executable cannot read is reported with the reason rather than skipped.",
185                input: Empty,
186                output: DeploymentList,
187                stability: Stability::BehaviorallyVerified,
188                exposure: Exposure {
189                    mcp: Some(McpExposure {
190                        tool: Some("majordomus_deployments".into()),
191                        resource: Some(McpResource { uri: DEPLOYMENTS_URI.into(), name: "deployments".into() }),
192                    }),
193                    http: get("/api/v1/deployments"),
194                    cli: None,
195                },
196                tags: ["deployment"],
197                handler: deploy_list,
198            },
199            capability! {
200                id: "deploy.get",
201                title: "One deployment",
202                description: "One deployment by its identity, typed, with the repository-relative file it was read from.",
203                input: GetDeploymentInput,
204                output: DeploymentView,
205                stability: Stability::BehaviorallyVerified,
206                exposure: Exposure { mcp: mcp("majordomus_deployment"), http: get("/api/v1/deployment"), cli: None },
207                tags: ["deployment"],
208                handler: deploy_get,
209            },
210            capability! {
211                id: "deploy.check",
212                title: "Would these deployments work",
213                description: "Every refusal the declared deployments earn locally: a health route no capability registers, a package or binary the workspace does not produce, a build input that does not resolve, more machines running than exist, a hosted process that would bind loopback. Each names the file, the key, the value observed and the correction. Nothing here contacts the provider.",
214                input: Empty,
215                output: DeploymentCheck,
216                stability: Stability::BehaviorallyVerified,
217                exposure: Exposure { mcp: mcp("majordomus_deploy_check"), http: get("/api/v1/deployments/check"), cli: None },
218                tags: ["deployment", "diagnostics"],
219                cache: CachePolicy::Process { max_entries: 2, ttl_seconds: Some(5) },
220                handler: deploy_check,
221            },
222            capability! {
223                id: "deploy.observed",
224                title: "What was deployed, and where",
225                description: "Every deployment the forge recorded, to every environment it has one in — GitHub Pages among them — with the commit, the ref, who created it, how it ended, where it is served and the run that did it; every environment described by its newest deployment; and every release the forge published. Read from the record `majordomus served forge` wrote when it last asked the forge: nothing here reaches the network, and a checkout that has never asked answers `observed: false` with what to run.",
226                input: Empty,
227                output: crate::deploy::observed::ObservedDeployments,
228                stability: Stability::BehaviorallyVerified,
229                exposure: Exposure { mcp: mcp("majordomus_deployments_observed"), http: get("/api/v1/deployments/observed"), cli: None },
230                tags: ["deployment", "delivery"],
231                handler: deploy_observed,
232            },
233        ],
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::capability::model::{CapabilityKind, HttpMethod};
241
242    /// Nothing here mutates. A deployment is changed by the trusted command line and by
243    /// CI; an operation that could start, stop or replace a machine must not be reachable
244    /// from a browser or from an MCP client, and `Query` is what says so to every
245    /// projection at once — read-only in MCP, `GET` in HTTP and OpenAPI.
246    #[test]
247    fn every_deployment_operation_is_a_read() {
248        for c in module().capabilities {
249            assert_eq!(
250                c.capability.kind,
251                CapabilityKind::Query,
252                "{} is not a read",
253                c.capability.id
254            );
255            if let Some(http) = &c.capability.exposure.http {
256                assert_eq!(
257                    http.method,
258                    HttpMethod::Get,
259                    "{} is not a GET",
260                    c.capability.id
261                );
262            }
263        }
264    }
265
266    /// The declaration is the whole registration: the ids, the routes and the MCP names
267    /// exist once, here, and every projection is derived from them. A test that pinned a
268    /// second list would be the defect; this one pins that the module composes what it
269    /// says it does.
270    #[test]
271    fn the_module_declares_the_reads_and_nothing_else() {
272        let ids: Vec<String> = module()
273            .capabilities
274            .iter()
275            .map(|c| c.capability.id.to_string())
276            .collect();
277        assert_eq!(
278            ids,
279            [
280                "deploy.list",
281                "deploy.get",
282                "deploy.check",
283                "deploy.observed"
284            ]
285        );
286    }
287
288    /// What was observed is read from the record and nothing else: a repository nobody has
289    /// asked the forge about answers that, with the command that asks.
290    #[test]
291    fn what_was_deployed_is_read_from_the_record_and_absent_is_said() {
292        let repo = crate::synthetic::SyntheticRepository::small().unwrap();
293        let ctx = repo.context().unwrap();
294        let answer = deploy_observed(&ctx, Empty {}).unwrap();
295        assert!(!answer.observed);
296        assert!(answer.reason.unwrap().contains("served forge"));
297
298        let observation = crate::deploy::observed::parse(
299            "o/r",
300            "2026-10-12T14:00:00Z",
301            &serde_json::json!({"data": {"repository": {"url": "https://forge.example/o/r",
302                "deployments": {"nodes": [{"databaseId": 1, "environment": "github-pages",
303                    "createdAt": "2026-10-12T10:00:00Z", "commitOid": "aaa",
304                    "latestStatus": {"state": "SUCCESS"}}]},
305                "releases": {"nodes": []}}}}),
306        );
307        crate::deploy::observed::store(repo.root(), &observation).unwrap();
308        let answer = deploy_observed(&ctx, Empty {}).unwrap();
309        assert!(answer.observed);
310        assert_eq!(answer.environments[0].name, "github-pages");
311        assert_eq!(answer.deployments[0].state, "success");
312    }
313
314    /// A deployment id the layer does not have is refused by name rather than answered
315    /// with an empty result: an absent deployment and a deployment with nothing in it are
316    /// different facts.
317    #[test]
318    fn an_absent_deployment_is_not_found() {
319        let repo = crate::synthetic::SyntheticRepository::new(crate::synthetic::Shape::default())
320            .expect("a synthetic repository");
321        let ctx = repo.context().expect("a context");
322        let e = deploy_get(
323            &ctx,
324            GetDeploymentInput {
325                id: "absent".into(),
326            },
327        )
328        .expect_err("refused");
329        assert!(matches!(e, CapabilityError::NotFound(_)), "{e}");
330    }
331
332    /// A layer that declares no deployment is not a failure: the list is empty and the
333    /// check is satisfied, because there is nothing that could be wrong.
334    #[test]
335    fn a_layer_with_no_deployments_lists_none_and_checks_clean() {
336        let repo = crate::synthetic::SyntheticRepository::new(crate::synthetic::Shape::default())
337            .expect("a synthetic repository");
338        let ctx = repo.context().expect("a context");
339        assert_eq!(deploy_list(&ctx, Empty {}).expect("listed").count, 0);
340        let check = deploy_check(&ctx, Empty {}).expect("checked");
341        assert!(check.ok);
342        assert_eq!(check.decided, 0);
343    }
344}