Skip to content
102 linesCodeBlameRaw
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
7use g1t_actions::managed;
8use g1t_actions::workflow;
9use g1t_contracts::actions::{RemoveManagedWorkflowArgs, SetManagedWorkflowArgs};
10use g1t_contracts::time::rfc3339;
11use g1t_contracts::{FailureCode, Outcome};
12use g1t_kit::now_ms;
13use serde::Deserialize;
14use worker::Result;
15
16use crate::sync::WorkflowFile;
17use crate::{Actions, fail};
18
19/// Whether `name` can end a managed workflow's path.
20fn 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)]
25struct Row {
26 name: String,
27 source: String,
28}
29
30impl 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 /// The managed workflows that run beside `files`, a repository's own:
71 /// each one no file of the same `name:` takes the place of.
72 pub(crate) async fn managed_beside(&self, repo_id: &str, files: &[WorkflowFile]) -> Result<Vec<WorkflowFile>> {
73 let rows = self
74 .db
75 .prepare("SELECT name, source FROM managed_workflows WHERE repo_id = ? ORDER BY name")
76 .bind(&[repo_id.into()])?
77 .all()
78 .await?
79 .results::<Row>()?;
80 if rows.is_empty() {
81 return Ok(Vec::new());
82 }
83 let installed = rows.into_iter().map(|row| (managed::path(&row.name), row.source)).collect();
84 Ok(managed::prefer_repository(files.iter().map(|file| (file.path.as_str(), file.source.as_str())), installed)
85 .into_iter()
86 .map(|(path, source)| WorkflowFile { path, source })
87 .collect())
88 }
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 #[test]
96 fn managed_workflows_are_named_as_files() {
97 assert!(valid_name("deploy") && valid_name("code-scanning-2"));
98 for bad in ["", "Deploy", "a/b", "a.yml", "../x", &"a".repeat(65)] {
99 assert!(!valid_name(bad), "{bad}");
100 }
101 }
102}