Skip to content
337 linesCodeBlameRaw
1//! 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 StartDeploymentBuild,
28}
29
30/// The states a deployment's status can have.
31const STATES: [&str; 6] = ["queued", "in_progress", "success", "failure", "error", "inactive"];
32
33impl DeploymentsOp {
34 /// Every one: `Op::ALL` lists each as `Op::Deployments(…)`, which a
35 /// test checks against this.
36 #[cfg(test)]
37 pub const ALL: [DeploymentsOp; 8] = [
38 DeploymentsOp::ListDeployments,
39 DeploymentsOp::GetDeployment,
40 DeploymentsOp::CreateDeployment,
41 DeploymentsOp::ListDeploymentStatuses,
42 DeploymentsOp::CreateDeploymentStatus,
43 DeploymentsOp::ListEnvironments,
44 DeploymentsOp::GetEnvironment,
45 DeploymentsOp::StartDeploymentBuild,
46 ];
47
48 pub fn name(self) -> &'static str {
49 match self {
50 DeploymentsOp::ListDeployments => "list_deployments",
51 DeploymentsOp::GetDeployment => "get_deployment",
52 DeploymentsOp::CreateDeployment => "create_deployment",
53 DeploymentsOp::ListDeploymentStatuses => "list_deployment_statuses",
54 DeploymentsOp::CreateDeploymentStatus => "create_deployment_status",
55 DeploymentsOp::ListEnvironments => "list_environments",
56 DeploymentsOp::GetEnvironment => "get_environment",
57 DeploymentsOp::StartDeploymentBuild => "start_deployment_build",
58 }
59 }
60
61 /// For the API reference: "List deployments".
62 pub fn title(self) -> &'static str {
63 match self {
64 DeploymentsOp::ListDeployments => "List deployments",
65 DeploymentsOp::GetDeployment => "Get a deployment",
66 DeploymentsOp::CreateDeployment => "Create a deployment",
67 DeploymentsOp::ListDeploymentStatuses => "List deployment statuses",
68 DeploymentsOp::CreateDeploymentStatus => "Create a deployment status",
69 DeploymentsOp::ListEnvironments => "List environments",
70 DeploymentsOp::GetEnvironment => "Get an environment",
71 DeploymentsOp::StartDeploymentBuild => "Start a g1t.page build from a workflow",
72 }
73 }
74
75 pub fn description(self) -> &'static str {
76 match self {
77 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.",
78 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.",
79 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.",
80 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.",
81 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.",
82 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, and after them those with protection rules but no deployment yet. 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, and, when it has rules, protection_rules (required_reviewers, wait_timer, branch_policy), deployment_branch_policy, branch_policies and can_admins_bypass. total_count counts deployments across every environment. Needs the Read role.",
83 DeploymentsOp::GetEnvironment => "Get one environment by name, matched without regard to case, with its current and latest deployments and its protection rules (see update_environment). An environment with rules but no deployment yet is found too. A name with slashes is URL-encoded in the path. Needs the Read role.",
84 DeploymentsOp::StartDeploymentBuild => "From a running g1t Actions job, open a g1t.page build of one of the repository's projects, which the job then builds in its own checkout with `g1t-runner deploy` (the g1t/deploy action does both). Only the job's own token (G1T_TOKEN) may call it, with `deployments: write` (or `pages: write`) in its `permissions:`. project is the project's slug; it may be left out when the repository has one project with Deployments on. The run decides what is built: a run on the project's production branch (a push, a dispatch or a schedule there) builds production, a pull request's run builds that pull request's preview, and a run on another branch builds that branch's preview; a tag's run is refused. sha is the commit built, the run's unless you say. A pull request from a fork or from someone without the Write role, and a run that had to be approved, get no build secrets. The workspace's plan must include Deployments and its spend limit must not be reached; the build's time is the job's Actions time and is not charged again. Returns deploy_id and token (DEPLOY_ID and DEPLOY_TOKEN for `g1t-runner deploy`, the token good for this build alone until it finishes), kind (production or preview), project, branch, number, commit, url (the app's address), trusted, the project's root_dir, build_command and output_dir, and build_env and build_secrets (the project's variables and secrets for builds of that kind). The commit's `g1t / deploy` check is pending until the build reports.",
85 }
86 }
87
88 /// Whether it changes anything.
89 pub fn writes(self) -> bool {
90 matches!(
91 self,
92 DeploymentsOp::CreateDeployment | DeploymentsOp::CreateDeploymentStatus | DeploymentsOp::StartDeploymentBuild
93 )
94 }
95
96 pub fn input(self) -> Value {
97 let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." });
98 let id = json!({ "type": "string", "description": "The deployment's id: dep_… for a reported one, dpl_… for a g1t.page build." });
99 let state = |what: &str| json!({ "type": "string", "enum": STATES, "description": what });
100 let address = |what: &str| json!({ "type": "string", "description": what });
101 let (properties, required): (Value, &[&str]) = match self {
102 DeploymentsOp::ListDeployments => (
103 json!({
104 "repo": repo,
105 "environment": { "type": "string", "description": "Only this environment's, matched without regard to case." },
106 "ref": { "type": "string", "description": "Only deployments of this branch, tag or commit as it was given." },
107 "sha": { "type": "string", "description": "Only deployments of this commit, or of commits starting with it." },
108 "task": { "type": "string", "description": "Only this task's, such as deploy." },
109 "state": state("Only deployments whose latest status has this state."),
110 "source": { "type": "string", "enum": ["api", "actions", "g1t_page"], "description": "Only those reported through the API, made by g1t Actions, or built on g1t.page." },
111 "creator": { "type": "string", "description": "Only those this username (or g1t) made." },
112 "page": { "type": "integer", "description": "Which page, from 1." },
113 "per_page": { "type": "integer", "description": "How many a page holds, 1 to 100; 30 by default." },
114 }),
115 &["repo"],
116 ),
117 DeploymentsOp::GetDeployment | DeploymentsOp::ListDeploymentStatuses => (json!({ "repo": repo, "id": id }), &["repo", "id"]),
118 DeploymentsOp::CreateDeployment => (
119 json!({
120 "repo": repo,
121 "ref": { "type": "string", "description": "The branch, tag or commit deployed, such as main or v1.4.0." },
122 "sha": { "type": "string", "description": "The commit deployed; resolved from ref when left out." },
123 "environment": { "type": "string", "description": "Where it went, such as production, staging or review/feature-x; production unless you say." },
124 "task": { "type": "string", "description": "What kind of deployment, such as deploy or deploy:migrations; deploy unless you say." },
125 "description": { "type": "string", "description": "A short note, at most 1,000 characters." },
126 "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." },
127 "production_environment": { "type": "boolean", "description": "Whether people use this environment directly. True for production unless you say." },
128 "transient_environment": { "type": "boolean", "description": "Whether the environment goes away, such as a review app. False unless you say." },
129 "state": state("Its first status: queued unless you say. Report in_progress, then success or failure, as it goes."),
130 "environment_url": address("Where it is served, an http(s) address."),
131 "log_url": address("Where its output can be read, an http(s) address."),
132 }),
133 &["repo", "ref"],
134 ),
135 DeploymentsOp::CreateDeploymentStatus => (
136 json!({
137 "repo": repo,
138 "id": id,
139 "state": state("Where it is now."),
140 "description": { "type": "string", "description": "A short note, at most 1,000 characters." },
141 "environment_url": address("Where it is served, an http(s) address."),
142 "log_url": address("Where its output can be read, an http(s) address."),
143 "auto_inactive": { "type": "boolean", "description": "On a success, make the environment's older successful deployments inactive. True unless you say." },
144 }),
145 &["repo", "id", "state"],
146 ),
147 DeploymentsOp::ListEnvironments => (json!({ "repo": repo }), &["repo"]),
148 DeploymentsOp::StartDeploymentBuild => (
149 json!({
150 "repo": repo,
151 "project": { "type": "string", "description": "The project's slug; may be left out when the repository has one project with Deployments on." },
152 "sha": { "type": "string", "description": "The commit built, in hex; the run's commit unless you say." },
153 }),
154 &["repo"],
155 ),
156 DeploymentsOp::GetEnvironment => (
157 json!({
158 "repo": repo,
159 "environment": { "type": "string", "description": "The environment's name, such as production." },
160 }),
161 &["repo", "environment"],
162 ),
163 };
164 json!({ "type": "object", "properties": properties, "required": required })
165 }
166}
167
168/// The fields of `input` named in `keys` that were given, as they were.
169fn given(input: &Value, keys: &[&str]) -> Map<String, Value> {
170 keys.iter()
171 .filter_map(|key| input.get(*key).filter(|value| !value.is_null()).map(|value| ((*key).to_owned(), value.clone())))
172 .collect()
173}
174
175/// A query parameter's number, given as a number or as text.
176fn number(input: &Value, key: &str) -> Option<u64> {
177 match &input[key] {
178 Value::Number(number) => number.as_u64(),
179 Value::String(digits) => digits.trim().parse().ok(),
180 _ => None,
181 }
182}
183
184/// A flag given as a boolean or as text.
185fn flag(input: &Value, key: &str) -> Option<bool> {
186 match &input[key] {
187 Value::Bool(value) => Some(*value),
188 Value::String(text) => match text.trim() {
189 "true" | "1" => Some(true),
190 "false" | "0" => Some(false),
191 _ => None,
192 },
193 _ => None,
194 }
195}
196
197/// The arguments the deployments service takes for `op`, from the
198/// operation's input; `Err` says what is missing or wrong.
199pub(crate) fn args(op: DeploymentsOp, input: &Value) -> std::result::Result<(&'static str, Map<String, Value>), String> {
200 let mut out = Map::new();
201 let repo = repo_path(input).ok_or("Give the repository as \"owner/name\".")?;
202 out.insert("repo".into(), json!({ "namespace": repo.namespace, "name": repo.name }));
203 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.");
204 if let Some(state) = input["state"].as_str()
205 && !STATES.contains(&state)
206 {
207 return Err(format!("{state} is not a state: use queued, in_progress, success, failure, error or inactive."));
208 }
209 let method = match op {
210 DeploymentsOp::ListDeployments => {
211 out.extend(given(input, &["environment", "ref", "sha", "task", "state", "source", "creator"]));
212 if let Some(page) = number(input, "page") {
213 out.insert("page".into(), page.into());
214 }
215 if let Some(per_page) = number(input, "per_page") {
216 out.insert("per_page".into(), per_page.into());
217 }
218 "list_deployments"
219 }
220 DeploymentsOp::GetDeployment => {
221 out.insert("id".into(), id()?.into());
222 "get_deployment"
223 }
224 DeploymentsOp::ListDeploymentStatuses => {
225 out.insert("id".into(), id()?.into());
226 "list_deployment_statuses"
227 }
228 DeploymentsOp::CreateDeployment => {
229 if input["ref"].as_str().is_none_or(|text| text.trim().is_empty()) && input["sha"].as_str().is_none() {
230 return Err("Give the ref deployed: a branch, a tag or a commit.".to_owned());
231 }
232 out.extend(given(
233 input,
234 &["ref", "sha", "environment", "task", "description", "payload", "state", "environment_url", "log_url"],
235 ));
236 for key in ["production_environment", "transient_environment"] {
237 if let Some(value) = flag(input, key) {
238 out.insert(key.into(), value.into());
239 }
240 }
241 "create_deployment"
242 }
243 DeploymentsOp::CreateDeploymentStatus => {
244 out.insert("id".into(), id()?.into());
245 if input["state"].as_str().is_none() {
246 return Err("Give the status's state: queued, in_progress, success, failure, error or inactive.".to_owned());
247 }
248 out.extend(given(input, &["state", "description", "environment_url", "log_url"]));
249 if let Some(value) = flag(input, "auto_inactive") {
250 out.insert("auto_inactive".into(), value.into());
251 }
252 "create_deployment_status"
253 }
254 DeploymentsOp::ListEnvironments => "list_environments",
255 // The deployments service checks the job's token itself: the
256 // caller (the job's workspace, with `token.job`) is the actor.
257 DeploymentsOp::StartDeploymentBuild => {
258 for key in ["project", "sha"] {
259 if let Some(value) = input[key].as_str().map(str::trim).filter(|value| !value.is_empty()) {
260 out.insert(key.into(), value.into());
261 }
262 }
263 "start_from_workflow"
264 }
265 DeploymentsOp::GetEnvironment => {
266 let name = input["environment"].as_str().map(str::trim).filter(|name| !name.is_empty()).ok_or("Name the environment.")?;
267 out.insert("name".into(), name.into());
268 "get_environment"
269 }
270 };
271 Ok((method, out))
272}
273
274pub async fn run(op: DeploymentsOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
275 let (method, mut args) = match args(op, input) {
276 Ok(found) => found,
277 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
278 };
279 if op.writes() {
280 let Some(actor) = viewer else {
281 return Ok(Outcome::fail(FailureCode::Unauthenticated, "Reporting a deployment needs a g1t access token."));
282 };
283 args.insert("actor".into(), serde_json::to_value(actor)?);
284 } else {
285 args.insert("viewer".into(), serde_json::to_value(viewer)?);
286 }
287 let answered: Outcome<Value> = g1t_kit::call(&services.deployments, method, &Value::Object(args)).await?;
288 // Environments carry their protection rules, kept by the actions service.
289 match op {
290 DeploymentsOp::ListEnvironments => crate::protection::with_protection(services, viewer, input, answered, None).await,
291 DeploymentsOp::GetEnvironment => {
292 let name = input["environment"].as_str().unwrap_or_default().trim().to_owned();
293 crate::protection::with_protection(services, viewer, input, answered, Some(&name)).await
294 }
295 _ => Ok(answered),
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
304 fn a_request_becomes_the_services_arguments() {
305 let (method, args) = super::args(
306 DeploymentsOp::CreateDeployment,
307 &json!({ "repo": "acme/web", "ref": "main", "environment": "staging", "payload": { "buildId": 7 }, "production_environment": "false", "ignored": 1 }),
308 )
309 .unwrap();
310 assert_eq!(method, "create_deployment");
311 assert_eq!(args["repo"], json!({ "namespace": "acme", "name": "web" }));
312 assert_eq!(args["payload"], json!({ "buildId": 7 }), "a payload passes through as given");
313 assert_eq!(args["production_environment"], json!(false));
314 assert!(!args.contains_key("ignored"));
315 let (method, args) = super::args(DeploymentsOp::ListDeployments, &json!({ "repo": "acme/web", "page": "2", "state": "failure" })).unwrap();
316 assert_eq!(method, "list_deployments");
317 assert_eq!(args["page"], json!(2));
318 assert!(super::args(DeploymentsOp::ListDeployments, &json!({ "repo": "acme/web", "state": "done" })).is_err());
319 assert!(super::args(DeploymentsOp::CreateDeployment, &json!({ "repo": "acme/web" })).is_err());
320 assert!(super::args(DeploymentsOp::CreateDeploymentStatus, &json!({ "repo": "acme/web", "id": "dep_1" })).is_err());
321 let (method, args) = super::args(DeploymentsOp::GetEnvironment, &json!({ "repo": "acme/web", "environment": "review/x" })).unwrap();
322 assert_eq!((method, args["name"].clone()), ("get_environment", json!("review/x")));
323 let (method, args) = super::args(DeploymentsOp::StartDeploymentBuild, &json!({ "repo": "acme/web", "project": " web ", "sha": "" })).unwrap();
324 assert_eq!(method, "start_from_workflow");
325 assert_eq!(args["project"], json!("web"));
326 assert!(!args.contains_key("sha"), "an empty sha is the run's");
327 assert!(DeploymentsOp::StartDeploymentBuild.writes());
328 }
329
330 #[test]
331 fn each_operation_is_described_with_a_schema() {
332 for op in DeploymentsOp::ALL {
333 assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name());
334 assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name());
335 }
336 }
337}