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 main: Deployments panel in the About, project homepage, both sides' operations | 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. 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. | |
| 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 | g1t_kit::call(&services.deployments, method, &Value::Object(args)).await | |
| 262 | } | |
| 263 | ||
| 264 | #[cfg(test)] | |
| 265 | mod 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.