| 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 | //! A project's g1t.page settings (whether it deploys at all, and how it |
| 12 | //! builds) are here too, named by workspace and project as the project |
| 13 | //! routes are: the deployments service keeps them in `camelCase`, as the |
| 14 | //! site reads them, so they are shaped here. |
| 15 | |
| 16 | use g1t_contracts::{FailureCode, Outcome, Viewer}; |
| 17 | use serde_json::{Map, Value, json}; |
| 18 | use worker::Result; |
| 19 | |
| 20 | use crate::operations::{Services, repo_path}; |
| 21 | |
| 22 | /// One operation on deployments. |
| 23 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 24 | pub enum DeploymentsOp { |
| 25 | ListDeployments, |
| 26 | GetDeployment, |
| 27 | CreateDeployment, |
| 28 | ListDeploymentStatuses, |
| 29 | CreateDeploymentStatus, |
| 30 | ListEnvironments, |
| 31 | GetEnvironment, |
| 32 | StartDeploymentBuild, |
| 33 | GetDeploymentSettings, |
| 34 | UpdateDeploymentSettings, |
| 35 | } |
| 36 | |
| 37 | /// The states a deployment's status can have. |
| 38 | const STATES: [&str; 6] = ["queued", "in_progress", "success", "failure", "error", "inactive"]; |
| 39 | |
| 40 | /// What a project may be built as (`BUILD_AS` in @g1t/contracts). |
| 41 | const BUILD_AS: [&str; 4] = ["auto", "static", "workers", "docker"]; |
| 42 | |
| 43 | /// The sizes a container app runs at (`APP_INSTANCES` in @g1t/contracts). |
| 44 | const INSTANCE_TYPES: [&str; 6] = ["lite", "basic", "standard-1", "standard-2", "standard-3", "standard-4"]; |
| 45 | |
| 46 | impl DeploymentsOp { |
| 47 | /// Every one: `Op::ALL` lists each as `Op::Deployments(…)`, which a |
| 48 | /// test checks against this. |
| 49 | #[cfg(test)] |
| 50 | pub const ALL: [DeploymentsOp; 10] = [ |
| 51 | DeploymentsOp::ListDeployments, |
| 52 | DeploymentsOp::GetDeployment, |
| 53 | DeploymentsOp::CreateDeployment, |
| 54 | DeploymentsOp::ListDeploymentStatuses, |
| 55 | DeploymentsOp::CreateDeploymentStatus, |
| 56 | DeploymentsOp::ListEnvironments, |
| 57 | DeploymentsOp::GetEnvironment, |
| 58 | DeploymentsOp::StartDeploymentBuild, |
| 59 | DeploymentsOp::GetDeploymentSettings, |
| 60 | DeploymentsOp::UpdateDeploymentSettings, |
| 61 | ]; |
| 62 | |
| 63 | pub fn name(self) -> &'static str { |
| 64 | match self { |
| 65 | DeploymentsOp::ListDeployments => "list_deployments", |
| 66 | DeploymentsOp::GetDeployment => "get_deployment", |
| 67 | DeploymentsOp::CreateDeployment => "create_deployment", |
| 68 | DeploymentsOp::ListDeploymentStatuses => "list_deployment_statuses", |
| 69 | DeploymentsOp::CreateDeploymentStatus => "create_deployment_status", |
| 70 | DeploymentsOp::ListEnvironments => "list_environments", |
| 71 | DeploymentsOp::GetEnvironment => "get_environment", |
| 72 | DeploymentsOp::StartDeploymentBuild => "start_deployment_build", |
| 73 | DeploymentsOp::GetDeploymentSettings => "get_deployment_settings", |
| 74 | DeploymentsOp::UpdateDeploymentSettings => "update_deployment_settings", |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | /// For the API reference: "List deployments". |
| 79 | pub fn title(self) -> &'static str { |
| 80 | match self { |
| 81 | DeploymentsOp::ListDeployments => "List deployments", |
| 82 | DeploymentsOp::GetDeployment => "Get a deployment", |
| 83 | DeploymentsOp::CreateDeployment => "Create a deployment", |
| 84 | DeploymentsOp::ListDeploymentStatuses => "List deployment statuses", |
| 85 | DeploymentsOp::CreateDeploymentStatus => "Create a deployment status", |
| 86 | DeploymentsOp::ListEnvironments => "List environments", |
| 87 | DeploymentsOp::GetEnvironment => "Get an environment", |
| 88 | DeploymentsOp::StartDeploymentBuild => "Start a g1t.page build from a workflow", |
| 89 | DeploymentsOp::GetDeploymentSettings => "Get a project's deployment settings", |
| 90 | DeploymentsOp::UpdateDeploymentSettings => "Update a project's deployment settings", |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | pub fn description(self) -> &'static str { |
| 95 | match self { |
| 96 | 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.", |
| 97 | 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.", |
| 98 | 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.", |
| 99 | 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.", |
| 100 | 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.", |
| 101 | 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.", |
| 102 | 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.", |
| 103 | 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, build_as (auto, static, workers or docker) and dockerfile (its Dockerfile's path, or null to find `Dockerfile` or `Containerfile`), 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.", |
| 104 | DeploymentsOp::GetDeploymentSettings => "A project's g1t.page deployment settings, as its Settings → Deployments page shows them: whether Deployments are on (`enabled`; off for every project until someone turns them on), whether the default branch deploys to production on every push (`production`) and every pull request gets a preview (`previews`), production's address (`production_url`, and `primary_domain` once a custom domain serves it), how it builds (`build_as`: auto, static, workers or docker; `build_command`, `output_dir`, `dockerfile` and `instance_type` for a container; `via_workflow` to build through the deploy workflow g1t manages), how many days an unvisited preview stays up (`idle_days`), and what its last finished build found it to be (`detected`: workers, static, html or container, with `framework`), null before one has. A repository's own project has its name. Needs the Read role on the project's repository.", |
| 105 | DeploymentsOp::UpdateDeploymentSettings => "Change a project's g1t.page deployment settings; only what you give changes. `enabled: true` turns Deployments on, as Turn on deployments under Settings → Deployments does: production starts building from the default branch at once, and every pull request gets a preview. That needs the g1t plan on the workspace (refused with payment_required otherwise; an owner starts it under Billing), and is refused for a project set to be a library, a tool or other until update_project makes it an app. `enabled: false` takes production and every preview down now and stops building; turning `production` or `previews` off takes those apps down. build_command, output_dir and dockerfile take null or \"\" to go back to what is found by itself; idle_days is 1 to 90; build_as is auto, static, workers or docker; instance_type is a container size (lite, basic, standard-1 to standard-4). Needs the Admin role on the project's repository. Returns the settings as get_deployment_settings does.", |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | /// Whether it changes anything. |
| 110 | pub fn writes(self) -> bool { |
| 111 | matches!( |
| 112 | self, |
| 113 | DeploymentsOp::CreateDeployment |
| 114 | | DeploymentsOp::CreateDeploymentStatus |
| 115 | | DeploymentsOp::StartDeploymentBuild |
| 116 | | DeploymentsOp::UpdateDeploymentSettings |
| 117 | ) |
| 118 | } |
| 119 | |
| 120 | /// Whether it is about one repository, named by `repo`: a project's |
| 121 | /// settings are named by its workspace and slug instead. |
| 122 | pub fn needs_repo(self) -> bool { |
| 123 | !matches!(self, DeploymentsOp::GetDeploymentSettings | DeploymentsOp::UpdateDeploymentSettings) |
| 124 | } |
| 125 | |
| 126 | pub fn input(self) -> Value { |
| 127 | let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." }); |
| 128 | let id = json!({ "type": "string", "description": "The deployment's id: dep_… for a reported one, dpl_… for a g1t.page build." }); |
| 129 | let state = |what: &str| json!({ "type": "string", "enum": STATES, "description": what }); |
| 130 | let address = |what: &str| json!({ "type": "string", "description": what }); |
| 131 | let workspace = json!({ "type": "string", "description": "The workspace's slug, e.g. \"flagon-io\"." }); |
| 132 | let project = json!({ "type": "string", "description": "The project's slug, as in g1t.sh/{workspace}/{project}; a repository's own project has its name." }); |
| 133 | let (properties, required): (Value, &[&str]) = match self { |
| 134 | DeploymentsOp::ListDeployments => ( |
| 135 | json!({ |
| 136 | "repo": repo, |
| 137 | "environment": { "type": "string", "description": "Only this environment's, matched without regard to case." }, |
| 138 | "ref": { "type": "string", "description": "Only deployments of this branch, tag or commit as it was given." }, |
| 139 | "sha": { "type": "string", "description": "Only deployments of this commit, or of commits starting with it." }, |
| 140 | "task": { "type": "string", "description": "Only this task's, such as deploy." }, |
| 141 | "state": state("Only deployments whose latest status has this state."), |
| 142 | "source": { "type": "string", "enum": ["api", "actions", "g1t_page"], "description": "Only those reported through the API, made by g1t Actions, or built on g1t.page." }, |
| 143 | "creator": { "type": "string", "description": "Only those this username (or g1t) made." }, |
| 144 | "page": { "type": "integer", "description": "Which page, from 1." }, |
| 145 | "per_page": { "type": "integer", "description": "How many a page holds, 1 to 100; 30 by default." }, |
| 146 | }), |
| 147 | &["repo"], |
| 148 | ), |
| 149 | DeploymentsOp::GetDeployment | DeploymentsOp::ListDeploymentStatuses => (json!({ "repo": repo, "id": id }), &["repo", "id"]), |
| 150 | DeploymentsOp::CreateDeployment => ( |
| 151 | json!({ |
| 152 | "repo": repo, |
| 153 | "ref": { "type": "string", "description": "The branch, tag or commit deployed, such as main or v1.4.0." }, |
| 154 | "sha": { "type": "string", "description": "The commit deployed; resolved from ref when left out." }, |
| 155 | "environment": { "type": "string", "description": "Where it went, such as production, staging or review/feature-x; production unless you say." }, |
| 156 | "task": { "type": "string", "description": "What kind of deployment, such as deploy or deploy:migrations; deploy unless you say." }, |
| 157 | "description": { "type": "string", "description": "A short note, at most 1,000 characters." }, |
| 158 | "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." }, |
| 159 | "production_environment": { "type": "boolean", "description": "Whether people use this environment directly. True for production unless you say." }, |
| 160 | "transient_environment": { "type": "boolean", "description": "Whether the environment goes away, such as a review app. False unless you say." }, |
| 161 | "state": state("Its first status: queued unless you say. Report in_progress, then success or failure, as it goes."), |
| 162 | "environment_url": address("Where it is served, an http(s) address."), |
| 163 | "log_url": address("Where its output can be read, an http(s) address."), |
| 164 | }), |
| 165 | &["repo", "ref"], |
| 166 | ), |
| 167 | DeploymentsOp::CreateDeploymentStatus => ( |
| 168 | json!({ |
| 169 | "repo": repo, |
| 170 | "id": id, |
| 171 | "state": state("Where it is now."), |
| 172 | "description": { "type": "string", "description": "A short note, at most 1,000 characters." }, |
| 173 | "environment_url": address("Where it is served, an http(s) address."), |
| 174 | "log_url": address("Where its output can be read, an http(s) address."), |
| 175 | "auto_inactive": { "type": "boolean", "description": "On a success, make the environment's older successful deployments inactive. True unless you say." }, |
| 176 | }), |
| 177 | &["repo", "id", "state"], |
| 178 | ), |
| 179 | DeploymentsOp::ListEnvironments => (json!({ "repo": repo }), &["repo"]), |
| 180 | DeploymentsOp::StartDeploymentBuild => ( |
| 181 | json!({ |
| 182 | "repo": repo, |
| 183 | "project": { "type": "string", "description": "The project's slug; may be left out when the repository has one project with Deployments on." }, |
| 184 | "sha": { "type": "string", "description": "The commit built, in hex; the run's commit unless you say." }, |
| 185 | }), |
| 186 | &["repo"], |
| 187 | ), |
| 188 | DeploymentsOp::GetEnvironment => ( |
| 189 | json!({ |
| 190 | "repo": repo, |
| 191 | "environment": { "type": "string", "description": "The environment's name, such as production." }, |
| 192 | }), |
| 193 | &["repo", "environment"], |
| 194 | ), |
| 195 | DeploymentsOp::GetDeploymentSettings => (json!({ "workspace": workspace, "project": project }), &["workspace", "project"]), |
| 196 | DeploymentsOp::UpdateDeploymentSettings => ( |
| 197 | json!({ |
| 198 | "workspace": workspace, |
| 199 | "project": project, |
| 200 | "enabled": { "type": "boolean", "description": "Turn Deployments on (production builds at once; needs the g1t plan) or off (every app comes down)." }, |
| 201 | "production": { "type": "boolean", "description": "Deploy the default branch to production on every push." }, |
| 202 | "previews": { "type": "boolean", "description": "A preview for every branch with an open pull request." }, |
| 203 | "build_as": { "type": "string", "enum": BUILD_AS, "description": "What it is built as; auto finds it from its files." }, |
| 204 | "build_command": { "type": ["string", "null"], "description": "Runs instead of the project's own build script; null or \"\" for its own." }, |
| 205 | "output_dir": { "type": ["string", "null"], "description": "What a static site serves; null or \"\" to find it by itself." }, |
| 206 | "dockerfile": { "type": ["string", "null"], "description": "For a container: its Dockerfile's path inside the project; null or \"\" for Dockerfile or Containerfile." }, |
| 207 | "instance_type": { "type": "string", "enum": INSTANCE_TYPES, "description": "For a container: the size it runs at." }, |
| 208 | "idle_days": { "type": "integer", "minimum": 1, "maximum": 90, "description": "A preview no one visits for this many days comes down." }, |
| 209 | "via_workflow": { "type": "boolean", "description": "Build through the deploy workflow g1t manages for the project, listed under Actions." }, |
| 210 | }), |
| 211 | &["workspace", "project"], |
| 212 | ), |
| 213 | }; |
| 214 | json!({ "type": "object", "properties": properties, "required": required }) |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | /// The fields of `input` named in `keys` that were given, as they were. |
| 219 | fn given(input: &Value, keys: &[&str]) -> Map<String, Value> { |
| 220 | keys.iter() |
| 221 | .filter_map(|key| input.get(*key).filter(|value| !value.is_null()).map(|value| ((*key).to_owned(), value.clone()))) |
| 222 | .collect() |
| 223 | } |
| 224 | |
| 225 | /// A query parameter's number, given as a number or as text. |
| 226 | fn number(input: &Value, key: &str) -> Option<u64> { |
| 227 | match &input[key] { |
| 228 | Value::Number(number) => number.as_u64(), |
| 229 | Value::String(digits) => digits.trim().parse().ok(), |
| 230 | _ => None, |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | /// A flag given as a boolean or as text. |
| 235 | fn flag(input: &Value, key: &str) -> Option<bool> { |
| 236 | match &input[key] { |
| 237 | Value::Bool(value) => Some(*value), |
| 238 | Value::String(text) => match text.trim() { |
| 239 | "true" | "1" => Some(true), |
| 240 | "false" | "0" => Some(false), |
| 241 | _ => None, |
| 242 | }, |
| 243 | _ => None, |
| 244 | } |
| 245 | } |
| 246 | |
| 247 | /// The arguments the deployments service takes for `op`, from the |
| 248 | /// operation's input; `Err` says what is missing or wrong. |
| 249 | pub(crate) fn args(op: DeploymentsOp, input: &Value) -> std::result::Result<(&'static str, Map<String, Value>), String> { |
| 250 | let mut out = Map::new(); |
| 251 | let repo = repo_path(input).ok_or("Give the repository as \"owner/name\".")?; |
| 252 | out.insert("repo".into(), json!({ "namespace": repo.namespace, "name": repo.name })); |
| 253 | 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."); |
| 254 | if let Some(state) = input["state"].as_str() |
| 255 | && !STATES.contains(&state) |
| 256 | { |
| 257 | return Err(format!("{state} is not a state: use queued, in_progress, success, failure, error or inactive.")); |
| 258 | } |
| 259 | let method = match op { |
| 260 | DeploymentsOp::ListDeployments => { |
| 261 | out.extend(given(input, &["environment", "ref", "sha", "task", "state", "source", "creator"])); |
| 262 | if let Some(page) = number(input, "page") { |
| 263 | out.insert("page".into(), page.into()); |
| 264 | } |
| 265 | if let Some(per_page) = number(input, "per_page") { |
| 266 | out.insert("per_page".into(), per_page.into()); |
| 267 | } |
| 268 | "list_deployments" |
| 269 | } |
| 270 | DeploymentsOp::GetDeployment => { |
| 271 | out.insert("id".into(), id()?.into()); |
| 272 | "get_deployment" |
| 273 | } |
| 274 | DeploymentsOp::ListDeploymentStatuses => { |
| 275 | out.insert("id".into(), id()?.into()); |
| 276 | "list_deployment_statuses" |
| 277 | } |
| 278 | DeploymentsOp::CreateDeployment => { |
| 279 | if input["ref"].as_str().is_none_or(|text| text.trim().is_empty()) && input["sha"].as_str().is_none() { |
| 280 | return Err("Give the ref deployed: a branch, a tag or a commit.".to_owned()); |
| 281 | } |
| 282 | out.extend(given( |
| 283 | input, |
| 284 | &["ref", "sha", "environment", "task", "description", "payload", "state", "environment_url", "log_url"], |
| 285 | )); |
| 286 | for key in ["production_environment", "transient_environment"] { |
| 287 | if let Some(value) = flag(input, key) { |
| 288 | out.insert(key.into(), value.into()); |
| 289 | } |
| 290 | } |
| 291 | "create_deployment" |
| 292 | } |
| 293 | DeploymentsOp::CreateDeploymentStatus => { |
| 294 | out.insert("id".into(), id()?.into()); |
| 295 | if input["state"].as_str().is_none() { |
| 296 | return Err("Give the status's state: queued, in_progress, success, failure, error or inactive.".to_owned()); |
| 297 | } |
| 298 | out.extend(given(input, &["state", "description", "environment_url", "log_url"])); |
| 299 | if let Some(value) = flag(input, "auto_inactive") { |
| 300 | out.insert("auto_inactive".into(), value.into()); |
| 301 | } |
| 302 | "create_deployment_status" |
| 303 | } |
| 304 | DeploymentsOp::ListEnvironments => "list_environments", |
| 305 | // The deployments service checks the job's token itself: the |
| 306 | // caller (the job's workspace, with `token.job`) is the actor. |
| 307 | DeploymentsOp::StartDeploymentBuild => { |
| 308 | for key in ["project", "sha"] { |
| 309 | if let Some(value) = input[key].as_str().map(str::trim).filter(|value| !value.is_empty()) { |
| 310 | out.insert(key.into(), value.into()); |
| 311 | } |
| 312 | } |
| 313 | "start_from_workflow" |
| 314 | } |
| 315 | DeploymentsOp::GetEnvironment => { |
| 316 | let name = input["environment"].as_str().map(str::trim).filter(|name| !name.is_empty()).ok_or("Name the environment.")?; |
| 317 | out.insert("name".into(), name.into()); |
| 318 | "get_environment" |
| 319 | } |
| 320 | // Named by workspace and project: see `settings_args`. |
| 321 | DeploymentsOp::GetDeploymentSettings | DeploymentsOp::UpdateDeploymentSettings => { |
| 322 | return Err("Name the workspace and the project.".to_owned()); |
| 323 | } |
| 324 | }; |
| 325 | Ok((method, out)) |
| 326 | } |
| 327 | |
| 328 | /// The project a settings operation names, as the deployments service |
| 329 | /// takes it. |
| 330 | fn project_ref(input: &Value) -> std::result::Result<Value, String> { |
| 331 | let slug = |key: &str| input[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_lowercase); |
| 332 | let workspace = slug("workspace").ok_or("Give the workspace's slug.")?; |
| 333 | let project = slug("project").ok_or("Give the project's slug.")?; |
| 334 | Ok(json!({ "workspace": workspace, "slug": project })) |
| 335 | } |
| 336 | |
| 337 | /// The change an update asks for, in the deployments service's spelling: |
| 338 | /// only what it gives. `Err` says what is wrong. |
| 339 | pub(crate) fn settings_changes(input: &Value) -> std::result::Result<Map<String, Value>, String> { |
| 340 | let mut out = Map::new(); |
| 341 | for (key, field) in [("enabled", "enabled"), ("production", "production"), ("previews", "previews"), ("via_workflow", "viaWorkflow")] { |
| 342 | match input.get(key) { |
| 343 | None | Some(Value::Null) => {} |
| 344 | Some(_) => { |
| 345 | let value = flag(input, key).ok_or(format!("{key} must be true or false."))?; |
| 346 | out.insert(field.into(), value.into()); |
| 347 | } |
| 348 | } |
| 349 | } |
| 350 | // Text that "" or null puts back to what is found by itself. |
| 351 | for (key, field) in [("build_command", "buildCommand"), ("output_dir", "outputDir"), ("dockerfile", "dockerfile")] { |
| 352 | match input.get(key) { |
| 353 | None => {} |
| 354 | Some(Value::Null) => { |
| 355 | out.insert(field.into(), Value::Null); |
| 356 | } |
| 357 | Some(Value::String(text)) if text.trim().chars().count() > 500 => { |
| 358 | return Err(format!("{key} is at most 500 characters.")); |
| 359 | } |
| 360 | Some(Value::String(text)) if text.trim().is_empty() => { |
| 361 | out.insert(field.into(), Value::Null); |
| 362 | } |
| 363 | Some(Value::String(text)) => { |
| 364 | out.insert(field.into(), text.trim().into()); |
| 365 | } |
| 366 | Some(_) => return Err(format!("{key} must be a string or null.")), |
| 367 | } |
| 368 | } |
| 369 | let word = |key: &str, allowed: &[&str]| -> std::result::Result<Option<String>, String> { |
| 370 | match input.get(key) { |
| 371 | None | Some(Value::Null) => Ok(None), |
| 372 | Some(Value::String(text)) if allowed.contains(&text.trim()) => Ok(Some(text.trim().to_owned())), |
| 373 | Some(_) => Err(format!("{key} must be one of {}.", allowed.join(", "))), |
| 374 | } |
| 375 | }; |
| 376 | if let Some(build_as) = word("build_as", &BUILD_AS)? { |
| 377 | out.insert("buildAs".into(), build_as.into()); |
| 378 | } |
| 379 | if let Some(instance_type) = word("instance_type", &INSTANCE_TYPES)? { |
| 380 | out.insert("instanceType".into(), instance_type.into()); |
| 381 | } |
| 382 | match input.get("idle_days") { |
| 383 | None | Some(Value::Null) => {} |
| 384 | Some(_) => match number(input, "idle_days") { |
| 385 | Some(days @ 1..=90) => { |
| 386 | out.insert("idleDays".into(), days.into()); |
| 387 | } |
| 388 | _ => return Err("idle_days must be a whole number from 1 to 90.".to_owned()), |
| 389 | }, |
| 390 | } |
| 391 | Ok(out) |
| 392 | } |
| 393 | |
| 394 | /// A project's deployment settings as the deployments service answers |
| 395 | /// (`camelCase`), as the API shows them. |
| 396 | pub(crate) fn settings_json(settings: &Value, project: &Value) -> Value { |
| 397 | json!({ |
| 398 | "workspace": project["workspace"], |
| 399 | "project": project["slug"], |
| 400 | "enabled": settings["enabled"].as_bool().unwrap_or(false), |
| 401 | "production": settings["production"].as_bool().unwrap_or(true), |
| 402 | "previews": settings["previews"].as_bool().unwrap_or(true), |
| 403 | "production_url": settings["productionUrl"], |
| 404 | "primary_domain": settings["primaryDomain"], |
| 405 | "build_as": settings["buildAs"].as_str().unwrap_or("auto"), |
| 406 | "build_command": settings["buildCommand"], |
| 407 | "output_dir": settings["outputDir"], |
| 408 | "dockerfile": settings["dockerfile"], |
| 409 | "instance_type": settings["instanceType"].as_str().unwrap_or(INSTANCE_TYPES[0]), |
| 410 | "idle_days": settings["idleDays"], |
| 411 | "via_workflow": settings["viaWorkflow"].as_bool().unwrap_or(false), |
| 412 | "detected": settings["detected"], |
| 413 | "framework": settings["framework"], |
| 414 | }) |
| 415 | } |
| 416 | |
| 417 | /// Reads or changes a project's deployment settings. |
| 418 | async fn run_settings(op: DeploymentsOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { |
| 419 | let project = match project_ref(input) { |
| 420 | Ok(project) => project, |
| 421 | Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)), |
| 422 | }; |
| 423 | let answered: Outcome<Value> = if op == DeploymentsOp::GetDeploymentSettings { |
| 424 | g1t_kit::call(&services.deployments, "settings", &json!({ "project": project, "viewer": viewer })).await? |
| 425 | } else { |
| 426 | let Some(actor) = viewer else { |
| 427 | return Ok(Outcome::fail(FailureCode::Unauthenticated, "Changing deployment settings needs a g1t access token.")); |
| 428 | }; |
| 429 | let changes = match settings_changes(input) { |
| 430 | Ok(changes) => changes, |
| 431 | Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)), |
| 432 | }; |
| 433 | g1t_kit::call(&services.deployments, "update_settings", &json!({ "actor": actor, "project": project, "changes": changes })).await? |
| 434 | }; |
| 435 | Ok(match answered { |
| 436 | Outcome::Ok(settings) => Outcome::Ok(settings_json(&settings, &project)), |
| 437 | Outcome::Fail(failure) => Outcome::Fail(failure), |
| 438 | }) |
| 439 | } |
| 440 | |
| 441 | /// For create_repo with `deploy`: turns Deployments on for the new |
| 442 | /// repository's own project (named as the repository is), as the site's |
| 443 | /// new-repository card does, as the person who made it. Never fails the |
| 444 | /// creation: what it answers is `deployments` (the settings) or |
| 445 | /// `deploy_error` (why they are not on: no g1t plan, a role too low). |
| 446 | pub(crate) async fn turn_on_for_new_repo(services: &Services, actor: &Value, namespace: &str, name: &str) -> (Value, Value) { |
| 447 | let project = json!({ "workspace": namespace.to_lowercase(), "slug": name.to_lowercase() }); |
| 448 | let asked = json!({ "actor": actor, "project": project, "changes": { "enabled": true } }); |
| 449 | let answered: Result<Outcome<Value>> = g1t_kit::call(&services.deployments, "update_settings", &asked).await; |
| 450 | match answered { |
| 451 | Ok(Outcome::Ok(settings)) => (settings_json(&settings, &project), Value::Null), |
| 452 | Ok(Outcome::Fail(failure)) => (Value::Null, failure.message.into()), |
| 453 | Err(error) => { |
| 454 | worker::console_error!("created {namespace}/{name} without deployments: {error}"); |
| 455 | ( |
| 456 | Value::Null, |
| 457 | "Deployments could not be turned on. Try again with update_deployment_settings, or from the project's Settings → Deployments.".into(), |
| 458 | ) |
| 459 | } |
| 460 | } |
| 461 | } |
| 462 | |
| 463 | pub async fn run(op: DeploymentsOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { |
| 464 | if !op.needs_repo() { |
| 465 | return run_settings(op, services, viewer, input).await; |
| 466 | } |
| 467 | let (method, mut args) = match args(op, input) { |
| 468 | Ok(found) => found, |
| 469 | Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)), |
| 470 | }; |
| 471 | if op.writes() { |
| 472 | let Some(actor) = viewer else { |
| 473 | return Ok(Outcome::fail(FailureCode::Unauthenticated, "Reporting a deployment needs a g1t access token.")); |
| 474 | }; |
| 475 | args.insert("actor".into(), serde_json::to_value(actor)?); |
| 476 | } else { |
| 477 | args.insert("viewer".into(), serde_json::to_value(viewer)?); |
| 478 | } |
| 479 | let answered: Outcome<Value> = g1t_kit::call(&services.deployments, method, &Value::Object(args)).await?; |
| 480 | // Environments carry their protection rules, kept by the actions service. |
| 481 | match op { |
| 482 | DeploymentsOp::ListEnvironments => crate::protection::with_protection(services, viewer, input, answered, None).await, |
| 483 | DeploymentsOp::GetEnvironment => { |
| 484 | let name = input["environment"].as_str().unwrap_or_default().trim().to_owned(); |
| 485 | crate::protection::with_protection(services, viewer, input, answered, Some(&name)).await |
| 486 | } |
| 487 | _ => Ok(answered), |
| 488 | } |
| 489 | } |
| 490 | |
| 491 | #[cfg(test)] |
| 492 | mod tests { |
| 493 | use super::*; |
| 494 | |
| 495 | #[test] |
| 496 | fn a_request_becomes_the_services_arguments() { |
| 497 | let (method, args) = super::args( |
| 498 | DeploymentsOp::CreateDeployment, |
| 499 | &json!({ "repo": "acme/web", "ref": "main", "environment": "staging", "payload": { "buildId": 7 }, "production_environment": "false", "ignored": 1 }), |
| 500 | ) |
| 501 | .unwrap(); |
| 502 | assert_eq!(method, "create_deployment"); |
| 503 | assert_eq!(args["repo"], json!({ "namespace": "acme", "name": "web" })); |
| 504 | assert_eq!(args["payload"], json!({ "buildId": 7 }), "a payload passes through as given"); |
| 505 | assert_eq!(args["production_environment"], json!(false)); |
| 506 | assert!(!args.contains_key("ignored")); |
| 507 | let (method, args) = super::args(DeploymentsOp::ListDeployments, &json!({ "repo": "acme/web", "page": "2", "state": "failure" })).unwrap(); |
| 508 | assert_eq!(method, "list_deployments"); |
| 509 | assert_eq!(args["page"], json!(2)); |
| 510 | assert!(super::args(DeploymentsOp::ListDeployments, &json!({ "repo": "acme/web", "state": "done" })).is_err()); |
| 511 | assert!(super::args(DeploymentsOp::CreateDeployment, &json!({ "repo": "acme/web" })).is_err()); |
| 512 | assert!(super::args(DeploymentsOp::CreateDeploymentStatus, &json!({ "repo": "acme/web", "id": "dep_1" })).is_err()); |
| 513 | let (method, args) = super::args(DeploymentsOp::GetEnvironment, &json!({ "repo": "acme/web", "environment": "review/x" })).unwrap(); |
| 514 | assert_eq!((method, args["name"].clone()), ("get_environment", json!("review/x"))); |
| 515 | let (method, args) = super::args(DeploymentsOp::StartDeploymentBuild, &json!({ "repo": "acme/web", "project": " web ", "sha": "" })).unwrap(); |
| 516 | assert_eq!(method, "start_from_workflow"); |
| 517 | assert_eq!(args["project"], json!("web")); |
| 518 | assert!(!args.contains_key("sha"), "an empty sha is the run's"); |
| 519 | assert!(DeploymentsOp::StartDeploymentBuild.writes()); |
| 520 | } |
| 521 | |
| 522 | #[test] |
| 523 | fn each_operation_is_described_with_a_schema() { |
| 524 | for op in DeploymentsOp::ALL { |
| 525 | assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name()); |
| 526 | let required = op.input()["required"].as_array().unwrap().clone(); |
| 527 | if op.needs_repo() { |
| 528 | assert!(required.contains(&json!("repo")), "{}", op.name()); |
| 529 | } else { |
| 530 | assert_eq!(required, vec![json!("workspace"), json!("project")], "{}", op.name()); |
| 531 | } |
| 532 | } |
| 533 | assert!(DeploymentsOp::UpdateDeploymentSettings.writes()); |
| 534 | assert!(!DeploymentsOp::GetDeploymentSettings.writes()); |
| 535 | } |
| 536 | |
| 537 | #[test] |
| 538 | fn a_settings_change_is_only_what_is_given_in_the_service_s_spelling() { |
| 539 | let sent = settings_changes(&json!({ |
| 540 | "workspace": "flagon-io", "project": "web", "enabled": true, "previews": "false", |
| 541 | "build_command": " npm run build ", "output_dir": "", "dockerfile": null, |
| 542 | "build_as": "static", "instance_type": "basic", "idle_days": "14", "ignored": 1, |
| 543 | })) |
| 544 | .unwrap(); |
| 545 | assert_eq!( |
| 546 | Value::Object(sent), |
| 547 | json!({ |
| 548 | "enabled": true, "previews": false, "buildCommand": "npm run build", "outputDir": null, |
| 549 | "dockerfile": null, "buildAs": "static", "instanceType": "basic", "idleDays": 14, |
| 550 | }) |
| 551 | ); |
| 552 | assert!(settings_changes(&json!({})).unwrap().is_empty()); |
| 553 | assert_eq!(Value::Object(settings_changes(&json!({ "enabled": false })).unwrap()), json!({ "enabled": false })); |
| 554 | for bad in [ |
| 555 | json!({ "enabled": "yes" }), |
| 556 | json!({ "build_as": "container" }), |
| 557 | json!({ "instance_type": "huge" }), |
| 558 | json!({ "idle_days": 0 }), |
| 559 | json!({ "idle_days": 91 }), |
| 560 | json!({ "build_command": 3 }), |
| 561 | json!({ "output_dir": "x".repeat(501) }), |
| 562 | ] { |
| 563 | assert!(settings_changes(&bad).is_err(), "{bad}"); |
| 564 | } |
| 565 | assert!(project_ref(&json!({ "workspace": "acme" })).is_err()); |
| 566 | assert_eq!(project_ref(&json!({ "workspace": " Acme ", "project": "Web" })).unwrap(), json!({ "workspace": "acme", "slug": "web" })); |
| 567 | } |
| 568 | |
| 569 | #[test] |
| 570 | fn settings_are_snake_case() { |
| 571 | let shown = settings_json( |
| 572 | &json!({ |
| 573 | "enabled": true, "previews": true, "production": true, "buildCommand": null, "outputDir": "dist", |
| 574 | "idleDays": 7, "viaWorkflow": false, "productionUrl": "https://web-acme.g1t.page", "primaryDomain": null, |
| 575 | "detected": "static", "framework": "astro", "buildAs": "auto", "dockerfile": null, "instanceType": "lite", |
| 576 | "containers": { "available": true, "sleepMinutes": 5, "prices": [] }, |
| 577 | }), |
| 578 | &json!({ "workspace": "acme", "slug": "web" }), |
| 579 | ); |
| 580 | assert!(g1t_kit::wire::camel_case_keys(&shown).is_empty(), "{:?}", g1t_kit::wire::camel_case_keys(&shown)); |
| 581 | assert_eq!(shown["production_url"], "https://web-acme.g1t.page"); |
| 582 | assert_eq!(shown["output_dir"], "dist"); |
| 583 | assert_eq!((shown["workspace"].clone(), shown["project"].clone()), (json!("acme"), json!("web"))); |
| 584 | assert!(shown.get("containers").is_none()); |
| 585 | } |
| 586 | } |