| 1 | //! Actions from GitHub, kept on g1t. |
| 2 | //! |
| 3 | //! An action `uses:` names that g1t does not host (reach.rs) comes from |
| 4 | //! GitHub. Before a job fetches it, g1t resolves its ref to a commit, as |
| 5 | //! GitHub's runner does, from the repository's advertisement |
| 6 | //! (`info/refs`, which no API rate limit applies to), and keeps that |
| 7 | //! commit's archive in R2. Every later job that uses the action at that |
| 8 | //! commit downloads it from g1t: faster than GitHub, the same bytes |
| 9 | //! whatever happens to the tag, and there while GitHub is not. |
| 10 | //! |
| 11 | //! - A ref is looked up again once `REF_FRESH_MS` has passed, so a moved |
| 12 | //! tag (`v4` moving to a new release) is followed within minutes. When |
| 13 | //! GitHub does not answer, the commit the ref last named is used, and |
| 14 | //! the job says so. |
| 15 | //! - A commit's archive is the one GitHub serves for it, which never |
| 16 | //! changes. One larger than `ARCHIVE_MAX_BYTES` is not kept: the job |
| 17 | //! fetches it from GitHub itself. |
| 18 | //! - An archive no job has used for `ARCHIVE_UNUSED_DAYS` is deleted by |
| 19 | //! the hourly sweep. Archives are g1t's own, shared by every workspace, |
| 20 | //! and charged to none. |
| 21 | |
| 22 | use futures_util::StreamExt; |
| 23 | use g1t_contracts::time::rfc3339; |
| 24 | use g1t_kit::now_ms; |
| 25 | use serde::Deserialize; |
| 26 | use serde_json::{Value, json}; |
| 27 | use worker::Result; |
| 28 | |
| 29 | use crate::Actions; |
| 30 | use crate::reach::UsesRef; |
| 31 | |
| 32 | /// The largest archive kept: the Worker holds it whole while saving it. |
| 33 | pub(crate) const ARCHIVE_MAX_BYTES: usize = 32 * 1024 * 1024; |
| 34 | /// How long a ref's commit is trusted before GitHub is asked again. |
| 35 | pub(crate) const REF_FRESH_MS: u64 = 5 * 60 * 1000; |
| 36 | /// An archive unused this long is deleted. |
| 37 | pub(crate) const ARCHIVE_UNUSED_DAYS: u64 = 30; |
| 38 | /// An advertisement larger than this is not read: a ref is never worth it. |
| 39 | const ADVERTISEMENT_MAX_BYTES: usize = 8 * 1024 * 1024; |
| 40 | const DAY_MS: u64 = 24 * 60 * 60 * 1000; |
| 41 | |
| 42 | /// Whether `text` is a whole commit id. |
| 43 | pub(crate) fn is_sha(text: &str) -> bool { |
| 44 | text.len() == 40 && text.chars().all(|c| c.is_ascii_hexdigit()) |
| 45 | } |
| 46 | |
| 47 | /// The refs a smart HTTP advertisement (`info/refs?service=git-upload-pack`) |
| 48 | /// lists, as (commit, name). Peeled tags keep their `^{}`. |
| 49 | pub(crate) fn parse_advertisement(bytes: &[u8]) -> Vec<(String, String)> { |
| 50 | let mut refs = Vec::new(); |
| 51 | let mut at = 0; |
| 52 | while at + 4 <= bytes.len() { |
| 53 | let Some(length) = std::str::from_utf8(&bytes[at..at + 4]).ok().and_then(|hex| usize::from_str_radix(hex, 16).ok()) else { |
| 54 | break; |
| 55 | }; |
| 56 | // A flush or delimiter packet. |
| 57 | if length < 4 { |
| 58 | at += 4; |
| 59 | continue; |
| 60 | } |
| 61 | if at + length > bytes.len() { |
| 62 | break; |
| 63 | } |
| 64 | let line = String::from_utf8_lossy(&bytes[at + 4..at + length]); |
| 65 | at += length; |
| 66 | let line = line.trim_end_matches('\n'); |
| 67 | if line.starts_with('#') { |
| 68 | continue; |
| 69 | } |
| 70 | // The first ref carries the capabilities after a NUL. |
| 71 | let line = line.split('\0').next().unwrap_or_default(); |
| 72 | if let Some((sha, name)) = line.split_once(' ') |
| 73 | && is_sha(sha) |
| 74 | { |
| 75 | refs.push((sha.to_ascii_lowercase(), name.to_owned())); |
| 76 | } |
| 77 | } |
| 78 | refs |
| 79 | } |
| 80 | |
| 81 | /// The commit `wanted` names among `refs`, as git reads a short name: a |
| 82 | /// commit id as it is, else a tag, else a branch (`refs/…` names one |
| 83 | /// exactly). A tag's commit is its peeled one. |
| 84 | pub(crate) fn pick(refs: &[(String, String)], wanted: &str) -> Option<String> { |
| 85 | if is_sha(wanted) { |
| 86 | return Some(wanted.to_ascii_lowercase()); |
| 87 | } |
| 88 | let names: Vec<String> = if wanted.starts_with("refs/") { |
| 89 | vec![wanted.to_owned()] |
| 90 | } else { |
| 91 | vec![format!("refs/tags/{wanted}"), format!("refs/heads/{wanted}")] |
| 92 | }; |
| 93 | names.iter().find_map(|name| { |
| 94 | let peeled = format!("{name}^{{}}"); |
| 95 | refs.iter() |
| 96 | .find(|(_, listed)| *listed == peeled) |
| 97 | .or_else(|| refs.iter().find(|(_, listed)| listed == name)) |
| 98 | .map(|(sha, _)| sha.clone()) |
| 99 | }) |
| 100 | } |
| 101 | |
| 102 | /// Where a commit's archive is kept in R2. |
| 103 | pub(crate) fn archive_object(repository: &str, sha: &str) -> String { |
| 104 | format!("action-archives/{}/{sha}.tar.gz", repository.to_ascii_lowercase()) |
| 105 | } |
| 106 | |
| 107 | /// A GET to GitHub, whose body is read up to `max` bytes. None when it |
| 108 | /// does not answer 200, or sends more. |
| 109 | async fn fetch_capped(url: &str, max: usize) -> Option<Vec<u8>> { |
| 110 | let headers = worker::Headers::new(); |
| 111 | // GitHub answers a smart HTTP advertisement to git, which it knows by |
| 112 | // its agent. |
| 113 | headers.set("user-agent", "git/2.47.0 (g1t)").ok()?; |
| 114 | let mut init = worker::RequestInit::new(); |
| 115 | init.with_method(worker::Method::Get).with_headers(headers); |
| 116 | let request = worker::Request::new_with_init(url, &init).ok()?; |
| 117 | let mut response = worker::Fetch::Request(request).send().await.ok()?; |
| 118 | if response.status_code() != 200 { |
| 119 | return None; |
| 120 | } |
| 121 | let mut stream = response.stream().ok()?; |
| 122 | let mut body = Vec::new(); |
| 123 | while let Some(chunk) = stream.next().await { |
| 124 | body.extend_from_slice(&chunk.ok()?); |
| 125 | if body.len() > max { |
| 126 | return None; |
| 127 | } |
| 128 | } |
| 129 | Some(body) |
| 130 | } |
| 131 | |
| 132 | /// The status GitHub answers a GET with, or None when it doesn't answer. |
| 133 | async fn status_of(url: &str) -> Option<u16> { |
| 134 | let headers = worker::Headers::new(); |
| 135 | headers.set("user-agent", "git/2.47.0 (g1t)").ok()?; |
| 136 | let mut init = worker::RequestInit::new(); |
| 137 | init.with_method(worker::Method::Get).with_headers(headers); |
| 138 | let request = worker::Request::new_with_init(url, &init).ok()?; |
| 139 | let response = worker::Fetch::Request(request).send().await.ok()?; |
| 140 | Some(response.status_code()) |
| 141 | } |
| 142 | |
| 143 | /// What GitHub's answer to a repository's advertisement says about it: |
| 144 | /// Some(true) when it publishes the repository, Some(false) when it says |
| 145 | /// there is none it will show (GitHub answers 401 for a repository that |
| 146 | /// doesn't exist as for a private one), None for anything else, such as a |
| 147 | /// rate limit or an error. |
| 148 | pub(crate) fn published_by(status: Option<u16>) -> Option<bool> { |
| 149 | match status? { |
| 150 | 200 => Some(true), |
| 151 | 401 | 404 => Some(false), |
| 152 | _ => None, |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | #[derive(Deserialize)] |
| 157 | struct RefRow { |
| 158 | sha: String, |
| 159 | resolved_at: String, |
| 160 | } |
| 161 | |
| 162 | impl Actions { |
| 163 | /// Where a job gets `used`, an action g1t does not host: |
| 164 | /// `{"source": "github", "sha", "archive", "note"}`. `sha` is the commit |
| 165 | /// its ref names (absent when GitHub does not know it, or never |
| 166 | /// answered), `archive` whether g1t keeps that commit's archive for the |
| 167 | /// job to download, and `note` anything the job should say about it. |
| 168 | pub(crate) async fn github_action(&self, used: &UsesRef) -> Result<Value> { |
| 169 | let repository = used.full_name().to_ascii_lowercase(); |
| 170 | let now = now_ms(); |
| 171 | let (sha, note) = self.resolve_github(&repository, &used.git_ref, now).await?; |
| 172 | let Some(sha) = sha else { |
| 173 | return Ok(json!({ "source": "github", "note": note })); |
| 174 | }; |
| 175 | let archive = self.keep_archive(&repository, &sha, now).await?; |
| 176 | Ok(json!({ "source": "github", "sha": sha, "archive": archive, "note": note })) |
| 177 | } |
| 178 | |
| 179 | /// Whether GitHub publishes `repository` (`owner/repo`): Some(true) when |
| 180 | /// it does, or once named a commit of it for a job; Some(false) when it |
| 181 | /// says it has no such repository; None when it could not say. |
| 182 | pub(crate) async fn github_publishes(&self, repository: &str) -> Result<Option<bool>> { |
| 183 | let repository = repository.to_ascii_lowercase(); |
| 184 | let seen = self |
| 185 | .db |
| 186 | .prepare("SELECT sha FROM action_refs WHERE repository = ? LIMIT 1") |
| 187 | .bind(&[repository.as_str().into()])? |
| 188 | .first::<Value>(None) |
| 189 | .await?; |
| 190 | if seen.is_some() { |
| 191 | return Ok(Some(true)); |
| 192 | } |
| 193 | Ok(published_by(status_of(&format!("https://github.com/{repository}.git/info/refs?service=git-upload-pack")).await)) |
| 194 | } |
| 195 | |
| 196 | /// The commit `git_ref` names in GitHub's `repository`, and a note when |
| 197 | /// it is the one it last named because GitHub did not answer. |
| 198 | async fn resolve_github(&self, repository: &str, git_ref: &str, now: u64) -> Result<(Option<String>, Option<String>)> { |
| 199 | if is_sha(git_ref) { |
| 200 | return Ok((Some(git_ref.to_ascii_lowercase()), None)); |
| 201 | } |
| 202 | let known = self |
| 203 | .db |
| 204 | .prepare("SELECT sha, resolved_at FROM action_refs WHERE repository = ? AND ref = ?") |
| 205 | .bind(&[repository.into(), git_ref.into()])? |
| 206 | .first::<RefRow>(None) |
| 207 | .await?; |
| 208 | if let Some(row) = &known |
| 209 | && row.resolved_at >= rfc3339(now.saturating_sub(REF_FRESH_MS)) |
| 210 | { |
| 211 | return Ok((Some(row.sha.clone()), None)); |
| 212 | } |
| 213 | let url = format!("https://github.com/{repository}.git/info/refs?service=git-upload-pack"); |
| 214 | match fetch_capped(&url, ADVERTISEMENT_MAX_BYTES).await { |
| 215 | Some(advertisement) => { |
| 216 | let Some(sha) = pick(&parse_advertisement(&advertisement), git_ref) else { |
| 217 | return Ok((None, None)); |
| 218 | }; |
| 219 | self.db |
| 220 | .prepare( |
| 221 | "INSERT INTO action_refs (repository, ref, sha, resolved_at) VALUES (?1, ?2, ?3, ?4) |
| 222 | ON CONFLICT (repository, ref) DO UPDATE SET sha = ?3, resolved_at = ?4", |
| 223 | ) |
| 224 | .bind(&[repository.into(), git_ref.into(), sha.as_str().into(), rfc3339(now).into()])? |
| 225 | .run() |
| 226 | .await?; |
| 227 | Ok((Some(sha), None)) |
| 228 | } |
| 229 | None => Ok(match known { |
| 230 | Some(row) => { |
| 231 | let note = format!( |
| 232 | "GitHub did not say which commit {repository}@{git_ref} is; using {}, which it named on {}.", |
| 233 | &row.sha, |
| 234 | row.resolved_at.get(..10).unwrap_or(&row.resolved_at) |
| 235 | ); |
| 236 | (Some(row.sha), Some(note)) |
| 237 | } |
| 238 | None => (None, None), |
| 239 | }), |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | /// Whether g1t keeps the archive of `repository` at `sha`, fetching it |
| 244 | /// from GitHub the first time. |
| 245 | async fn keep_archive(&self, repository: &str, sha: &str, now: u64) -> Result<bool> { |
| 246 | let Some(bucket) = &self.cache else { return Ok(false) }; |
| 247 | #[derive(Deserialize)] |
| 248 | struct Kept { |
| 249 | used_at: String, |
| 250 | } |
| 251 | let kept = self |
| 252 | .db |
| 253 | .prepare("SELECT used_at FROM action_archives WHERE repository = ? AND sha = ?") |
| 254 | .bind(&[repository.into(), sha.into()])? |
| 255 | .first::<Kept>(None) |
| 256 | .await?; |
| 257 | if let Some(kept) = kept { |
| 258 | // Marked used at most once a day: the sweep needs no more. |
| 259 | if kept.used_at < rfc3339(now.saturating_sub(DAY_MS)) { |
| 260 | self.db |
| 261 | .prepare("UPDATE action_archives SET used_at = ? WHERE repository = ? AND sha = ?") |
| 262 | .bind(&[rfc3339(now).into(), repository.into(), sha.into()])? |
| 263 | .run() |
| 264 | .await?; |
| 265 | } |
| 266 | return Ok(true); |
| 267 | } |
| 268 | let url = format!("https://codeload.github.com/{repository}/tar.gz/{sha}"); |
| 269 | let Some(archive) = fetch_capped(&url, ARCHIVE_MAX_BYTES).await else { |
| 270 | return Ok(false); |
| 271 | }; |
| 272 | let object = archive_object(repository, sha); |
| 273 | let size = archive.len(); |
| 274 | if let Err(error) = bucket.put(&object, archive).execute().await { |
| 275 | worker::console_error!("actions: the archive of {repository}@{sha} was not kept: {error}"); |
| 276 | return Ok(false); |
| 277 | } |
| 278 | let at = rfc3339(now); |
| 279 | self.db |
| 280 | .prepare( |
| 281 | "INSERT INTO action_archives (repository, sha, object, size, stored_at, used_at) VALUES (?1, ?2, ?3, ?4, ?5, ?5) |
| 282 | ON CONFLICT (repository, sha) DO UPDATE SET used_at = ?5", |
| 283 | ) |
| 284 | .bind(&[repository.into(), sha.into(), object.into(), (size as f64).into(), at.into()])? |
| 285 | .run() |
| 286 | .await?; |
| 287 | Ok(true) |
| 288 | } |
| 289 | |
| 290 | /// `job_action_archive`: a running job downloads an action's archive |
| 291 | /// g1t keeps, by `report.repository` and `report.sha`. Answers the R2 |
| 292 | /// object to send, `{"object", "size"}`. Any job may: they are public |
| 293 | /// repositories' code. |
| 294 | pub async fn job_action_archive(&self, a: g1t_contracts::actions::JobCallArgs) -> Result<g1t_contracts::Outcome<Value>> { |
| 295 | crate::check!(self.job_for_token(&a).await?); |
| 296 | let repository = a.report["repository"].as_str().unwrap_or_default().to_ascii_lowercase(); |
| 297 | let sha = a.report["sha"].as_str().unwrap_or_default().to_ascii_lowercase(); |
| 298 | if !is_sha(&sha) || crate::reach::parse_uses(&format!("{repository}@{sha}")).is_none() { |
| 299 | return Ok(crate::fail(g1t_contracts::FailureCode::Invalid, "Name the action's repository as owner/repo, and its commit.")); |
| 300 | } |
| 301 | #[derive(Deserialize)] |
| 302 | struct Kept { |
| 303 | object: String, |
| 304 | size: f64, |
| 305 | } |
| 306 | let kept = self |
| 307 | .db |
| 308 | .prepare("SELECT object, size FROM action_archives WHERE repository = ? AND sha = ?") |
| 309 | .bind(&[repository.as_str().into(), sha.as_str().into()])? |
| 310 | .first::<Kept>(None) |
| 311 | .await?; |
| 312 | Ok(match kept { |
| 313 | Some(kept) => g1t_contracts::Outcome::Ok(json!({ "object": kept.object, "size": kept.size })), |
| 314 | None => crate::fail(g1t_contracts::FailureCode::NotFound, format!("g1t does not keep {repository}@{sha}.")), |
| 315 | }) |
| 316 | } |
| 317 | |
| 318 | /// Hourly: archives no job has used for `ARCHIVE_UNUSED_DAYS`, and refs |
| 319 | /// not looked up as long. |
| 320 | pub async fn sweep_action_archives(&self, now: u64) -> Result<()> { |
| 321 | let before = rfc3339(now.saturating_sub(ARCHIVE_UNUSED_DAYS * DAY_MS)); |
| 322 | #[derive(Deserialize)] |
| 323 | struct Gone { |
| 324 | repository: String, |
| 325 | sha: String, |
| 326 | object: String, |
| 327 | } |
| 328 | let gone = self |
| 329 | .db |
| 330 | .prepare("SELECT repository, sha, object FROM action_archives WHERE used_at < ? LIMIT 100") |
| 331 | .bind(&[before.as_str().into()])? |
| 332 | .all() |
| 333 | .await? |
| 334 | .results::<Gone>()?; |
| 335 | if let Some(bucket) = &self.cache |
| 336 | && !gone.is_empty() |
| 337 | { |
| 338 | bucket.delete_multiple(gone.iter().map(|g| g.object.as_str()).collect()).await?; |
| 339 | for archive in &gone { |
| 340 | self.db |
| 341 | .prepare("DELETE FROM action_archives WHERE repository = ? AND sha = ?") |
| 342 | .bind(&[archive.repository.as_str().into(), archive.sha.as_str().into()])? |
| 343 | .run() |
| 344 | .await?; |
| 345 | } |
| 346 | } |
| 347 | self.db.prepare("DELETE FROM action_refs WHERE resolved_at < ?").bind(&[before.into()])?.run().await?; |
| 348 | Ok(()) |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | #[cfg(test)] |
| 353 | mod tests { |
| 354 | use super::*; |
| 355 | |
| 356 | fn pkt(payload: &str) -> Vec<u8> { |
| 357 | let mut out = format!("{:04x}", payload.len() + 4).into_bytes(); |
| 358 | out.extend_from_slice(payload.as_bytes()); |
| 359 | out |
| 360 | } |
| 361 | |
| 362 | const MAIN: &str = "1111111111111111111111111111111111111111"; |
| 363 | const V4_TAG: &str = "2222222222222222222222222222222222222222"; |
| 364 | const V4_COMMIT: &str = "3333333333333333333333333333333333333333"; |
| 365 | const V1_BRANCH: &str = "4444444444444444444444444444444444444444"; |
| 366 | const V1_TAG: &str = "5555555555555555555555555555555555555555"; |
| 367 | |
| 368 | fn advertisement() -> Vec<u8> { |
| 369 | let mut bytes = pkt("# service=git-upload-pack\n"); |
| 370 | bytes.extend_from_slice(b"0000"); |
| 371 | bytes.extend(pkt(&format!("{MAIN} HEAD\0multi_ack thin-pack symref=HEAD:refs/heads/main\n"))); |
| 372 | bytes.extend(pkt(&format!("{MAIN} refs/heads/main\n"))); |
| 373 | bytes.extend(pkt(&format!("{V1_BRANCH} refs/heads/v1\n"))); |
| 374 | bytes.extend(pkt(&format!("{V1_TAG} refs/tags/v1\n"))); |
| 375 | bytes.extend(pkt(&format!("{V4_TAG} refs/tags/v4\n"))); |
| 376 | bytes.extend(pkt(&format!("{V4_COMMIT} refs/tags/v4^{{}}\n"))); |
| 377 | bytes.extend_from_slice(b"0000"); |
| 378 | bytes |
| 379 | } |
| 380 | |
| 381 | #[test] |
| 382 | fn only_an_answer_from_github_says_it_has_no_such_repository() { |
| 383 | assert_eq!(published_by(Some(200)), Some(true)); |
| 384 | assert_eq!(published_by(Some(401)), Some(false)); |
| 385 | assert_eq!(published_by(Some(404)), Some(false)); |
| 386 | // A rate limit, an error or no answer says nothing, so the name stays GitHub's. |
| 387 | assert_eq!(published_by(Some(429)), None); |
| 388 | assert_eq!(published_by(Some(503)), None); |
| 389 | assert_eq!(published_by(None), None); |
| 390 | } |
| 391 | |
| 392 | #[test] |
| 393 | fn an_advertisement_lists_its_refs_capabilities_aside() { |
| 394 | let refs = parse_advertisement(&advertisement()); |
| 395 | assert_eq!(refs.len(), 6); |
| 396 | assert_eq!(refs[0], (MAIN.to_owned(), "HEAD".to_owned())); |
| 397 | assert_eq!(refs[5], (V4_COMMIT.to_owned(), "refs/tags/v4^{}".to_owned())); |
| 398 | // Cut short: what came whole is kept. |
| 399 | let whole = advertisement(); |
| 400 | assert_eq!(parse_advertisement(&whole[..whole.len() - 30]).len(), 5); |
| 401 | assert!(parse_advertisement(b"not git").is_empty()); |
| 402 | } |
| 403 | |
| 404 | #[test] |
| 405 | fn a_ref_is_read_as_git_reads_a_short_name() { |
| 406 | let refs = parse_advertisement(&advertisement()); |
| 407 | // A tag's commit, not the tag object. |
| 408 | assert_eq!(pick(&refs, "v4").as_deref(), Some(V4_COMMIT)); |
| 409 | // A tag before a branch of the same name. |
| 410 | assert_eq!(pick(&refs, "v1").as_deref(), Some(V1_TAG)); |
| 411 | assert_eq!(pick(&refs, "refs/heads/v1").as_deref(), Some(V1_BRANCH)); |
| 412 | assert_eq!(pick(&refs, "main").as_deref(), Some(MAIN)); |
| 413 | // A commit is itself, known or not. |
| 414 | let other = "ABCDEF0123456789ABCDEF0123456789ABCDEF01"; |
| 415 | assert_eq!(pick(&refs, other).as_deref(), Some("abcdef0123456789abcdef0123456789abcdef01")); |
| 416 | assert_eq!(pick(&refs, "v9"), None); |
| 417 | assert_eq!(pick(&refs, "abc123"), None); |
| 418 | } |
| 419 | |
| 420 | #[test] |
| 421 | fn archives_are_kept_by_repository_and_commit() { |
| 422 | assert_eq!(archive_object("Actions/Checkout", V4_COMMIT), format!("action-archives/actions/checkout/{V4_COMMIT}.tar.gz")); |
| 423 | assert!(is_sha(MAIN)); |
| 424 | assert!(!is_sha("v4")); |
| 425 | } |
| 426 | } |