Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
586 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//!
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
16use g1t_contracts::{FailureCode, Outcome, Viewer};
17use serde_json::{Map, Value, json};
18use worker::Result;
19
20use crate::operations::{Services, repo_path};
21
22/// One operation on deployments.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub 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.
38const STATES: [&str; 6] = ["queued", "in_progress", "success", "failure", "error", "inactive"];
39
40/// What a project may be built as (`BUILD_AS` in @g1t/contracts).
41const BUILD_AS: [&str; 4] = ["auto", "static", "workers", "docker"];
42
43/// The sizes a container app runs at (`APP_INSTANCES` in @g1t/contracts).
44const INSTANCE_TYPES: [&str; 6] = ["lite", "basic", "standard-1", "standard-2", "standard-3", "standard-4"];
45
46impl 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.
219fn 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.
226fn 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.
235fn 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.
249pub(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.
330fn 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.
339pub(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.
396pub(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.
418async 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).
446pub(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
463pub 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)]
492mod 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}