| 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 | |
| 11 | use g1t_contracts::{FailureCode, Outcome, Viewer}; |
| 12 | use serde_json::{Map, Value, json}; |
| 13 | use worker::Result; |
| 14 | |
| 15 | use crate::operations::{Services, repo_path}; |
| 16 | |
| 17 | /// One operation on deployments. |
| 18 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 19 | pub 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. |
| 30 | const STATES: [&str; 6] = ["queued", "in_progress", "success", "failure", "error", "inactive"]; |
| 31 | |
| 32 | impl 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, 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.", |
| 79 | 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.", |
| 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. |
| 153 | fn 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. |
| 160 | fn 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. |
| 169 | fn 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. |
| 183 | pub(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 | |
| 248 | pub 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 | let answered: Outcome<Value> = g1t_kit::call(&services.deployments, method, &Value::Object(args)).await?; |
| 262 | // Environments carry their protection rules, kept by the actions service. |
| 263 | match op { |
| 264 | DeploymentsOp::ListEnvironments => crate::protection::with_protection(services, viewer, input, answered, None).await, |
| 265 | DeploymentsOp::GetEnvironment => { |
| 266 | let name = input["environment"].as_str().unwrap_or_default().trim().to_owned(); |
| 267 | crate::protection::with_protection(services, viewer, input, answered, Some(&name)).await |
| 268 | } |
| 269 | _ => Ok(answered), |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | #[cfg(test)] |
| 274 | mod tests { |
| 275 | use super::*; |
| 276 | |
| 277 | #[test] |
| 278 | fn a_request_becomes_the_services_arguments() { |
| 279 | let (method, args) = super::args( |
| 280 | DeploymentsOp::CreateDeployment, |
| 281 | &json!({ "repo": "acme/web", "ref": "main", "environment": "staging", "payload": { "buildId": 7 }, "production_environment": "false", "ignored": 1 }), |
| 282 | ) |
| 283 | .unwrap(); |
| 284 | assert_eq!(method, "create_deployment"); |
| 285 | assert_eq!(args["repo"], json!({ "namespace": "acme", "name": "web" })); |
| 286 | assert_eq!(args["payload"], json!({ "buildId": 7 }), "a payload passes through as given"); |
| 287 | assert_eq!(args["production_environment"], json!(false)); |
| 288 | assert!(!args.contains_key("ignored")); |
| 289 | let (method, args) = super::args(DeploymentsOp::ListDeployments, &json!({ "repo": "acme/web", "page": "2", "state": "failure" })).unwrap(); |
| 290 | assert_eq!(method, "list_deployments"); |
| 291 | assert_eq!(args["page"], json!(2)); |
| 292 | assert!(super::args(DeploymentsOp::ListDeployments, &json!({ "repo": "acme/web", "state": "done" })).is_err()); |
| 293 | assert!(super::args(DeploymentsOp::CreateDeployment, &json!({ "repo": "acme/web" })).is_err()); |
| 294 | assert!(super::args(DeploymentsOp::CreateDeploymentStatus, &json!({ "repo": "acme/web", "id": "dep_1" })).is_err()); |
| 295 | let (method, args) = super::args(DeploymentsOp::GetEnvironment, &json!({ "repo": "acme/web", "environment": "review/x" })).unwrap(); |
| 296 | assert_eq!((method, args["name"].clone()), ("get_environment", json!("review/x"))); |
| 297 | } |
| 298 | |
| 299 | #[test] |
| 300 | fn each_operation_is_described_with_a_schema() { |
| 301 | for op in DeploymentsOp::ALL { |
| 302 | assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name()); |
| 303 | assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name()); |
| 304 | } |
| 305 | } |
| 306 | } |