Mirrors in work, Actions, deployments and the inbox
A standby mirror takes no issues, pull requests or agents (its settings, rules and commit checks stay open), runs no workflows unless CI is kept warm, and deploys nothing. In CI failover and during a takeover Actions reads .github/workflows too, g1t's own winning a name; a workflow that deploys waits for approval, and a push copied in that changes workflows waits before it may use secrets. mirror.* events reach webhooks and, when a link asks for it, the owners' inbox.
9 files+357−450/9 viewed
| 10 | 10 | ||
| 11 | 11 | /// Where workflows live: GitHub's `.github/workflows`, under g1t's own | |
| 12 | 12 | /// folder, so moving a repository to g1t is renaming `.github` to `.g1t`. | |
| 13 | − | /// g1t never reads `.github`, which stays GitHub's. | |
| 13 | + | /// g1t reads `.github` only for a mirror of a GitHub repository in CI | |
| 14 | + | /// failover or taken over (services/actions/src/mirrored.rs); otherwise it | |
| 15 | + | /// stays GitHub's. | |
| 14 | 16 | pub const FOLDER: &str = ".g1t/workflows"; | |
| 15 | 17 | ||
| 16 | 18 | /// The events a workflow can name that g1t starts runs for. |
| 117 | 117 | "vulnerability_alert.fixed", | |
| 118 | 118 | "vulnerability_alert.dismissed", | |
| 119 | 119 | "vulnerability_alert.reopened", | |
| 120 | + | "mirror.unreachable", | |
| 121 | + | "mirror.reachable", | |
| 122 | + | "mirror.state_changed", | |
| 123 | + | "mirror.moved_in", | |
| 120 | 124 | ]; | |
| 121 | 125 | ||
| 122 | 126 | /// What a webhook belongs to. |
| 110 | 110 | "vulnerability_alert.fixed", | |
| 111 | 111 | "vulnerability_alert.dismissed", | |
| 112 | 112 | "vulnerability_alert.reopened", | |
| 113 | + | "mirror.unreachable", | |
| 114 | + | "mirror.reachable", | |
| 115 | + | "mirror.state_changed", | |
| 116 | + | "mirror.moved_in", | |
| 113 | 117 | ] as const; | |
| 114 | 118 | ||
| 115 | 119 | export type Hook = { |
| 24 | 24 | ||
| 25 | 25 | mod artifacts; | |
| 26 | 26 | mod cache; | |
| 27 | + | mod mirrored; | |
| 27 | 28 | mod payload; | |
| 28 | 29 | mod plan; | |
| 29 | 30 | mod protection; |
| 1 | + | //! What runs on a mirror (see `g1t_contracts::mirrors`). | |
| 2 | + | //! | |
| 3 | + | //! A mirror standing by runs nothing: its workflows run where it is | |
| 4 | + | //! mirrored from. Its owners can keep CI warm, which runs `.g1t/workflows` | |
| 5 | + | //! on the pushes copied in. In CI failover the remote keeps the code and | |
| 6 | + | //! g1t runs its workflows, GitHub's `.github/workflows` as well as g1t's | |
| 7 | + | //! own; during a takeover it does too, unless the link says not to. A | |
| 8 | + | //! workflow that deploys waits for approval in both, unless the link says | |
| 9 | + | //! otherwise, so nothing deploys from two places. While a takeover is | |
| 10 | + | //! handed back, nothing starts. | |
| 11 | + | ||
| 12 | + | use g1t_actions::workflow::Workflow; | |
| 13 | + | use g1t_contracts::mirrors::{MirrorState, RepoMirror}; | |
| 14 | + | ||
| 15 | + | /// GitHub's own folder, read on a mirror in CI failover or taken over. | |
| 16 | + | pub const GITHUB_FOLDER: &str = ".github/workflows"; | |
| 17 | + | ||
| 18 | + | #[derive(Clone, Debug, Default, PartialEq, Eq)] | |
| 19 | + | pub struct Policy { | |
| 20 | + | /// Whether workflows start at all. | |
| 21 | + | pub runs: bool, | |
| 22 | + | /// Whether `.github/workflows` is read as well as `.g1t/workflows`. | |
| 23 | + | pub github: bool, | |
| 24 | + | /// Set when a workflow that deploys waits for approval: the remote, as | |
| 25 | + | /// people name it. | |
| 26 | + | pub hold: Option<String>, | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// What runs on a repository with `mirror`, for an event that was a push | |
| 30 | + | /// copied in from the remote (`copied_in`) or anything else. | |
| 31 | + | pub fn policy(mirror: Option<&RepoMirror>, copied_in: bool) -> Policy { | |
| 32 | + | let Some(mirror) = mirror else { | |
| 33 | + | return Policy { runs: true, github: false, hold: None }; | |
| 34 | + | }; | |
| 35 | + | let hold = mirror.hold_deploys.then(|| mirror.remote.clone()); | |
| 36 | + | match mirror.state { | |
| 37 | + | MirrorState::Standby => Policy { runs: copied_in && mirror.warm, github: false, hold: None }, | |
| 38 | + | MirrorState::Ci => Policy { runs: true, github: true, hold }, | |
| 39 | + | MirrorState::Takeover => Policy { runs: true, github: mirror.github_workflows, hold }, | |
| 40 | + | MirrorState::HandingBack => Policy::default(), | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// Why nothing runs, for someone who asked for a run. | |
| 45 | + | pub fn refused(namespace: &str, name: &str, mirror: &RepoMirror) -> String { | |
| 46 | + | match mirror.state { | |
| 47 | + | MirrorState::HandingBack => format!("{namespace}/{name} is handing back to {}. Workflows start again once that is done.", mirror.remote), | |
| 48 | + | _ => format!( | |
| 49 | + | "{namespace}/{name} is a standby mirror of {remote}: its workflows run there. Start CI failover or take over in Settings → Mirroring to run them on g1t.", | |
| 50 | + | remote = mirror.remote | |
| 51 | + | ), | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// The environment a workflow deploys to, if any job names one. | |
| 56 | + | pub fn deploys_to(workflow: &Workflow) -> Option<String> { | |
| 57 | + | workflow.jobs.iter().find_map(|job| match &job.raw["environment"] { | |
| 58 | + | serde_json::Value::String(name) => Some(name.clone()), | |
| 59 | + | serde_json::Value::Object(environment) => { | |
| 60 | + | Some(environment.get("name").and_then(|name| name.as_str()).unwrap_or("an environment").to_owned()) | |
| 61 | + | } | |
| 62 | + | _ => None, | |
| 63 | + | }) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /// Why a deploying workflow waits. | |
| 67 | + | pub fn held(remote: &str, environment: &str) -> String { | |
| 68 | + | format!( | |
| 69 | + | "This workflow deploys to {environment}. While {remote} may still deploy too, runs that deploy wait for approval so nothing deploys twice." | |
| 70 | + | ) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /// Whether a change touches what runs: workflows or local actions. | |
| 74 | + | pub fn touches_workflows(paths: &[String]) -> bool { | |
| 75 | + | paths.iter().any(|path| path.starts_with(".g1t/") || path.starts_with(".github/workflows/") || path.starts_with(".github/actions/")) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// Why a run from a push copied in waits. | |
| 79 | + | pub fn copied_workflows(remote: &str) -> String { | |
| 80 | + | format!( | |
| 81 | + | "This push came from {remote} and changes workflows. Approve the run to let it use this repository's secrets and variables." | |
| 82 | + | ) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /// Drops `.github` workflows that a `.g1t` one of the same name stands in | |
| 86 | + | /// for: g1t's own wins. | |
| 87 | + | pub fn prefer_g1t(files: Vec<crate::sync::WorkflowFile>) -> Vec<crate::sync::WorkflowFile> { | |
| 88 | + | let name = |file: &crate::sync::WorkflowFile| { | |
| 89 | + | g1t_actions::workflow::parse(&file.source) | |
| 90 | + | .map(|workflow| workflow.display_name(&file.path)) | |
| 91 | + | .unwrap_or_else(|_| file.path.clone()) | |
| 92 | + | }; | |
| 93 | + | let ours: Vec<String> = files.iter().filter(|file| !file.path.starts_with(GITHUB_FOLDER)).map(name).collect(); | |
| 94 | + | files | |
| 95 | + | .into_iter() | |
| 96 | + | .filter(|file| !file.path.starts_with(GITHUB_FOLDER) || !ours.contains(&name(file))) | |
| 97 | + | .collect() | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | #[cfg(test)] | |
| 101 | + | mod tests { | |
| 102 | + | use super::*; | |
| 103 | + | ||
| 104 | + | fn mirror(state: MirrorState) -> RepoMirror { | |
| 105 | + | RepoMirror { | |
| 106 | + | state, | |
| 107 | + | remote: "github.com/acme/web".into(), | |
| 108 | + | warm: false, | |
| 109 | + | github_workflows: true, | |
| 110 | + | hold_deploys: true, | |
| 111 | + | ..RepoMirror::default() | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | #[test] | |
| 116 | + | fn a_standby_mirror_runs_nothing_unless_kept_warm() { | |
| 117 | + | assert_eq!(policy(None, false), Policy { runs: true, github: false, hold: None }); | |
| 118 | + | assert!(!policy(Some(&mirror(MirrorState::Standby)), true).runs); | |
| 119 | + | let warm = RepoMirror { warm: true, ..mirror(MirrorState::Standby) }; | |
| 120 | + | assert_eq!(policy(Some(&warm), true), Policy { runs: true, github: false, hold: None }); | |
| 121 | + | assert!(!policy(Some(&warm), false).runs, "only the pushes copied in"); | |
| 122 | + | assert!(!policy(Some(&mirror(MirrorState::HandingBack)), true).runs); | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | #[test] | |
| 126 | + | fn ci_failover_and_takeover_run_githubs_workflows_and_hold_deploys() { | |
| 127 | + | let ci = policy(Some(&mirror(MirrorState::Ci)), true); | |
| 128 | + | assert!(ci.runs && ci.github); | |
| 129 | + | assert_eq!(ci.hold.as_deref(), Some("github.com/acme/web")); | |
| 130 | + | let quiet = RepoMirror { github_workflows: false, hold_deploys: false, ..mirror(MirrorState::Takeover) }; | |
| 131 | + | assert_eq!(policy(Some(&quiet), false), Policy { runs: true, github: false, hold: None }); | |
| 132 | + | assert!(refused("acme", "web", &mirror(MirrorState::Standby)).contains("standby mirror of github.com/acme/web")); | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | #[test] | |
| 136 | + | fn a_workflow_deploys_when_a_job_names_an_environment() { | |
| 137 | + | let parse = |text: &str| g1t_actions::workflow::parse(text).unwrap(); | |
| 138 | + | let build = parse("on: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n"); | |
| 139 | + | assert_eq!(deploys_to(&build), None); | |
| 140 | + | let deploy = parse("on: push\njobs:\n ship:\n runs-on: ubuntu-latest\n environment: production\n steps:\n - run: echo\n"); | |
| 141 | + | assert_eq!(deploys_to(&deploy).as_deref(), Some("production")); | |
| 142 | + | let named = parse( | |
| 143 | + | "on: push\njobs:\n ship:\n runs-on: ubuntu-latest\n environment:\n name: staging\n url: https://x\n steps:\n - run: echo\n", | |
| 144 | + | ); | |
| 145 | + | assert_eq!(deploys_to(&named).as_deref(), Some("staging")); | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | #[test] | |
| 149 | + | fn a_copied_push_that_changes_workflows_waits() { | |
| 150 | + | let paths = |list: &[&str]| list.iter().map(|path| path.to_string()).collect::<Vec<_>>(); | |
| 151 | + | assert!(touches_workflows(&paths(&["src/a.rs", ".g1t/workflows/ci.yml"]))); | |
| 152 | + | assert!(touches_workflows(&paths(&[".github/workflows/deploy.yml"]))); | |
| 153 | + | assert!(touches_workflows(&paths(&[".github/actions/setup/action.yml"]))); | |
| 154 | + | assert!(!touches_workflows(&paths(&[".github/CODEOWNERS", "README.md"]))); | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | #[test] | |
| 158 | + | fn g1ts_own_workflow_stands_in_for_githubs_of_the_same_name() { | |
| 159 | + | let file = |path: &str, name: &str| crate::sync::WorkflowFile { | |
| 160 | + | path: path.into(), | |
| 161 | + | source: format!("name: {name}\non: push\njobs:\n a:\n runs-on: x\n steps:\n - run: echo\n"), | |
| 162 | + | }; | |
| 163 | + | let kept = prefer_g1t(vec![ | |
| 164 | + | file(".g1t/workflows/ci.yml", "CI"), | |
| 165 | + | file(".github/workflows/ci.yml", "CI"), | |
| 166 | + | file(".github/workflows/lint.yml", "Lint"), | |
| 167 | + | ]); | |
| 168 | + | let paths: Vec<&str> = kept.iter().map(|file| file.path.as_str()).collect(); | |
| 169 | + | assert_eq!(paths, [".g1t/workflows/ci.yml", ".github/workflows/lint.yml"]); | |
| 170 | + | } | |
| 171 | + | } |
| 57 | 57 | /// The workflow files of `path` as of `git_ref` (the default branch | |
| 58 | 58 | /// when absent). | |
| 59 | 59 | pub async fn read_workflows(&self, path: &RepoPath, actor: &User, git_ref: Option<&str>) -> Result<Read> { | |
| 60 | − | let viewer = Some(actor.clone()); | |
| 61 | − | let tree: Outcome<TreeView> = g1t_kit::call( | |
| 62 | − | &self.repos, | |
| 63 | − | "tree", | |
| 64 | − | &TreeArgs { | |
| 65 | − | path: path.clone(), | |
| 66 | − | viewer: viewer.clone(), | |
| 67 | − | git_ref: git_ref.map(str::to_owned), | |
| 68 | − | tree_path: FOLDER.to_owned(), | |
| 69 | − | }, | |
| 70 | − | ) | |
| 71 | − | .await?; | |
| 72 | − | let (entries, head, resolved) = match tree { | |
| 73 | − | Outcome::Ok(tree) => (tree.entries, tree.head.map(|commit| commit.hash), tree.git_ref), | |
| 74 | − | // No folder: no workflows. | |
| 75 | − | Outcome::Fail(_) => return Ok(Read { files: Vec::new(), head: None }), | |
| 60 | + | self.read_folders(path, actor, git_ref, &[FOLDER]).await | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// The workflow files a repository runs: `.g1t/workflows`, and on a | |
| 64 | + | /// mirror in CI failover or taken over, `.github/workflows` too (see | |
| 65 | + | /// mirrored.rs), where g1t's own stands in for one of the same name. | |
| 66 | + | pub async fn read_for(&self, repo: &Repo, actor: &User, git_ref: Option<&str>, github: bool) -> Result<Read> { | |
| 67 | + | let path = RepoPath { | |
| 68 | + | namespace: repo.namespace.clone(), | |
| 69 | + | name: repo.name.clone(), | |
| 76 | 70 | }; | |
| 77 | − | let at = head.clone().unwrap_or(resolved); | |
| 71 | + | if !github { | |
| 72 | + | return self.read_workflows(&path, actor, git_ref).await; | |
| 73 | + | } | |
| 74 | + | let read = self.read_folders(&path, actor, git_ref, &[FOLDER, crate::mirrored::GITHUB_FOLDER]).await?; | |
| 75 | + | Ok(Read { | |
| 76 | + | files: crate::mirrored::prefer_g1t(read.files), | |
| 77 | + | head: read.head, | |
| 78 | + | }) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | async fn read_folders(&self, path: &RepoPath, actor: &User, git_ref: Option<&str>, folders: &[&str]) -> Result<Read> { | |
| 82 | + | let viewer = Some(actor.clone()); | |
| 78 | 83 | let mut files = Vec::new(); | |
| 79 | − | for entry in entries | |
| 80 | − | .into_iter() | |
| 81 | − | .filter(|entry| matches!(entry.kind, EntryKind::Blob | EntryKind::Exec)) | |
| 82 | − | .filter(|entry| entry.name.ends_with(".yml") || entry.name.ends_with(".yaml")) | |
| 83 | − | .take(MAX_WORKFLOWS) | |
| 84 | − | { | |
| 85 | − | let file_path = format!("{FOLDER}/{}", entry.name); | |
| 86 | − | let blob: Outcome<BlobView> = g1t_kit::call( | |
| 84 | + | let mut found_head = None; | |
| 85 | + | for folder in folders { | |
| 86 | + | let tree: Outcome<TreeView> = g1t_kit::call( | |
| 87 | 87 | &self.repos, | |
| 88 | − | "blob", | |
| 89 | − | &BlobArgs { | |
| 88 | + | "tree", | |
| 89 | + | &TreeArgs { | |
| 90 | 90 | path: path.clone(), | |
| 91 | 91 | viewer: viewer.clone(), | |
| 92 | − | git_ref: at.clone(), | |
| 93 | − | file_path: file_path.clone(), | |
| 92 | + | git_ref: git_ref.map(str::to_owned), | |
| 93 | + | tree_path: (*folder).to_owned(), | |
| 94 | 94 | }, | |
| 95 | 95 | ) | |
| 96 | 96 | .await?; | |
| 97 | − | if let Outcome::Ok(BlobView { text: Some(source), .. }) = blob { | |
| 98 | − | files.push(WorkflowFile { path: file_path, source }); | |
| 97 | + | let (entries, head, resolved) = match tree { | |
| 98 | + | Outcome::Ok(tree) => (tree.entries, tree.head.map(|commit| commit.hash), tree.git_ref), | |
| 99 | + | // No folder: no workflows from it. | |
| 100 | + | Outcome::Fail(_) => continue, | |
| 101 | + | }; | |
| 102 | + | let at = head.clone().unwrap_or(resolved); | |
| 103 | + | found_head = found_head.or(head); | |
| 104 | + | for entry in entries | |
| 105 | + | .into_iter() | |
| 106 | + | .filter(|entry| matches!(entry.kind, EntryKind::Blob | EntryKind::Exec)) | |
| 107 | + | .filter(|entry| entry.name.ends_with(".yml") || entry.name.ends_with(".yaml")) | |
| 108 | + | .take(MAX_WORKFLOWS.saturating_sub(files.len())) | |
| 109 | + | { | |
| 110 | + | let file_path = format!("{folder}/{}", entry.name); | |
| 111 | + | let blob: Outcome<BlobView> = g1t_kit::call( | |
| 112 | + | &self.repos, | |
| 113 | + | "blob", | |
| 114 | + | &BlobArgs { | |
| 115 | + | path: path.clone(), | |
| 116 | + | viewer: viewer.clone(), | |
| 117 | + | git_ref: at.clone(), | |
| 118 | + | file_path: file_path.clone(), | |
| 119 | + | }, | |
| 120 | + | ) | |
| 121 | + | .await?; | |
| 122 | + | if let Outcome::Ok(BlobView { text: Some(source), .. }) = blob { | |
| 123 | + | files.push(WorkflowFile { path: file_path, source }); | |
| 124 | + | } | |
| 99 | 125 | } | |
| 100 | 126 | } | |
| 101 | − | Ok(Read { files, head }) | |
| 127 | + | Ok(Read { files, head: found_head }) | |
| 102 | 128 | } | |
| 103 | 129 | ||
| 104 | 130 | /// Keeps the `workflows` table in step with the default branch. | |
| 105 | 131 | pub async fn sync(&self, repo: &Repo, actor: &User) -> Result<()> { | |
| 106 | − | let path = RepoPath { | |
| 107 | − | namespace: repo.namespace.clone(), | |
| 108 | − | name: repo.name.clone(), | |
| 109 | − | }; | |
| 110 | − | let read = self.read_workflows(&path, actor, None).await?; | |
| 132 | + | let github = crate::mirrored::policy(repo.mirror.as_ref(), false).github; | |
| 133 | + | let read = self.read_for(repo, actor, None, github).await?; | |
| 111 | 134 | let full_name = format!("{}/{}", repo.namespace, repo.name); | |
| 112 | 135 | let now = rfc3339(now_ms()); | |
| 113 | 136 | let mut statements = Vec::new(); |
| 526 | 526 | if pushed_default { | |
| 527 | 527 | self.sync(&repo, &ws).await?; | |
| 528 | 528 | } | |
| 529 | + | // A mirror runs only what its state says (mirrored.rs). | |
| 530 | + | let copied_in = event.kind == "git.push" && event.data["mirrored"].as_bool() == Some(true); | |
| 531 | + | let policy = crate::mirrored::policy(repo.mirror.as_ref(), copied_in); | |
| 532 | + | if !policy.runs { | |
| 533 | + | return Ok(()); | |
| 534 | + | } | |
| 529 | 535 | // What a workflow job's own token did starts no workflows, as on | |
| 530 | 536 | // GitHub, so a workflow cannot set itself off; only | |
| 531 | 537 | // `workflow_dispatch` and `repository_dispatch` do. | |
| ⋯ | |||
| 548 | 554 | let Some(mut subject) = self.subject(event, event_name, action, &repo, &ws, &sender).await? else { | |
| 549 | 555 | continue; | |
| 550 | 556 | }; | |
| 551 | − | let read = self.read_workflows(&subject.source, &ws, subject.source_ref.as_deref()).await?; | |
| 557 | + | // A change to workflows made on the remote, by someone g1t | |
| 558 | + | // knows nothing of, waits before it may use this repository's | |
| 559 | + | // secrets. | |
| 560 | + | if copied_in && matches!(event_name, "push" | "create") { | |
| 561 | + | if subject.paths.is_none() { | |
| 562 | + | let (base, head) = subject.compare.clone().unwrap_or((None, subject.sha.clone())); | |
| 563 | + | subject.paths = Some(self.changed_paths(&repo, &ws, base, head).await?); | |
| 564 | + | } | |
| 565 | + | if crate::mirrored::touches_workflows(subject.paths.as_deref().unwrap_or_default()) | |
| 566 | + | && let Some(mirror) = &repo.mirror | |
| 567 | + | { | |
| 568 | + | subject.approval = subject.approval.take().or_else(|| Some(crate::mirrored::copied_workflows(&mirror.remote))); | |
| 569 | + | } | |
| 570 | + | } | |
| 571 | + | // A pull request from a fork reads the fork's files. | |
| 572 | + | let read = if subject.source == Self::repo_path(&repo) { | |
| 573 | + | self.read_for(&repo, &ws, subject.source_ref.as_deref(), policy.github).await? | |
| 574 | + | } else { | |
| 575 | + | self.read_workflows(&subject.source, &ws, subject.source_ref.as_deref()).await? | |
| 576 | + | }; | |
| 552 | 577 | // A pull request's head runs each workflow once, however many | |
| 553 | 578 | // events say it is there (marked ready, and pushed). | |
| 554 | 579 | let key = match subject.pull { | |
| ⋯ | |||
| 611 | 636 | if self.disabled(&repo.id, &file.path).await? { | |
| 612 | 637 | continue; | |
| 613 | 638 | } | |
| 639 | + | // On a mirror the remote may deploy too: a workflow that deploys | |
| 640 | + | // waits for approval (mirrored.rs). | |
| 641 | + | let held = crate::mirrored::policy(repo.mirror.as_ref(), false) | |
| 642 | + | .hold | |
| 643 | + | .zip(crate::mirrored::deploys_to(&workflow)) | |
| 644 | + | .map(|(remote, environment)| crate::mirrored::held(&remote, &environment)); | |
| 614 | 645 | self.create_run(NewRun { | |
| 615 | 646 | repo: repo.clone(), | |
| 616 | 647 | path: file.path, | |
| ⋯ | |||
| 625 | 656 | actor_id: actor_id.map(str::to_owned), | |
| 626 | 657 | actor: Some(sender.to_owned()), | |
| 627 | 658 | trusted: subject.trusted, | |
| 628 | − | approval: subject.approval.clone(), | |
| 659 | + | approval: subject.approval.clone().or(held), | |
| 629 | 660 | }) | |
| 630 | 661 | .await?; | |
| 631 | 662 | } | |
| ⋯ | |||
| 731 | 762 | continue; | |
| 732 | 763 | }; | |
| 733 | 764 | let Ok(workflow) = workflow::parse(&row.source) else { continue }; | |
| 734 | − | // Schedules wait while a repository is archived; a deleted one is not found. | |
| 735 | − | let Some((repo, _ws)) = self.repo_by_id(&row.repo_id).await?.filter(|(repo, _)| !repo.archived()) else { continue }; | |
| 765 | + | // Schedules wait while a repository is archived, or a mirror runs | |
| 766 | + | // nothing; a deleted one is not found. | |
| 767 | + | let Some((repo, _ws)) = self | |
| 768 | + | .repo_by_id(&row.repo_id) | |
| 769 | + | .await? | |
| 770 | + | .filter(|(repo, _)| !repo.archived() && crate::mirrored::policy(repo.mirror.as_ref(), false).runs) | |
| 771 | + | else { | |
| 772 | + | continue; | |
| 773 | + | }; | |
| 736 | 774 | let Some(sha) = self.default_head(&repo).await? else { continue }; | |
| 737 | 775 | let payload = json!({ "schedule": cron, "repository": payload::repository(&repo), "workflow": row.path }); | |
| 738 | 776 | let mut subject = Subject { | |
| ⋯ | |||
| 802 | 840 | format!("refs/{}/{git_ref}", if is_branch { "heads" } else { "tags" }) | |
| 803 | 841 | }; | |
| 804 | 842 | let short = full_ref.trim_start_matches("refs/heads/").trim_start_matches("refs/tags/").to_owned(); | |
| 805 | − | let read = self.read_workflows(&Self::repo_path(&repo), &ws, Some(&short)).await?; | |
| 843 | + | let policy = crate::mirrored::policy(repo.mirror.as_ref(), false); | |
| 844 | + | if let Some(mirror) = repo.mirror.as_ref().filter(|_| !policy.runs) { | |
| 845 | + | return Ok(fail(FailureCode::Forbidden, crate::mirrored::refused(&repo.namespace, &repo.name, mirror))); | |
| 846 | + | } | |
| 847 | + | let read = self.read_for(&repo, &ws, Some(&short), policy.github).await?; | |
| 806 | 848 | let Some(sha) = read.head.clone() else { | |
| 807 | 849 | return Ok(fail(FailureCode::NotFound, format!("There is no branch or tag called {short}."))); | |
| 808 | 850 | }; | |
| ⋯ | |||
| 906 | 948 | let Some(ws) = self.workspace_actor(&repo.namespace).await? else { | |
| 907 | 949 | return Ok(fail(FailureCode::NotFound, "There is no such workspace.")); | |
| 908 | 950 | }; | |
| 909 | − | let read = self.read_workflows(&Self::repo_path(&repo), &ws, Some(&repo.default_branch)).await?; | |
| 951 | + | let policy = crate::mirrored::policy(repo.mirror.as_ref(), false); | |
| 952 | + | if let Some(mirror) = repo.mirror.as_ref().filter(|_| !policy.runs) { | |
| 953 | + | return Ok(fail(FailureCode::Forbidden, crate::mirrored::refused(&repo.namespace, &repo.name, mirror))); | |
| 954 | + | } | |
| 955 | + | let read = self.read_for(&repo, &ws, Some(&repo.default_branch), policy.github).await?; | |
| 910 | 956 | let Some(sha) = read.head.clone() else { | |
| 911 | 957 | return Ok(fail(FailureCode::NotFound, "The repository has no default branch to run on yet.")); | |
| 912 | 958 | }; | |
| 44 | 44 | fail, | |
| 45 | 45 | identityClient, | |
| 46 | 46 | isProtectedWorkspace, | |
| 47 | + | mirrorWritable, | |
| 47 | 48 | newId, | |
| 48 | 49 | ok, | |
| 49 | 50 | openD1, | |
| ⋯ | |||
| 1404 | 1405 | break; | |
| 1405 | 1406 | case "git.push": | |
| 1406 | 1407 | if (!event.data.defaultBranch) break; | |
| 1408 | + | // A mirror deploys only while g1t leads it: standing by, or in CI | |
| 1409 | + | // failover, the remote's own deploys stand (see contracts mirrors). | |
| 1410 | + | if (!mirrorWritable(event.data.mirror)) break; | |
| 1407 | 1411 | for (const project of await this.projects.byRepo(event.data.repoId)) { | |
| 1408 | 1412 | await this.deployProduction(project, event.data.after, event.actor ?? "g1t"); | |
| 1409 | 1413 | } | |
| 148 | 148 | "comment.created" => on(Some(number("number")?), Some(text("commentId")?)), | |
| 149 | 149 | // Security alerts are threads of their own, not of an issue. | |
| 150 | 150 | kind if SECURITY_EVENTS.contains(&kind) => on(None, None), | |
| 151 | + | // So is a repository's mirror, told to whom its settings name. | |
| 152 | + | kind if MIRROR_EVENTS.contains(&kind) => on(None, None), | |
| 151 | 153 | _ => None, | |
| 152 | 154 | } | |
| 153 | 155 | } | |
| ⋯ | |||
| 163 | 165 | "secret_scanning.bypass_reviewed", | |
| 164 | 166 | ]; | |
| 165 | 167 | ||
| 168 | + | /// The integrations service's mirroring events (see | |
| 169 | + | /// `g1t_contracts::mirrors::MirrorEvent`). People are told only when the | |
| 170 | + | /// link's settings ask for the inbox, and the event names them. | |
| 171 | + | pub const MIRROR_EVENTS: [&str; 4] = ["mirror.unreachable", "mirror.reachable", "mirror.state_changed", "mirror.moved_in"]; | |
| 172 | + | ||
| 166 | 173 | /// Where an event's items go: which thread, what it is, and where it is. | |
| 167 | 174 | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 168 | 175 | pub struct Thread { | |
| ⋯ | |||
| 221 | 228 | link: None, | |
| 222 | 229 | } | |
| 223 | 230 | } | |
| 231 | + | // A repository's mirror: one thread, its settings page the link. | |
| 232 | + | kind if MIRROR_EVENTS.contains(&kind) => Thread { | |
| 233 | + | key: format!("{}/mirror", wanted.repo_id), | |
| 234 | + | kind: None, | |
| 235 | + | number: None, | |
| 236 | + | run_id: None, | |
| 237 | + | link: text("link"), | |
| 238 | + | }, | |
| 224 | 239 | // One alert, or one bypass request: its page is the link. | |
| 225 | 240 | kind if SECURITY_EVENTS.contains(&kind) => { | |
| 226 | 241 | let which = text("requestId").or_else(|| text("alertId")).unwrap_or_else(|| event.id.clone()); | |
| ⋯ | |||
| 396 | 411 | | "deployment.failed" | |
| 397 | 412 | | "deployment.succeeded" | |
| 398 | 413 | | "deployment.review_requested" | |
| 399 | − | ) || SECURITY_EVENTS.contains(&event.kind.as_str()); | |
| 414 | + | ) || SECURITY_EVENTS.contains(&event.kind.as_str()) | |
| 415 | + | || MIRROR_EVENTS.contains(&event.kind.as_str()); | |
| 400 | 416 | let mut told = Told { | |
| 401 | 417 | actor: if outcome { &nobody } else { actor }, | |
| 402 | 418 | audience, | |
| ⋯ | |||
| 574 | 590 | told.tell(&name, Reason::ReviewRequested, Severity::Warning, &title, body, true); | |
| 575 | 591 | } | |
| 576 | 592 | } | |
| 593 | + | (kind, None) if MIRROR_EVENTS.contains(&kind) => { | |
| 594 | + | let severity = match kind { | |
| 595 | + | "mirror.unreachable" => Severity::Warning, | |
| 596 | + | "mirror.state_changed" if data["state"].as_str() == Some("takeover") => Severity::Warning, | |
| 597 | + | "mirror.state_changed" if data["state"].as_str() == Some("standby") => Severity::Success, | |
| 598 | + | _ => Severity::Info, | |
| 599 | + | }; | |
| 600 | + | let title = data["title"].as_str().unwrap_or("A mirror changed"); | |
| 601 | + | let body = data["detail"].as_str().unwrap_or_default(); | |
| 602 | + | for name in names(data, "notify") { | |
| 603 | + | told.tell(&name, Reason::StateChange, severity, title, body, true); | |
| 604 | + | } | |
| 605 | + | } | |
| 577 | 606 | (kind, None) if SECURITY_EVENTS.contains(&kind) => { | |
| 578 | 607 | let severity = match data["severity"].as_str() { | |
| 579 | 608 | _ if kind == "secret_scanning.bypass_requested" => Severity::Warning, | |
| ⋯ | |||
| 2030 | 2059 | } | |
| 2031 | 2060 | ||
| 2032 | 2061 | #[test] | |
| 2062 | + | fn a_mirror_tells_only_the_people_its_settings_name() { | |
| 2063 | + | let down = event( | |
| 2064 | + | "mirror.unreachable", | |
| 2065 | + | None, | |
| 2066 | + | json!({ | |
| 2067 | + | "repoId": "rep_1", "repo": "acme/rocket", "remoteId": "rmt_1", "remote": "github.com/acme/rocket", | |
| 2068 | + | "state": "standby", "title": "github.com/acme/rocket is not answering. acme/rocket keeps its copy.", | |
| 2069 | + | "detail": "Take over acme/rocket to keep working on g1t until it is back.", | |
| 2070 | + | "notify": ["ana"], "link": "/acme/rocket/settings/mirroring" | |
| 2071 | + | }), | |
| 2072 | + | ); | |
| 2073 | + | assert_eq!(wants(&down), Some(Wanted { repo_id: "rep_1".into(), number: None, comment_id: None })); | |
| 2074 | + | // Watchers of everything hear nothing: only those named. | |
| 2075 | + | let audience = watched(&[("eve", WatchLevel::All, &[])]); | |
| 2076 | + | let notices_ = notices(&down, "acme/rocket", &Actor::default(), None, &audience); | |
| 2077 | + | assert_eq!(told(¬ices_), vec![("ana", Reason::StateChange, Severity::Warning)]); | |
| 2078 | + | assert!(notices_[0].body.contains("Take over")); | |
| 2079 | + | let thread = thread_of(&down, &wants(&down).unwrap(), None); | |
| 2080 | + | assert_eq!(thread.key, "rep_1/mirror"); | |
| 2081 | + | assert_eq!(url(Some("acme/rocket"), thread.kind, None, None, thread.link.as_deref()), "/acme/rocket/settings/mirroring"); | |
| 2082 | + | // The banner-only default names nobody, so nobody is told. | |
| 2083 | + | let quiet = event("mirror.unreachable", None, json!({ "repoId": "rep_1", "title": "x", "link": "/x" })); | |
| 2084 | + | assert!(notices(&quiet, "acme/rocket", &Actor::default(), None, &audience).is_empty()); | |
| 2085 | + | let back = event("mirror.state_changed", None, json!({ "repoId": "rep_1", "state": "standby", "title": "handed back", "notify": ["ana"], "link": "/x" })); | |
| 2086 | + | assert_eq!(told(¬ices(&back, "acme/rocket", &Actor::default(), None, &audience)), vec![("ana", Reason::StateChange, Severity::Success)]); | |
| 2087 | + | } | |
| 2088 | + | ||
| 2089 | + | #[test] | |
| 2033 | 2090 | fn security_alerts_tell_the_pusher_the_owners_and_member_watchers() { | |
| 2034 | 2091 | let blocked = event( | |
| 2035 | 2092 | "secret_scanning_alert.created", | |