Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook. | 1 | //! Artifacts of workflow runs: which there are, kept here, while the API |
| 2 | //! keeps their bytes in R2 (the ACTIONS_CACHE bucket, under `a/`). | |
| 3 | //! | |
| 4 | //! - A name is one artifact in a run. Uploading a name the run has is | |
| 5 | //! refused unless the upload says `overwrite`, which replaces it. | |
| 6 | //! - One artifact is at most `ARTIFACT_MAX_BYTES`, and a run's artifacts at | |
| 7 | //! most `RUN_ARTIFACTS_MAX_BYTES` together. | |
| 8 | //! - Each is kept for its `retention-days`, or the repository's setting | |
| 9 | //! when it gives none, and never longer than that setting (1 to 90 days, | |
| 10 | //! 14 unless changed). The hourly sweep deletes what has expired, and | |
| 11 | //! uploads left unfinished. | |
| 12 | //! - Ids are numbers, as GitHub's are. | |
| 13 | //! | |
| 14 | //! A sandbox reaches these with its job's token or its runtime token | |
| 15 | //! (runtime.rs); people through the API and the run's page. | |
| 16 | ||
| 17 | use g1t_contracts::access::Capability; | |
| 18 | use g1t_contracts::actions::{ | |
| 19 | ARTIFACT_MAX_BYTES, ARTIFACT_RETENTION_DEFAULT_DAYS, ARTIFACT_RETENTION_MAX_DAYS, Artifact, ArtifactArgs, ArtifactBlob, ArtifactCommitArgs, | |
| 20 | ArtifactList, ArtifactReservation, ArtifactReserveArgs, ArtifactRetention, ArtifactRetentionArgs, ArtifactsArgs, DeleteArtifactArgs, | |
| 21 | JobArtifactsArgs, RUN_ARTIFACTS_MAX_BYTES, | |
| 22 | }; | |
| 23 | use g1t_contracts::time::{parse_rfc3339, rfc3339}; | |
| 24 | use g1t_contracts::{FailureCode, Outcome, new_id}; | |
| 25 | use g1t_kit::now_ms; | |
| 26 | use serde::Deserialize; | |
| 27 | use worker::Result; | |
| 28 | use worker::wasm_bindgen::JsValue; | |
| 29 | ||
| 30 | use crate::runtime::{DOWNLOAD_SECONDS, LINK_SECONDS}; | |
| 31 | use crate::{Actions, check, fail}; | |
| 32 | ||
| 33 | const DAY_MS: u64 = 24 * 60 * 60 * 1000; | |
| 34 | /// An upload not finished after this long is given up. | |
| 35 | const PENDING_MS: u64 = 6 * 60 * 60 * 1000; | |
| 36 | const COLUMNS: &str = "artifacts.*, runs.git_ref AS git_ref, runs.sha AS sha"; | |
| 37 | ||
| 38 | #[derive(Debug, Deserialize)] | |
| 39 | struct Row { | |
| 40 | id: f64, | |
| 41 | repo_id: String, | |
| 42 | run_id: String, | |
| 43 | job_id: String, | |
| 44 | name: String, | |
| 45 | object: String, | |
| 46 | format: String, | |
| 47 | size: f64, | |
| 48 | digest: Option<String>, | |
| 49 | status: String, | |
| 50 | created_at: String, | |
| 51 | updated_at: String, | |
| 52 | expires_at: String, | |
| 53 | #[serde(default)] | |
| 54 | git_ref: Option<String>, | |
| 55 | #[serde(default)] | |
| 56 | sha: Option<String>, | |
| 57 | } | |
| 58 | ||
| 59 | impl Row { | |
| 60 | fn view(&self) -> Artifact { | |
| 61 | Artifact { | |
| 62 | id: self.id as u64, | |
| 63 | name: self.name.clone(), | |
| 64 | size: self.size as u64, | |
| 65 | digest: self.digest.clone(), | |
| 66 | format: self.format.clone(), | |
| 67 | run_id: self.run_id.clone(), | |
| 68 | job_id: self.job_id.clone(), | |
| 69 | repo_id: self.repo_id.clone(), | |
| 70 | expired: self.status == "expired", | |
| 71 | created_at: self.created_at.clone(), | |
| 72 | updated_at: self.updated_at.clone(), | |
| 73 | expires_at: self.expires_at.clone(), | |
| 74 | head_branch: self.git_ref.as_deref().map(|r| r.strip_prefix("refs/heads/").unwrap_or(r).to_owned()), | |
| 75 | head_sha: self.sha.clone(), | |
| 76 | } | |
| 77 | } | |
| 78 | } | |
| 79 | ||
| 80 | /// GitHub's rule for an artifact's name: 1 to 256 characters, none of | |
| 81 | /// `" : < > | * ? \ /` or line breaks. | |
| 82 | pub(crate) fn valid_name(name: &str) -> bool { | |
| 83 | !name.trim().is_empty() && name.chars().count() <= 256 && !name.chars().any(|c| matches!(c, '"' | ':' | '<' | '>' | '|' | '*' | '?' | '\\' | '/' | '\r' | '\n')) | |
| 84 | } | |
| 85 | ||
| 86 | /// The days an artifact is kept: what it asked for (as days, or as a time | |
| 87 | /// to expire), else the repository's `setting`, and never more than the | |
| 88 | /// setting. | |
| 89 | pub(crate) fn retention(asked_days: u32, asked_until_ms: Option<u64>, setting: u32, now: u64) -> u32 { | |
| 90 | let setting = setting.clamp(1, ARTIFACT_RETENTION_MAX_DAYS); | |
| 91 | let days = match asked_until_ms { | |
| 92 | _ if asked_days > 0 => asked_days, | |
| 93 | Some(until) if until > now => (until - now).div_ceil(DAY_MS) as u32, | |
| 94 | _ => setting, | |
| 95 | }; | |
| 96 | days.clamp(1, setting) | |
| 97 | } | |
| 98 | ||
| 99 | /// Where an artifact is in R2: under its repository, by its id. The API's | |
| 100 | /// blobs.rs names it the same way. | |
| 101 | pub(crate) fn object_of(repo_id: &str, id: u64) -> String { | |
| 102 | format!("a/{repo_id}/{id}") | |
| 103 | } | |
| 104 | ||
| 105 | fn number(id: u64) -> JsValue { | |
| 106 | JsValue::from_f64(id as f64) | |
| 107 | } | |
| 108 | ||
| 109 | impl Actions { | |
| 110 | /// How long the repository keeps artifacts. | |
| 111 | pub(crate) async fn retention_setting(&self, repo_id: &str) -> Result<u32> { | |
| 112 | #[derive(Deserialize)] | |
| 113 | struct Setting { | |
| 114 | artifact_retention_days: f64, | |
| 115 | } | |
| 116 | let setting = self | |
| 117 | .db | |
| 118 | .prepare("SELECT artifact_retention_days FROM repo_settings WHERE repo_id = ?") | |
| 119 | .bind(&[repo_id.into()])? | |
| 120 | .first::<Setting>(None) | |
| 121 | .await?; | |
| 122 | Ok(setting.map_or(ARTIFACT_RETENTION_DEFAULT_DAYS, |s| s.artifact_retention_days as u32)) | |
| 123 | } | |
| 124 | ||
| 125 | async fn artifact_rows(&self, filter: &str, values: &[JsValue]) -> Result<Vec<Row>> { | |
| 126 | self.db | |
| 127 | .prepare(format!("SELECT {COLUMNS} FROM artifacts JOIN runs ON runs.id = artifacts.run_id WHERE {filter}")) | |
| 128 | .bind(values)? | |
| 129 | .all() | |
| 130 | .await? | |
| 131 | .results::<Row>() | |
| 132 | } | |
| 133 | ||
| 134 | /// The bytes a run's artifacts hold, `except` one. | |
| 135 | async fn run_bytes(&self, run: &str, except: u64) -> Result<u64> { | |
| 136 | #[derive(Deserialize)] | |
| 137 | struct Sum { | |
| 138 | bytes: Option<f64>, | |
| 139 | } | |
| 140 | let sum = self | |
| 141 | .db | |
| 142 | .prepare("SELECT SUM(size) AS bytes FROM artifacts WHERE run_id = ? AND status <> 'expired' AND id <> ?") | |
| 143 | .bind(&[run.into(), number(except)])? | |
| 144 | .first::<Sum>(None) | |
| 145 | .await?; | |
| 146 | Ok(sum.and_then(|s| s.bytes).unwrap_or(0.0) as u64) | |
| 147 | } | |
| 148 | ||
| 149 | /// Deletes artifacts' objects, then their rows. Objects that cannot be | |
| 150 | /// deleted now are left to the sweep, their rows marked expired. | |
| 151 | async fn forget_artifacts(&self, rows: &[(u64, String)]) -> Result<()> { | |
| 152 | for (id, _) in rows { | |
| 153 | self.db | |
| 154 | .prepare("UPDATE artifacts SET status = 'expired', updated_at = ? WHERE id = ?") | |
| 155 | .bind(&[rfc3339(now_ms()).into(), number(*id)])? | |
| 156 | .run() | |
| 157 | .await?; | |
| 158 | } | |
| 159 | let Some(bucket) = &self.cache else { return Ok(()) }; | |
| 160 | for chunk in rows.chunks(100) { | |
| 161 | if let Err(error) = bucket.delete_multiple(chunk.iter().map(|(_, object)| object.as_str()).collect()).await { | |
| 162 | worker::console_error!("actions: artifact objects not deleted: {error}"); | |
| 163 | return Ok(()); | |
| 164 | } | |
| 165 | for (id, _) in chunk { | |
| 166 | self.db.prepare("DELETE FROM artifacts WHERE id = ?").bind(&[number(*id)])?.run().await?; | |
| 167 | } | |
| 168 | } | |
| 169 | Ok(()) | |
| 170 | } | |
| 171 | ||
| 172 | /// `artifact_reserve`. | |
| 173 | pub async fn artifact_reserve(&self, a: ArtifactReserveArgs) -> Result<Outcome<ArtifactReservation>> { | |
| 174 | let job = check!(self.job_for_credential(&a.job, &a.token).await?); | |
| 175 | if !valid_name(&a.name) { | |
| 176 | return Ok(fail( | |
| 177 | FailureCode::Invalid, | |
| 178 | format!("`{}` is not an artifact name: 1 to 256 characters, none of \" : < > | * ? \\ / or line breaks.", a.name), | |
| 179 | )); | |
| 180 | } | |
| 181 | if a.size > ARTIFACT_MAX_BYTES { | |
| 182 | return Ok(fail( | |
| 183 | FailureCode::Invalid, | |
| 184 | format!("It is {} MB; an artifact is at most {} MB.", a.size / 1_048_576, ARTIFACT_MAX_BYTES / 1_048_576), | |
| 185 | )); | |
| 186 | } | |
| 187 | let held = self.run_bytes(&job.run_id, 0).await?; | |
| 188 | if held + a.size > RUN_ARTIFACTS_MAX_BYTES { | |
| 189 | return Ok(fail( | |
| 190 | FailureCode::Invalid, | |
| 191 | format!("A run's artifacts are at most {} MB together, and this run's hold {} MB.", RUN_ARTIFACTS_MAX_BYTES / 1_048_576, held / 1_048_576), | |
| 192 | )); | |
| 193 | } | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 194 | if let Some(why) = self.storage_refused(&job.namespace, a.size).await? { |
| 195 | return Ok(fail(FailureCode::Limit, why)); | |
| 196 | } | |
| The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook. | 197 | let existing = self |
| 198 | .artifact_rows("artifacts.run_id = ? AND artifacts.name = ? AND artifacts.status <> 'expired'", &[job.run_id.as_str().into(), a.name.as_str().into()]) | |
| 199 | .await?; | |
| 200 | if !existing.is_empty() { | |
| 201 | if !a.overwrite { | |
| 202 | return Ok(fail( | |
| 203 | FailureCode::Conflict, | |
| 204 | format!("This run already has an artifact named {}. Give the upload another name, or `overwrite: true` to replace it.", a.name), | |
| 205 | )); | |
| 206 | } | |
| 207 | let replaced: Vec<(u64, String)> = existing.iter().map(|row| (row.id as u64, row.object.clone())).collect(); | |
| 208 | self.forget_artifacts(&replaced).await?; | |
| 209 | } | |
| 210 | let now = now_ms(); | |
| 211 | let setting = self.retention_setting(&job.repo_id).await?; | |
| 212 | let days = retention(a.retention_days, a.expires_at.as_deref().and_then(parse_rfc3339), setting, now); | |
| 213 | let expires_at = rfc3339(now + u64::from(days) * DAY_MS); | |
| 214 | let format = match a.format.as_deref() { | |
| 215 | Some("tgz") => "tgz", | |
| 216 | _ => "zip", | |
| 217 | }; | |
| 218 | // Named by its id once it has one (`object_of` in the API's blobs.rs); | |
| 219 | // until then, by a placeholder no other row has. | |
| 220 | let placeholder = format!("a/{}/{}", job.repo_id, new_id("art", now)); | |
| 221 | let at = rfc3339(now); | |
| 222 | #[derive(Deserialize)] | |
| 223 | struct Inserted { | |
| 224 | id: f64, | |
| 225 | } | |
| 226 | let inserted = self | |
| 227 | .db | |
| 228 | .prepare( | |
| 229 | "INSERT INTO artifacts (repo_id, namespace, run_id, job_id, name, object, format, size, status, created_at, updated_at, expires_at) | |
| 230 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?9, ?10) | |
| 231 | ON CONFLICT DO NOTHING | |
| 232 | RETURNING id", | |
| 233 | ) | |
| 234 | .bind(&[ | |
| 235 | job.repo_id.as_str().into(), | |
| 236 | job.namespace.as_str().into(), | |
| 237 | job.run_id.as_str().into(), | |
| 238 | job.id.as_str().into(), | |
| 239 | a.name.as_str().into(), | |
| 240 | placeholder.as_str().into(), | |
| 241 | format.into(), | |
| 242 | (a.size as f64).into(), | |
| 243 | at.as_str().into(), | |
| 244 | expires_at.as_str().into(), | |
| 245 | ])? | |
| 246 | .first::<Inserted>(None) | |
| 247 | .await?; | |
| 248 | let Some(inserted) = inserted else { | |
| 249 | return Ok(fail(FailureCode::Conflict, format!("Another job of this run is uploading an artifact named {}.", a.name))); | |
| 250 | }; | |
| 251 | let id = inserted.id as u64; | |
| 252 | let object = object_of(&job.repo_id, id); | |
| 253 | self.db.prepare("UPDATE artifacts SET object = ? WHERE id = ?").bind(&[object.as_str().into(), number(id)])?.run().await?; | |
| 254 | Ok(Outcome::Ok(ArtifactReservation { id, object, retention_days: days, expires_at })) | |
| 255 | } | |
| 256 | ||
| 257 | /// The pending artifact of a job's run named by id or name. | |
| 258 | async fn pending_artifact(&self, run: &str, id: Option<u64>, name: Option<&str>) -> Result<Option<Row>> { | |
| 259 | let rows = match (id, name) { | |
| 260 | (Some(id), _) => self.artifact_rows("artifacts.id = ? AND artifacts.run_id = ? AND artifacts.status = 'pending'", &[number(id), run.into()]).await?, | |
| 261 | (None, Some(name)) => { | |
| 262 | self.artifact_rows("artifacts.name = ? AND artifacts.run_id = ? AND artifacts.status = 'pending'", &[name.into(), run.into()]).await? | |
| 263 | } | |
| 264 | (None, None) => Vec::new(), | |
| 265 | }; | |
| 266 | Ok(rows.into_iter().next()) | |
| 267 | } | |
| 268 | ||
| 269 | /// `artifact_commit`. | |
| 270 | pub async fn artifact_commit(&self, a: ArtifactCommitArgs) -> Result<Outcome<Artifact>> { | |
| 271 | let job = check!(self.job_for_credential(&a.job, &a.token).await?); | |
| 272 | let Some(row) = self.pending_artifact(&job.run_id, a.id, a.name.as_deref()).await? else { | |
| 273 | return Ok(fail(FailureCode::NotFound, "No upload of that artifact is in progress.")); | |
| 274 | }; | |
| 275 | let id = row.id as u64; | |
| 276 | // 0: the size it was measured at as it was stored (the toolkit's | |
| 277 | // uploads), rather than one the job says. | |
| 278 | let size = if a.size > 0 { a.size } else { row.size as u64 }; | |
| 279 | let held = self.run_bytes(&job.run_id, id).await?; | |
| 280 | let refused = if size > ARTIFACT_MAX_BYTES { | |
| 281 | Some(format!("It is {} MB; an artifact is at most {} MB.", size / 1_048_576, ARTIFACT_MAX_BYTES / 1_048_576)) | |
| 282 | } else if held + size > RUN_ARTIFACTS_MAX_BYTES { | |
| 283 | Some(format!("A run's artifacts are at most {} MB together, and this run's hold {} MB.", RUN_ARTIFACTS_MAX_BYTES / 1_048_576, held / 1_048_576)) | |
| 284 | } else { | |
| 285 | None | |
| 286 | }; | |
| 287 | if let Some(message) = refused { | |
| 288 | self.forget_artifacts(&[(id, row.object.clone())]).await?; | |
| 289 | return Ok(fail(FailureCode::Invalid, message)); | |
| 290 | } | |
| 291 | let digest = a.digest.filter(|d| d.starts_with("sha256:") && d.len() == 71); | |
| 292 | self.db | |
| 293 | .prepare("UPDATE artifacts SET status = 'ready', size = ?, digest = ?, updated_at = ? WHERE id = ? AND status = 'pending'") | |
| 294 | .bind(&[(size as f64).into(), crate::optional(digest.as_deref()), rfc3339(now_ms()).into(), number(id)])? | |
| 295 | .run() | |
| 296 | .await?; | |
| 297 | let rows = self.artifact_rows("artifacts.id = ?", &[number(id)]).await?; | |
| 298 | Ok(rows.first().map_or_else(|| fail(FailureCode::NotFound, "No such artifact."), |row| Outcome::Ok(row.view()))) | |
| 299 | } | |
| 300 | ||
| 301 | /// `artifact_abort`. | |
| 302 | pub async fn artifact_abort(&self, a: JobArtifactsArgs) -> Result<Outcome<bool>> { | |
| 303 | let job = check!(self.job_for_credential(&a.job, &a.token).await?); | |
| 304 | if let Some(row) = self.pending_artifact(&job.run_id, a.id, a.name.as_deref()).await? { | |
| 305 | self.forget_artifacts(&[(row.id as u64, row.object)]).await?; | |
| 306 | } | |
| 307 | Ok(Outcome::Ok(true)) | |
| 308 | } | |
| 309 | ||
| 310 | /// The ready artifacts of `run`, which must be in the job's repository. | |
| 311 | async fn readable_by_job(&self, a: &JobArtifactsArgs) -> Result<Outcome<(crate::plan::JobRow, Vec<Row>)>> { | |
| 312 | let job = check!(self.job_for_credential(&a.job, &a.token).await?); | |
| 313 | // An id names one artifact of the repository, whichever run made it. | |
| 314 | if let (Some(id), None) = (a.id, a.run_id.as_deref().filter(|r| !r.is_empty())) { | |
| 315 | let rows = self | |
| 316 | .artifact_rows("artifacts.id = ? AND artifacts.repo_id = ? AND artifacts.status = 'ready'", &[number(id), job.repo_id.as_str().into()]) | |
| 317 | .await?; | |
| 318 | return Ok(Outcome::Ok((job, rows))); | |
| 319 | } | |
| 320 | let run = a.run_id.clone().filter(|r| !r.is_empty()).unwrap_or_else(|| job.run_id.clone()); | |
| 321 | let mut rows = self | |
| 322 | .artifact_rows("artifacts.run_id = ? AND artifacts.repo_id = ? AND artifacts.status = 'ready' ORDER BY artifacts.id", &[run.as_str().into(), job.repo_id.as_str().into()]) | |
| 323 | .await?; | |
| 324 | if rows.is_empty() && run != job.run_id { | |
| 325 | let found = self.run_row(&run).await?; | |
| 326 | if found.is_none_or(|found| found.repo_id != job.repo_id) { | |
| 327 | return Ok(fail(FailureCode::NotFound, format!("This repository has no run {run}."))); | |
| 328 | } | |
| 329 | } | |
| 330 | if let Some(name) = &a.name { | |
| 331 | rows.retain(|row| &row.name == name); | |
| 332 | } | |
| 333 | if let Some(id) = a.id { | |
| 334 | rows.retain(|row| row.id as u64 == id); | |
| 335 | } | |
| 336 | Ok(Outcome::Ok((job, rows))) | |
| 337 | } | |
| 338 | ||
| 339 | /// `job_artifacts`. | |
| 340 | pub async fn job_artifacts(&self, a: JobArtifactsArgs) -> Result<Outcome<Vec<Artifact>>> { | |
| 341 | let (_, rows) = check!(self.readable_by_job(&a).await?); | |
| 342 | Ok(Outcome::Ok(rows.iter().map(Row::view).collect())) | |
| 343 | } | |
| 344 | ||
| 345 | fn blob_of(&self, row: &Row, seconds: u64) -> Outcome<ArtifactBlob> { | |
| 346 | match self.download_token("artifact", &(row.id as u64).to_string(), &row.object, seconds) { | |
| 347 | Some(blob) => Outcome::Ok(ArtifactBlob { artifact: row.view(), object: row.object.clone(), blob }), | |
| 348 | // Without a key there are no download links; the API streams | |
| 349 | // the object itself for those who need only its name. | |
| 350 | None => Outcome::Ok(ArtifactBlob { artifact: row.view(), object: row.object.clone(), blob: String::new() }), | |
| 351 | } | |
| 352 | } | |
| 353 | ||
| 354 | /// `job_artifact`. | |
| 355 | pub async fn job_artifact(&self, a: JobArtifactsArgs) -> Result<Outcome<ArtifactBlob>> { | |
| 356 | let (_, rows) = check!(self.readable_by_job(&a).await?); | |
| 357 | Ok(match rows.last() { | |
| 358 | Some(row) => self.blob_of(row, DOWNLOAD_SECONDS), | |
| 359 | None => fail(FailureCode::NotFound, "No such artifact, or it has expired."), | |
| 360 | }) | |
| 361 | } | |
| 362 | ||
| 363 | /// `job_delete_artifact`. | |
| 364 | pub async fn job_delete_artifact(&self, a: JobArtifactsArgs) -> Result<Outcome<Artifact>> { | |
| 365 | let job = check!(self.job_for_credential(&a.job, &a.token).await?); | |
| 366 | let own = JobArtifactsArgs { run_id: Some(job.run_id.clone()), ..a }; | |
| 367 | let (_, rows) = check!(self.readable_by_job(&own).await?); | |
| 368 | let Some(row) = rows.last() else { | |
| 369 | return Ok(fail(FailureCode::NotFound, "This run has no such artifact.")); | |
| 370 | }; | |
| 371 | self.forget_artifacts(&[(row.id as u64, row.object.clone())]).await?; | |
| 372 | let mut gone = row.view(); | |
| 373 | gone.expired = true; | |
| 374 | Ok(Outcome::Ok(gone)) | |
| 375 | } | |
| 376 | ||
| 377 | /// `artifacts`. | |
| 378 | pub async fn artifacts(&self, a: ArtifactsArgs) -> Result<Outcome<ArtifactList>> { | |
| 379 | let Some(repo) = self.visible_repo(&a.repo, &a.viewer).await? else { | |
| 380 | return Ok(fail(FailureCode::NotFound, "There is no such repository.")); | |
| 381 | }; | |
| 382 | let mut filter = "artifacts.repo_id = ? AND artifacts.status = 'ready'".to_owned(); | |
| 383 | let mut values: Vec<JsValue> = vec![repo.id.as_str().into()]; | |
| 384 | if let Some(run) = a.run.as_deref().filter(|r| !r.is_empty()) { | |
| 385 | if self.run_in(&a.repo, run).await?.into_result().is_err() { | |
| 386 | return Ok(fail(FailureCode::NotFound, "No such run.")); | |
| 387 | } | |
| 388 | filter.push_str(" AND artifacts.run_id = ?"); | |
| 389 | values.push(run.into()); | |
| 390 | } | |
| 391 | if let Some(name) = a.name.as_deref().filter(|n| !n.is_empty()) { | |
| 392 | filter.push_str(" AND artifacts.name = ?"); | |
| 393 | values.push(name.into()); | |
| 394 | } | |
| 395 | let rows = self.artifact_rows(&format!("{filter} ORDER BY artifacts.id DESC"), &values).await?; | |
| 396 | let per_page = a.per_page.unwrap_or(30).clamp(1, 100) as usize; | |
| 397 | let page = a.page.unwrap_or(1).max(1) as usize; | |
| 398 | Ok(Outcome::Ok(ArtifactList { | |
| 399 | total_count: rows.len() as u64, | |
| 400 | artifacts: rows.iter().skip((page - 1) * per_page).take(per_page).map(Row::view).collect(), | |
| 401 | })) | |
| 402 | } | |
| 403 | ||
| 404 | /// One ready artifact of a repository the viewer can see. | |
| 405 | async fn visible_artifact(&self, a: &ArtifactArgs) -> Result<Outcome<Row>> { | |
| 406 | let Some(repo) = self.visible_repo(&a.repo, &a.viewer).await? else { | |
| 407 | return Ok(fail(FailureCode::NotFound, "There is no such repository.")); | |
| 408 | }; | |
| 409 | let rows = match (a.id, a.run.as_deref(), a.name.as_deref()) { | |
| 410 | (Some(id), _, _) => self.artifact_rows("artifacts.id = ? AND artifacts.repo_id = ? AND artifacts.status = 'ready'", &[number(id), repo.id.as_str().into()]).await?, | |
| 411 | (None, Some(run), Some(name)) => { | |
| 412 | self.artifact_rows( | |
| 413 | "artifacts.run_id = ? AND artifacts.name = ? AND artifacts.repo_id = ? AND artifacts.status = 'ready'", | |
| 414 | &[run.into(), name.into(), repo.id.as_str().into()], | |
| 415 | ) | |
| 416 | .await? | |
| 417 | } | |
| 418 | _ => return Ok(fail(FailureCode::Invalid, "Name the artifact by its id, or by its run and name.")), | |
| 419 | }; | |
| 420 | Ok(rows.into_iter().next().map_or_else(|| fail(FailureCode::NotFound, "No such artifact, or it has expired."), Outcome::Ok)) | |
| 421 | } | |
| 422 | ||
| 423 | /// `artifact`. | |
| 424 | pub async fn artifact(&self, a: ArtifactArgs) -> Result<Outcome<Artifact>> { | |
| 425 | let row = check!(self.visible_artifact(&a).await?); | |
| 426 | Ok(Outcome::Ok(row.view())) | |
| 427 | } | |
| 428 | ||
| 429 | /// `artifact_download`. | |
| 430 | pub async fn artifact_download(&self, a: ArtifactArgs) -> Result<Outcome<ArtifactBlob>> { | |
| 431 | let row = check!(self.visible_artifact(&a).await?); | |
| 432 | Ok(self.blob_of(&row, LINK_SECONDS)) | |
| 433 | } | |
| 434 | ||
| 435 | /// `delete_artifact`. | |
| 436 | pub async fn delete_artifact(&self, a: DeleteArtifactArgs) -> Result<Outcome<Artifact>> { | |
| 437 | let repo = check!(self.may(&a.actor, &a.repo, Capability::Run).await?); | |
| 438 | let rows = self.artifact_rows("artifacts.id = ? AND artifacts.repo_id = ? AND artifacts.status <> 'expired'", &[number(a.id), repo.id.as_str().into()]).await?; | |
| 439 | let Some(row) = rows.into_iter().next() else { | |
| 440 | return Ok(fail(FailureCode::NotFound, "No such artifact, or it has expired.")); | |
| 441 | }; | |
| 442 | self.forget_artifacts(&[(row.id as u64, row.object.clone())]).await?; | |
| 443 | let mut gone = row.view(); | |
| 444 | gone.expired = true; | |
| 445 | Ok(Outcome::Ok(gone)) | |
| 446 | } | |
| 447 | ||
| 448 | /// `artifact_retention`. | |
| 449 | pub async fn artifact_retention(&self, a: ArtifactRetentionArgs) -> Result<Outcome<ArtifactRetention>> { | |
| 450 | let repo = match a.days { | |
| 451 | Some(days) => { | |
| 452 | let Some(actor) = a.viewer.clone() else { | |
| 453 | return Ok(fail(FailureCode::Unauthenticated, "Sign in to change how long artifacts are kept.")); | |
| 454 | }; | |
| 455 | let repo = check!(self.may(&actor, &a.repo, Capability::ManageSettings).await?); | |
| 456 | if !(1..=ARTIFACT_RETENTION_MAX_DAYS).contains(&days) { | |
| 457 | return Ok(fail(FailureCode::Invalid, format!("Artifacts are kept for 1 to {ARTIFACT_RETENTION_MAX_DAYS} days."))); | |
| 458 | } | |
| 459 | self.db | |
| 460 | .prepare( | |
| 461 | "INSERT INTO repo_settings (repo_id, artifact_retention_days, updated_at, updated_by) VALUES (?1, ?2, ?3, ?4) | |
| 462 | ON CONFLICT (repo_id) DO UPDATE SET artifact_retention_days = ?2, updated_at = ?3, updated_by = ?4", | |
| 463 | ) | |
| 464 | .bind(&[repo.id.as_str().into(), f64::from(days).into(), rfc3339(now_ms()).into(), actor.id.as_str().into()])? | |
| 465 | .run() | |
| 466 | .await?; | |
| 467 | repo | |
| 468 | } | |
| 469 | None => match self.visible_repo(&a.repo, &a.viewer).await? { | |
| 470 | Some(repo) => repo, | |
| 471 | None => return Ok(fail(FailureCode::NotFound, "There is no such repository.")), | |
| 472 | }, | |
| 473 | }; | |
| 474 | Ok(Outcome::Ok(ArtifactRetention { days: self.retention_setting(&repo.id).await?, maximum_allowed_days: ARTIFACT_RETENTION_MAX_DAYS })) | |
| 475 | } | |
| 476 | ||
| 477 | /// Hourly: artifacts past their time, and uploads left unfinished, | |
| 478 | /// with their objects. | |
| 479 | pub(crate) async fn sweep_artifacts(&self, now: u64) -> Result<()> { | |
| 480 | let at = rfc3339(now); | |
| 481 | self.db | |
| 482 | .prepare("UPDATE artifacts SET status = 'expired', updated_at = ?1 WHERE (status = 'ready' AND expires_at < ?1) OR (status = 'pending' AND created_at < ?2)") | |
| 483 | .bind(&[at.as_str().into(), rfc3339(now.saturating_sub(PENDING_MS)).into()])? | |
| 484 | .run() | |
| 485 | .await?; | |
| 486 | #[derive(Deserialize)] | |
| 487 | struct Gone { | |
| 488 | id: f64, | |
| 489 | object: String, | |
| 490 | } | |
| 491 | let gone = self.db.prepare("SELECT id, object FROM artifacts WHERE status = 'expired' LIMIT 500").all().await?.results::<Gone>()?; | |
| 492 | let gone: Vec<(u64, String)> = gone.into_iter().map(|g| (g.id as u64, g.object)).collect(); | |
| 493 | self.forget_artifacts(&gone).await | |
| 494 | } | |
| 495 | } | |
| 496 | ||
| 497 | #[cfg(test)] | |
| 498 | mod tests { | |
| 499 | use super::*; | |
| 500 | ||
| 501 | #[test] | |
| 502 | fn names_follow_github_s_rule() { | |
| 503 | for good in ["dist", "coverage report", "build (linux, 22)", "my-app_1.0", "résumé"] { | |
| 504 | assert!(valid_name(good), "{good}"); | |
| 505 | } | |
| 506 | for bad in ["", " ", "a/b", "a\\b", "a:b", "a*", "what?", "a\"b", "<x>", "a|b", "a\nb", &"x".repeat(257)] { | |
| 507 | assert!(!valid_name(bad), "{bad:?}"); | |
| 508 | } | |
| 509 | } | |
| 510 | ||
| 511 | #[test] | |
| 512 | fn retention_is_what_was_asked_up_to_the_setting() { | |
| 513 | let now = 1_000 * DAY_MS; | |
| 514 | // Nothing asked: the repository's setting. | |
| 515 | assert_eq!(retention(0, None, 14, now), 14); | |
| 516 | assert_eq!(retention(0, None, 90, now), 90); | |
| 517 | // Asked for fewer days, or more than the setting allows. | |
| 518 | assert_eq!(retention(3, None, 14, now), 3); | |
| 519 | assert_eq!(retention(30, None, 14, now), 14); | |
| 520 | // The toolkit says when to expire it instead: rounded up to days. | |
| 521 | assert_eq!(retention(0, Some(now + 5 * DAY_MS), 30, now), 5); | |
| 522 | assert_eq!(retention(0, Some(now + 5 * DAY_MS - 1000), 30, now), 5); | |
| 523 | assert_eq!(retention(0, Some(now - 1), 30, now), 30); | |
| 524 | // A setting out of range is held to 1 to 90 days. | |
| 525 | assert_eq!(retention(0, None, 0, now), 1); | |
| 526 | assert_eq!(retention(200, None, 400, now), 90); | |
| 527 | } | |
| 528 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.