1use 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
29pub const DEPLOYMENTS_URI: &str = "majordomus://deployments";
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
34pub struct DeploymentView {
35 pub file: String,
37 #[serde(flatten)]
39 pub deployment: Deployment,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
44pub struct DeploymentList {
45 pub count: usize,
47 pub deployments: Vec<DeploymentView>,
49 #[serde(default, skip_serializing_if = "Vec::is_empty")]
52 pub unreadable: Vec<Refusal>,
53}
54
55#[derive(Debug, Serialize, Deserialize, JsonSchema)]
57#[serde(deny_unknown_fields)]
58pub struct GetDeploymentInput {
59 pub id: String,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
66pub struct DeploymentCheck {
67 pub ok: bool,
69 pub decided: usize,
71 pub refusals: Vec<Refusal>,
73}
74
75impl 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
173pub 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 #[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 #[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 #[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 #[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 #[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}