| 1 | //! Managed workflows (`g1t_actions::managed`): workflows g1t's services |
| 2 | //! install into a repository without committing them. They are kept here, |
| 3 | //! read beside the repository's own files wherever its workflows are read |
| 4 | //! (`sync.rs` `read_for`), and so listed, started, run and re-run as any |
| 5 | //! other workflow is. |
| 6 | |
| 7 | use g1t_actions::managed; |
| 8 | use g1t_actions::workflow; |
| 9 | use g1t_contracts::actions::{RemoveManagedWorkflowArgs, SetManagedDeployArgs, SetManagedWorkflowArgs}; |
| 10 | use g1t_contracts::time::rfc3339; |
| 11 | use g1t_contracts::{FailureCode, Outcome}; |
| 12 | use g1t_kit::now_ms; |
| 13 | use serde::Deserialize; |
| 14 | use worker::Result; |
| 15 | |
| 16 | use crate::sync::WorkflowFile; |
| 17 | use crate::{Actions, fail}; |
| 18 | |
| 19 | /// Whether `name` can end a managed workflow's path. |
| 20 | fn valid_name(name: &str) -> bool { |
| 21 | !name.is_empty() && name.len() <= 64 && name.bytes().all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') |
| 22 | } |
| 23 | |
| 24 | #[derive(Deserialize)] |
| 25 | struct Row { |
| 26 | name: String, |
| 27 | source: String, |
| 28 | } |
| 29 | |
| 30 | impl Actions { |
| 31 | /// `set_managed_workflow`. |
| 32 | pub async fn set_managed_workflow(&self, a: SetManagedWorkflowArgs) -> Result<Outcome<bool>> { |
| 33 | if !valid_name(&a.name) { |
| 34 | return Ok(fail(FailureCode::Invalid, "A managed workflow's name is lowercase letters, digits and hyphens.")); |
| 35 | } |
| 36 | if let Err(problem) = workflow::parse(&a.source) { |
| 37 | return Ok(fail(FailureCode::Invalid, format!("The managed workflow does not read as a workflow: {problem}"))); |
| 38 | } |
| 39 | let Some((repo, ws)) = self.repo_by_id(&a.repo_id).await? else { |
| 40 | return Ok(fail(FailureCode::NotFound, "No such repository.")); |
| 41 | }; |
| 42 | self.db |
| 43 | .prepare( |
| 44 | "INSERT INTO managed_workflows (repo_id, name, source, origin, updated_at) VALUES (?, ?, ?, ?, ?) |
| 45 | ON CONFLICT (repo_id, name) DO UPDATE SET source = excluded.source, origin = excluded.origin, updated_at = excluded.updated_at", |
| 46 | ) |
| 47 | .bind(&[a.repo_id.as_str().into(), a.name.as_str().into(), a.source.as_str().into(), a.origin.as_str().into(), rfc3339(now_ms()).into()])? |
| 48 | .run() |
| 49 | .await?; |
| 50 | // Listed, and on schedule, at once. |
| 51 | self.sync(&repo, &ws).await?; |
| 52 | Ok(Outcome::Ok(true)) |
| 53 | } |
| 54 | |
| 55 | /// `remove_managed_workflow`. |
| 56 | pub async fn remove_managed_workflow(&self, a: RemoveManagedWorkflowArgs) -> Result<Outcome<bool>> { |
| 57 | let removed = self |
| 58 | .db |
| 59 | .prepare("DELETE FROM managed_workflows WHERE repo_id = ? AND name = ? RETURNING name") |
| 60 | .bind(&[a.repo_id.as_str().into(), a.name.as_str().into()])? |
| 61 | .first::<serde_json::Value>(None) |
| 62 | .await? |
| 63 | .is_some(); |
| 64 | if removed && let Some((repo, ws)) = self.repo_by_id(&a.repo_id).await? { |
| 65 | self.sync(&repo, &ws).await?; |
| 66 | } |
| 67 | Ok(Outcome::Ok(removed)) |
| 68 | } |
| 69 | |
| 70 | /// `set_managed_deploy`. |
| 71 | pub async fn set_managed_deploy(&self, a: SetManagedDeployArgs) -> Result<Outcome<bool>> { |
| 72 | let name = format!("deploy-{}", a.project); |
| 73 | let settings = managed::DeploySettings { |
| 74 | project: a.project, |
| 75 | production_branch: a.production_branch, |
| 76 | root_dir: a.root_dir, |
| 77 | build_command: a.build_command, |
| 78 | output_dir: a.output_dir, |
| 79 | previews: a.previews, |
| 80 | production: a.production, |
| 81 | }; |
| 82 | match managed::deploy_workflow(&settings) { |
| 83 | Some(source) => self.set_managed_workflow(SetManagedWorkflowArgs { repo_id: a.repo_id, name, source, origin: "deployments".to_owned() }).await, |
| 84 | None => { |
| 85 | self.remove_managed_workflow(RemoveManagedWorkflowArgs { repo_id: a.repo_id, name }).await?; |
| 86 | Ok(Outcome::Ok(false)) |
| 87 | } |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | /// The managed workflows that run beside `files`, a repository's own: |
| 92 | /// each one no file of the same `name:` takes the place of. |
| 93 | pub(crate) async fn managed_beside(&self, repo_id: &str, files: &[WorkflowFile]) -> Result<Vec<WorkflowFile>> { |
| 94 | let rows = self |
| 95 | .db |
| 96 | .prepare("SELECT name, source FROM managed_workflows WHERE repo_id = ? ORDER BY name") |
| 97 | .bind(&[repo_id.into()])? |
| 98 | .all() |
| 99 | .await? |
| 100 | .results::<Row>()?; |
| 101 | if rows.is_empty() { |
| 102 | return Ok(Vec::new()); |
| 103 | } |
| 104 | let installed = rows.into_iter().map(|row| (managed::path(&row.name), row.source)).collect(); |
| 105 | Ok(managed::prefer_repository(files.iter().map(|file| (file.path.as_str(), file.source.as_str())), installed) |
| 106 | .into_iter() |
| 107 | .map(|(path, source)| WorkflowFile { path, source }) |
| 108 | .collect()) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | #[cfg(test)] |
| 113 | mod tests { |
| 114 | use super::*; |
| 115 | |
| 116 | #[test] |
| 117 | fn managed_workflows_are_named_as_files() { |
| 118 | assert!(valid_name("deploy") && valid_name("code-scanning-2")); |
| 119 | for bad in ["", "Deploy", "a/b", "a.yml", "../x", &"a".repeat(65)] { |
| 120 | assert!(!valid_name(bad), "{bad}"); |
| 121 | } |
| 122 | } |
| 123 | } |