Skip to content

g1t/apps/api/src/deployments.rs

297 lines18,372 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971//! Deployments over REST and MCP: a repository's deployments wherever they
2//! run, their statuses, and its environments.
3//!
4//! Any CI reports a deployment and its statuses here; a g1t Actions job
5//! with an `environment:` makes them itself, and g1t.page builds are read
6//! in alongside. The deployments service decides who may see and report
7//! them (Read to see, Write to report) and keeps them; deployments travel
8//! in `snake_case` between services too, so a request's fields reach it
9//! as they are, and a deployment's `payload` comes back as it was given.
10
11use g1t_contracts::{FailureCode, Outcome, Viewer};
12use serde_json::{Map, Value, json};
13use worker::Result;
14
15use crate::operations::{Services, repo_path};
16
17/// One operation on deployments.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum DeploymentsOp {
20 ListDeployments,
21 GetDeployment,
22 CreateDeployment,
23 ListDeploymentStatuses,
24 CreateDeploymentStatus,
25 ListEnvironments,
26 GetEnvironment,
27}
28
29/// The states a deployment's status can have.
30const STATES: [&str; 6] = ["queued", "in_progress", "success", "failure", "error", "inactive"];
31
32impl DeploymentsOp {
33 /// Every one: `Op::ALL` lists each as `Op::Deployments(…)`, which a
34 /// test checks against this.
35 #[cfg(test)]
36 pub const ALL: [DeploymentsOp; 7] = [
37 DeploymentsOp::ListDeployments,
38 DeploymentsOp::GetDeployment,
39 DeploymentsOp::CreateDeployment,
40 DeploymentsOp::ListDeploymentStatuses,
41 DeploymentsOp::CreateDeploymentStatus,
42 DeploymentsOp::ListEnvironments,
43 DeploymentsOp::GetEnvironment,
44 ];
45
46 pub fn name(self) -> &'static str {
47 match self {
48 DeploymentsOp::ListDeployments => "list_deployments",
49 DeploymentsOp::GetDeployment => "get_deployment",
50 DeploymentsOp::CreateDeployment => "create_deployment",
51 DeploymentsOp::ListDeploymentStatuses => "list_deployment_statuses",
52 DeploymentsOp::CreateDeploymentStatus => "create_deployment_status",
53 DeploymentsOp::ListEnvironments => "list_environments",
54 DeploymentsOp::GetEnvironment => "get_environment",
55 }
56 }
57
58 /// For the API reference: "List deployments".
59 pub fn title(self) -> &'static str {
60 match self {
61 DeploymentsOp::ListDeployments => "List deployments",
62 DeploymentsOp::GetDeployment => "Get a deployment",
63 DeploymentsOp::CreateDeployment => "Create a deployment",
64 DeploymentsOp::ListDeploymentStatuses => "List deployment statuses",
65 DeploymentsOp::CreateDeploymentStatus => "Create a deployment status",
66 DeploymentsOp::ListEnvironments => "List environments",
67 DeploymentsOp::GetEnvironment => "Get an environment",
68 }
69 }
70
71 pub fn description(self) -> &'static str {
72 match self {
73 DeploymentsOp::ListDeployments => "List a repository's deployments wherever they run, newest first: those reported through this API, those g1t Actions made for jobs with an `environment:`, and g1t.page builds (production and previews). Each has its environment, ref and sha, task, description, payload, transient_environment and production_environment, its latest state (queued, in_progress, success, failure, error or inactive), environment_url and log_url, creator, and source (api, actions or g1t_page), with run_id and run_url for g1t Actions and project and number for g1t.page. Filter by environment, ref, sha (or a prefix), task, state, source and creator; page with page and per_page (30 by default, at most 100). total_count counts every match. Needs the Read role; a public repository's are open to anyone.",
74 DeploymentsOp::GetDeployment => "Get one deployment by id (dep_… for a reported one, dpl_… for a g1t.page build), with every status it has had, oldest first. Needs the Read role.",
75 DeploymentsOp::CreateDeployment => "Report a deployment of a commit to an environment, from any CI or script. ref is the branch, tag or commit deployed; sha is resolved from it unless you give the whole commit id. environment is production unless you say (any name up to 255 characters, such as staging or review/feature-x; names are matched without regard to case, and the first spelling is kept). task is deploy unless you say; payload is any JSON object, returned as given. production_environment is true for an environment named production unless you say; transient_environment marks one that goes away, such as a review app. Its first status is state (queued unless you say), with environment_url and log_url. Each status also shows on the commit as the check `deploy / <environment>`, which a ruleset's required_deployments rule can require. Needs the Write role. Returns the deployment with its statuses.",
76 DeploymentsOp::ListDeploymentStatuses => "List a deployment's statuses, newest first: each with its state, description, environment_url, log_url, creator and created_at. A g1t.page build's are read from the build itself. Needs the Read role.",
77 DeploymentsOp::CreateDeploymentStatus => "Add a status to a reported deployment: state (queued, in_progress, success, failure, error or inactive), description, environment_url (where it is served) and log_url (where its output can be read). The deployment takes its state, and any address it gives. A success with auto_inactive (true unless you say) makes the environment's older successful deployments inactive. The commit's `deploy / <environment>` check follows: pending while queued or in progress, then success, failure or error. A g1t.page build's statuses come from the build and cannot be added to. Needs the Write role.",
78 DeploymentsOp::ListEnvironments => "List the environments a repository's deployments went to, those people use directly first (production by name before others), then the most recently deployed. Each has its name, url (where its current deployment is served), production_environment, transient_environment, deployments_count, latest (its newest deployment, whatever its state), current (its newest successful deployment that is still active) and updated_at. total_count counts deployments across every environment. Needs the Read role.",
79 DeploymentsOp::GetEnvironment => "Get one environment by name, matched without regard to case, with its current and latest deployments. A name with slashes is URL-encoded in the path. Needs the Read role.",
80 }
81 }
82
83 /// Whether it changes anything.
84 pub fn writes(self) -> bool {
85 matches!(self, DeploymentsOp::CreateDeployment | DeploymentsOp::CreateDeploymentStatus)
86 }
87
88 pub fn input(self) -> Value {
89 let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." });
90 let id = json!({ "type": "string", "description": "The deployment's id: dep_… for a reported one, dpl_… for a g1t.page build." });
91 let state = |what: &str| json!({ "type": "string", "enum": STATES, "description": what });
92 let address = |what: &str| json!({ "type": "string", "description": what });
93 let (properties, required): (Value, &[&str]) = match self {
94 DeploymentsOp::ListDeployments => (
95 json!({
96 "repo": repo,
97 "environment": { "type": "string", "description": "Only this environment's, matched without regard to case." },
98 "ref": { "type": "string", "description": "Only deployments of this branch, tag or commit as it was given." },
99 "sha": { "type": "string", "description": "Only deployments of this commit, or of commits starting with it." },
100 "task": { "type": "string", "description": "Only this task's, such as deploy." },
101 "state": state("Only deployments whose latest status has this state."),
102 "source": { "type": "string", "enum": ["api", "actions", "g1t_page"], "description": "Only those reported through the API, made by g1t Actions, or built on g1t.page." },
103 "creator": { "type": "string", "description": "Only those this username (or g1t) made." },
104 "page": { "type": "integer", "description": "Which page, from 1." },
105 "per_page": { "type": "integer", "description": "How many a page holds, 1 to 100; 30 by default." },
106 }),
107 &["repo"],
108 ),
109 DeploymentsOp::GetDeployment | DeploymentsOp::ListDeploymentStatuses => (json!({ "repo": repo, "id": id }), &["repo", "id"]),
110 DeploymentsOp::CreateDeployment => (
111 json!({
112 "repo": repo,
113 "ref": { "type": "string", "description": "The branch, tag or commit deployed, such as main or v1.4.0." },
114 "sha": { "type": "string", "description": "The commit deployed; resolved from ref when left out." },
115 "environment": { "type": "string", "description": "Where it went, such as production, staging or review/feature-x; production unless you say." },
116 "task": { "type": "string", "description": "What kind of deployment, such as deploy or deploy:migrations; deploy unless you say." },
117 "description": { "type": "string", "description": "A short note, at most 1,000 characters." },
118 "payload": { "type": "object", "description": "Anything else to keep with it, as a JSON object (a JSON string of one is read too), at most 64 KB. Returned as given." },
119 "production_environment": { "type": "boolean", "description": "Whether people use this environment directly. True for production unless you say." },
120 "transient_environment": { "type": "boolean", "description": "Whether the environment goes away, such as a review app. False unless you say." },
121 "state": state("Its first status: queued unless you say. Report in_progress, then success or failure, as it goes."),
122 "environment_url": address("Where it is served, an http(s) address."),
123 "log_url": address("Where its output can be read, an http(s) address."),
124 }),
125 &["repo", "ref"],
126 ),
127 DeploymentsOp::CreateDeploymentStatus => (
128 json!({
129 "repo": repo,
130 "id": id,
131 "state": state("Where it is now."),
132 "description": { "type": "string", "description": "A short note, at most 1,000 characters." },
133 "environment_url": address("Where it is served, an http(s) address."),
134 "log_url": address("Where its output can be read, an http(s) address."),
135 "auto_inactive": { "type": "boolean", "description": "On a success, make the environment's older successful deployments inactive. True unless you say." },
136 }),
137 &["repo", "id", "state"],
138 ),
139 DeploymentsOp::ListEnvironments => (json!({ "repo": repo }), &["repo"]),
140 DeploymentsOp::GetEnvironment => (
141 json!({
142 "repo": repo,
143 "environment": { "type": "string", "description": "The environment's name, such as production." },
144 }),
145 &["repo", "environment"],
146 ),
147 };
148 json!({ "type": "object", "properties": properties, "required": required })
149 }
150}
151
152/// The fields of `input` named in `keys` that were given, as they were.
153fn given(input: &Value, keys: &[&str]) -> Map<String, Value> {
154 keys.iter()
155 .filter_map(|key| input.get(*key).filter(|value| !value.is_null()).map(|value| ((*key).to_owned(), value.clone())))
156 .collect()
157}
158
159/// A query parameter's number, given as a number or as text.
160fn number(input: &Value, key: &str) -> Option<u64> {
161 match &input[key] {
162 Value::Number(number) => number.as_u64(),
163 Value::String(digits) => digits.trim().parse().ok(),
164 _ => None,
165 }
166}
167
168/// A flag given as a boolean or as text.
169fn flag(input: &Value, key: &str) -> Option<bool> {
170 match &input[key] {
171 Value::Bool(value) => Some(*value),
172 Value::String(text) => match text.trim() {
173 "true" | "1" => Some(true),
174 "false" | "0" => Some(false),
175 _ => None,
176 },
177 _ => None,
178 }
179}
180
181/// The arguments the deployments service takes for `op`, from the
182/// operation's input; `Err` says what is missing or wrong.
183pub(crate) fn args(op: DeploymentsOp, input: &Value) -> std::result::Result<(&'static str, Map<String, Value>), String> {
184 let mut out = Map::new();
185 let repo = repo_path(input).ok_or("Give the repository as \"owner/name\".")?;
186 out.insert("repo".into(), json!({ "namespace": repo.namespace, "name": repo.name }));
187 let id = || input["id"].as_str().map(str::trim).filter(|id| !id.is_empty()).map(str::to_owned).ok_or("Give the deployment's id.");
188 if let Some(state) = input["state"].as_str()
189 && !STATES.contains(&state)
190 {
191 return Err(format!("{state} is not a state: use queued, in_progress, success, failure, error or inactive."));
192 }
193 let method = match op {
194 DeploymentsOp::ListDeployments => {
195 out.extend(given(input, &["environment", "ref", "sha", "task", "state", "source", "creator"]));
196 if let Some(page) = number(input, "page") {
197 out.insert("page".into(), page.into());
198 }
199 if let Some(per_page) = number(input, "per_page") {
200 out.insert("per_page".into(), per_page.into());
201 }
202 "list_deployments"
203 }
204 DeploymentsOp::GetDeployment => {
205 out.insert("id".into(), id()?.into());
206 "get_deployment"
207 }
208 DeploymentsOp::ListDeploymentStatuses => {
209 out.insert("id".into(), id()?.into());
210 "list_deployment_statuses"
211 }
212 DeploymentsOp::CreateDeployment => {
213 if input["ref"].as_str().is_none_or(|text| text.trim().is_empty()) && input["sha"].as_str().is_none() {
214 return Err("Give the ref deployed: a branch, a tag or a commit.".to_owned());
215 }
216 out.extend(given(
217 input,
218 &["ref", "sha", "environment", "task", "description", "payload", "state", "environment_url", "log_url"],
219 ));
220 for key in ["production_environment", "transient_environment"] {
221 if let Some(value) = flag(input, key) {
222 out.insert(key.into(), value.into());
223 }
224 }
225 "create_deployment"
226 }
227 DeploymentsOp::CreateDeploymentStatus => {
228 out.insert("id".into(), id()?.into());
229 if input["state"].as_str().is_none() {
230 return Err("Give the status's state: queued, in_progress, success, failure, error or inactive.".to_owned());
231 }
232 out.extend(given(input, &["state", "description", "environment_url", "log_url"]));
233 if let Some(value) = flag(input, "auto_inactive") {
234 out.insert("auto_inactive".into(), value.into());
235 }
236 "create_deployment_status"
237 }
238 DeploymentsOp::ListEnvironments => "list_environments",
239 DeploymentsOp::GetEnvironment => {
240 let name = input["environment"].as_str().map(str::trim).filter(|name| !name.is_empty()).ok_or("Name the environment.")?;
241 out.insert("name".into(), name.into());
242 "get_environment"
243 }
244 };
245 Ok((method, out))
246}
247
248pub async fn run(op: DeploymentsOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
249 let (method, mut args) = match args(op, input) {
250 Ok(found) => found,
251 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
252 };
253 if op.writes() {
254 let Some(actor) = viewer else {
255 return Ok(Outcome::fail(FailureCode::Unauthenticated, "Reporting a deployment needs a g1t access token."));
256 };
257 args.insert("actor".into(), serde_json::to_value(actor)?);
258 } else {
259 args.insert("viewer".into(), serde_json::to_value(viewer)?);
260 }
261 g1t_kit::call(&services.deployments, method, &Value::Object(args)).await
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn a_request_becomes_the_services_arguments() {
270 let (method, args) = super::args(
271 DeploymentsOp::CreateDeployment,
272 &json!({ "repo": "acme/web", "ref": "main", "environment": "staging", "payload": { "buildId": 7 }, "production_environment": "false", "ignored": 1 }),
273 )
274 .unwrap();
275 assert_eq!(method, "create_deployment");
276 assert_eq!(args["repo"], json!({ "namespace": "acme", "name": "web" }));
277 assert_eq!(args["payload"], json!({ "buildId": 7 }), "a payload passes through as given");
278 assert_eq!(args["production_environment"], json!(false));
279 assert!(!args.contains_key("ignored"));
280 let (method, args) = super::args(DeploymentsOp::ListDeployments, &json!({ "repo": "acme/web", "page": "2", "state": "failure" })).unwrap();
281 assert_eq!(method, "list_deployments");
282 assert_eq!(args["page"], json!(2));
283 assert!(super::args(DeploymentsOp::ListDeployments, &json!({ "repo": "acme/web", "state": "done" })).is_err());
284 assert!(super::args(DeploymentsOp::CreateDeployment, &json!({ "repo": "acme/web" })).is_err());
285 assert!(super::args(DeploymentsOp::CreateDeploymentStatus, &json!({ "repo": "acme/web", "id": "dep_1" })).is_err());
286 let (method, args) = super::args(DeploymentsOp::GetEnvironment, &json!({ "repo": "acme/web", "environment": "review/x" })).unwrap();
287 assert_eq!((method, args["name"].clone()), ("get_environment", json!("review/x")));
288 }
289
290 #[test]
291 fn each_operation_is_described_with_a_schema() {
292 for op in DeploymentsOp::ALL {
293 assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name());
294 assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name());
295 }
296 }
297}

This file's history is long; its oldest lines are credited to the oldest commit read.