Merge main into the run-protection branch
Builds on main's actions/0006: run protection moves to 0007, adds its columns to repo_settings and keeps the cache's toolkit version and upload in the scoped rebuild. The toolkit cache and actions/cache share one ref scope; OIDC reads the full permissions model. Token defaults the GitHub way: existing repositories keep read and write, new ones take their workspace's default, a workspace can cap repositories at read-only, and "Allow g1t Actions to create and approve pull requests" (repository and workspace, off by default) decides whether a job's token may open or approve pull requests.
| 16 | 16 | # with `deployment: false`, so a dry run or a change that deploys nothing | |
| 17 | 17 | # makes no deployment. | |
| 18 | 18 | # | |
| 19 | − | # Needs the repository secret CLOUDFLARE_API_TOKEN (a Production row), the | |
| 20 | − | # variable CLOUDFLARE_ACCOUNT_ID, and api.cloudflare.com among the project's | |
| 21 | − | # workflow-only domains for deploy.yml in production (Settings, Guardrails), | |
| 22 | − | # and registry.cloudflare.com there too, to find the runner's image. See | |
| 23 | − | # docs/DEPLOYING.md. | |
| 19 | + | # Needs the repository secret CLOUDFLARE_API_TOKEN (a Production row, with | |
| 20 | + | # Containers write), the variable CLOUDFLARE_ACCOUNT_ID, and | |
| 21 | + | # api.cloudflare.com among the project's workflow-only domains for | |
| 22 | + | # deploy.yml in production (Settings, Guardrails), and | |
| 23 | + | # registry.cloudflare.com there too, to find, pull and push the runner's | |
| 24 | + | # image. A job that must build that image (the `runner-image` group) does | |
| 25 | + | # so with its own Docker Engine, on a larger machine. See docs/DEPLOYING.md. | |
| 24 | 26 | name: Deploy | |
| 25 | 27 | ||
| 26 | 28 | on: | |
| 131 | 133 | # Runs when nothing before it failed: a migrate job skipped for having | |
| 132 | 134 | # nothing to apply is not a failure. | |
| 133 | 135 | if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_core == 'true' && inputs.dry_run != true }} | |
| 134 | − | # Rust builds get 4 vCPUs; everything else the standard machine. | |
| 135 | − | runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }} | |
| 136 | + | # Rust builds and the runner's image get 4 vCPUs; everything else the | |
| 137 | + | # standard machine. | |
| 138 | + | runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }} | |
| 136 | 139 | environment: | |
| 137 | 140 | name: production | |
| 138 | 141 | url: https://g1t.sh | |
| 190 | 193 | restore-keys: | | |
| 191 | 194 | cargo-target-${{ runner.os }}-${{ matrix.group }}- | |
| 192 | 195 | cargo-target-${{ runner.os }}- | |
| 196 | + | # The runner's image: its binary, built natively for musl (the base | |
| 197 | + | # has musl-gcc; the target is added here), with its Cargo target kept | |
| 198 | + | # between runs. The image itself is built and pushed with the job's | |
| 199 | + | # own Docker Engine (scripts/deploy/image.mjs). | |
| 200 | + | - name: Rust for the runner | |
| 201 | + | if: ${{ matrix.image }} | |
| 202 | + | run: rustup target add x86_64-unknown-linux-musl | |
| 203 | + | - name: Cache the runner's build | |
| 204 | + | if: ${{ matrix.image }} | |
| 205 | + | uses: actions/cache@v4 | |
| 206 | + | with: | |
| 207 | + | path: | | |
| 208 | + | ~/.cargo/registry/cache | |
| 209 | + | target/x86_64-unknown-linux-musl/release | |
| 210 | + | !target/**/incremental | |
| 211 | + | key: runner-musl-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'services/runner/base.json') }} | |
| 212 | + | restore-keys: runner-musl-${{ runner.os }}- | |
| 193 | 213 | - name: Install | |
| 194 | 214 | run: node scripts/deploy.mjs install --only "${{ matrix.units }}" | |
| 195 | 215 | - name: Deploy ${{ matrix.units }} | |
| 201 | 221 | name: edge (${{ matrix.group }}) | |
| 202 | 222 | needs: [plan, migrate, core] | |
| 203 | 223 | if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_edge == 'true' && inputs.dry_run != true }} | |
| 204 | − | runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }} | |
| 224 | + | runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }} | |
| 205 | 225 | environment: | |
| 206 | 226 | name: production | |
| 207 | 227 | url: https://g1t.sh | |
| 216 | 236 | name: front (${{ matrix.group }}) | |
| 217 | 237 | needs: [plan, migrate, core, edge] | |
| 218 | 238 | if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_front == 'true' && inputs.dry_run != true }} | |
| 219 | − | runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }} | |
| 239 | + | runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }} | |
| 220 | 240 | environment: | |
| 221 | 241 | name: production | |
| 222 | 242 | url: https://g1t.sh |
| 7 | 7 | # Weekly, for security updates and new stable toolchains; when the base's | |
| 8 | 8 | # folder changes on main (a change that forgot to rebuild it); and by hand. | |
| 9 | 9 | # | |
| 10 | − | # It needs Docker, which g1t's own sandboxes do not have, so it runs on a | |
| 11 | − | # self-hosted runner with the `docker` label. Until one is registered, run | |
| 12 | − | # the same thing by hand on a machine with Docker: | |
| 10 | + | # It runs on a self-hosted runner with the `docker` label. g1t's own | |
| 11 | + | # machines have Docker now, but this build's own downloads (Docker's apt | |
| 12 | + | # repository over HTTPS) do not yet trust a guarded job's egress | |
| 13 | + | # certificate, so it stays where the network is open. Until a runner is | |
| 14 | + | # registered, run the same thing by hand on a machine with Docker: | |
| 13 | 15 | # | |
| 14 | 16 | # node scripts/deploy.mjs build-base | |
| 15 | 17 | # |
| 77 | 77 | image: | |
| 78 | 78 | name: Container image | |
| 79 | 79 | needs: binaries | |
| 80 | − | # Needs Docker, which g1t's own sandboxes do not have. | |
| 80 | + | # Builds for arm64 as well as amd64, which needs QEMU's emulators | |
| 81 | + | # registered on the machine: a self-hosted runner's, for now. | |
| 81 | 82 | runs-on: [self-hosted, docker] | |
| 82 | 83 | environment: production | |
| 83 | 84 | timeout-minutes: 30 |
| 923 | 923 | name = "g1t-actions-service" | |
| 924 | 924 | version = "0.1.0" | |
| 925 | 925 | dependencies = [ | |
| 926 | + | "base64 0.22.1", | |
| 926 | 927 | "g1t-actions", | |
| 927 | 928 | "g1t-contracts", | |
| 928 | 929 | "g1t-kit", | |
| 929 | 930 | "g1t-secrets", | |
| 931 | + | "hex", | |
| 930 | 932 | "serde", | |
| 931 | 933 | "serde_json", | |
| 932 | 934 | "worker", | |
| 942 | 944 | "g1t-kit", | |
| 943 | 945 | "serde", | |
| 944 | 946 | "serde_json", | |
| 947 | + | "sha2 0.10.9", | |
| 945 | 948 | "worker", | |
| 946 | 949 | ] | |
| 947 | 950 | ||
| 1094 | 1097 | dependencies = [ | |
| 1095 | 1098 | "anyhow", | |
| 1096 | 1099 | "base64 0.22.1", | |
| 1100 | + | "crc32fast", | |
| 1097 | 1101 | "ed25519-dalek", | |
| 1102 | + | "flate2", | |
| 1098 | 1103 | "g1t-actions", | |
| 1099 | 1104 | "g1t-scan", | |
| 1100 | 1105 | "hex", |
| 15 | 15 | serde_json = { workspace = true, features = ["preserve_order"] } | |
| 16 | 16 | worker.workspace = true | |
| 17 | 17 | base64 = "0.22" | |
| 18 | + | sha2 = "0.10" | |
| 18 | 19 | form_urlencoded = "1" | |
| 19 | 20 | ||
| 20 | 21 | # wasm-opt at -O1: about the same gzipped size as -O in a tenth of the |
| 1 | + | //! Workflow run artifacts over REST and MCP, in GitHub's shapes: listing a | |
| 2 | + | //! repository's or a run's, one by id, a link to download it, deleting it, | |
| 3 | + | //! and how long a repository keeps them. | |
| 4 | + | //! | |
| 5 | + | //! The actions service keeps them and decides who may see and change | |
| 6 | + | //! them (`g1t_contracts::actions`); their bytes are in R2, downloaded | |
| 7 | + | //! through the toolkit's blob endpoint (toolkit.rs) with a link signed for | |
| 8 | + | //! a few minutes. `GET …/artifacts/{id}/zip` answers with a redirect to | |
| 9 | + | //! that link, as GitHub's does. | |
| 10 | + | ||
| 11 | + | use g1t_contracts::actions::{Artifact, ArtifactArgs, ArtifactBlob, ArtifactList, ArtifactRetention, ArtifactRetentionArgs, ArtifactsArgs, DeleteArtifactArgs}; | |
| 12 | + | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 13 | + | use serde_json::{Value, json}; | |
| 14 | + | use worker::Result; | |
| 15 | + | ||
| 16 | + | use crate::operations::{Services, repo_path}; | |
| 17 | + | ||
| 18 | + | /// One operation on artifacts. | |
| 19 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 20 | + | pub enum ArtifactsOp { | |
| 21 | + | ListArtifacts, | |
| 22 | + | ListRunArtifacts, | |
| 23 | + | GetArtifact, | |
| 24 | + | DownloadArtifact, | |
| 25 | + | DeleteArtifact, | |
| 26 | + | GetArtifactRetention, | |
| 27 | + | SetArtifactRetention, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | impl ArtifactsOp { | |
| 31 | + | /// Every one: `Op::ALL` lists each as `Op::Artifacts(…)`, which a test | |
| 32 | + | /// checks against this. | |
| 33 | + | #[cfg(test)] | |
| 34 | + | pub const ALL: [ArtifactsOp; 7] = [ | |
| 35 | + | ArtifactsOp::ListArtifacts, | |
| 36 | + | ArtifactsOp::ListRunArtifacts, | |
| 37 | + | ArtifactsOp::GetArtifact, | |
| 38 | + | ArtifactsOp::DownloadArtifact, | |
| 39 | + | ArtifactsOp::DeleteArtifact, | |
| 40 | + | ArtifactsOp::GetArtifactRetention, | |
| 41 | + | ArtifactsOp::SetArtifactRetention, | |
| 42 | + | ]; | |
| 43 | + | ||
| 44 | + | pub fn name(self) -> &'static str { | |
| 45 | + | match self { | |
| 46 | + | ArtifactsOp::ListArtifacts => "list_artifacts", | |
| 47 | + | ArtifactsOp::ListRunArtifacts => "list_workflow_run_artifacts", | |
| 48 | + | ArtifactsOp::GetArtifact => "get_artifact", | |
| 49 | + | ArtifactsOp::DownloadArtifact => "download_artifact", | |
| 50 | + | ArtifactsOp::DeleteArtifact => "delete_artifact", | |
| 51 | + | ArtifactsOp::GetArtifactRetention => "get_artifact_retention", | |
| 52 | + | ArtifactsOp::SetArtifactRetention => "set_artifact_retention", | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// For the API reference. | |
| 57 | + | pub fn title(self) -> &'static str { | |
| 58 | + | match self { | |
| 59 | + | ArtifactsOp::ListArtifacts => "List a repository's artifacts", | |
| 60 | + | ArtifactsOp::ListRunArtifacts => "List a workflow run's artifacts", | |
| 61 | + | ArtifactsOp::GetArtifact => "Get an artifact", | |
| 62 | + | ArtifactsOp::DownloadArtifact => "Download an artifact", | |
| 63 | + | ArtifactsOp::DeleteArtifact => "Delete an artifact", | |
| 64 | + | ArtifactsOp::GetArtifactRetention => "Get artifact retention", | |
| 65 | + | ArtifactsOp::SetArtifactRetention => "Set artifact retention", | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | pub fn description(self) -> &'static str { | |
| 70 | + | match self { | |
| 71 | + | ArtifactsOp::ListArtifacts => "List a repository's artifacts that have not expired, newest first: each with its id (a number), name, size_in_bytes, digest (sha256:… of its zip), created_at, expires_at, archive_download_url, and workflow_run (its run's id, head_branch and head_sha). Narrow with name; page with page and per_page (30 by default, at most 100). total_count counts every match. Needs the Read role; a public repository's are open to anyone.", | |
| 72 | + | ArtifactsOp::ListRunArtifacts => "List one workflow run's artifacts that have not expired, oldest first, in the same shape as list_artifacts. Narrow with name. Needs the Read role.", | |
| 73 | + | ArtifactsOp::GetArtifact => "Get one artifact by its id: its name, size_in_bytes, digest, when it was made and when it expires, and its run. Needs the Read role.", | |
| 74 | + | ArtifactsOp::DownloadArtifact => "A link to download an artifact as a zip file (an artifact an older runner kept is a .tar.gz), good for 10 minutes and needing no token. Over REST, GET …/zip answers 302 with the link in Location, as GitHub does: `curl -L` follows it. Over MCP the link is returned as url, with expires_at. Needs the Read role.", | |
| 75 | + | ArtifactsOp::DeleteArtifact => "Delete an artifact before it expires: its bytes go at once and its id stops resolving. Needs the Write role.", | |
| 76 | + | ArtifactsOp::GetArtifactRetention => "How many days the repository keeps artifacts (days), and the most it may choose (maximum_allowed_days, 90). A workflow's retention-days can ask for fewer days, never more. Needs the Read role.", | |
| 77 | + | ArtifactsOp::SetArtifactRetention => "Set how many days the repository keeps artifacts by default, and at most: days, from 1 to 90. Artifacts already uploaded keep the expiry they were given. Needs the Maintain role.", | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /// Whether it changes anything. | |
| 82 | + | pub fn writes(self) -> bool { | |
| 83 | + | matches!(self, ArtifactsOp::DeleteArtifact | ArtifactsOp::SetArtifactRetention) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | pub fn input(self) -> Value { | |
| 87 | + | let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." }); | |
| 88 | + | let id = json!({ "type": ["integer", "string"], "description": "The artifact's id, a number." }); | |
| 89 | + | let (properties, required): (Value, &[&str]) = match self { | |
| 90 | + | ArtifactsOp::ListArtifacts => ( | |
| 91 | + | json!({ | |
| 92 | + | "repo": repo, | |
| 93 | + | "name": { "type": "string", "description": "Only artifacts with exactly this name." }, | |
| 94 | + | "page": { "type": "integer", "description": "The page, from 1." }, | |
| 95 | + | "per_page": { "type": "integer", "description": "Artifacts a page: 30 unless you say, at most 100." }, | |
| 96 | + | }), | |
| 97 | + | &["repo"], | |
| 98 | + | ), | |
| 99 | + | ArtifactsOp::ListRunArtifacts => ( | |
| 100 | + | json!({ | |
| 101 | + | "repo": repo, | |
| 102 | + | "id": { "type": "string", "description": "The run's id (run_…)." }, | |
| 103 | + | "name": { "type": "string", "description": "Only the artifact with exactly this name." }, | |
| 104 | + | }), | |
| 105 | + | &["repo", "id"], | |
| 106 | + | ), | |
| 107 | + | ArtifactsOp::GetArtifact | ArtifactsOp::DownloadArtifact | ArtifactsOp::DeleteArtifact => (json!({ "repo": repo, "id": id }), &["repo", "id"]), | |
| 108 | + | ArtifactsOp::GetArtifactRetention => (json!({ "repo": repo }), &["repo"]), | |
| 109 | + | ArtifactsOp::SetArtifactRetention => ( | |
| 110 | + | json!({ | |
| 111 | + | "repo": repo, | |
| 112 | + | "days": { "type": "integer", "minimum": 1, "maximum": 90, "description": "Days to keep artifacts, by default and at most." }, | |
| 113 | + | }), | |
| 114 | + | &["repo", "days"], | |
| 115 | + | ), | |
| 116 | + | }; | |
| 117 | + | json!({ "type": "object", "properties": properties, "required": required }) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /// A number from a path segment or a JSON number. | |
| 122 | + | fn number(input: &Value, key: &str) -> Option<u64> { | |
| 123 | + | match &input[key] { | |
| 124 | + | Value::Number(n) => n.as_u64(), | |
| 125 | + | Value::String(s) => s.trim().parse().ok(), | |
| 126 | + | _ => None, | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | /// An outcome's value made into what is sent; its failure as it is. | |
| 131 | + | fn mapped<T>(outcome: Outcome<T>, f: impl FnOnce(T) -> Value) -> Outcome<Value> { | |
| 132 | + | match outcome { | |
| 133 | + | Outcome::Ok(value) => Outcome::Ok(f(value)), | |
| 134 | + | Outcome::Fail(refused) => Outcome::Fail(refused), | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | fn text(input: &Value, key: &str) -> Option<String> { | |
| 139 | + | input[key].as_str().map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | /// An artifact as GitHub's REST API shows one. | |
| 143 | + | pub fn shown(artifact: &Artifact, api: &str, repository: &str) -> Value { | |
| 144 | + | let url = format!("{api}/repos/{repository}/actions/artifacts/{}", artifact.id); | |
| 145 | + | json!({ | |
| 146 | + | "id": artifact.id, | |
| 147 | + | "node_id": format!("artifact_{}", artifact.id), | |
| 148 | + | "name": artifact.name, | |
| 149 | + | "size_in_bytes": artifact.size, | |
| 150 | + | "url": url, | |
| 151 | + | "archive_download_url": format!("{url}/zip"), | |
| 152 | + | "expired": artifact.expired, | |
| 153 | + | "digest": artifact.digest, | |
| 154 | + | "created_at": artifact.created_at, | |
| 155 | + | "updated_at": artifact.updated_at, | |
| 156 | + | "expires_at": artifact.expires_at, | |
| 157 | + | "workflow_run": { | |
| 158 | + | "id": artifact.run_id, | |
| 159 | + | "repository_id": artifact.repo_id, | |
| 160 | + | "head_repository_id": artifact.repo_id, | |
| 161 | + | "head_branch": artifact.head_branch, | |
| 162 | + | "head_sha": artifact.head_sha, | |
| 163 | + | }, | |
| 164 | + | }) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /// Where a signed blob token downloads from. | |
| 168 | + | pub fn blob_url(api: &str, blob: &str) -> String { | |
| 169 | + | format!("{api}/actions/toolkit/blobs/{blob}") | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | fn list(found: ArtifactList, api: &str, repository: &str) -> Value { | |
| 173 | + | json!({ | |
| 174 | + | "total_count": found.total_count, | |
| 175 | + | "artifacts": found.artifacts.iter().map(|a| shown(a, api, repository)).collect::<Vec<_>>(), | |
| 176 | + | }) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | pub async fn run(op: ArtifactsOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 180 | + | let Some(repo) = repo_path(input) else { | |
| 181 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\".")); | |
| 182 | + | }; | |
| 183 | + | let repository = format!("{}/{}", repo.namespace, repo.name); | |
| 184 | + | let api = services.addresses.api.clone(); | |
| 185 | + | let actions = &services.actions; | |
| 186 | + | let id = number(input, "id"); | |
| 187 | + | let by_id = || ArtifactArgs { repo: repo.clone(), viewer: viewer.clone(), id, run: None, name: None }; | |
| 188 | + | if matches!(op, ArtifactsOp::GetArtifact | ArtifactsOp::DownloadArtifact | ArtifactsOp::DeleteArtifact) && id.is_none() { | |
| 189 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Name the artifact by its id, a number.")); | |
| 190 | + | } | |
| 191 | + | Ok(match op { | |
| 192 | + | ArtifactsOp::ListArtifacts | ArtifactsOp::ListRunArtifacts => { | |
| 193 | + | let run = (op == ArtifactsOp::ListRunArtifacts).then(|| text(input, "id")).flatten(); | |
| 194 | + | let args = ArtifactsArgs { | |
| 195 | + | repo: repo.clone(), | |
| 196 | + | viewer: viewer.clone(), | |
| 197 | + | run, | |
| 198 | + | name: text(input, "name"), | |
| 199 | + | page: number(input, "page").map(|n| n as u32), | |
| 200 | + | per_page: number(input, "per_page").map(|n| n as u32), | |
| 201 | + | }; | |
| 202 | + | let found: Outcome<ArtifactList> = g1t_kit::call(actions, "artifacts", &args).await?; | |
| 203 | + | mapped(found, |mut found| { | |
| 204 | + | // A run's are listed in the order they were made. | |
| 205 | + | if op == ArtifactsOp::ListRunArtifacts { | |
| 206 | + | found.artifacts.reverse(); | |
| 207 | + | } | |
| 208 | + | list(found, &api, &repository) | |
| 209 | + | }) | |
| 210 | + | } | |
| 211 | + | ArtifactsOp::GetArtifact => { | |
| 212 | + | let found: Outcome<Artifact> = g1t_kit::call(actions, "artifact", &by_id()).await?; | |
| 213 | + | mapped(found, |a| shown(&a, &api, &repository)) | |
| 214 | + | } | |
| 215 | + | ArtifactsOp::DownloadArtifact => { | |
| 216 | + | let found: Outcome<ArtifactBlob> = g1t_kit::call(actions, "artifact_download", &by_id()).await?; | |
| 217 | + | match found { | |
| 218 | + | Outcome::Ok(found) if found.blob.is_empty() => Outcome::fail(FailureCode::Invalid, "Download links are not set up on this installation."), | |
| 219 | + | Outcome::Ok(found) => Outcome::Ok(json!({ | |
| 220 | + | "url": blob_url(&api, &found.blob), | |
| 221 | + | "expires_at": g1t_contracts::time::rfc3339(g1t_kit::now_ms() + 10 * 60 * 1000), | |
| 222 | + | "artifact": shown(&found.artifact, &api, &repository), | |
| 223 | + | })), | |
| 224 | + | Outcome::Fail(refused) => Outcome::Fail(refused), | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ArtifactsOp::DeleteArtifact => { | |
| 228 | + | let Some(actor) = viewer.clone() else { | |
| 229 | + | return Ok(Outcome::fail(FailureCode::Unauthenticated, "Deleting an artifact needs a g1t access token.")); | |
| 230 | + | }; | |
| 231 | + | let done: Outcome<Artifact> = g1t_kit::call(actions, "delete_artifact", &DeleteArtifactArgs { actor, repo, id: id.unwrap_or_default() }).await?; | |
| 232 | + | mapped(done, |a| json!({ "deleted": true, "id": a.id, "name": a.name })) | |
| 233 | + | } | |
| 234 | + | ArtifactsOp::GetArtifactRetention | ArtifactsOp::SetArtifactRetention => { | |
| 235 | + | let days = if op == ArtifactsOp::SetArtifactRetention { | |
| 236 | + | match number(input, "days") { | |
| 237 | + | Some(days) => Some(days.min(u64::from(u32::MAX)) as u32), | |
| 238 | + | None => return Ok(Outcome::fail(FailureCode::Invalid, "Give days, from 1 to 90.")), | |
| 239 | + | } | |
| 240 | + | } else { | |
| 241 | + | None | |
| 242 | + | }; | |
| 243 | + | let found: Outcome<ArtifactRetention> = g1t_kit::call(actions, "artifact_retention", &ArtifactRetentionArgs { repo, viewer: viewer.clone(), days }).await?; | |
| 244 | + | mapped(found, |r| json!({ "days": r.days, "maximum_allowed_days": r.maximum_allowed_days })) | |
| 245 | + | } | |
| 246 | + | }) | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | #[cfg(test)] | |
| 250 | + | mod tests { | |
| 251 | + | use super::*; | |
| 252 | + | ||
| 253 | + | fn artifact() -> Artifact { | |
| 254 | + | Artifact { | |
| 255 | + | id: 42, | |
| 256 | + | name: "dist".into(), | |
| 257 | + | size: 1024, | |
| 258 | + | digest: Some(format!("sha256:{}", "a".repeat(64))), | |
| 259 | + | format: "zip".into(), | |
| 260 | + | run_id: "run_1".into(), | |
| 261 | + | job_id: "job_1".into(), | |
| 262 | + | repo_id: "repo_1".into(), | |
| 263 | + | expired: false, | |
| 264 | + | created_at: "2026-10-08T12:00:00.000Z".into(), | |
| 265 | + | updated_at: "2026-10-08T12:00:00.000Z".into(), | |
| 266 | + | expires_at: "2026-10-22T12:00:00.000Z".into(), | |
| 267 | + | head_branch: Some("main".into()), | |
| 268 | + | head_sha: Some("abc".into()), | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | #[test] | |
| 273 | + | fn an_artifact_is_shown_as_github_shows_one() { | |
| 274 | + | let shown = shown(&artifact(), "https://api.g1t.sh", "acme/web"); | |
| 275 | + | assert_eq!(shown["id"], 42); | |
| 276 | + | assert_eq!(shown["size_in_bytes"], 1024); | |
| 277 | + | assert_eq!(shown["url"], "https://api.g1t.sh/repos/acme/web/actions/artifacts/42"); | |
| 278 | + | assert_eq!(shown["archive_download_url"], "https://api.g1t.sh/repos/acme/web/actions/artifacts/42/zip"); | |
| 279 | + | assert_eq!(shown["workflow_run"]["head_branch"], "main"); | |
| 280 | + | assert!(g1t_kit::wire::camel_case_keys(&shown).is_empty()); | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | #[test] | |
| 284 | + | fn ids_are_read_from_a_path_or_a_number() { | |
| 285 | + | assert_eq!(number(&json!({ "id": "42" }), "id"), Some(42)); | |
| 286 | + | assert_eq!(number(&json!({ "id": 42 }), "id"), Some(42)); | |
| 287 | + | assert_eq!(number(&json!({ "id": "run_1" }), "id"), None); | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | #[test] | |
| 291 | + | fn each_operation_is_described_with_a_schema() { | |
| 292 | + | for op in ArtifactsOp::ALL { | |
| 293 | + | assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Artifacts(op)), "{}", op.name()); | |
| 294 | + | assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name()); | |
| 295 | + | assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name()); | |
| 296 | + | let level = g1t_contracts::scopes::scope_for(op.name()).unwrap().level(); | |
| 297 | + | assert_eq!(op.writes(), level == g1t_contracts::scopes::Level::Write, "{}", op.name()); | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | } |
| 1 | − | //! Artifacts and the cache of GitHub Actions jobs. | |
| 1 | + | //! Artifacts and the cache of GitHub Actions jobs, as g1t's own runner | |
| 2 | + | //! reaches them. (Actions built on GitHub's toolkit reach the same entries | |
| 3 | + | //! through toolkit.rs.) | |
| 2 | 4 | //! | |
| 3 | − | //! Artifacts are kept in Workers KV in chunks, with KV's own expiry: 14 | |
| 4 | − | //! days with their run. Cache entries are kept in R2 (ACTIONS_CACHE), up | |
| 5 | − | //! to 2 GB each, uploaded in parts; the actions service lists them and | |
| 6 | − | //! decides what is found, what fits and what is evicted | |
| 7 | − | //! (services/actions/src/cache.rs). Entries saved in KV before the cache | |
| 8 | − | //! moved are still found there until they expire. | |
| 5 | + | //! Artifacts are kept in R2 (ACTIONS_CACHE, under `a/`), uploaded in | |
| 6 | + | //! parts; the actions service lists them and decides names, sizes and how | |
| 7 | + | //! long each is kept (services/actions/src/artifacts.rs). Artifacts older | |
| 8 | + | //! runners kept in Workers KV are still listed and found there until KV | |
| 9 | + | //! expires them. Cache entries are kept in R2 too, up to 2 GB each, | |
| 10 | + | //! uploaded in parts; the actions service decides what is found, what | |
| 11 | + | //! fits and what is evicted (services/actions/src/cache.rs). | |
| 9 | 12 | //! | |
| 10 | 13 | //! A sandbox reaches these with its job's token: | |
| 11 | 14 | //! | |
| 12 | − | //! - `GET /actions/jobs/{job}/artifacts`, `PUT|GET .../artifacts/{name}` | |
| 15 | + | //! - `GET /actions/jobs/{job}/artifacts[?run_id=]`: its run's (or another run's) | |
| 16 | + | //! - `POST .../artifacts/uploads?name=&size=&retention_days=&overwrite=&format=`: | |
| 17 | + | //! `{ id, upload, part_bytes, retention_days, expires_at }` | |
| 18 | + | //! - `PUT .../artifacts/uploads/{id}/{part}?upload=`, `POST …/complete` with | |
| 19 | + | //! `{ size, parts, digest }`, `DELETE .../artifacts/uploads/{id}?upload=` | |
| 20 | + | //! - `GET .../artifacts/{id}/download[?run_id=]`, `DELETE .../artifacts/{id}` | |
| 21 | + | //! - `PUT|GET .../artifacts/{name}`: a whole artifact by name (older runners) | |
| 13 | 22 | //! - `GET .../cache?key=&restore=`: the entry, streamed, its key in `x-g1t-key` | |
| 14 | 23 | //! - `POST .../cache/uploads?key=&size=`: `{ id, upload, part_bytes }` | |
| 15 | 24 | //! - `PUT .../cache/uploads/{id}/{part}?upload=`: one part, `{ part, etag }` | |
| 17 | 26 | //! - `DELETE .../cache/uploads/{id}?upload=`: gives the upload up | |
| 18 | 27 | //! - `PUT .../cache?key=`: a whole entry of at most 60 MB at once (older runners) | |
| 19 | 28 | //! | |
| 20 | − | //! People download an artifact at | |
| 21 | − | //! `/repos/{owner}/{repo}/actions/runs/{run}/artifacts/{name}`. | |
| 29 | + | //! People download an artifact through the REST API (artifacts.rs), or by | |
| 30 | + | //! name at `/repos/{owner}/{repo}/actions/runs/{run}/artifacts/{name}`. | |
| 22 | 31 | ||
| 23 | 32 | use serde::{Deserialize, Serialize}; | |
| 24 | 33 | use serde_json::{Value, json}; | |
| 25 | 34 | use worker::kv::KvStore; | |
| 26 | − | use worker::{Bucket, Env, Request, Response, Result, UploadedPart}; | |
| 35 | + | use worker::{Bucket, Env, Request, Response, Result, UploadedPart, Url}; | |
| 27 | 36 | ||
| 28 | 37 | use g1t_contracts::actions::{ | |
| 29 | − | CACHE_PART_BYTES, CacheAbortArgs, CacheCommitArgs, CacheCommitted, CacheHit, CacheLookupArgs, CacheReservation, CacheReserveArgs, | |
| 38 | + | ARTIFACT_PART_BYTES, Artifact, ArtifactArgs, ArtifactBlob, ArtifactCommitArgs, ArtifactReservation, ArtifactReserveArgs, CACHE_PART_BYTES, | |
| 39 | + | CacheAbortArgs, CacheCommitArgs, CacheCommitted, CacheHit, CacheLookupArgs, CacheReservation, CacheReserveArgs, JobArtifactsArgs, | |
| 30 | 40 | }; | |
| 31 | 41 | use g1t_contracts::{FailureCode, Outcome}; | |
| 32 | 42 | ||
| 33 | 43 | use crate::operations::Services; | |
| 34 | 44 | ||
| 35 | − | /// KV's largest value is 25 MiB; chunks stay under it. | |
| 36 | − | const CHUNK: usize = 20 * 1024 * 1024; | |
| 37 | − | /// The largest artifact or cache entry, kept within a Worker's memory. | |
| 45 | + | /// The largest artifact or cache entry an older runner sends at once, | |
| 46 | + | /// held in a Worker's memory. | |
| 38 | 47 | const MAX_BYTES: usize = 60 * 1024 * 1024; | |
| 39 | − | const ARTIFACT_TTL: u64 = 14 * 24 * 60 * 60; | |
| 40 | 48 | ||
| 41 | 49 | #[derive(Serialize, Deserialize)] | |
| 42 | 50 | struct Meta { | |
| 52 | 60 | env.kv("BLOBS") | |
| 53 | 61 | } | |
| 54 | 62 | ||
| 55 | − | async fn put(kv: &KvStore, base: &str, name: &str, bytes: &[u8], ttl: u64) -> Result<()> { | |
| 56 | − | let chunks: Vec<&[u8]> = if bytes.is_empty() { vec![&[][..]] } else { bytes.chunks(CHUNK).collect() }; | |
| 57 | − | for (index, chunk) in chunks.iter().enumerate() { | |
| 58 | − | kv.put_bytes(&format!("{base}#{index}"), chunk)?.expiration_ttl(ttl).execute().await?; | |
| 59 | − | } | |
| 60 | − | let meta = Meta { size: bytes.len(), chunks: chunks.len(), at: g1t_kit::now_ms(), name: name.to_owned() }; | |
| 61 | − | // The metadata travels with the key in listings, so the newest entry | |
| 62 | − | // can be found without reading each. | |
| 63 | − | kv.put(base, serde_json::to_string(&meta)?)? | |
| 64 | − | .metadata(&meta)? | |
| 65 | − | .expiration_ttl(ttl) | |
| 66 | − | .execute() | |
| 67 | − | .await?; | |
| 68 | − | Ok(()) | |
| 69 | − | } | |
| 70 | − | ||
| 71 | 63 | async fn get(kv: &KvStore, base: &str) -> Result<Option<Vec<u8>>> { | |
| 72 | 64 | let Some(meta) = kv.get(base).json::<Meta>().await? else { return Ok(None) }; | |
| 73 | 65 | let mut out = Vec::with_capacity(meta.size); | |
| 110 | 102 | ||
| 111 | 103 | fn error(status: u16, message: &str) -> Result<Response> { | |
| 112 | 104 | Ok(crate::reply(&json!({ "error": { "message": message } }))?.with_status(status)) | |
| 113 | − | } | |
| 114 | − | ||
| 115 | − | fn valid_name(name: &str) -> bool { | |
| 116 | − | !name.is_empty() && name.len() <= 200 && !name.starts_with('.') && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ' ')) | |
| 117 | 105 | } | |
| 118 | 106 | ||
| 119 | 107 | fn decode(text: &str) -> String { | |
| 154 | 142 | ||
| 155 | 143 | /// A sandbox storing or fetching an artifact or cache entry. `rest` is | |
| 156 | 144 | /// the path after `/actions/jobs/`. | |
| 157 | − | pub async fn for_job(mut request: Request, env: &Env, services: &Services, method: &str, rest: &str) -> Result<Response> { | |
| 145 | + | pub async fn for_job(request: Request, env: &Env, services: &Services, method: &str, rest: &str) -> Result<Response> { | |
| 158 | 146 | let (job, what) = rest.split_once('/').unwrap_or((rest, "")); | |
| 159 | 147 | let token = request | |
| 160 | 148 | .headers() | |
| 170 | 158 | let repo = owner["repoId"].as_str().unwrap_or_default().to_owned(); | |
| 171 | 159 | let kv = store(env)?; | |
| 172 | 160 | match (method, what) { | |
| 173 | − | ("GET", "artifacts") => { | |
| 174 | − | let listed: Vec<Value> = list(&kv, &format!("a/{run}/")) | |
| 175 | − | .await? | |
| 176 | − | .into_iter() | |
| 177 | − | .map(|(_, meta)| json!({ "name": meta.name, "size": meta.size })) | |
| 178 | − | .collect(); | |
| 179 | − | crate::reply(&listed) | |
| 180 | − | } | |
| 181 | − | (_, what) if what.starts_with("artifacts/") => { | |
| 182 | − | let name = decode(&what["artifacts/".len()..]); | |
| 183 | − | if !valid_name(&name) { | |
| 184 | − | return error(400, "That is not an artifact name."); | |
| 185 | − | } | |
| 186 | − | let base = format!("a/{run}/{name}"); | |
| 187 | − | if method == "PUT" { | |
| 188 | − | let bytes = request.bytes().await?; | |
| 189 | − | if bytes.len() > MAX_BYTES { | |
| 190 | − | return error(413, "Artifacts are at most 60 MB."); | |
| 191 | − | } | |
| 192 | − | put(&kv, &base, &name, &bytes, ARTIFACT_TTL).await?; | |
| 193 | − | return crate::reply(&json!({ "name": name, "size": bytes.len() })); | |
| 194 | − | } | |
| 195 | − | match get(&kv, &base).await? { | |
| 196 | − | Some(bytes) => Response::from_bytes(bytes), | |
| 197 | − | None => error(404, "No such artifact."), | |
| 198 | − | } | |
| 161 | + | (_, what) if what == "artifacts" || what.starts_with("artifacts/") => { | |
| 162 | + | let bucket = env.bucket("ACTIONS_CACHE")?; | |
| 163 | + | artifacts(request, &kv, &bucket, services, method, job, &token, &run, &repo, what).await | |
| 199 | 164 | } | |
| 200 | 165 | (_, what) if what == "cache" || what.starts_with("cache/") => { | |
| 201 | 166 | let bucket = env.bucket("ACTIONS_CACHE")?; | |
| 260 | 225 | let found: Outcome<Option<CacheHit>> = g1t_kit::call( | |
| 261 | 226 | &services.actions, | |
| 262 | 227 | "cache_lookup", | |
| 263 | − | &CacheLookupArgs { job: job.to_owned(), token: token.to_owned(), key: key.clone(), restore, version: version.clone() }, | |
| 228 | + | &CacheLookupArgs { job: job.to_owned(), token: token.to_owned(), key: key.clone(), restore, version: Some(version.clone()).filter(|v| !v.is_empty()) }, | |
| 264 | 229 | ) | |
| 265 | 230 | .await?; | |
| 266 | 231 | let found = match refused(found) { | |
| 292 | 257 | let reserved: Outcome<CacheReservation> = g1t_kit::call( | |
| 293 | 258 | &services.actions, | |
| 294 | 259 | "cache_reserve", | |
| 295 | − | &CacheReserveArgs { job: job.to_owned(), token: token.to_owned(), key, size: bytes.len() as u64, version: version.clone() }, | |
| 260 | + | &CacheReserveArgs { job: job.to_owned(), token: token.to_owned(), key, size: bytes.len() as u64, version: Some(version.clone()).filter(|v| !v.is_empty()) }, | |
| 296 | 261 | ) | |
| 297 | 262 | .await?; | |
| 298 | 263 | let reserved = match reserved { | |
| 315 | 280 | let reserved: Outcome<CacheReservation> = g1t_kit::call( | |
| 316 | 281 | &services.actions, | |
| 317 | 282 | "cache_reserve", | |
| 318 | − | &CacheReserveArgs { job: job.to_owned(), token: token.to_owned(), key, size, version: version.clone() }, | |
| 283 | + | &CacheReserveArgs { job: job.to_owned(), token: token.to_owned(), key, size, version: Some(version.clone()).filter(|v| !v.is_empty()) }, | |
| 319 | 284 | ) | |
| 320 | 285 | .await?; | |
| 321 | 286 | let reserved = match refused(reserved) { | |
| 372 | 337 | format!("c/{repo}/{id}") | |
| 373 | 338 | } | |
| 374 | 339 | ||
| 340 | + | /// Where an artifact is in R2, as the actions service names it. | |
| 341 | + | fn artifact_object(repo: &str, id: u64) -> String { | |
| 342 | + | format!("a/{repo}/{id}") | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | /// An artifact as a job's runner lists it. | |
| 346 | + | fn for_runner(artifact: &Artifact) -> Value { | |
| 347 | + | json!({ | |
| 348 | + | "id": artifact.id, | |
| 349 | + | "name": artifact.name, | |
| 350 | + | "size": artifact.size, | |
| 351 | + | "digest": artifact.digest, | |
| 352 | + | "format": artifact.format, | |
| 353 | + | "created_at": artifact.created_at, | |
| 354 | + | "expires_at": artifact.expires_at, | |
| 355 | + | }) | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | /// An R2 object streamed back, with what it is. | |
| 359 | + | async fn stream(bucket: &Bucket, object: &str, format: &str) -> Result<Response> { | |
| 360 | + | let Some(found) = bucket.get(object).execute().await? else { return error(404, "That artifact is gone: it expired or was deleted.") }; | |
| 361 | + | let size = found.size(); | |
| 362 | + | let Some(body) = found.body() else { return error(404, "That artifact is gone: it expired or was deleted.") }; | |
| 363 | + | let mut response = Response::from_body(body.response_body()?)?; | |
| 364 | + | let headers = response.headers_mut(); | |
| 365 | + | headers.set("content-length", &size.to_string())?; | |
| 366 | + | headers.set("content-type", if format == "tgz" { "application/gzip" } else { "application/zip" })?; | |
| 367 | + | headers.set("x-g1t-format", format)?; | |
| 368 | + | Ok(response) | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | /// A job's artifacts: listing its run's (or another run's of its | |
| 372 | + | /// repository), uploading in parts, downloading, deleting; and the whole | |
| 373 | + | /// uploads and downloads by name of older runners. | |
| 374 | + | #[allow(clippy::too_many_arguments)] | |
| 375 | + | async fn artifacts( | |
| 376 | + | mut request: Request, | |
| 377 | + | kv: &KvStore, | |
| 378 | + | bucket: &Bucket, | |
| 379 | + | services: &Services, | |
| 380 | + | method: &str, | |
| 381 | + | job: &str, | |
| 382 | + | token: &str, | |
| 383 | + | run: &str, | |
| 384 | + | repo: &str, | |
| 385 | + | what: &str, | |
| 386 | + | ) -> Result<Response> { | |
| 387 | + | let parts: Vec<&str> = what.split('/').collect(); | |
| 388 | + | let upload_id = query(&request, "upload").unwrap_or_default(); | |
| 389 | + | let credential = |id: Option<u64>, name: Option<String>, run_id: Option<String>| JobArtifactsArgs { | |
| 390 | + | job: job.to_owned(), | |
| 391 | + | token: token.to_owned(), | |
| 392 | + | run_id, | |
| 393 | + | name, | |
| 394 | + | id, | |
| 395 | + | }; | |
| 396 | + | let actions = &services.actions; | |
| 397 | + | match (method, parts.as_slice()) { | |
| 398 | + | ("GET", ["artifacts"]) => { | |
| 399 | + | let run_id = query(&request, "run_id").filter(|r| !r.is_empty()); | |
| 400 | + | let found: Outcome<Vec<Artifact>> = g1t_kit::call(actions, "job_artifacts", &credential(None, None, run_id.clone())).await?; | |
| 401 | + | match refused(found) { | |
| 402 | + | Ok(found) => { | |
| 403 | + | let mut listed: Vec<Value> = found.iter().map(for_runner).collect(); | |
| 404 | + | // Artifacts older runners kept in KV, for the days they | |
| 405 | + | // are still there. | |
| 406 | + | let legacy_run = run_id.as_deref().unwrap_or(run); | |
| 407 | + | for (_, meta) in list(kv, &format!("a/{legacy_run}/")).await? { | |
| 408 | + | if !listed.iter().any(|a| a["name"] == meta.name.as_str()) { | |
| 409 | + | listed.push(json!({ "name": meta.name, "size": meta.size, "format": "tgz" })); | |
| 410 | + | } | |
| 411 | + | } | |
| 412 | + | crate::reply(&listed) | |
| 413 | + | } | |
| 414 | + | Err(reply) => reply, | |
| 415 | + | } | |
| 416 | + | } | |
| 417 | + | ("POST", ["artifacts", "uploads"]) => { | |
| 418 | + | let flag = |name: &str| query(&request, name).is_some_and(|v| v == "true"); | |
| 419 | + | let args = ArtifactReserveArgs { | |
| 420 | + | job: job.to_owned(), | |
| 421 | + | token: token.to_owned(), | |
| 422 | + | name: query(&request, "name").unwrap_or_default(), | |
| 423 | + | size: query(&request, "size").and_then(|s| s.parse().ok()).unwrap_or(0), | |
| 424 | + | retention_days: query(&request, "retention_days").and_then(|d| d.parse().ok()).unwrap_or(0), | |
| 425 | + | expires_at: None, | |
| 426 | + | overwrite: flag("overwrite"), | |
| 427 | + | format: query(&request, "format"), | |
| 428 | + | }; | |
| 429 | + | let reserved: Outcome<ArtifactReservation> = g1t_kit::call(actions, "artifact_reserve", &args).await?; | |
| 430 | + | let reserved = match refused(reserved) { | |
| 431 | + | Ok(reserved) => reserved, | |
| 432 | + | Err(reply) => return reply, | |
| 433 | + | }; | |
| 434 | + | let upload = bucket.create_multipart_upload(&reserved.object).execute().await?; | |
| 435 | + | crate::reply(&json!({ | |
| 436 | + | "id": reserved.id, | |
| 437 | + | "upload": upload.upload_id().await, | |
| 438 | + | "part_bytes": ARTIFACT_PART_BYTES, | |
| 439 | + | "retention_days": reserved.retention_days, | |
| 440 | + | "expires_at": reserved.expires_at, | |
| 441 | + | })) | |
| 442 | + | } | |
| 443 | + | ("PUT", ["artifacts", "uploads", id, part]) => { | |
| 444 | + | let (Ok(id), Ok(part)) = (id.parse::<u64>(), part.parse::<u16>()) else { | |
| 445 | + | return error(400, "A part is numbered from 1, of an artifact named by its number."); | |
| 446 | + | }; | |
| 447 | + | if part == 0 || upload_id.is_empty() { | |
| 448 | + | return error(400, "A part is numbered from 1, and names its upload."); | |
| 449 | + | } | |
| 450 | + | let length = request.headers().get("content-length")?.and_then(|l| l.parse::<u64>().ok()).unwrap_or(0); | |
| 451 | + | if length == 0 || length > ARTIFACT_PART_BYTES { | |
| 452 | + | return error(413, &format!("A part is 1 to {} MB, with its length.", ARTIFACT_PART_BYTES / 1_048_576)); | |
| 453 | + | } | |
| 454 | + | let Some(body) = request.inner().body() else { return error(400, "The part is empty.") }; | |
| 455 | + | let upload = bucket.resume_multipart_upload(artifact_object(repo, id), &upload_id)?; | |
| 456 | + | let uploaded = upload.upload_part(part, body).await?; | |
| 457 | + | crate::reply(&json!({ "part": uploaded.part_number(), "etag": uploaded.etag() })) | |
| 458 | + | } | |
| 459 | + | ("POST", ["artifacts", "uploads", id, "complete"]) => { | |
| 460 | + | let Ok(id) = id.parse::<u64>() else { return error(404, "No such upload.") }; | |
| 461 | + | let done: Value = request.json().await.unwrap_or(Value::Null); | |
| 462 | + | let mut parts: Vec<Part> = serde_json::from_value(done["parts"].clone()).unwrap_or_default(); | |
| 463 | + | if parts.is_empty() { | |
| 464 | + | return error(400, "Send { size, parts: [{ part, etag }], digest }."); | |
| 465 | + | } | |
| 466 | + | parts.sort_by_key(|p| p.part); | |
| 467 | + | let upload = bucket.resume_multipart_upload(artifact_object(repo, id), &upload_id)?; | |
| 468 | + | let object = match upload.complete(parts.into_iter().map(|p| UploadedPart::new(p.part, p.etag))).await { | |
| 469 | + | Ok(object) => object, | |
| 470 | + | Err(problem) => { | |
| 471 | + | let _: Outcome<bool> = g1t_kit::call(actions, "artifact_abort", &credential(Some(id), None, None)).await?; | |
| 472 | + | return error(400, &format!("The upload could not be completed: {problem}")); | |
| 473 | + | } | |
| 474 | + | }; | |
| 475 | + | let args = ArtifactCommitArgs { | |
| 476 | + | job: job.to_owned(), | |
| 477 | + | token: token.to_owned(), | |
| 478 | + | id: Some(id), | |
| 479 | + | name: None, | |
| 480 | + | // What R2 holds, not what the runner says. | |
| 481 | + | size: object.size(), | |
| 482 | + | digest: done["digest"].as_str().map(str::to_owned), | |
| 483 | + | }; | |
| 484 | + | let committed: Outcome<Artifact> = g1t_kit::call(actions, "artifact_commit", &args).await?; | |
| 485 | + | match refused(committed) { | |
| 486 | + | Ok(artifact) => crate::reply(&for_runner(&artifact)), | |
| 487 | + | Err(reply) => reply, | |
| 488 | + | } | |
| 489 | + | } | |
| 490 | + | ("DELETE", ["artifacts", "uploads", id]) => { | |
| 491 | + | let Ok(id) = id.parse::<u64>() else { return error(404, "No such upload.") }; | |
| 492 | + | if let Ok(upload) = bucket.resume_multipart_upload(artifact_object(repo, id), &upload_id) { | |
| 493 | + | let _ = upload.abort().await; | |
| 494 | + | } | |
| 495 | + | let _: Outcome<bool> = g1t_kit::call(actions, "artifact_abort", &credential(Some(id), None, None)).await?; | |
| 496 | + | crate::reply(&json!({ "aborted": true })) | |
| 497 | + | } | |
| 498 | + | ("GET", ["artifacts", id, "download"]) => { | |
| 499 | + | let Ok(id) = id.parse::<u64>() else { return error(404, "No such artifact.") }; | |
| 500 | + | // Any run of the job's repository: the service checks. | |
| 501 | + | let found: Outcome<ArtifactBlob> = g1t_kit::call(actions, "job_artifact", &credential(Some(id), None, query(&request, "run_id"))).await?; | |
| 502 | + | match found { | |
| 503 | + | Outcome::Ok(found) => stream(bucket, &found.object, &found.artifact.format).await, | |
| 504 | + | Outcome::Fail(failure) => error(failure.code.http_status(), &failure.message), | |
| 505 | + | } | |
| 506 | + | } | |
| 507 | + | ("DELETE", ["artifacts", id]) => { | |
| 508 | + | let Ok(id) = id.parse::<u64>() else { return error(404, "No such artifact.") }; | |
| 509 | + | let done: Outcome<Artifact> = g1t_kit::call(actions, "job_delete_artifact", &credential(Some(id), None, None)).await?; | |
| 510 | + | match refused(done) { | |
| 511 | + | Ok(artifact) => crate::reply(&for_runner(&artifact)), | |
| 512 | + | Err(reply) => reply, | |
| 513 | + | } | |
| 514 | + | } | |
| 515 | + | // Older runners: a whole artifact of at most 60 MB, by name. | |
| 516 | + | ("PUT", ["artifacts", name]) => { | |
| 517 | + | let name = decode(name); | |
| 518 | + | let bytes = request.bytes().await?; | |
| 519 | + | if bytes.len() > MAX_BYTES { | |
| 520 | + | return error(413, "An artifact sent at once is at most 60 MB; newer runners upload it in parts."); | |
| 521 | + | } | |
| 522 | + | let args = ArtifactReserveArgs { | |
| 523 | + | job: job.to_owned(), | |
| 524 | + | token: token.to_owned(), | |
| 525 | + | name: name.clone(), | |
| 526 | + | size: bytes.len() as u64, | |
| 527 | + | format: Some("tgz".to_owned()), | |
| 528 | + | ..ArtifactReserveArgs::default() | |
| 529 | + | }; | |
| 530 | + | let reserved: Outcome<ArtifactReservation> = g1t_kit::call(actions, "artifact_reserve", &args).await?; | |
| 531 | + | let reserved = match refused(reserved) { | |
| 532 | + | Ok(reserved) => reserved, | |
| 533 | + | Err(reply) => return reply, | |
| 534 | + | }; | |
| 535 | + | let size = bytes.len() as u64; | |
| 536 | + | bucket.put(&reserved.object, bytes).execute().await?; | |
| 537 | + | let args = ArtifactCommitArgs { job: job.to_owned(), token: token.to_owned(), id: Some(reserved.id), name: None, size, digest: None }; | |
| 538 | + | let committed: Outcome<Artifact> = g1t_kit::call(actions, "artifact_commit", &args).await?; | |
| 539 | + | match refused(committed) { | |
| 540 | + | Ok(_) => crate::reply(&json!({ "name": name, "size": size })), | |
| 541 | + | Err(reply) => reply, | |
| 542 | + | } | |
| 543 | + | } | |
| 544 | + | ("GET", ["artifacts", name]) => { | |
| 545 | + | let name = decode(name); | |
| 546 | + | let found: Outcome<ArtifactBlob> = g1t_kit::call(actions, "job_artifact", &credential(None, Some(name.clone()), None)).await?; | |
| 547 | + | match found { | |
| 548 | + | Outcome::Ok(found) => stream(bucket, &found.object, &found.artifact.format).await, | |
| 549 | + | // One an older runner kept in KV. | |
| 550 | + | Outcome::Fail(_) => match get(kv, &format!("a/{run}/{name}")).await? { | |
| 551 | + | Some(bytes) => Response::from_bytes(bytes), | |
| 552 | + | None => error(404, "No such artifact."), | |
| 553 | + | }, | |
| 554 | + | } | |
| 555 | + | } | |
| 556 | + | _ => error(404, "No such endpoint."), | |
| 557 | + | } | |
| 558 | + | } | |
| 559 | + | ||
| 375 | 560 | /// Marks an uploaded entry ready, and deletes what that evicted. | |
| 376 | 561 | async fn commit(bucket: &Bucket, services: &Services, job: &str, token: &str, id: &str, size: u64) -> Result<()> { | |
| 377 | 562 | let committed: Outcome<CacheCommitted> = g1t_kit::call( | |
| 412 | 597 | return error(status, &refused.message); | |
| 413 | 598 | } | |
| 414 | 599 | let name = decode(name); | |
| 600 | + | // Kept in R2: a redirect to a link signed for a few minutes. | |
| 601 | + | let args = ArtifactArgs { | |
| 602 | + | repo: g1t_contracts::repos::RepoPath { namespace: owner.to_owned(), name: repo.to_owned() }, | |
| 603 | + | viewer: viewer.clone(), | |
| 604 | + | id: None, | |
| 605 | + | run: Some(run.to_owned()), | |
| 606 | + | name: Some(name.clone()), | |
| 607 | + | }; | |
| 608 | + | let found: Outcome<ArtifactBlob> = g1t_kit::call(&services.actions, "artifact_download", &args).await?; | |
| 609 | + | if let Outcome::Ok(found) = found | |
| 610 | + | && !found.blob.is_empty() | |
| 611 | + | { | |
| 612 | + | return Response::redirect_with_status(Url::parse(&crate::artifacts::blob_url(&services.addresses.api, &found.blob))?, 302); | |
| 613 | + | } | |
| 614 | + | // Kept in KV by an older runner. | |
| 415 | 615 | match get(&store(env)?, &format!("a/{run}/{name}")).await? { | |
| 416 | 616 | Some(bytes) => { | |
| 417 | 617 | let mut response = Response::from_bytes(bytes)?; | |
| 424 | 624 | } | |
| 425 | 625 | } | |
| 426 | 626 | ||
| 427 | − | /// A run's artifacts, for its page. | |
| 428 | − | pub async fn of_run(env: &Env, run: &str) -> Result<Vec<Value>> { | |
| 429 | − | Ok(list(&store(env)?, &format!("a/{run}/")) | |
| 430 | − | .await? | |
| 431 | − | .into_iter() | |
| 432 | − | .map(|(_, meta)| json!({ "name": meta.name, "size": meta.size, "at": meta.at })) | |
| 433 | − | .collect()) | |
| 434 | − | } |
| 5 | 5 | //! the data. This Worker holds none. | |
| 6 | 6 | ||
| 7 | 7 | mod about; | |
| 8 | + | mod artifacts; | |
| 8 | 9 | mod addresses; | |
| 9 | 10 | mod alerts; | |
| 10 | 11 | mod audit; | |
| 15 | 16 | mod mcp; | |
| 16 | 17 | mod notifications; | |
| 17 | 18 | mod oauth; | |
| 19 | + | mod oidc; | |
| 18 | 20 | mod openapi; | |
| 19 | 21 | mod pins; | |
| 20 | 22 | mod projects; | |
| 28 | 30 | mod runners; | |
| 29 | 31 | mod security; | |
| 30 | 32 | mod tools; | |
| 33 | + | mod toolkit; | |
| 31 | 34 | ||
| 32 | 35 | use g1t_contracts::billing::{FinishRunArgs, RunTokens}; | |
| 33 | 36 | use g1t_contracts::identity::{ | |
| 73 | 76 | "spec", "workflow", "github", "event", "contexts", "checkout", "permissions", | |
| 74 | 77 | ]; | |
| 75 | 78 | ||
| 79 | + | /// Puts the toolkit's variables in a job's spec: its runtime token, where | |
| 80 | + | /// the toolkit's services are, and where to ask for an OIDC token when the | |
| 81 | + | /// job may have one and this installation issues them. The `runtime` the | |
| 82 | + | /// actions service sent goes no further. | |
| 83 | + | fn with_runtime(spec: &mut Value, api: &str, oidc: bool) { | |
| 84 | + | let Some(runtime) = spec.as_object_mut().and_then(|s| s.remove("runtime")) else { return }; | |
| 85 | + | let Some(token) = runtime["token"].as_str().filter(|t| !t.is_empty()) else { return }; | |
| 86 | + | let id_token = oidc && runtime["id_token"].as_bool() == Some(true); | |
| 87 | + | let vars = toolkit::runtime_variables(api, token, id_token); | |
| 88 | + | if let Some(variables) = spec.get_mut("variables").and_then(Value::as_object_mut) { | |
| 89 | + | variables.extend(vars); | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 76 | 93 | /// An error in the shape every endpoint uses. | |
| 77 | 94 | fn failure(failure: &Failure) -> Result<Response> { | |
| 78 | 95 | Ok(reply(&json!({ "error": failure }))?.with_status(failure.code.http_status())) | |
| 547 | 564 | })); | |
| 548 | 565 | } | |
| 549 | 566 | ||
| 567 | + | // The services GitHub's toolkit calls from inside a job, with its | |
| 568 | + | // runtime token, and the links they hand out (toolkit.rs). | |
| 569 | + | if !on_mcp && method == "POST" | |
| 570 | + | && let Some(rest) = path.strip_prefix("/twirp/") | |
| 571 | + | { | |
| 572 | + | let (service, rpc) = rest.split_once('/').unwrap_or((rest, "")); | |
| 573 | + | let (service, rpc) = (service.to_owned(), rpc.to_owned()); | |
| 574 | + | return toolkit::twirp(request, env, &services, &service, &rpc).await; | |
| 575 | + | } | |
| 576 | + | if !on_mcp && let Some(rest) = path.strip_prefix("/actions/toolkit/_apis/artifactcache/") { | |
| 577 | + | let rest = rest.to_owned(); | |
| 578 | + | return toolkit::cache_v1(request, env, &services, method, &rest).await; | |
| 579 | + | } | |
| 580 | + | if !on_mcp && let Some(token) = path.strip_prefix("/actions/toolkit/blobs/") { | |
| 581 | + | let token = token.to_owned(); | |
| 582 | + | return toolkit::blob(request, env, &services, method, &token).await; | |
| 583 | + | } | |
| 584 | + | // g1t as an OIDC issuer for workflow jobs (oidc.rs). | |
| 585 | + | if !on_mcp && method == "GET" && path.starts_with("/actions/oidc/") { | |
| 586 | + | return oidc::handle(&request, env, &services, &path).await; | |
| 587 | + | } | |
| 588 | + | ||
| 550 | 589 | // A sandbox's artifacts and cache, with its job's token, which is not a | |
| 551 | 590 | // g1t token either. | |
| 552 | 591 | if !on_mcp | |
| 603 | 642 | match (method, path.trim_end_matches('/')) { | |
| 604 | 643 | ("GET", "") => return reply(&index(&services.addresses)), | |
| 605 | 644 | ("GET", "/openapi.json") => return Response::from_json(&openapi::document()), | |
| 606 | − | // A run's artifacts: listed, or one downloaded. | |
| 607 | − | ("GET", path) if path.starts_with("/repos/") && path.contains("/actions/runs/") && path.contains("/artifacts") => { | |
| 645 | + | // One of a run's artifacts downloaded by name (the run's artifacts | |
| 646 | + | // are listed by the REST route in rest.rs). | |
| 647 | + | ("GET", path) if path.starts_with("/repos/") && path.contains("/actions/runs/") && path.contains("/artifacts/") => { | |
| 608 | 648 | let parts: Vec<&str> = path.trim_start_matches("/repos/").split('/').collect(); | |
| 609 | − | if let [owner, repo, "actions", "runs", run, "artifacts", rest @ ..] = parts.as_slice() { | |
| 649 | + | if let [owner, repo, "actions", "runs", run, "artifacts", name] = parts.as_slice() { | |
| 610 | 650 | // A workflow job's token reaches its own repository only. | |
| 611 | 651 | if viewer | |
| 612 | 652 | .as_ref() | |
| 615 | 655 | { | |
| 616 | 656 | return fail(FailureCode::NotFound, "No such run."); | |
| 617 | 657 | } | |
| 618 | − | return match rest { | |
| 619 | − | [] => { | |
| 620 | − | let seen: Outcome<Value> = g1t_kit::call( | |
| 621 | − | &services.actions, | |
| 622 | − | "run", | |
| 623 | − | &json!({ "repo": { "namespace": owner, "name": repo }, "viewer": viewer, "id": run }), | |
| 624 | − | ) | |
| 625 | − | .await?; | |
| 626 | − | match seen { | |
| 627 | − | Outcome::Ok(_) => reply(&blobs::of_run(env, run).await?), | |
| 628 | − | Outcome::Fail(refused) => failure(&refused), | |
| 629 | − | } | |
| 630 | − | } | |
| 631 | − | [name] => blobs::download(env, &services, &viewer, owner, repo, run, name).await, | |
| 632 | − | _ => fail(FailureCode::NotFound, "No such endpoint."), | |
| 633 | − | }; | |
| 658 | + | return blobs::download(env, &services, &viewer, owner, repo, run, name).await; | |
| 634 | 659 | } | |
| 635 | 660 | } | |
| 636 | 661 | ("POST", "/device/code") => return device_code(&mut request, &services).await, | |
| 679 | 704 | return match answered { | |
| 680 | 705 | // A job's spec is the workflow and its contexts as GitHub | |
| 681 | 706 | // has them; only g1t's own keys around them are converted. | |
| 682 | − | Outcome::Ok(value) => Response::from_json(&wire::snake_case_keeping(value, JOB_SPEC_AS_GIVEN)), | |
| 707 | + | Outcome::Ok(value) => { | |
| 708 | + | let mut spec = wire::snake_case_keeping(value, JOB_SPEC_AS_GIVEN); | |
| 709 | + | with_runtime(&mut spec, &services.addresses.api, oidc::configured(env)); | |
| 710 | + | Response::from_json(&spec) | |
| 711 | + | } | |
| 683 | 712 | Outcome::Fail(refused) => failure(&refused), | |
| 684 | 713 | }; | |
| 685 | 714 | } | |
| 767 | 796 | return fail(FailureCode::NotFound, "No such endpoint."); | |
| 768 | 797 | }; | |
| 769 | 798 | match audit::run(route.op, &services, &viewer, &input).await? { | |
| 799 | + | // A download is a redirect to its signed link, as GitHub's is. | |
| 800 | + | Outcome::Ok(value) if route.op == operations::Op::Artifacts(artifacts::ArtifactsOp::DownloadArtifact) => { | |
| 801 | + | match value["url"].as_str().and_then(|url| worker::Url::parse(url).ok()) { | |
| 802 | + | Some(url) => Response::redirect_with_status(url, 302), | |
| 803 | + | None => reply(&value), | |
| 804 | + | } | |
| 805 | + | } | |
| 770 | 806 | Outcome::Ok(value) => reply(&value), | |
| 771 | 807 | // A token without the scope a call needs is told which one. | |
| 772 | 808 | Outcome::Fail(refused) => match (refused.code, audit::missing_scope(route.op, &viewer, &input)) { | |
| 815 | 851 | use serde_json::json; | |
| 816 | 852 | ||
| 817 | 853 | #[test] | |
| 854 | + | fn a_job_spec_gets_the_toolkits_variables() { | |
| 855 | + | // As the actions service sends it, converted as the API does. | |
| 856 | + | let sent = json!({ "variables": { "GITHUB_SHA": "abc" }, "runtime": { "token": "h.p.s", "idToken": true } }); | |
| 857 | + | let mut spec = g1t_kit::wire::snake_case_keeping(sent.clone(), super::JOB_SPEC_AS_GIVEN); | |
| 858 | + | super::with_runtime(&mut spec, "https://api.g1t.sh", true); | |
| 859 | + | assert!(spec.get("runtime").is_none(), "the runner never sees it"); | |
| 860 | + | let vars = &spec["variables"]; | |
| 861 | + | assert_eq!(vars["GITHUB_SHA"], "abc"); | |
| 862 | + | assert_eq!(vars["ACTIONS_RUNTIME_TOKEN"], "h.p.s"); | |
| 863 | + | assert_eq!(vars["ACTIONS_CACHE_URL"], "https://api.g1t.sh/actions/toolkit/"); | |
| 864 | + | assert_eq!(vars["ACTIONS_ID_TOKEN_REQUEST_TOKEN"], "h.p.s"); | |
| 865 | + | // No OIDC key here: no OIDC variables, whatever the job may do. | |
| 866 | + | let mut spec = g1t_kit::wire::snake_case_keeping(sent, super::JOB_SPEC_AS_GIVEN); | |
| 867 | + | super::with_runtime(&mut spec, "https://api.g1t.sh", false); | |
| 868 | + | assert!(spec["variables"].get("ACTIONS_ID_TOKEN_REQUEST_URL").is_none()); | |
| 869 | + | assert_eq!(spec["variables"]["ACTIONS_RESULTS_URL"], "https://api.g1t.sh/"); | |
| 870 | + | } | |
| 871 | + | ||
| 872 | + | #[test] | |
| 818 | 873 | fn camel_case_keys_are_accepted() { | |
| 819 | 874 | assert_eq!( | |
| 820 | 875 | snake_case_keys(json!({ "countAgentApprovals": false, "title": "x" })), |
| 1 | + | //! OIDC tokens for workflow jobs: g1t as an OpenID Connect issuer, so a | |
| 2 | + | //! job with `permissions: id-token: write` can trade a short-lived token | |
| 3 | + | //! for a cloud provider's credentials instead of keeping a long-lived key | |
| 4 | + | //! in a secret. | |
| 5 | + | //! | |
| 6 | + | //! - The issuer is `{API}/actions/oidc` (`https://api.g1t.sh/actions/oidc` | |
| 7 | + | //! hosted): its discovery document at | |
| 8 | + | //! `/.well-known/openid-configuration` under it, and its keys at | |
| 9 | + | //! `/.well-known/jwks`. No host of its own: the API's. | |
| 10 | + | //! - A job asks `GET {issuer}/token?api-version=2.0&audience=…` with its | |
| 11 | + | //! runtime token (`ACTIONS_ID_TOKEN_REQUEST_URL` and `…_TOKEN`, as the | |
| 12 | + | //! toolkit's `core.getIDToken` reads them) and gets `{ "value": jwt }`. | |
| 13 | + | //! - Tokens are RS256, good for five minutes, with GitHub's claims (the | |
| 14 | + | //! actions service decides them and whether the job may have one). | |
| 15 | + | //! - The signing key is the Worker secret `ACTIONS_OIDC_KEY`, an RSA | |
| 16 | + | //! private key in PEM (PKCS#8 or PKCS#1). `ACTIONS_OIDC_KEY_PREVIOUS`, | |
| 17 | + | //! while it is set, is published too, so tokens it signed still verify | |
| 18 | + | //! while the new key takes over. Each key's `kid` is its RFC 7638 | |
| 19 | + | //! thumbprint. docs/DEPLOYING.md says how to make and rotate them. | |
| 20 | + | ||
| 21 | + | use base64::Engine; | |
| 22 | + | use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; | |
| 23 | + | use g1t_contracts::actions::RuntimeAuthArgs; | |
| 24 | + | use g1t_contracts::{FailureCode, Outcome}; | |
| 25 | + | use g1t_kit::js; | |
| 26 | + | use serde_json::{Value, json}; | |
| 27 | + | use sha2::{Digest, Sha256}; | |
| 28 | + | use worker::js_sys::{self, Uint8Array}; | |
| 29 | + | use worker::{Env, Error, Request, Response, Result}; | |
| 30 | + | ||
| 31 | + | use crate::operations::Services; | |
| 32 | + | ||
| 33 | + | /// How long a token is good for. | |
| 34 | + | const LIFETIME_SECONDS: u64 = 5 * 60; | |
| 35 | + | pub const KEY_SECRET: &str = "ACTIONS_OIDC_KEY"; | |
| 36 | + | pub const PREVIOUS_KEY_SECRET: &str = "ACTIONS_OIDC_KEY_PREVIOUS"; | |
| 37 | + | ||
| 38 | + | /// The issuer, under the API's address. | |
| 39 | + | pub fn issuer(api: &str) -> String { | |
| 40 | + | format!("{api}/actions/oidc") | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /// The OpenID Provider configuration, as relying parties fetch it. | |
| 44 | + | pub fn discovery(api: &str) -> Value { | |
| 45 | + | let issuer = issuer(api); | |
| 46 | + | json!({ | |
| 47 | + | "issuer": issuer, | |
| 48 | + | "jwks_uri": format!("{issuer}/.well-known/jwks"), | |
| 49 | + | "subject_types_supported": ["public", "pairwise"], | |
| 50 | + | "response_types_supported": ["id_token"], | |
| 51 | + | "claims_supported": [ | |
| 52 | + | "sub", "aud", "exp", "iat", "iss", "jti", "nbf", "ref", "sha", "repository", "repository_id", "repository_owner", | |
| 53 | + | "repository_owner_id", "repository_visibility", "run_id", "run_number", "run_attempt", "actor", "actor_id", "workflow", | |
| 54 | + | "workflow_ref", "workflow_sha", "job_workflow_ref", "job_workflow_sha", "head_ref", "base_ref", "event_name", "ref_type", | |
| 55 | + | "ref_protected", "environment", "runner_environment" | |
| 56 | + | ], | |
| 57 | + | "id_token_signing_alg_values_supported": ["RS256"], | |
| 58 | + | "scopes_supported": ["openid"], | |
| 59 | + | }) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // ── Keys ──────────────────────────────────────────────────────────────────── | |
| 63 | + | ||
| 64 | + | /// A DER element: its tag, and its contents; and what follows it. | |
| 65 | + | fn der(input: &[u8]) -> Option<(u8, &[u8], &[u8])> { | |
| 66 | + | let (&tag, rest) = input.split_first()?; | |
| 67 | + | let (&first, rest) = rest.split_first()?; | |
| 68 | + | let (length, rest) = if first < 0x80 { | |
| 69 | + | (first as usize, rest) | |
| 70 | + | } else { | |
| 71 | + | let count = (first & 0x7f) as usize; | |
| 72 | + | if count == 0 || count > 4 || rest.len() < count { | |
| 73 | + | return None; | |
| 74 | + | } | |
| 75 | + | let length = rest[..count].iter().fold(0usize, |n, b| (n << 8) | *b as usize); | |
| 76 | + | (length, &rest[count..]) | |
| 77 | + | }; | |
| 78 | + | if rest.len() < length { | |
| 79 | + | return None; | |
| 80 | + | } | |
| 81 | + | Some((tag, &rest[..length], &rest[length..])) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /// An INTEGER's magnitude, without the sign byte DER may put in front. | |
| 85 | + | fn unsigned(bytes: &[u8]) -> &[u8] { | |
| 86 | + | let mut bytes = bytes; | |
| 87 | + | while bytes.len() > 1 && bytes[0] == 0 { | |
| 88 | + | bytes = &bytes[1..]; | |
| 89 | + | } | |
| 90 | + | bytes | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /// The modulus and public exponent of an RSA private key: PKCS#1 | |
| 94 | + | /// `RSAPrivateKey`, or PKCS#8 `PrivateKeyInfo` holding one. | |
| 95 | + | pub fn public_numbers(key: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> { | |
| 96 | + | let (0x30, body, _) = der(key)? else { return None }; | |
| 97 | + | let (0x02, _version, rest) = der(body)? else { return None }; | |
| 98 | + | let rsa = match der(rest)? { | |
| 99 | + | // PKCS#8: the algorithm, then the key in an OCTET STRING. | |
| 100 | + | (0x30, _algorithm, after) => { | |
| 101 | + | let (0x04, inner, _) = der(after)? else { return None }; | |
| 102 | + | let (0x30, rsa, _) = der(inner)? else { return None }; | |
| 103 | + | let (0x02, _version, rsa) = der(rsa)? else { return None }; | |
| 104 | + | rsa | |
| 105 | + | } | |
| 106 | + | // PKCS#1: the modulus is next. | |
| 107 | + | (0x02, _, _) => rest, | |
| 108 | + | _ => return None, | |
| 109 | + | }; | |
| 110 | + | let (0x02, n, rsa) = der(rsa)? else { return None }; | |
| 111 | + | let (0x02, e, _) = der(rsa)? else { return None }; | |
| 112 | + | Some((unsigned(n).to_vec(), unsigned(e).to_vec())) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// A DER length. | |
| 116 | + | fn der_length(length: usize) -> Vec<u8> { | |
| 117 | + | if length < 0x80 { | |
| 118 | + | return vec![length as u8]; | |
| 119 | + | } | |
| 120 | + | let bytes: Vec<u8> = length.to_be_bytes().into_iter().skip_while(|byte| *byte == 0).collect(); | |
| 121 | + | let mut out = vec![0x80 | bytes.len() as u8]; | |
| 122 | + | out.extend(bytes); | |
| 123 | + | out | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | /// Wraps a PKCS#1 key in PKCS#8, which is all WebCrypto imports. | |
| 127 | + | fn pkcs1_to_pkcs8(pkcs1: &[u8]) -> Vec<u8> { | |
| 128 | + | const RSA_ALGORITHM: [u8; 15] = [0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00]; | |
| 129 | + | let mut octets = vec![0x04]; | |
| 130 | + | octets.extend(der_length(pkcs1.len())); | |
| 131 | + | octets.extend_from_slice(pkcs1); | |
| 132 | + | let mut body = vec![0x02, 0x01, 0x00]; | |
| 133 | + | body.extend_from_slice(&RSA_ALGORITHM); | |
| 134 | + | body.extend(octets); | |
| 135 | + | let mut out = vec![0x30]; | |
| 136 | + | out.extend(der_length(body.len())); | |
| 137 | + | out.extend(body); | |
| 138 | + | out | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | /// A signing key: its PKCS#8 DER, and its public half as a JWK. | |
| 142 | + | pub struct SigningKey { | |
| 143 | + | pub pkcs8: Vec<u8>, | |
| 144 | + | pub jwk: Value, | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | impl SigningKey { | |
| 148 | + | /// From PEM, either form; line breaks pasted as `\n` are read too. | |
| 149 | + | pub fn from_pem(pem: &str) -> std::result::Result<SigningKey, String> { | |
| 150 | + | let pem = pem.replace("\\n", "\n"); | |
| 151 | + | let pkcs1 = pem.contains("BEGIN RSA PRIVATE KEY"); | |
| 152 | + | if !pkcs1 && !pem.contains("BEGIN PRIVATE KEY") { | |
| 153 | + | return Err(format!("{KEY_SECRET} is not a PEM RSA private key.")); | |
| 154 | + | } | |
| 155 | + | let body: String = pem.lines().filter(|l| !l.starts_with("-----")).flat_map(str::chars).filter(|c| !c.is_whitespace()).collect(); | |
| 156 | + | let der = STANDARD.decode(body).map_err(|_| format!("{KEY_SECRET} is not valid base64."))?; | |
| 157 | + | let (n, e) = public_numbers(&der).ok_or_else(|| format!("{KEY_SECRET} is not an RSA key."))?; | |
| 158 | + | let pkcs8 = if pkcs1 { pkcs1_to_pkcs8(&der) } else { der }; | |
| 159 | + | Ok(SigningKey { pkcs8, jwk: jwk(&n, &e) }) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | pub fn kid(&self) -> String { | |
| 163 | + | self.jwk["kid"].as_str().unwrap_or_default().to_owned() | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /// The public JWK of a key, its `kid` the RFC 7638 thumbprint. | |
| 168 | + | pub fn jwk(n: &[u8], e: &[u8]) -> Value { | |
| 169 | + | let (n, e) = (URL_SAFE_NO_PAD.encode(n), URL_SAFE_NO_PAD.encode(e)); | |
| 170 | + | // RFC 7638: the required members, in lexicographic order, no spaces. | |
| 171 | + | let canonical = format!(r#"{{"e":"{e}","kty":"RSA","n":"{n}"}}"#); | |
| 172 | + | let kid = URL_SAFE_NO_PAD.encode(Sha256::digest(canonical.as_bytes())); | |
| 173 | + | json!({ "kty": "RSA", "alg": "RS256", "use": "sig", "kid": kid, "n": n, "e": e }) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | /// The keys configured: the current one first. | |
| 177 | + | pub fn keys(env: &Env) -> (Option<std::result::Result<SigningKey, String>>, Option<SigningKey>) { | |
| 178 | + | let read = |name: &str| env.secret(name).ok().map(|s| s.to_string()).filter(|s| !s.trim().is_empty()); | |
| 179 | + | let current = read(KEY_SECRET).map(|pem| SigningKey::from_pem(&pem)); | |
| 180 | + | let previous = read(PREVIOUS_KEY_SECRET).and_then(|pem| SigningKey::from_pem(&pem).ok()); | |
| 181 | + | (current, previous) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | /// Whether this installation can issue OIDC tokens. | |
| 185 | + | pub fn configured(env: &Env) -> bool { | |
| 186 | + | matches!(keys(env).0, Some(Ok(_))) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | pub fn jwks(current: Option<&SigningKey>, previous: Option<&SigningKey>) -> Value { | |
| 190 | + | json!({ "keys": current.into_iter().chain(previous).map(|k| k.jwk.clone()).collect::<Vec<_>>() }) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // ── Tokens ────────────────────────────────────────────────────────────────── | |
| 194 | + | ||
| 195 | + | /// The token's claims: what the actions service said about the job, and | |
| 196 | + | /// who issued it, for whom and when. | |
| 197 | + | pub fn full_claims(mut claims: Value, issuer: &str, audience: &str, jti: &str, now: u64) -> Value { | |
| 198 | + | claims["iss"] = json!(issuer); | |
| 199 | + | claims["aud"] = json!(audience); | |
| 200 | + | claims["jti"] = json!(jti); | |
| 201 | + | claims["iat"] = json!(now); | |
| 202 | + | claims["nbf"] = json!(now.saturating_sub(60)); | |
| 203 | + | claims["exp"] = json!(now + LIFETIME_SECONDS); | |
| 204 | + | claims | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | /// The header and claims, base64url-encoded and joined: what is signed. | |
| 208 | + | pub fn signing_input(kid: &str, claims: &Value) -> String { | |
| 209 | + | let header = json!({ "typ": "JWT", "alg": "RS256", "kid": kid, "x5t": kid }); | |
| 210 | + | format!("{}.{}", URL_SAFE_NO_PAD.encode(header.to_string()), URL_SAFE_NO_PAD.encode(claims.to_string())) | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | /// RSASSA-PKCS1-v1_5 with SHA-256, by WebCrypto. | |
| 214 | + | async fn sign_rs256(pkcs8: &[u8], data: &[u8]) -> Result<Vec<u8>> { | |
| 215 | + | let subtle = js::get(&js::get(&js_sys::global(), "crypto"), "subtle"); | |
| 216 | + | let algorithm = js::to_js(&json!({ "name": "RSASSA-PKCS1-v1_5", "hash": "SHA-256" }))?; | |
| 217 | + | let usages = js::to_js(&json!(["sign"]))?; | |
| 218 | + | let key = js::call(&subtle, "importKey", &["pkcs8".into(), Uint8Array::from(pkcs8).into(), algorithm.clone(), false.into(), usages]) | |
| 219 | + | .await | |
| 220 | + | .map_err(|thrown| Error::RustError(format!("{KEY_SECRET} could not be used: {thrown}")))?; | |
| 221 | + | let signature = js::call(&subtle, "sign", &[algorithm, key, Uint8Array::from(data).into()]).await?; | |
| 222 | + | Ok(Uint8Array::new(&signature).to_vec()) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | fn error(status: u16, message: &str) -> Result<Response> { | |
| 226 | + | Ok(crate::reply(&json!({ "error": { "message": message } }))?.with_status(status)) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | /// `/actions/oidc/…`: discovery, keys, and a job's token. | |
| 230 | + | pub async fn handle(request: &Request, env: &Env, services: &Services, path: &str) -> Result<Response> { | |
| 231 | + | let api = services.addresses.api.clone(); | |
| 232 | + | let (current, previous) = keys(env); | |
| 233 | + | let current = match current { | |
| 234 | + | Some(Ok(key)) => key, | |
| 235 | + | Some(Err(problem)) => { | |
| 236 | + | worker::console_error!("oidc: {problem}"); | |
| 237 | + | return error(503, "OIDC tokens are not set up on this installation."); | |
| 238 | + | } | |
| 239 | + | None => return error(404, "OIDC tokens are not set up on this installation."), | |
| 240 | + | }; | |
| 241 | + | let cached = |value: &Value| -> Result<Response> { | |
| 242 | + | let mut response = Response::from_json(value)?; | |
| 243 | + | response.headers_mut().set("cache-control", "public, max-age=300")?; | |
| 244 | + | Ok(response) | |
| 245 | + | }; | |
| 246 | + | match path { | |
| 247 | + | "/actions/oidc/.well-known/openid-configuration" => cached(&discovery(&api)), | |
| 248 | + | "/actions/oidc/.well-known/jwks" => cached(&jwks(Some(¤t), previous.as_ref())), | |
| 249 | + | "/actions/oidc/token" => { | |
| 250 | + | let token = crate::toolkit::bearer(request); | |
| 251 | + | let Some(job) = crate::toolkit::runtime_job(&token) else { | |
| 252 | + | return error(401, "Send the job's ACTIONS_ID_TOKEN_REQUEST_TOKEN as a bearer token."); | |
| 253 | + | }; | |
| 254 | + | let claims: Outcome<Value> = g1t_kit::call(&services.actions, "oidc_claims", &RuntimeAuthArgs { job, token }).await?; | |
| 255 | + | let claims = match claims { | |
| 256 | + | Outcome::Ok(claims) => claims, | |
| 257 | + | Outcome::Fail(refused) => { | |
| 258 | + | let status = match refused.code { | |
| 259 | + | FailureCode::Unauthenticated => 401, | |
| 260 | + | FailureCode::Forbidden => 403, | |
| 261 | + | _ => 404, | |
| 262 | + | }; | |
| 263 | + | return error(status, &refused.message); | |
| 264 | + | } | |
| 265 | + | }; | |
| 266 | + | let url = request.url()?; | |
| 267 | + | let owner = claims["repository_owner"].as_str().unwrap_or_default().to_owned(); | |
| 268 | + | let audience = url | |
| 269 | + | .query_pairs() | |
| 270 | + | .find(|(k, _)| k == "audience") | |
| 271 | + | .map(|(_, v)| v.into_owned()) | |
| 272 | + | .filter(|a| !a.trim().is_empty()) | |
| 273 | + | // GitHub's default: the owner's address. | |
| 274 | + | .unwrap_or_else(|| format!("{}/{owner}", services.addresses.site)); | |
| 275 | + | let now = g1t_kit::now_ms() / 1000; | |
| 276 | + | let jti = g1t_contracts::new_id("oidc", g1t_kit::now_ms()); | |
| 277 | + | let claims = full_claims(claims, &issuer(&api), &audience, &jti, now); | |
| 278 | + | let input = signing_input(¤t.kid(), &claims); | |
| 279 | + | let signature = sign_rs256(¤t.pkcs8, input.as_bytes()).await?; | |
| 280 | + | let value = format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature)); | |
| 281 | + | Response::from_json(&json!({ "count": value.len(), "value": value })) | |
| 282 | + | } | |
| 283 | + | _ => error(404, "No such endpoint."), | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | #[cfg(test)] | |
| 288 | + | mod tests { | |
| 289 | + | use super::*; | |
| 290 | + | use std::process::Command; | |
| 291 | + | ||
| 292 | + | #[test] | |
| 293 | + | fn discovery_names_the_issuer_and_its_keys() { | |
| 294 | + | let doc = discovery("https://api.g1t.sh"); | |
| 295 | + | assert_eq!(doc["issuer"], "https://api.g1t.sh/actions/oidc"); | |
| 296 | + | assert_eq!(doc["jwks_uri"], "https://api.g1t.sh/actions/oidc/.well-known/jwks"); | |
| 297 | + | assert_eq!(doc["id_token_signing_alg_values_supported"], json!(["RS256"])); | |
| 298 | + | assert!(doc["claims_supported"].as_array().unwrap().contains(&json!("job_workflow_ref"))); | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | #[test] | |
| 302 | + | fn a_thumbprint_is_rfc_7638s() { | |
| 303 | + | // RFC 7638, section 3.1: the example key and its thumbprint. | |
| 304 | + | let n = URL_SAFE_NO_PAD | |
| 305 | + | .decode("0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw") | |
| 306 | + | .unwrap(); | |
| 307 | + | let key = jwk(&n, &[1, 0, 1]); | |
| 308 | + | assert_eq!(key["e"], "AQAB"); | |
| 309 | + | assert_eq!(key["kid"], "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"); | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | #[test] | |
| 313 | + | fn claims_get_who_issued_them_and_a_short_life() { | |
| 314 | + | let claims = full_claims(json!({ "sub": "repo:acme/web:ref:refs/heads/main" }), "https://api.g1t.sh/actions/oidc", "sts.amazonaws.com", "oidc_1", 1_700_000_000); | |
| 315 | + | assert_eq!(claims["iss"], "https://api.g1t.sh/actions/oidc"); | |
| 316 | + | assert_eq!(claims["aud"], "sts.amazonaws.com"); | |
| 317 | + | assert_eq!(claims["exp"].as_u64().unwrap() - claims["iat"].as_u64().unwrap(), LIFETIME_SECONDS); | |
| 318 | + | assert!(claims["nbf"].as_u64().unwrap() <= claims["iat"].as_u64().unwrap()); | |
| 319 | + | let input = signing_input("kid1", &claims); | |
| 320 | + | let header: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(input.split('.').next().unwrap()).unwrap()).unwrap(); | |
| 321 | + | assert_eq!((header["alg"].as_str(), header["kid"].as_str()), (Some("RS256"), Some("kid1"))); | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | #[test] | |
| 325 | + | fn a_key_that_is_not_one_is_refused() { | |
| 326 | + | assert!(SigningKey::from_pem("hello").is_err()); | |
| 327 | + | let pem = format!("{}\nAAAA\n{}", concat!("-----BEGIN ", "PRIVATE KEY-----"), concat!("-----END ", "PRIVATE KEY-----")); | |
| 328 | + | assert!(SigningKey::from_pem(&pem).is_err()); | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | fn openssl(args: &[&str]) -> Option<std::process::Output> { | |
| 332 | + | Command::new("openssl").args(args).output().ok().filter(|o| o.status.success()) | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | /// With openssl on the machine: a key made here, read in both PEM | |
| 336 | + | /// forms, gives the modulus openssl gives, and a token signed with it | |
| 337 | + | /// (by openssl, as WebCrypto is not here) verifies against the public | |
| 338 | + | /// key built from the JWKS. | |
| 339 | + | #[test] | |
| 340 | + | fn a_real_key_signs_tokens_its_jwks_verifies() { | |
| 341 | + | let dir = std::env::temp_dir().join(format!("g1t-oidc-test-{}", std::process::id())); | |
| 342 | + | let _ = std::fs::create_dir_all(&dir); | |
| 343 | + | let path = |name: &str| dir.join(name).display().to_string(); | |
| 344 | + | if openssl(&["genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", "-out", &path("key.pem")]).is_none() { | |
| 345 | + | eprintln!("openssl is not here; skipped"); | |
| 346 | + | return; | |
| 347 | + | } | |
| 348 | + | let pem = std::fs::read_to_string(path("key.pem")).unwrap(); | |
| 349 | + | let key = SigningKey::from_pem(&pem).unwrap(); | |
| 350 | + | // The modulus is openssl's. | |
| 351 | + | let modulus = openssl(&["rsa", "-in", &path("key.pem"), "-noout", "-modulus"]).unwrap(); | |
| 352 | + | let modulus = String::from_utf8_lossy(&modulus.stdout).trim().trim_start_matches("Modulus=").to_lowercase(); | |
| 353 | + | let n = URL_SAFE_NO_PAD.decode(key.jwk["n"].as_str().unwrap()).unwrap(); | |
| 354 | + | assert_eq!(n.iter().map(|b| format!("{b:02x}")).collect::<String>(), modulus); | |
| 355 | + | assert_eq!(key.jwk["e"], "AQAB"); | |
| 356 | + | // The traditional form reads to the same key. | |
| 357 | + | if openssl(&["rsa", "-in", &path("key.pem"), "-traditional", "-out", &path("key1.pem")]).is_some() { | |
| 358 | + | let pkcs1 = std::fs::read_to_string(path("key1.pem")).unwrap(); | |
| 359 | + | if pkcs1.contains("BEGIN RSA PRIVATE KEY") { | |
| 360 | + | let again = SigningKey::from_pem(&pkcs1).unwrap(); | |
| 361 | + | assert_eq!(again.kid(), key.kid()); | |
| 362 | + | assert_eq!(again.pkcs8, key.pkcs8, "PKCS#1 is wrapped as openssl writes PKCS#8"); | |
| 363 | + | } | |
| 364 | + | } | |
| 365 | + | // Sign the token's input with openssl, verify with the JWKS's key. | |
| 366 | + | let claims = full_claims(json!({ "sub": "repo:acme/web:environment:prod" }), "https://api.g1t.sh/actions/oidc", "sts.amazonaws.com", "j", 1_700_000_000); | |
| 367 | + | let input = signing_input(&key.kid(), &claims); | |
| 368 | + | std::fs::write(path("input"), &input).unwrap(); | |
| 369 | + | openssl(&["dgst", "-sha256", "-sign", &path("key.pem"), "-out", &path("sig"), &path("input")]).unwrap(); | |
| 370 | + | // The public key from n and e alone: RSAPublicKey DER. | |
| 371 | + | let integer = |bytes: &[u8]| { | |
| 372 | + | let mut value = bytes.to_vec(); | |
| 373 | + | if value[0] & 0x80 != 0 { | |
| 374 | + | value.insert(0, 0); | |
| 375 | + | } | |
| 376 | + | let mut out = vec![0x02]; | |
| 377 | + | out.extend(der_length(value.len())); | |
| 378 | + | out.extend(value); | |
| 379 | + | out | |
| 380 | + | }; | |
| 381 | + | let mut body = integer(&n); | |
| 382 | + | body.extend(integer(&URL_SAFE_NO_PAD.decode(key.jwk["e"].as_str().unwrap()).unwrap())); | |
| 383 | + | let mut public = vec![0x30]; | |
| 384 | + | public.extend(der_length(body.len())); | |
| 385 | + | public.extend(body); | |
| 386 | + | std::fs::write(path("public.der"), &public).unwrap(); | |
| 387 | + | let checked = openssl(&["rsa", "-RSAPublicKey_in", "-inform", "DER", "-in", &path("public.der"), "-pubout", "-out", &path("public.pem")]); | |
| 388 | + | assert!(checked.is_some(), "openssl reads the public key built from the JWKS"); | |
| 389 | + | let verified = openssl(&["dgst", "-sha256", "-verify", &path("public.pem"), "-signature", &path("sig"), &path("input")]); | |
| 390 | + | assert!(verified.is_some(), "the JWKS key verifies the token's signature"); | |
| 391 | + | // And not a token whose claims were changed. | |
| 392 | + | std::fs::write(path("input"), format!("{input}A")).unwrap(); | |
| 393 | + | let forged = openssl(&["dgst", "-sha256", "-verify", &path("public.pem"), "-signature", &path("sig"), &path("input")]); | |
| 394 | + | assert!(forged.is_none()); | |
| 395 | + | let _ = std::fs::remove_dir_all(&dir); | |
| 396 | + | } | |
| 397 | + | } |
| 8 | 8 | use serde_json::{Map, Value, json}; | |
| 9 | 9 | ||
| 10 | 10 | use crate::about::AboutOp; | |
| 11 | + | use crate::artifacts::ArtifactsOp; | |
| 11 | 12 | use crate::deployments::DeploymentsOp; | |
| 12 | 13 | use crate::protection::ProtectionOp; | |
| 13 | 14 | use crate::operations::Op; | |
| 367 | 368 | Op::CancelWorkflowRun, | |
| 368 | 369 | Op::RerunWorkflowRun, | |
| 369 | 370 | Op::UpdateWorkflow, | |
| 371 | + | Op::Artifacts(ArtifactsOp::ListArtifacts), | |
| 372 | + | Op::Artifacts(ArtifactsOp::ListRunArtifacts), | |
| 373 | + | Op::Artifacts(ArtifactsOp::GetArtifact), | |
| 374 | + | Op::Artifacts(ArtifactsOp::DownloadArtifact), | |
| 375 | + | Op::Artifacts(ArtifactsOp::DeleteArtifact), | |
| 376 | + | Op::Artifacts(ArtifactsOp::GetArtifactRetention), | |
| 377 | + | Op::Artifacts(ArtifactsOp::SetArtifactRetention), | |
| 370 | 378 | ], | |
| 371 | 379 | ), | |
| 372 | 380 | ( | |
| 396 | 404 | Op::Protection(ProtectionOp::GetForkPrApproval), | |
| 397 | 405 | Op::Protection(ProtectionOp::SetForkPrApproval), | |
| 398 | 406 | Op::Protection(ProtectionOp::CreateRepositoryDispatch), | |
| 407 | + | Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions), | |
| 408 | + | Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions), | |
| 399 | 409 | ], | |
| 400 | 410 | ), | |
| 401 | 411 | ( | |
| 651 | 661 | Op::About(op) => op.title(), | |
| 652 | 662 | Op::Deployments(op) => op.title(), | |
| 653 | 663 | Op::Protection(op) => op.title(), | |
| 664 | + | Op::Artifacts(op) => op.title(), | |
| 654 | 665 | } | |
| 655 | 666 | } | |
| 656 | 667 |
| 28 | 28 | use crate::alerts::{AlertKind, SecurityAlert}; | |
| 29 | 29 | use crate::checks::ChecksOp; | |
| 30 | 30 | use crate::about::AboutOp; | |
| 31 | + | use crate::artifacts::ArtifactsOp; | |
| 31 | 32 | use crate::deployments::DeploymentsOp; | |
| 32 | 33 | use crate::protection::ProtectionOp; | |
| 33 | 34 | use crate::rules::RulesOp; | |
| 287 | 288 | /// Environments' protection rules, approving runs, the token's default | |
| 288 | 289 | /// permissions and repository dispatch: protection.rs. | |
| 289 | 290 | Protection(ProtectionOp), | |
| 291 | + | /// Workflow run artifacts, and how long they are kept: artifacts.rs. | |
| 292 | + | Artifacts(ArtifactsOp), | |
| 290 | 293 | } | |
| 291 | 294 | ||
| 292 | 295 | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| 649 | 652 | } | |
| 650 | 653 | ||
| 651 | 654 | impl Op { | |
| 652 | − | pub const ALL: [Op; 267] = [ | |
| 655 | + | pub const ALL: [Op; 276] = [ | |
| 653 | 656 | Op::Whoami, | |
| 654 | 657 | Op::GetWorkspace, | |
| 655 | 658 | Op::CreateWorkspace, | |
| 907 | 910 | Op::Deployments(DeploymentsOp::CreateDeploymentStatus), | |
| 908 | 911 | Op::Deployments(DeploymentsOp::ListEnvironments), | |
| 909 | 912 | Op::Deployments(DeploymentsOp::GetEnvironment), | |
| 913 | + | Op::Artifacts(ArtifactsOp::ListArtifacts), | |
| 914 | + | Op::Artifacts(ArtifactsOp::ListRunArtifacts), | |
| 915 | + | Op::Artifacts(ArtifactsOp::GetArtifact), | |
| 916 | + | Op::Artifacts(ArtifactsOp::DownloadArtifact), | |
| 917 | + | Op::Artifacts(ArtifactsOp::DeleteArtifact), | |
| 918 | + | Op::Artifacts(ArtifactsOp::GetArtifactRetention), | |
| 919 | + | Op::Artifacts(ArtifactsOp::SetArtifactRetention), | |
| 910 | 920 | Op::Protection(ProtectionOp::UpdateEnvironment), | |
| 911 | 921 | Op::Protection(ProtectionOp::DeleteEnvironment), | |
| 912 | 922 | Op::Protection(ProtectionOp::GetPendingDeployments), | |
| 917 | 927 | Op::Protection(ProtectionOp::GetForkPrApproval), | |
| 918 | 928 | Op::Protection(ProtectionOp::SetForkPrApproval), | |
| 919 | 929 | Op::Protection(ProtectionOp::CreateRepositoryDispatch), | |
| 930 | + | Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions), | |
| 931 | + | Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions), | |
| 920 | 932 | ]; | |
| 921 | 933 | ||
| 922 | 934 | pub fn by_name(name: &str) -> Option<Op> { | |
| 1111 | 1123 | Op::About(op) => op.name(), | |
| 1112 | 1124 | Op::Deployments(op) => op.name(), | |
| 1113 | 1125 | Op::Protection(op) => op.name(), | |
| 1126 | + | Op::Artifacts(op) => op.name(), | |
| 1114 | 1127 | } | |
| 1115 | 1128 | } | |
| 1116 | 1129 | ||
| 1625 | 1638 | Op::About(op) => op.description(), | |
| 1626 | 1639 | Op::Deployments(op) => op.description(), | |
| 1627 | 1640 | Op::Protection(op) => op.description(), | |
| 1641 | + | Op::Artifacts(op) => op.description(), | |
| 1628 | 1642 | } | |
| 1629 | 1643 | } | |
| 1630 | 1644 | ||
| 2997 | 3011 | Op::About(op) => op.input(), | |
| 2998 | 3012 | Op::Deployments(op) => op.input(), | |
| 2999 | 3013 | Op::Protection(op) => op.input(), | |
| 3014 | + | Op::Artifacts(op) => op.input(), | |
| 3000 | 3015 | } | |
| 3001 | 3016 | } | |
| 3002 | 3017 | ||
| 3009 | 3024 | if let Op::About(op) = self { | |
| 3010 | 3025 | return !op.anonymous(); | |
| 3011 | 3026 | } | |
| 3027 | + | // A public repository's artifacts are anyone's to read. | |
| 3028 | + | if let Op::Artifacts(op) = self { | |
| 3029 | + | return op.writes(); | |
| 3030 | + | } | |
| 3012 | 3031 | !matches!( | |
| 3013 | 3032 | self, | |
| 3014 | 3033 | Op::ListRepos | |
| 3061 | 3080 | if let Op::Security(op) = self { | |
| 3062 | 3081 | return op.needs_repo(); | |
| 3063 | 3082 | } | |
| 3083 | + | if let Op::Protection(op) = self { | |
| 3084 | + | return op.needs_repo(); | |
| 3085 | + | } | |
| 3064 | 3086 | !matches!( | |
| 3065 | 3087 | self, | |
| 3066 | 3088 | Op::Whoami | |
| 5060 | 5082 | Op::About(op) => crate::about::run(op, services, viewer, input).await, | |
| 5061 | 5083 | Op::Deployments(op) => crate::deployments::run(op, services, viewer, input).await, | |
| 5062 | 5084 | Op::Protection(op) => crate::protection::run(op, services, viewer, input).await, | |
| 5085 | + | Op::Artifacts(op) => crate::artifacts::run(op, services, viewer, input).await, | |
| 5063 | 5086 | Op::ReopenSecurityAlert => { | |
| 5064 | 5087 | let changed: Outcome<AlertChange> = call( | |
| 5065 | 5088 | &services.security, |
| 25 | 25 | GetForkPrApproval, | |
| 26 | 26 | SetForkPrApproval, | |
| 27 | 27 | CreateRepositoryDispatch, | |
| 28 | + | GetWorkspaceWorkflowPermissions, | |
| 29 | + | SetWorkspaceWorkflowPermissions, | |
| 28 | 30 | } | |
| 29 | 31 | ||
| 30 | 32 | impl ProtectionOp { | |
| 31 | 33 | /// Every one: `Op::ALL` lists each as `Op::Protection(…)`, which a test | |
| 32 | 34 | /// checks against this. | |
| 33 | 35 | #[cfg(test)] | |
| 34 | − | pub const ALL: [ProtectionOp; 10] = [ | |
| 36 | + | pub const ALL: [ProtectionOp; 12] = [ | |
| 35 | 37 | ProtectionOp::UpdateEnvironment, | |
| 36 | 38 | ProtectionOp::DeleteEnvironment, | |
| 37 | 39 | ProtectionOp::GetPendingDeployments, | |
| 42 | 44 | ProtectionOp::GetForkPrApproval, | |
| 43 | 45 | ProtectionOp::SetForkPrApproval, | |
| 44 | 46 | ProtectionOp::CreateRepositoryDispatch, | |
| 47 | + | ProtectionOp::GetWorkspaceWorkflowPermissions, | |
| 48 | + | ProtectionOp::SetWorkspaceWorkflowPermissions, | |
| 45 | 49 | ]; | |
| 46 | 50 | ||
| 47 | 51 | pub fn name(self) -> &'static str { | |
| 56 | 60 | ProtectionOp::GetForkPrApproval => "get_fork_pr_approval", | |
| 57 | 61 | ProtectionOp::SetForkPrApproval => "set_fork_pr_approval", | |
| 58 | 62 | ProtectionOp::CreateRepositoryDispatch => "create_repository_dispatch", | |
| 63 | + | ProtectionOp::GetWorkspaceWorkflowPermissions => "get_workspace_workflow_permissions", | |
| 64 | + | ProtectionOp::SetWorkspaceWorkflowPermissions => "set_workspace_workflow_permissions", | |
| 59 | 65 | } | |
| 60 | 66 | } | |
| 61 | 67 | ||
| 68 | + | /// Whether it is about one repository, named by `repo`; the rest are a | |
| 69 | + | /// workspace's. | |
| 70 | + | pub fn needs_repo(self) -> bool { | |
| 71 | + | !matches!(self, ProtectionOp::GetWorkspaceWorkflowPermissions | ProtectionOp::SetWorkspaceWorkflowPermissions) | |
| 72 | + | } | |
| 73 | + | ||
| 62 | 74 | pub fn title(self) -> &'static str { | |
| 63 | 75 | match self { | |
| 64 | 76 | ProtectionOp::UpdateEnvironment => "Create or update an environment's protection rules", | |
| 71 | 83 | ProtectionOp::GetForkPrApproval => "Get the approval policy for outside pull requests", | |
| 72 | 84 | ProtectionOp::SetForkPrApproval => "Set the approval policy for outside pull requests", | |
| 73 | 85 | ProtectionOp::CreateRepositoryDispatch => "Create a repository dispatch event", | |
| 86 | + | ProtectionOp::GetWorkspaceWorkflowPermissions => "Get a workspace's default workflow permissions", | |
| 87 | + | ProtectionOp::SetWorkspaceWorkflowPermissions => "Set a workspace's default workflow permissions", | |
| 74 | 88 | } | |
| 75 | 89 | } | |
| 76 | 90 | ||
| 81 | 95 | ProtectionOp::GetPendingDeployments => "The environments whose protection rules hold a run's jobs, this attempt: each with the environment's name, state (waiting, approved or rejected), wait_timer and wait_until (when its timer lets its jobs start), its reviewers, the jobs it holds, who reviewed it and their comment, and current_user_can_approve. Needs the Read role.", | |
| 82 | 96 | ProtectionOp::ReviewPendingDeployments => "Approve or reject the jobs a run's environments hold. environment_names names them (every waiting one if left out; environment_ids is read as names too); state is approved or rejected; comment is kept with the review. Only one of the environment's reviewers may, or an admin when can_admins_bypass is on, which also skips the wait timer; with prevent_self_review, not whoever started the run. A rejected environment's jobs fail. A workflow job's own token cannot review. Returns the pending deployments as they stand.", | |
| 83 | 97 | ProtectionOp::ApproveWorkflowRun => "Let a run of a pull request from outside start: it waits as action_required, by the repository's approval policy (get_fork_pr_approval), until someone with the Write role approves it. A workflow job's own token cannot approve. Returns the run.", | |
| 84 | − | ProtectionOp::GetWorkflowPermissions => "What a job's G1T_TOKEN (GITHUB_TOKEN) may do when its workflow and job write no `permissions:`: default_workflow_permissions is read (contents and packages read; the default) or write (every permission). can_approve_pull_request_reviews is always false: a job's token never approves pull requests. Needs the Read role.", | |
| 85 | − | ProtectionOp::SetWorkflowPermissions => "Set default_workflow_permissions to read or write. Workflows that write `permissions:` get what they write either way, and a pull request's run from outside gets read-only. Needs the Admin role.", | |
| 98 | + | ProtectionOp::GetWorkflowPermissions => "What a job's G1T_TOKEN (GITHUB_TOKEN) may do when its workflow and job write no `permissions:`: default_workflow_permissions is read (contents and packages read) or write (every permission). Unless the repository chose (default_chosen), a repository made before restricted tokens has write and a newer one its workspace's default; it is never more than the workspace's max_workflow_permissions. can_approve_pull_request_reviews says whether its jobs may open and approve pull requests (off unless chosen, and only where the workspace allows it). Needs the Read role.", | |
| 99 | + | ProtectionOp::SetWorkflowPermissions => "Set default_workflow_permissions to read, write (refused where the workspace's maximum is read) or inherit (back to the workspace's default, or write for a repository made before restricted tokens), and can_approve_pull_request_reviews, \"Allow g1t Actions to create and approve pull requests\" (refused where the workspace does not allow it). Workflows that write `permissions:` get what they write either way, and a pull request's run from outside gets read-only. Needs the Admin role.", | |
| 86 | 100 | ProtectionOp::GetForkPrApproval => "Which pull requests' runs wait for someone with the Write role to approve them before anything runs (approve_workflow_run): approval_policy is first_time_contributors (a pull request from someone outside the workspace who has not had one merged here), outside_contributors (the default: also everyone outside who cannot push here) or all_external_contributors (everyone outside the workspace, outside collaborators included). Members never wait, nor does g1t's own work. Needs the Read role.", | |
| 87 | 101 | ProtectionOp::SetForkPrApproval => "Set approval_policy: first_time_contributors, outside_contributors or all_external_contributors. Needs the Admin role.", | |
| 102 | + | ProtectionOp::GetWorkspaceWorkflowPermissions => "A workspace's policy for its repositories' job tokens: default_workflow_permissions (read, the default, or write) is what a repository made from now on gets until it chooses; max_workflow_permissions (write, the default, or read) is the most any repository's default may be, so read holds every repository to read-only; can_approve_pull_request_reviews (off by default) lets its repositories allow jobs to open and approve pull requests. Members only.", | |
| 103 | + | ProtectionOp::SetWorkspaceWorkflowPermissions => "Change a workspace's default_workflow_permissions, max_workflow_permissions and can_approve_pull_request_reviews; fields left out stay as they are. A maximum of read makes the default read too. Owners only.", | |
| 88 | 104 | ProtectionOp::CreateRepositoryDispatch => "Start the default branch's workflows that run `on: repository_dispatch` for event_type (those listing it under types, or with none). client_payload, a JSON object of at most 10 properties and 64 KB, is github.event.client_payload; github.event.action is event_type. A workflow job's own token may send one: with workflow_dispatch, it is how one workflow starts another. Needs the Write role (code:write). Returns how many runs started.", | |
| 89 | 105 | } | |
| 90 | 106 | } | |
| 91 | 107 | ||
| 92 | 108 | /// Whether it changes anything (the caller is its actor). | |
| 93 | 109 | pub fn writes(self) -> bool { | |
| 94 | − | !matches!(self, ProtectionOp::GetPendingDeployments | ProtectionOp::GetWorkflowPermissions | ProtectionOp::GetForkPrApproval) | |
| 110 | + | !matches!( | |
| 111 | + | self, | |
| 112 | + | ProtectionOp::GetPendingDeployments | |
| 113 | + | | ProtectionOp::GetWorkflowPermissions | |
| 114 | + | | ProtectionOp::GetForkPrApproval | |
| 115 | + | | ProtectionOp::GetWorkspaceWorkflowPermissions | |
| 116 | + | ) | |
| 95 | 117 | } | |
| 96 | 118 | ||
| 97 | 119 | pub fn input(self) -> Value { | |
| 98 | 120 | let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." }); | |
| 99 | 121 | let run = json!({ "type": "string", "description": "The run's id, run_…." }); | |
| 122 | + | let workspace = json!({ "type": "string", "description": "The workspace's name, e.g. \"acme\"." }); | |
| 100 | 123 | let environment = json!({ "type": "string", "description": "The environment's name, such as production." }); | |
| 101 | 124 | let (properties, required): (Value, &[&str]) = match self { | |
| 102 | 125 | ProtectionOp::UpdateEnvironment => ( | |
| 155 | 178 | ProtectionOp::SetWorkflowPermissions => ( | |
| 156 | 179 | json!({ | |
| 157 | 180 | "repo": repo, | |
| 158 | − | "default_workflow_permissions": { "type": "string", "enum": ["read", "write"] }, | |
| 181 | + | "default_workflow_permissions": { "type": "string", "enum": ["read", "write", "inherit"] }, | |
| 182 | + | "can_approve_pull_request_reviews": { "type": "boolean", "description": "Allow g1t Actions to create and approve pull requests." }, | |
| 183 | + | }), | |
| 184 | + | &["repo"], | |
| 185 | + | ), | |
| 186 | + | ProtectionOp::GetWorkspaceWorkflowPermissions => (json!({ "workspace": workspace }), &["workspace"]), | |
| 187 | + | ProtectionOp::SetWorkspaceWorkflowPermissions => ( | |
| 188 | + | json!({ | |
| 189 | + | "workspace": workspace, | |
| 190 | + | "default_workflow_permissions": { "type": "string", "enum": ["read", "write"], "description": "What new repositories get." }, | |
| 191 | + | "max_workflow_permissions": { "type": "string", "enum": ["read", "write"], "description": "The most any repository's default may be." }, | |
| 192 | + | "can_approve_pull_request_reviews": { "type": "boolean", "description": "Let repositories allow jobs to open and approve pull requests." }, | |
| 159 | 193 | }), | |
| 160 | − | &["repo", "default_workflow_permissions"], | |
| 194 | + | &["workspace"], | |
| 161 | 195 | ), | |
| 162 | 196 | ProtectionOp::SetForkPrApproval => ( | |
| 163 | 197 | json!({ | |
| 384 | 418 | }) | |
| 385 | 419 | } | |
| 386 | 420 | ||
| 421 | + | /// A workspace's policy, in the standard shape. | |
| 422 | + | fn workspace_view(settings: &Value) -> Value { | |
| 423 | + | json!({ | |
| 424 | + | "default_workflow_permissions": settings["defaultPermissions"], | |
| 425 | + | "max_workflow_permissions": settings["maxPermissions"], | |
| 426 | + | "can_approve_pull_request_reviews": settings["canApprovePullRequests"], | |
| 427 | + | }) | |
| 428 | + | } | |
| 429 | + | ||
| 387 | 430 | pub async fn run(op: ProtectionOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 388 | − | let Some(repo) = repo_path(input) else { | |
| 389 | − | return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\".")); | |
| 390 | − | }; | |
| 391 | 431 | if op.writes() && viewer.is_none() { | |
| 392 | 432 | return Ok(Outcome::fail(FailureCode::Unauthenticated, "This needs a g1t access token.")); | |
| 393 | 433 | } | |
| 394 | 434 | let actor = || viewer.clone().unwrap_or_default(); | |
| 395 | 435 | let actions = &services.actions; | |
| 436 | + | if !op.needs_repo() { | |
| 437 | + | let Some(workspace) = text(input, "workspace") else { | |
| 438 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace.")); | |
| 439 | + | }; | |
| 440 | + | let settings: Outcome<Value> = if op == ProtectionOp::GetWorkspaceWorkflowPermissions { | |
| 441 | + | g1t_kit::call(actions, "workspace_actions_settings", &json!({ "viewer": viewer, "workspace": workspace })).await? | |
| 442 | + | } else { | |
| 443 | + | g1t_kit::call( | |
| 444 | + | actions, | |
| 445 | + | "set_workspace_actions_settings", | |
| 446 | + | &json!({ | |
| 447 | + | "actor": actor(), | |
| 448 | + | "workspace": workspace, | |
| 449 | + | "defaultPermissions": text(input, "default_workflow_permissions"), | |
| 450 | + | "maxPermissions": text(input, "max_workflow_permissions"), | |
| 451 | + | "canApprovePullRequests": flag(input, "can_approve_pull_request_reviews"), | |
| 452 | + | }), | |
| 453 | + | ) | |
| 454 | + | .await? | |
| 455 | + | }; | |
| 456 | + | return Ok(map(settings, |s| workspace_view(&s))); | |
| 457 | + | } | |
| 458 | + | let Some(repo) = repo_path(input) else { | |
| 459 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\".")); | |
| 460 | + | }; | |
| 396 | 461 | let id = text(input, "id").unwrap_or_default(); | |
| 397 | 462 | let environment = text(input, "environment").unwrap_or_default(); | |
| 398 | 463 | Ok(match op { | |
| 445 | 510 | g1t_kit::call( | |
| 446 | 511 | actions, | |
| 447 | 512 | "set_actions_settings", | |
| 448 | − | &json!({ "actor": actor(), "repo": repo, "defaultPermissions": text(input, "default_workflow_permissions").unwrap_or_default() }), | |
| 513 | + | &json!({ | |
| 514 | + | "actor": actor(), | |
| 515 | + | "repo": repo, | |
| 516 | + | "defaultPermissions": text(input, "default_workflow_permissions"), | |
| 517 | + | "canApprovePullRequests": flag(input, "can_approve_pull_request_reviews"), | |
| 518 | + | }), | |
| 449 | 519 | ) | |
| 450 | 520 | .await? | |
| 451 | 521 | }; | |
| 452 | − | map(settings, |s| json!({ "default_workflow_permissions": s["defaultPermissions"], "can_approve_pull_request_reviews": false })) | |
| 522 | + | map(settings, |s| { | |
| 523 | + | json!({ | |
| 524 | + | "default_workflow_permissions": s["defaultPermissions"], | |
| 525 | + | "default_chosen": s["defaultChosen"], | |
| 526 | + | "max_workflow_permissions": s["maxPermissions"], | |
| 527 | + | "can_approve_pull_request_reviews": s["canApprovePullRequests"], | |
| 528 | + | }) | |
| 529 | + | }) | |
| 453 | 530 | } | |
| 454 | 531 | ProtectionOp::GetForkPrApproval | ProtectionOp::SetForkPrApproval => { | |
| 455 | 532 | let settings: Outcome<Value> = if op == ProtectionOp::GetForkPrApproval { | |
| 478 | 555 | .await?; | |
| 479 | 556 | map(started, |runs| json!({ "runs": runs })) | |
| 480 | 557 | } | |
| 558 | + | // Answered above, before a repository is read. | |
| 559 | + | ProtectionOp::GetWorkspaceWorkflowPermissions | ProtectionOp::SetWorkspaceWorkflowPermissions => { | |
| 560 | + | Outcome::fail(FailureCode::Invalid, "Name the workspace.") | |
| 561 | + | } | |
| 481 | 562 | }) | |
| 482 | 563 | } | |
| 483 | 564 | ||
| 525 | 606 | fn each_operation_is_described_with_a_schema() { | |
| 526 | 607 | for op in ProtectionOp::ALL { | |
| 527 | 608 | assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name()); | |
| 528 | − | assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name()); | |
| 609 | + | let needs = if op.needs_repo() { "repo" } else { "workspace" }; | |
| 610 | + | assert!(op.input()["required"].as_array().unwrap().contains(&json!(needs)), "{}", op.name()); | |
| 529 | 611 | } | |
| 530 | 612 | } | |
| 531 | 613 | } |
| 10112 | 10112 | }, | |
| 10113 | 10113 | "response": { | |
| 10114 | 10114 | "default_workflow_permissions": "read", | |
| 10115 | + | "default_chosen": true, | |
| 10116 | + | "max_workflow_permissions": "write", | |
| 10115 | 10117 | "can_approve_pull_request_reviews": false | |
| 10116 | 10118 | } | |
| 10117 | 10119 | }, | |
| 10125 | 10127 | }, | |
| 10126 | 10128 | "response": { | |
| 10127 | 10129 | "default_workflow_permissions": "write", | |
| 10130 | + | "default_chosen": true, | |
| 10131 | + | "max_workflow_permissions": "write", | |
| 10128 | 10132 | "can_approve_pull_request_reviews": false | |
| 10129 | 10133 | } | |
| 10130 | 10134 | }, | |
| 10164 | 10168 | "runs": 1 | |
| 10165 | 10169 | } | |
| 10166 | 10170 | }, | |
| 10171 | + | "get_workspace_workflow_permissions": { | |
| 10172 | + | "params": { | |
| 10173 | + | "workspace": "flagon-io" | |
| 10174 | + | }, | |
| 10175 | + | "response": { | |
| 10176 | + | "default_workflow_permissions": "read", | |
| 10177 | + | "max_workflow_permissions": "write", | |
| 10178 | + | "can_approve_pull_request_reviews": false | |
| 10179 | + | } | |
| 10180 | + | }, | |
| 10181 | + | "set_workspace_workflow_permissions": { | |
| 10182 | + | "params": { | |
| 10183 | + | "workspace": "flagon-io" | |
| 10184 | + | }, | |
| 10185 | + | "request": { | |
| 10186 | + | "max_workflow_permissions": "read" | |
| 10187 | + | }, | |
| 10188 | + | "response": { | |
| 10189 | + | "default_workflow_permissions": "read", | |
| 10190 | + | "max_workflow_permissions": "read", | |
| 10191 | + | "can_approve_pull_request_reviews": false | |
| 10192 | + | }, | |
| 10193 | + | "notes": "A maximum of read holds every repository's default to read-only, and makes the workspace's default read too." | |
| 10194 | + | }, | |
| 10167 | 10195 | "get_languages": { | |
| 10168 | 10196 | "response": { | |
| 10169 | 10197 | "head": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f", | |
| 10870 | 10898 | "response": { | |
| 10871 | 10899 | "rerequested": true | |
| 10872 | 10900 | } | |
| 10901 | + | }, | |
| 10902 | + | "list_artifacts": { | |
| 10903 | + | "params": { | |
| 10904 | + | "owner": "flagon-io", | |
| 10905 | + | "name": "g1t" | |
| 10906 | + | }, | |
| 10907 | + | "query": { | |
| 10908 | + | "name": "web-dist", | |
| 10909 | + | "per_page": "1" | |
| 10910 | + | }, | |
| 10911 | + | "response": { | |
| 10912 | + | "total_count": 9, | |
| 10913 | + | "artifacts": [ | |
| 10914 | + | { | |
| 10915 | + | "id": 4182, | |
| 10916 | + | "node_id": "artifact_4182", | |
| 10917 | + | "name": "web-dist", | |
| 10918 | + | "size_in_bytes": 18734120, | |
| 10919 | + | "url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182", | |
| 10920 | + | "archive_download_url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182/zip", | |
| 10921 | + | "expired": false, | |
| 10922 | + | "digest": "sha256:5f1c3a9e7b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a", | |
| 10923 | + | "created_at": "2026-10-08T14:03:51.204Z", | |
| 10924 | + | "updated_at": "2026-10-08T14:03:52.880Z", | |
| 10925 | + | "expires_at": "2026-10-22T14:03:51.204Z", | |
| 10926 | + | "workflow_run": { | |
| 10927 | + | "id": "run_01kq9c4e6g8j0m2p4r6t8v0x2z", | |
| 10928 | + | "repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0", | |
| 10929 | + | "head_repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0", | |
| 10930 | + | "head_branch": "main", | |
| 10931 | + | "head_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7" | |
| 10932 | + | } | |
| 10933 | + | } | |
| 10934 | + | ] | |
| 10935 | + | }, | |
| 10936 | + | "notes": "Expired and deleted artifacts are not listed. `workflow_run.id` is the run's id, which `get_workflow_run` takes." | |
| 10937 | + | }, | |
| 10938 | + | "list_workflow_run_artifacts": { | |
| 10939 | + | "params": { | |
| 10940 | + | "owner": "flagon-io", | |
| 10941 | + | "name": "g1t", | |
| 10942 | + | "id": "run_01kq9c4e6g8j0m2p4r6t8v0x2z" | |
| 10943 | + | }, | |
| 10944 | + | "response": { | |
| 10945 | + | "total_count": 2, | |
| 10946 | + | "artifacts": [ | |
| 10947 | + | { | |
| 10948 | + | "id": 4181, | |
| 10949 | + | "node_id": "artifact_4181", | |
| 10950 | + | "name": "coverage", | |
| 10951 | + | "size_in_bytes": 412870, | |
| 10952 | + | "url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4181", | |
| 10953 | + | "archive_download_url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4181/zip", | |
| 10954 | + | "expired": false, | |
| 10955 | + | "digest": "sha256:5f1c3a9e7b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a", | |
| 10956 | + | "created_at": "2026-10-08T14:03:51.204Z", | |
| 10957 | + | "updated_at": "2026-10-08T14:03:52.880Z", | |
| 10958 | + | "expires_at": "2026-10-22T14:03:51.204Z", | |
| 10959 | + | "workflow_run": { | |
| 10960 | + | "id": "run_01kq9c4e6g8j0m2p4r6t8v0x2z", | |
| 10961 | + | "repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0", | |
| 10962 | + | "head_repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0", | |
| 10963 | + | "head_branch": "main", | |
| 10964 | + | "head_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7" | |
| 10965 | + | } | |
| 10966 | + | }, | |
| 10967 | + | { | |
| 10968 | + | "id": 4182, | |
| 10969 | + | "node_id": "artifact_4182", | |
| 10970 | + | "name": "web-dist", | |
| 10971 | + | "size_in_bytes": 18734120, | |
| 10972 | + | "url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182", | |
| 10973 | + | "archive_download_url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182/zip", | |
| 10974 | + | "expired": false, | |
| 10975 | + | "digest": "sha256:5f1c3a9e7b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a", | |
| 10976 | + | "created_at": "2026-10-08T14:03:51.204Z", | |
| 10977 | + | "updated_at": "2026-10-08T14:03:52.880Z", | |
| 10978 | + | "expires_at": "2026-10-22T14:03:51.204Z", | |
| 10979 | + | "workflow_run": { | |
| 10980 | + | "id": "run_01kq9c4e6g8j0m2p4r6t8v0x2z", | |
| 10981 | + | "repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0", | |
| 10982 | + | "head_repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0", | |
| 10983 | + | "head_branch": "main", | |
| 10984 | + | "head_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7" | |
| 10985 | + | } | |
| 10986 | + | } | |
| 10987 | + | ] | |
| 10988 | + | } | |
| 10989 | + | }, | |
| 10990 | + | "get_artifact": { | |
| 10991 | + | "params": { | |
| 10992 | + | "owner": "flagon-io", | |
| 10993 | + | "name": "g1t", | |
| 10994 | + | "id": "4182" | |
| 10995 | + | }, | |
| 10996 | + | "response": { | |
| 10997 | + | "id": 4182, | |
| 10998 | + | "node_id": "artifact_4182", | |
| 10999 | + | "name": "web-dist", | |
| 11000 | + | "size_in_bytes": 18734120, | |
| 11001 | + | "url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182", | |
| 11002 | + | "archive_download_url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182/zip", | |
| 11003 | + | "expired": false, | |
| 11004 | + | "digest": "sha256:5f1c3a9e7b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a", | |
| 11005 | + | "created_at": "2026-10-08T14:03:51.204Z", | |
| 11006 | + | "updated_at": "2026-10-08T14:03:52.880Z", | |
| 11007 | + | "expires_at": "2026-10-22T14:03:51.204Z", | |
| 11008 | + | "workflow_run": { | |
| 11009 | + | "id": "run_01kq9c4e6g8j0m2p4r6t8v0x2z", | |
| 11010 | + | "repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0", | |
| 11011 | + | "head_repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0", | |
| 11012 | + | "head_branch": "main", | |
| 11013 | + | "head_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7" | |
| 11014 | + | } | |
| 11015 | + | } | |
| 11016 | + | }, | |
| 11017 | + | "download_artifact": { | |
| 11018 | + | "params": { | |
| 11019 | + | "owner": "flagon-io", | |
| 11020 | + | "name": "g1t", | |
| 11021 | + | "id": "4182" | |
| 11022 | + | }, | |
| 11023 | + | "response": { | |
| 11024 | + | "url": "https://api.g1t.sh/actions/toolkit/blobs/eyJrIjoiYXJ0aWZhY3QiLCJpIjoiNDE4MiJ9.q3VbS1x9", | |
| 11025 | + | "expires_at": "2026-10-08T15:13:00.000Z", | |
| 11026 | + | "artifact": { | |
| 11027 | + | "id": 4182, | |
| 11028 | + | "node_id": "artifact_4182", | |
| 11029 | + | "name": "web-dist", | |
| 11030 | + | "size_in_bytes": 18734120, | |
| 11031 | + | "url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182", | |
| 11032 | + | "archive_download_url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182/zip", | |
| 11033 | + | "expired": false, | |
| 11034 | + | "digest": "sha256:5f1c3a9e7b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a", | |
| 11035 | + | "created_at": "2026-10-08T14:03:51.204Z", | |
| 11036 | + | "updated_at": "2026-10-08T14:03:52.880Z", | |
| 11037 | + | "expires_at": "2026-10-22T14:03:51.204Z", | |
| 11038 | + | "workflow_run": { | |
| 11039 | + | "id": "run_01kq9c4e6g8j0m2p4r6t8v0x2z", | |
| 11040 | + | "repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0", | |
| 11041 | + | "head_repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0", | |
| 11042 | + | "head_branch": "main", | |
| 11043 | + | "head_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7" | |
| 11044 | + | } | |
| 11045 | + | } | |
| 11046 | + | }, | |
| 11047 | + | "notes": "Over REST the answer is `302 Found` with the link in `Location`, as GitHub's is, so `curl -L -o web-dist.zip …/zip` saves the file; the body above is what the MCP `workflow` tool's `download_artifact` returns. The link needs no token and stops working after 10 minutes." | |
| 11048 | + | }, | |
| 11049 | + | "delete_artifact": { | |
| 11050 | + | "params": { | |
| 11051 | + | "owner": "flagon-io", | |
| 11052 | + | "name": "g1t", | |
| 11053 | + | "id": "4181" | |
| 11054 | + | }, | |
| 11055 | + | "response": { | |
| 11056 | + | "deleted": true, | |
| 11057 | + | "id": 4181, | |
| 11058 | + | "name": "coverage" | |
| 11059 | + | } | |
| 11060 | + | }, | |
| 11061 | + | "get_artifact_retention": { | |
| 11062 | + | "params": { | |
| 11063 | + | "owner": "flagon-io", | |
| 11064 | + | "name": "g1t" | |
| 11065 | + | }, | |
| 11066 | + | "response": { | |
| 11067 | + | "days": 14, | |
| 11068 | + | "maximum_allowed_days": 90 | |
| 11069 | + | } | |
| 11070 | + | }, | |
| 11071 | + | "set_artifact_retention": { | |
| 11072 | + | "params": { | |
| 11073 | + | "owner": "flagon-io", | |
| 11074 | + | "name": "g1t" | |
| 11075 | + | }, | |
| 11076 | + | "request": { | |
| 11077 | + | "days": 30 | |
| 11078 | + | }, | |
| 11079 | + | "response": { | |
| 11080 | + | "days": 30, | |
| 11081 | + | "maximum_allowed_days": 90 | |
| 11082 | + | }, | |
| 11083 | + | "notes": "Only artifacts uploaded afterwards are kept the new number of days. A workflow's `retention-days` asks for fewer, never more." | |
| 10873 | 11084 | } | |
| 10874 | 11085 | } |
| 112 | 112 | Op::Rules(RulesOp::DeleteRepoRuleset | RulesOp::DeleteWorkspaceRuleset) => return as_is, | |
| 113 | 113 | // Deployments travel in `snake_case` between services too. | |
| 114 | 114 | Op::Deployments(_) => return as_is, | |
| 115 | + | // Artifacts are shaped by the API itself, in `snake_case`. | |
| 116 | + | Op::Artifacts(_) => return as_is, | |
| 115 | 117 | // Built by the API itself, in `snake_case`. | |
| 116 | 118 | Op::ListSecurityAlerts => return through::<Vec<crate::alerts::SecurityAlert>>(op, as_is), | |
| 117 | 119 | Op::DismissSecurityAlert | Op::ReopenSecurityAlert => { |
| 3 | 3 | use serde_json::{Map, Value}; | |
| 4 | 4 | ||
| 5 | 5 | use crate::about::AboutOp; | |
| 6 | + | use crate::artifacts::ArtifactsOp; | |
| 6 | 7 | use crate::deployments::DeploymentsOp; | |
| 7 | 8 | use crate::protection::ProtectionOp; | |
| 8 | 9 | use crate::operations::Op; | |
| 355 | 356 | &[], | |
| 356 | 357 | ), | |
| 357 | 358 | route("POST", "/repos/:owner/:name/dispatches", Op::Protection(ProtectionOp::CreateRepositoryDispatch), &[]), | |
| 359 | + | route( | |
| 360 | + | "GET", | |
| 361 | + | "/workspaces/:workspace/actions/permissions/workflow", | |
| 362 | + | Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions), | |
| 363 | + | &[], | |
| 364 | + | ), | |
| 365 | + | route( | |
| 366 | + | "PUT", | |
| 367 | + | "/workspaces/:workspace/actions/permissions/workflow", | |
| 368 | + | Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions), | |
| 369 | + | &[], | |
| 370 | + | ), | |
| 358 | 371 | route("GET", "/repos/:owner/:name/queue", Op::GetMergeQueue, &[]), | |
| 359 | 372 | route( | |
| 360 | 373 | "POST", | |
| 667 | 680 | Op::GetJobLogs, | |
| 668 | 681 | &[("after", "after")], | |
| 669 | 682 | ), | |
| 683 | + | // Artifacts, at GitHub's addresses. `…/zip` answers with a redirect to | |
| 684 | + | // a signed link (lib.rs). | |
| 685 | + | route("GET", "/repos/:owner/:name/actions/artifacts", Op::Artifacts(ArtifactsOp::ListArtifacts), &[("name", "name"), ("page", "page"), ("per_page", "per_page")]), | |
| 686 | + | route("GET", "/repos/:owner/:name/actions/runs/:id/artifacts", Op::Artifacts(ArtifactsOp::ListRunArtifacts), &[("name", "name")]), | |
| 687 | + | route("GET", "/repos/:owner/:name/actions/artifacts/:id", Op::Artifacts(ArtifactsOp::GetArtifact), &[]), | |
| 688 | + | route("GET", "/repos/:owner/:name/actions/artifacts/:id/zip", Op::Artifacts(ArtifactsOp::DownloadArtifact), &[]), | |
| 689 | + | route("DELETE", "/repos/:owner/:name/actions/artifacts/:id", Op::Artifacts(ArtifactsOp::DeleteArtifact), &[]), | |
| 690 | + | route("GET", "/repos/:owner/:name/actions/permissions/artifact-and-log-retention", Op::Artifacts(ArtifactsOp::GetArtifactRetention), &[]), | |
| 691 | + | route("PUT", "/repos/:owner/:name/actions/permissions/artifact-and-log-retention", Op::Artifacts(ArtifactsOp::SetArtifactRetention), &[]), | |
| 670 | 692 | route( | |
| 671 | 693 | "GET", | |
| 672 | 694 | "/repos/:owner/:name/actions/secrets", |
| 1 | + | //! The services GitHub's Actions toolkit calls from inside a job, so that | |
| 2 | + | //! actions built on `@actions/cache` and `@actions/artifact` (such as | |
| 3 | + | //! `actions/setup-node` with `cache:`, or `Swatinem/rust-cache`) work on | |
| 4 | + | //! g1t unchanged. A job is told where they are in its variables | |
| 5 | + | //! (`ACTIONS_RUNTIME_TOKEN`, `ACTIONS_RESULTS_URL`, `ACTIONS_CACHE_URL`, | |
| 6 | + | //! `ACTIONS_CACHE_SERVICE_V2`; see `runtime_variables`). | |
| 7 | + | //! | |
| 8 | + | //! - Twirp, at `/twirp/github.actions.results.api.v1.CacheService/…` and | |
| 9 | + | //! `…ArtifactService/…`: the cache's newer protocol (`CreateCacheEntry`, | |
| 10 | + | //! `FinalizeCacheEntryUpload`, `GetCacheEntryDownloadURL`) and the | |
| 11 | + | //! artifacts' (`CreateArtifact`, `FinalizeArtifact`, `ListArtifacts`, | |
| 12 | + | //! `GetSignedArtifactURL`, `DeleteArtifact`). JSON, the toolkit's field | |
| 13 | + | //! names. | |
| 14 | + | //! - The cache's older protocol, at `{ACTIONS_CACHE_URL}_apis/artifactcache/…`, | |
| 15 | + | //! which the toolkit's client uses whenever the server it runs against is | |
| 16 | + | //! not github.com: on g1t, that is the one it uses. | |
| 17 | + | //! - Blobs, at `/actions/toolkit/blobs/{token}`: the signed links those | |
| 18 | + | //! hand out. Downloads are a plain GET. Uploads speak the part of Azure | |
| 19 | + | //! Blob Storage's protocol the toolkit's client uses (Put Blob, Put | |
| 20 | + | //! Block, Put Block List), mapped onto an R2 multipart upload: a block's | |
| 21 | + | //! id ends in its index, which is its part's number. | |
| 22 | + | //! | |
| 23 | + | //! Every call carries the job's runtime token; the actions service checks | |
| 24 | + | //! it and keeps the entries (cache.rs, artifacts.rs, runtime.rs there). | |
| 25 | + | ||
| 26 | + | use base64::Engine; | |
| 27 | + | use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; | |
| 28 | + | use g1t_contracts::actions::{ | |
| 29 | + | ARTIFACT_MAX_BYTES, Artifact, ArtifactBlob, ArtifactCommitArgs, ArtifactReservation, ArtifactReserveArgs, BlobArgs, BlobGrant, BlobPart, | |
| 30 | + | BlobSignArgs, CACHE_MAX_ENTRY_BYTES, CACHE_PART_BYTES, CacheCommitArgs, CacheCommitted, CacheHit, CacheLookupArgs, CacheReservation, | |
| 31 | + | CacheReserveArgs, CacheUploadArgs, JobArtifactsArgs, | |
| 32 | + | }; | |
| 33 | + | use g1t_contracts::{Failure, FailureCode, Outcome}; | |
| 34 | + | use serde_json::{Map, Value, json}; | |
| 35 | + | use worker::{Bucket, Env, Request, Response, Result, UploadedPart}; | |
| 36 | + | ||
| 37 | + | use crate::artifacts::blob_url; | |
| 38 | + | use crate::operations::Services; | |
| 39 | + | ||
| 40 | + | /// The Twirp services, by name. | |
| 41 | + | pub const CACHE_SERVICE: &str = "github.actions.results.api.v1.CacheService"; | |
| 42 | + | pub const ARTIFACT_SERVICE: &str = "github.actions.results.api.v1.ArtifactService"; | |
| 43 | + | /// Where the cache's older protocol is: `ACTIONS_CACHE_URL`. | |
| 44 | + | pub const CACHE_PATH: &str = "/actions/toolkit/"; | |
| 45 | + | /// The largest single block or blob a request may carry. | |
| 46 | + | const MAX_BLOCK_BYTES: u64 = 256 * 1024 * 1024; | |
| 47 | + | ||
| 48 | + | /// The bearer token of a request. | |
| 49 | + | pub fn bearer(request: &Request) -> String { | |
| 50 | + | request | |
| 51 | + | .headers() | |
| 52 | + | .get("authorization") | |
| 53 | + | .ok() | |
| 54 | + | .flatten() | |
| 55 | + | .and_then(|h| h.split_once(' ').map(|(_, t)| t.trim().to_owned())) | |
| 56 | + | .unwrap_or_default() | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | fn claims(token: &str) -> Option<Value> { | |
| 60 | + | serde_json::from_slice(&URL_SAFE_NO_PAD.decode(token.split('.').nth(1)?).ok()?).ok() | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// The job a runtime token names, unchecked: the actions service checks it. | |
| 64 | + | pub fn runtime_job(token: &str) -> Option<String> { | |
| 65 | + | claims(token)?["job"].as_str().map(str::to_owned) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | /// The variables a job gets for the toolkit: its runtime token and where | |
| 69 | + | /// the services are, and where to ask for an OIDC token when it may. | |
| 70 | + | pub fn runtime_variables(api: &str, token: &str, id_token: bool) -> Map<String, Value> { | |
| 71 | + | let mut vars = Map::new(); | |
| 72 | + | let mut set = |k: &str, v: String| { | |
| 73 | + | vars.insert(k.to_owned(), Value::String(v)); | |
| 74 | + | }; | |
| 75 | + | set("ACTIONS_RUNTIME_TOKEN", token.to_owned()); | |
| 76 | + | set("ACTIONS_RESULTS_URL", format!("{api}/")); | |
| 77 | + | set("ACTIONS_CACHE_URL", format!("{api}{CACHE_PATH}")); | |
| 78 | + | set("ACTIONS_CACHE_SERVICE_V2", "True".to_owned()); | |
| 79 | + | if id_token { | |
| 80 | + | set("ACTIONS_ID_TOKEN_REQUEST_URL", format!("{}/token?api-version=2.0", crate::oidc::issuer(api))); | |
| 81 | + | set("ACTIONS_ID_TOKEN_REQUEST_TOKEN", token.to_owned()); | |
| 82 | + | } | |
| 83 | + | vars | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // ── Twirp ─────────────────────────────────────────────────────────────────── | |
| 87 | + | ||
| 88 | + | /// A Twirp error: its code and message, at the status Twirp gives it. | |
| 89 | + | fn twirp_error(code: &str, message: &str) -> Result<Response> { | |
| 90 | + | let status = match code { | |
| 91 | + | "unauthenticated" => 401, | |
| 92 | + | "permission_denied" => 403, | |
| 93 | + | "not_found" => 404, | |
| 94 | + | "already_exists" => 409, | |
| 95 | + | "invalid_argument" => 400, | |
| 96 | + | "failed_precondition" => 412, | |
| 97 | + | "resource_exhausted" => 429, | |
| 98 | + | _ => 500, | |
| 99 | + | }; | |
| 100 | + | Ok(Response::from_json(&json!({ "code": code, "msg": message }))?.with_status(status)) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | fn twirp_failure(failure: &Failure) -> Result<Response> { | |
| 104 | + | let code = match failure.code { | |
| 105 | + | FailureCode::Unauthenticated => "unauthenticated", | |
| 106 | + | FailureCode::Forbidden => "permission_denied", | |
| 107 | + | FailureCode::NotFound => "not_found", | |
| 108 | + | FailureCode::Conflict => "already_exists", | |
| 109 | + | FailureCode::Invalid => "invalid_argument", | |
| 110 | + | _ => "failed_precondition", | |
| 111 | + | }; | |
| 112 | + | twirp_error(code, &failure.message) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// A field in the toolkit's spelling (`snake_case`), or its JSON name. | |
| 116 | + | fn field<'a>(body: &'a Value, name: &str) -> &'a Value { | |
| 117 | + | if !body[name].is_null() { | |
| 118 | + | return &body[name]; | |
| 119 | + | } | |
| 120 | + | let camel: String = name.split('_').enumerate().map(|(i, p)| if i == 0 { p.to_owned() } else { p[..1].to_uppercase() + &p[1..] }).collect(); | |
| 121 | + | &body[camel] | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | fn text(body: &Value, name: &str) -> String { | |
| 125 | + | match field(body, name) { | |
| 126 | + | Value::String(s) => s.clone(), | |
| 127 | + | Value::Number(n) => n.to_string(), | |
| 128 | + | // A wrapper written as an object, `{ "value": … }`. | |
| 129 | + | Value::Object(o) => o.get("value").map(|v| v.as_str().map_or_else(|| v.to_string(), str::to_owned)).unwrap_or_default(), | |
| 130 | + | _ => String::new(), | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | fn number(body: &Value, name: &str) -> Option<u64> { | |
| 135 | + | text(body, name).trim().parse().ok() | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | /// The run and job a runtime token names, which a request's backend ids | |
| 139 | + | /// must match. | |
| 140 | + | fn backend_ids(token: &str) -> (String, String) { | |
| 141 | + | let c = claims(token).unwrap_or_default(); | |
| 142 | + | (c["run"].as_str().unwrap_or_default().to_owned(), c["job"].as_str().unwrap_or_default().to_owned()) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /// What the toolkit's artifact client lists. | |
| 146 | + | fn listed(artifact: &Artifact) -> Value { | |
| 147 | + | json!({ | |
| 148 | + | "workflow_run_backend_id": artifact.run_id, | |
| 149 | + | "workflow_job_run_backend_id": artifact.job_id, | |
| 150 | + | "database_id": artifact.id.to_string(), | |
| 151 | + | "name": artifact.name, | |
| 152 | + | "size": artifact.size.to_string(), | |
| 153 | + | "created_at": artifact.created_at, | |
| 154 | + | "digest": artifact.digest, | |
| 155 | + | }) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /// Starts an R2 upload for an entry the service reserved, and the signed | |
| 159 | + | /// link the toolkit sends it to. | |
| 160 | + | async fn start_upload(bucket: &Bucket, services: &Services, job: &str, token: &str, kind: &str, id: &str, object: &str) -> Result<Outcome<String>> { | |
| 161 | + | let upload = bucket.create_multipart_upload(object).execute().await?; | |
| 162 | + | let upload = upload.upload_id().await; | |
| 163 | + | let signed: Outcome<String> = g1t_kit::call( | |
| 164 | + | &services.actions, | |
| 165 | + | "blob_sign", | |
| 166 | + | &BlobSignArgs { job: job.to_owned(), token: token.to_owned(), kind: kind.to_owned(), id: id.to_owned(), upload }, | |
| 167 | + | ) | |
| 168 | + | .await?; | |
| 169 | + | Ok(match signed { | |
| 170 | + | Outcome::Ok(blob) => Outcome::Ok(blob_url(&services.addresses.api, &blob)), | |
| 171 | + | Outcome::Fail(refused) => Outcome::Fail(refused), | |
| 172 | + | }) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | /// `POST /twirp/{service}/{method}`. | |
| 176 | + | pub async fn twirp(mut request: Request, env: &Env, services: &Services, service: &str, method: &str) -> Result<Response> { | |
| 177 | + | let token = bearer(&request); | |
| 178 | + | let Some(job) = runtime_job(&token) else { | |
| 179 | + | return twirp_error("unauthenticated", "Send the job's ACTIONS_RUNTIME_TOKEN as a bearer token."); | |
| 180 | + | }; | |
| 181 | + | let body: Value = request.json().await.unwrap_or(Value::Null); | |
| 182 | + | let bucket = env.bucket("ACTIONS_CACHE")?; | |
| 183 | + | let actions = &services.actions; | |
| 184 | + | let (run, own_job) = backend_ids(&token); | |
| 185 | + | // An artifact call names its run, and for an upload its job: the | |
| 186 | + | // token's. | |
| 187 | + | if service == ARTIFACT_SERVICE { | |
| 188 | + | let asked_run = text(&body, "workflow_run_backend_id"); | |
| 189 | + | if !asked_run.is_empty() && asked_run != run { | |
| 190 | + | return twirp_error("permission_denied", "The runtime token is for another run."); | |
| 191 | + | } | |
| 192 | + | let asked_job = text(&body, "workflow_job_run_backend_id"); | |
| 193 | + | if matches!(method, "CreateArtifact" | "FinalizeArtifact") && !asked_job.is_empty() && asked_job != own_job { | |
| 194 | + | return twirp_error("permission_denied", "The runtime token is for another job."); | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | match (service, method) { | |
| 198 | + | (CACHE_SERVICE, "GetCacheEntryDownloadURL") => { | |
| 199 | + | let restore: Vec<String> = field(&body, "restore_keys").as_array().map(|k| k.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()).unwrap_or_default(); | |
| 200 | + | let args = CacheLookupArgs { job, token, key: text(&body, "key"), restore, version: Some(text(&body, "version")) }; | |
| 201 | + | let found: Outcome<Option<CacheHit>> = g1t_kit::call(actions, "cache_lookup", &args).await?; | |
| 202 | + | match found { | |
| 203 | + | Outcome::Ok(Some(CacheHit { key, blob: Some(blob), .. })) => { | |
| 204 | + | Response::from_json(&json!({ "ok": true, "signed_download_url": blob_url(&services.addresses.api, &blob), "matched_key": key })) | |
| 205 | + | } | |
| 206 | + | Outcome::Ok(_) => Response::from_json(&json!({ "ok": false, "signed_download_url": "", "matched_key": "" })), | |
| 207 | + | Outcome::Fail(refused) => twirp_failure(&refused), | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | (CACHE_SERVICE, "CreateCacheEntry") => { | |
| 211 | + | let args = CacheReserveArgs { job: job.clone(), token: token.clone(), key: text(&body, "key"), size: 0, version: Some(text(&body, "version")) }; | |
| 212 | + | let reserved: Outcome<CacheReservation> = g1t_kit::call(actions, "cache_reserve", &args).await?; | |
| 213 | + | let reserved = match reserved { | |
| 214 | + | Outcome::Ok(reserved) => reserved, | |
| 215 | + | // The client warns with this and goes on, as for a key | |
| 216 | + | // another job is saving. | |
| 217 | + | Outcome::Fail(refused) => return Response::from_json(&json!({ "ok": false, "signed_upload_url": "", "message": refused.message })), | |
| 218 | + | }; | |
| 219 | + | match start_upload(&bucket, services, &job, &token, "cache", &reserved.id, &reserved.object).await? { | |
| 220 | + | Outcome::Ok(url) => Response::from_json(&json!({ "ok": true, "signed_upload_url": url })), | |
| 221 | + | Outcome::Fail(refused) => Response::from_json(&json!({ "ok": false, "signed_upload_url": "", "message": refused.message })), | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | (CACHE_SERVICE, "FinalizeCacheEntryUpload") => { | |
| 225 | + | let args = CacheUploadArgs { job: job.clone(), token: token.clone(), number: None, key: Some(text(&body, "key")), version: Some(text(&body, "version")) }; | |
| 226 | + | let pending: Outcome<CacheReservation> = g1t_kit::call(actions, "cache_upload", &args).await?; | |
| 227 | + | let pending = match pending { | |
| 228 | + | Outcome::Ok(pending) => pending, | |
| 229 | + | Outcome::Fail(refused) => return Response::from_json(&json!({ "ok": false, "entry_id": "0", "message": refused.message })), | |
| 230 | + | }; | |
| 231 | + | let Some(object) = bucket.head(&pending.object).await? else { | |
| 232 | + | return Response::from_json(&json!({ "ok": false, "entry_id": "0", "message": "Nothing was uploaded for that entry." })); | |
| 233 | + | }; | |
| 234 | + | match commit_cache(&bucket, services, &job, &token, &pending.id, object.size()).await? { | |
| 235 | + | Outcome::Ok(()) => Response::from_json(&json!({ "ok": true, "entry_id": pending.number.to_string() })), | |
| 236 | + | Outcome::Fail(refused) => Response::from_json(&json!({ "ok": false, "entry_id": "0", "message": refused.message })), | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | (ARTIFACT_SERVICE, "CreateArtifact") => { | |
| 240 | + | let expires_at = Some(text(&body, "expires_at")).filter(|e| !e.is_empty()); | |
| 241 | + | let args = ArtifactReserveArgs { job: job.clone(), token: token.clone(), name: text(&body, "name"), expires_at, ..ArtifactReserveArgs::default() }; | |
| 242 | + | let reserved: Outcome<ArtifactReservation> = g1t_kit::call(actions, "artifact_reserve", &args).await?; | |
| 243 | + | let reserved = match reserved { | |
| 244 | + | Outcome::Ok(reserved) => reserved, | |
| 245 | + | Outcome::Fail(refused) => return twirp_failure(&refused), | |
| 246 | + | }; | |
| 247 | + | match start_upload(&bucket, services, &job, &token, "artifact", &reserved.id.to_string(), &reserved.object).await? { | |
| 248 | + | Outcome::Ok(url) => Response::from_json(&json!({ "ok": true, "signed_upload_url": url })), | |
| 249 | + | Outcome::Fail(refused) => twirp_failure(&refused), | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | (ARTIFACT_SERVICE, "FinalizeArtifact") => { | |
| 253 | + | let digest = Some(text(&body, "hash")).filter(|h| !h.is_empty()); | |
| 254 | + | // The size it was measured at as it was stored, not the one it | |
| 255 | + | // says. | |
| 256 | + | let args = ArtifactCommitArgs { job, token, id: None, name: Some(text(&body, "name")), size: 0, digest }; | |
| 257 | + | let done: Outcome<Artifact> = g1t_kit::call(actions, "artifact_commit", &args).await?; | |
| 258 | + | match done { | |
| 259 | + | Outcome::Ok(artifact) => Response::from_json(&json!({ "ok": true, "artifact_id": artifact.id.to_string() })), | |
| 260 | + | Outcome::Fail(refused) => twirp_failure(&refused), | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | (ARTIFACT_SERVICE, "ListArtifacts") => { | |
| 264 | + | let name = Some(text(&body, "name_filter")).filter(|n| !n.is_empty()); | |
| 265 | + | let id = number(&body, "id_filter"); | |
| 266 | + | let args = JobArtifactsArgs { job, token, run_id: None, name, id }; | |
| 267 | + | let found: Outcome<Vec<Artifact>> = g1t_kit::call(actions, "job_artifacts", &args).await?; | |
| 268 | + | match found { | |
| 269 | + | Outcome::Ok(found) => Response::from_json(&json!({ "artifacts": found.iter().map(listed).collect::<Vec<_>>() })), | |
| 270 | + | Outcome::Fail(refused) => twirp_failure(&refused), | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | (ARTIFACT_SERVICE, "GetSignedArtifactURL") => { | |
| 274 | + | let args = JobArtifactsArgs { job, token, run_id: None, name: Some(text(&body, "name")), id: None }; | |
| 275 | + | let found: Outcome<ArtifactBlob> = g1t_kit::call(actions, "job_artifact", &args).await?; | |
| 276 | + | match found { | |
| 277 | + | Outcome::Ok(found) if !found.blob.is_empty() => Response::from_json(&json!({ "signed_url": blob_url(&services.addresses.api, &found.blob) })), | |
| 278 | + | Outcome::Ok(_) => twirp_error("failed_precondition", "Download links are not set up on this installation."), | |
| 279 | + | Outcome::Fail(refused) => twirp_failure(&refused), | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | (ARTIFACT_SERVICE, "DeleteArtifact") => { | |
| 283 | + | let args = JobArtifactsArgs { job, token, run_id: None, name: Some(text(&body, "name")), id: None }; | |
| 284 | + | let done: Outcome<Artifact> = g1t_kit::call(actions, "job_delete_artifact", &args).await?; | |
| 285 | + | match done { | |
| 286 | + | Outcome::Ok(artifact) => Response::from_json(&json!({ "ok": true, "artifact_id": artifact.id.to_string() })), | |
| 287 | + | Outcome::Fail(refused) => twirp_failure(&refused), | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | _ => twirp_error("bad_route", &format!("No method {method} on {service}.")), | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | /// Marks an uploaded cache entry ready, and deletes what that evicted. | |
| 295 | + | async fn commit_cache(bucket: &Bucket, services: &Services, job: &str, token: &str, id: &str, size: u64) -> Result<Outcome<()>> { | |
| 296 | + | let committed: Outcome<CacheCommitted> = g1t_kit::call( | |
| 297 | + | &services.actions, | |
| 298 | + | "cache_commit", | |
| 299 | + | &CacheCommitArgs { job: job.to_owned(), token: token.to_owned(), id: id.to_owned(), size }, | |
| 300 | + | ) | |
| 301 | + | .await?; | |
| 302 | + | Ok(match committed { | |
| 303 | + | Outcome::Ok(committed) => { | |
| 304 | + | if !committed.evicted.is_empty() { | |
| 305 | + | bucket.delete_multiple(committed.evicted.iter().map(String::as_str).collect()).await?; | |
| 306 | + | } | |
| 307 | + | Outcome::Ok(()) | |
| 308 | + | } | |
| 309 | + | Outcome::Fail(refused) => Outcome::Fail(refused), | |
| 310 | + | }) | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | // ── The cache's older protocol ────────────────────────────────────────────── | |
| 314 | + | ||
| 315 | + | fn plain_error(status: u16, message: &str) -> Result<Response> { | |
| 316 | + | Ok(Response::from_json(&json!({ "message": message, "error": { "message": message } }))?.with_status(status)) | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | fn query(request: &Request, name: &str) -> Option<String> { | |
| 320 | + | request.url().ok()?.query_pairs().find(|(k, _)| k == name).map(|(_, v)| v.into_owned()) | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | /// The part a chunk of the older protocol is, from its `Content-Range`: | |
| 324 | + | /// chunks are `CACHE_PART_BYTES` apart, as the toolkit sends them. | |
| 325 | + | pub fn chunk_part(range: &str) -> Option<(u16, u64)> { | |
| 326 | + | let range = range.trim().strip_prefix("bytes ")?; | |
| 327 | + | let (span, _) = range.split_once('/')?; | |
| 328 | + | let (start, end) = span.split_once('-')?; | |
| 329 | + | let (start, end): (u64, u64) = (start.trim().parse().ok()?, end.trim().parse().ok()?); | |
| 330 | + | if end < start || start % CACHE_PART_BYTES != 0 || end - start + 1 > CACHE_PART_BYTES { | |
| 331 | + | return None; | |
| 332 | + | } | |
| 333 | + | Some(((start / CACHE_PART_BYTES + 1) as u16, end - start + 1)) | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | /// `{ACTIONS_CACHE_URL}_apis/artifactcache/…`. `rest` is the path after it. | |
| 337 | + | pub async fn cache_v1(mut request: Request, env: &Env, services: &Services, method: &str, rest: &str) -> Result<Response> { | |
| 338 | + | let token = bearer(&request); | |
| 339 | + | let Some(job) = runtime_job(&token) else { | |
| 340 | + | return plain_error(401, "Send the job's ACTIONS_RUNTIME_TOKEN as a bearer token."); | |
| 341 | + | }; | |
| 342 | + | let bucket = env.bucket("ACTIONS_CACHE")?; | |
| 343 | + | let actions = &services.actions; | |
| 344 | + | let parts: Vec<&str> = rest.split('/').filter(|p| !p.is_empty()).collect(); | |
| 345 | + | match (method, parts.as_slice()) { | |
| 346 | + | ("GET", ["cache"]) => { | |
| 347 | + | let keys: Vec<String> = query(&request, "keys").unwrap_or_default().split(',').map(|k| k.trim().to_owned()).filter(|k| !k.is_empty()).collect(); | |
| 348 | + | let Some((key, restore)) = keys.split_first() else { | |
| 349 | + | return plain_error(400, "Give keys."); | |
| 350 | + | }; | |
| 351 | + | let version = query(&request, "version").unwrap_or_default(); | |
| 352 | + | let args = CacheLookupArgs { job, token, key: key.clone(), restore: restore.to_vec(), version: Some(version.clone()) }; | |
| 353 | + | let found: Outcome<Option<CacheHit>> = g1t_kit::call(actions, "cache_lookup", &args).await?; | |
| 354 | + | match found { | |
| 355 | + | Outcome::Ok(Some(CacheHit { key, blob: Some(blob), created_at, .. })) => Response::from_json(&json!({ | |
| 356 | + | "cacheKey": key, | |
| 357 | + | "cacheVersion": version, | |
| 358 | + | "scope": "", | |
| 359 | + | "creationTime": created_at, | |
| 360 | + | "archiveLocation": blob_url(&services.addresses.api, &blob), | |
| 361 | + | })), | |
| 362 | + | Outcome::Ok(_) => Ok(Response::empty()?.with_status(204)), | |
| 363 | + | Outcome::Fail(refused) => plain_error(refused.code.http_status(), &refused.message), | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | ("POST", ["caches"]) => { | |
| 367 | + | let body: Value = request.json().await.unwrap_or(Value::Null); | |
| 368 | + | let size = body["cacheSize"].as_u64().unwrap_or(0); | |
| 369 | + | let key = body["key"].as_str().unwrap_or_default().to_owned(); | |
| 370 | + | let version = body["version"].as_str().unwrap_or_default().to_owned(); | |
| 371 | + | let args = CacheReserveArgs { job: job.clone(), token: token.clone(), key, size, version: Some(version) }; | |
| 372 | + | let reserved: Outcome<CacheReservation> = g1t_kit::call(actions, "cache_reserve", &args).await?; | |
| 373 | + | let reserved = match reserved { | |
| 374 | + | Outcome::Ok(reserved) => reserved, | |
| 375 | + | Outcome::Fail(refused) => return plain_error(refused.code.http_status(), &refused.message), | |
| 376 | + | }; | |
| 377 | + | match start_upload(&bucket, services, &job, &token, "cache", &reserved.id, &reserved.object).await? { | |
| 378 | + | Outcome::Ok(_) => Ok(Response::from_json(&json!({ "cacheId": reserved.number }))?.with_status(201)), | |
| 379 | + | Outcome::Fail(refused) => plain_error(refused.code.http_status(), &refused.message), | |
| 380 | + | } | |
| 381 | + | } | |
| 382 | + | (_, ["caches", number]) => { | |
| 383 | + | let Ok(number) = number.parse::<u64>() else { | |
| 384 | + | return plain_error(404, "No such cache entry."); | |
| 385 | + | }; | |
| 386 | + | let args = CacheUploadArgs { job: job.clone(), token: token.clone(), number: Some(number), key: None, version: None }; | |
| 387 | + | let pending: Outcome<CacheReservation> = g1t_kit::call(actions, "cache_upload", &args).await?; | |
| 388 | + | let pending = match pending { | |
| 389 | + | Outcome::Ok(pending) => pending, | |
| 390 | + | Outcome::Fail(refused) => return plain_error(refused.code.http_status(), &refused.message), | |
| 391 | + | }; | |
| 392 | + | let (Some(blob), Some(upload_id)) = (pending.blob.clone(), pending.upload.clone()) else { | |
| 393 | + | return plain_error(409, "That entry's upload was not started."); | |
| 394 | + | }; | |
| 395 | + | match method { | |
| 396 | + | "PATCH" => { | |
| 397 | + | let range = request.headers().get("content-range")?.unwrap_or_default(); | |
| 398 | + | let Some((part, length)) = chunk_part(&range) else { | |
| 399 | + | return plain_error(400, &format!("Send the entry in chunks of {} MB, each with its Content-Range.", CACHE_PART_BYTES / 1_048_576)); | |
| 400 | + | }; | |
| 401 | + | let Some(body) = request.inner().body() else { return plain_error(400, "The chunk is empty.") }; | |
| 402 | + | let upload = bucket.resume_multipart_upload(&pending.object, &upload_id)?; | |
| 403 | + | let uploaded = upload.upload_part(part, body).await?; | |
| 404 | + | let recorded = BlobArgs { blob, part: u32::from(part), etag: uploaded.etag(), size: length }; | |
| 405 | + | let _: Outcome<bool> = g1t_kit::call(actions, "blob_part", &recorded).await?; | |
| 406 | + | Ok(Response::empty()?.with_status(204)) | |
| 407 | + | } | |
| 408 | + | "POST" => { | |
| 409 | + | let parts: Outcome<Vec<BlobPart>> = g1t_kit::call(actions, "blob_parts", &BlobArgs { blob: blob.clone(), ..BlobArgs::default() }).await?; | |
| 410 | + | let parts = match parts { | |
| 411 | + | Outcome::Ok(parts) => parts, | |
| 412 | + | Outcome::Fail(refused) => return plain_error(refused.code.http_status(), &refused.message), | |
| 413 | + | }; | |
| 414 | + | let size = match finish(&bucket, &pending.object, &upload_id, &parts).await { | |
| 415 | + | Ok(size) => size, | |
| 416 | + | Err(problem) => return plain_error(400, &format!("The entry could not be completed: {problem}")), | |
| 417 | + | }; | |
| 418 | + | let _: Outcome<bool> = g1t_kit::call(actions, "blob_done", &BlobArgs { blob, size, ..BlobArgs::default() }).await?; | |
| 419 | + | match commit_cache(&bucket, services, &job, &token, &pending.id, size).await? { | |
| 420 | + | Outcome::Ok(()) => Ok(Response::empty()?.with_status(204)), | |
| 421 | + | Outcome::Fail(refused) => plain_error(refused.code.http_status(), &refused.message), | |
| 422 | + | } | |
| 423 | + | } | |
| 424 | + | _ => plain_error(405, "PATCH a chunk, or POST to commit."), | |
| 425 | + | } | |
| 426 | + | } | |
| 427 | + | _ => plain_error(404, "No such endpoint."), | |
| 428 | + | } | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | /// Completes an R2 upload from its recorded parts, in order; the size it | |
| 432 | + | /// came to. An upload with no parts is an empty object. | |
| 433 | + | async fn finish(bucket: &Bucket, object: &str, upload_id: &str, parts: &[BlobPart]) -> std::result::Result<u64, String> { | |
| 434 | + | let upload = bucket.resume_multipart_upload(object, upload_id).map_err(|e| e.to_string())?; | |
| 435 | + | if parts.is_empty() { | |
| 436 | + | let _ = upload.abort().await; | |
| 437 | + | bucket.put(object, Vec::<u8>::new()).execute().await.map_err(|e| e.to_string())?; | |
| 438 | + | return Ok(0); | |
| 439 | + | } | |
| 440 | + | let done = upload | |
| 441 | + | .complete(parts.iter().map(|p| UploadedPart::new(p.part as u16, p.etag.clone()))) | |
| 442 | + | .await | |
| 443 | + | .map_err(|e| e.to_string())?; | |
| 444 | + | Ok(done.size()) | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | // ── Blobs ─────────────────────────────────────────────────────────────────── | |
| 448 | + | ||
| 449 | + | /// A block's index, from its id: the toolkit's client (Azure's SDK) makes | |
| 450 | + | /// block ids as base64 of a prefix and the index padded with zeros. | |
| 451 | + | pub fn block_index(id: &str) -> Option<u32> { | |
| 452 | + | let decoded = STANDARD.decode(id.trim()).ok()?; | |
| 453 | + | let text = String::from_utf8(decoded).ok()?; | |
| 454 | + | let digits: String = text.chars().rev().take_while(char::is_ascii_digit).collect::<Vec<_>>().into_iter().rev().collect(); | |
| 455 | + | if digits.is_empty() { | |
| 456 | + | return None; | |
| 457 | + | } | |
| 458 | + | digits.parse().ok() | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | /// The block ids of a Put Block List body, in order. | |
| 462 | + | pub fn block_list(xml: &str) -> Vec<String> { | |
| 463 | + | let mut ids = Vec::new(); | |
| 464 | + | let mut rest = xml; | |
| 465 | + | while let Some(open) = rest.find('<') { | |
| 466 | + | rest = &rest[open + 1..]; | |
| 467 | + | let Some(close) = rest.find('>') else { break }; | |
| 468 | + | let tag = &rest[..close]; | |
| 469 | + | rest = &rest[close + 1..]; | |
| 470 | + | if matches!(tag, "Latest" | "Committed" | "Uncommitted") { | |
| 471 | + | let Some(end) = rest.find("</") else { break }; | |
| 472 | + | ids.push(rest[..end].trim().to_owned()); | |
| 473 | + | rest = &rest[end..]; | |
| 474 | + | } | |
| 475 | + | } | |
| 476 | + | ids | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | /// The parts a block list names, as recorded: each block must have been | |
| 480 | + | /// sent, as the part its index says, and they must run from the first. | |
| 481 | + | pub fn parts_for(ids: &[String], recorded: &[BlobPart]) -> std::result::Result<Vec<BlobPart>, String> { | |
| 482 | + | let mut out = Vec::with_capacity(ids.len()); | |
| 483 | + | for (position, id) in ids.iter().enumerate() { | |
| 484 | + | let index = block_index(id).ok_or_else(|| format!("The block id {id} does not end in its index."))?; | |
| 485 | + | let part = index + 1; | |
| 486 | + | if part as usize != position + 1 { | |
| 487 | + | return Err("The blocks must be listed in the order they were numbered.".to_owned()); | |
| 488 | + | } | |
| 489 | + | let found = recorded.iter().find(|p| p.part == part).ok_or_else(|| format!("Block {id} was never sent."))?; | |
| 490 | + | out.push(found.clone()); | |
| 491 | + | } | |
| 492 | + | Ok(out) | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | fn azure(status: u16) -> Result<Response> { | |
| 496 | + | let mut response = Response::empty()?.with_status(status); | |
| 497 | + | let headers = response.headers_mut(); | |
| 498 | + | headers.set("x-ms-request-id", &g1t_contracts::new_id("req", g1t_kit::now_ms()))?; | |
| 499 | + | headers.set("x-ms-version", "2024-11-04")?; | |
| 500 | + | headers.set("x-ms-request-server-encrypted", "true")?; | |
| 501 | + | Ok(response) | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | fn azure_error(status: u16, code: &str, message: &str) -> Result<Response> { | |
| 505 | + | let body = format!("<?xml version=\"1.0\" encoding=\"utf-8\"?><Error><Code>{code}</Code><Message>{message}</Message></Error>"); | |
| 506 | + | let mut response = Response::ok(body)?.with_status(status); | |
| 507 | + | response.headers_mut().set("content-type", "application/xml")?; | |
| 508 | + | response.headers_mut().set("x-ms-error-code", code)?; | |
| 509 | + | Ok(response) | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | /// `/actions/toolkit/blobs/{token}`: GET or HEAD a download, PUT an upload. | |
| 513 | + | pub async fn blob(mut request: Request, env: &Env, services: &Services, method: &str, token: &str) -> Result<Response> { | |
| 514 | + | let opened: Outcome<BlobGrant> = g1t_kit::call(&services.actions, "blob_open", &BlobArgs { blob: token.to_owned(), ..BlobArgs::default() }).await?; | |
| 515 | + | let grant = match opened { | |
| 516 | + | Outcome::Ok(grant) => grant, | |
| 517 | + | Outcome::Fail(refused) => { | |
| 518 | + | let status = if refused.code == FailureCode::NotFound { 404 } else { 403 }; | |
| 519 | + | return azure_error(status, if status == 404 { "BlobNotFound" } else { "AuthenticationFailed" }, &refused.message); | |
| 520 | + | } | |
| 521 | + | }; | |
| 522 | + | let bucket = env.bucket("ACTIONS_CACHE")?; | |
| 523 | + | match (method, grant.upload.as_deref()) { | |
| 524 | + | ("GET" | "HEAD", None) => { | |
| 525 | + | let headers = |response: &mut Response, size: u64| -> Result<()> { | |
| 526 | + | let headers = response.headers_mut(); | |
| 527 | + | headers.set("content-length", &size.to_string())?; | |
| 528 | + | headers.set("content-type", grant.content_type.as_deref().unwrap_or("application/octet-stream"))?; | |
| 529 | + | headers.set("x-ms-blob-type", "BlockBlob")?; | |
| 530 | + | if let Some(name) = &grant.filename { | |
| 531 | + | headers.set("content-disposition", &format!("attachment; filename=\"{}\"", name.replace('"', "")))?; | |
| 532 | + | } | |
| 533 | + | Ok(()) | |
| 534 | + | }; | |
| 535 | + | if method == "HEAD" { | |
| 536 | + | let Some(object) = bucket.head(&grant.object).await? else { return azure_error(404, "BlobNotFound", "It is gone.") }; | |
| 537 | + | let mut response = Response::empty()?; | |
| 538 | + | headers(&mut response, object.size())?; | |
| 539 | + | return Ok(response); | |
| 540 | + | } | |
| 541 | + | let Some(object) = bucket.get(&grant.object).execute().await? else { return azure_error(404, "BlobNotFound", "It is gone.") }; | |
| 542 | + | let size = object.size(); | |
| 543 | + | let Some(body) = object.body() else { return azure_error(404, "BlobNotFound", "It is gone.") }; | |
| 544 | + | let mut response = Response::from_body(body.response_body()?)?; | |
| 545 | + | headers(&mut response, size)?; | |
| 546 | + | Ok(response) | |
| 547 | + | } | |
| 548 | + | ("PUT", Some(upload_id)) => { | |
| 549 | + | let comp = query(&request, "comp").unwrap_or_default(); | |
| 550 | + | let limit = if grant.kind == "cache" { CACHE_MAX_ENTRY_BYTES } else { ARTIFACT_MAX_BYTES }; | |
| 551 | + | match comp.as_str() { | |
| 552 | + | // Put Block, or Put Blob: one part. | |
| 553 | + | "block" | "" => { | |
| 554 | + | let part = if comp == "block" { | |
| 555 | + | match query(&request, "blockid").as_deref().and_then(block_index) { | |
| 556 | + | Some(index) if index < 10_000 => index + 1, | |
| 557 | + | _ => return azure_error(400, "InvalidQueryParameterValue", "A block id ends in its index, from 0."), | |
| 558 | + | } | |
| 559 | + | } else { | |
| 560 | + | 1 | |
| 561 | + | }; | |
| 562 | + | let length = request.headers().get("content-length")?.and_then(|l| l.parse::<u64>().ok()).unwrap_or(0); | |
| 563 | + | if length > MAX_BLOCK_BYTES || length > limit { | |
| 564 | + | return azure_error(413, "RequestBodyTooLarge", "That block is larger than g1t takes at once."); | |
| 565 | + | } | |
| 566 | + | let upload = bucket.resume_multipart_upload(&grant.object, upload_id)?; | |
| 567 | + | let uploaded = match request.inner().body() { | |
| 568 | + | Some(body) if length > 0 => upload.upload_part(part as u16, body).await?, | |
| 569 | + | _ => upload.upload_part(part as u16, Vec::<u8>::new()).await?, | |
| 570 | + | }; | |
| 571 | + | let recorded = BlobArgs { blob: token.to_owned(), part, etag: uploaded.etag(), size: length }; | |
| 572 | + | let _: Outcome<bool> = g1t_kit::call(&services.actions, "blob_part", &recorded).await?; | |
| 573 | + | if comp == "block" { | |
| 574 | + | return azure(201); | |
| 575 | + | } | |
| 576 | + | // Put Blob is the whole thing: finish it now. | |
| 577 | + | complete_blob(&bucket, services, token, &grant, upload_id, &[], limit, true).await | |
| 578 | + | } | |
| 579 | + | "blocklist" => { | |
| 580 | + | let xml = request.text().await.unwrap_or_default(); | |
| 581 | + | let ids = block_list(&xml); | |
| 582 | + | complete_blob(&bucket, services, token, &grant, upload_id, &ids, limit, false).await | |
| 583 | + | } | |
| 584 | + | _ => azure_error(400, "InvalidQueryParameterValue", "g1t takes Put Blob, Put Block and Put Block List."), | |
| 585 | + | } | |
| 586 | + | } | |
| 587 | + | _ => azure_error(405, "UnsupportedHttpVerb", "That link is not for this."), | |
| 588 | + | } | |
| 589 | + | } | |
| 590 | + | ||
| 591 | + | /// Finishes an upload from its parts: the blocks a list names, or the one | |
| 592 | + | /// part of a Put Blob. | |
| 593 | + | #[allow(clippy::too_many_arguments)] | |
| 594 | + | async fn complete_blob( | |
| 595 | + | bucket: &Bucket, | |
| 596 | + | services: &Services, | |
| 597 | + | token: &str, | |
| 598 | + | grant: &BlobGrant, | |
| 599 | + | upload_id: &str, | |
| 600 | + | ids: &[String], | |
| 601 | + | limit: u64, | |
| 602 | + | whole: bool, | |
| 603 | + | ) -> Result<Response> { | |
| 604 | + | let recorded: Outcome<Vec<BlobPart>> = g1t_kit::call(&services.actions, "blob_parts", &BlobArgs { blob: token.to_owned(), ..BlobArgs::default() }).await?; | |
| 605 | + | let recorded = match recorded { | |
| 606 | + | Outcome::Ok(recorded) => recorded, | |
| 607 | + | Outcome::Fail(refused) => return azure_error(403, "AuthenticationFailed", &refused.message), | |
| 608 | + | }; | |
| 609 | + | let parts = if whole { | |
| 610 | + | recorded.into_iter().filter(|p| p.part == 1).collect() | |
| 611 | + | } else { | |
| 612 | + | match parts_for(ids, &recorded) { | |
| 613 | + | Ok(parts) => parts, | |
| 614 | + | Err(problem) => return azure_error(400, "InvalidBlockList", &problem), | |
| 615 | + | } | |
| 616 | + | }; | |
| 617 | + | let size = match finish(bucket, &grant.object, upload_id, &parts).await { | |
| 618 | + | Ok(size) => size, | |
| 619 | + | Err(problem) => return azure_error(400, "InvalidBlockList", &format!("The upload could not be completed: {problem}")), | |
| 620 | + | }; | |
| 621 | + | if size > limit { | |
| 622 | + | bucket.delete(&grant.object).await?; | |
| 623 | + | return azure_error(413, "RequestBodyTooLarge", &format!("It is {} MB, more than g1t keeps ({} MB).", size / 1_048_576, limit / 1_048_576)); | |
| 624 | + | } | |
| 625 | + | let _: Outcome<bool> = g1t_kit::call(&services.actions, "blob_done", &BlobArgs { blob: token.to_owned(), size, ..BlobArgs::default() }).await?; | |
| 626 | + | azure(201) | |
| 627 | + | } | |
| 628 | + | ||
| 629 | + | #[cfg(test)] | |
| 630 | + | mod tests { | |
| 631 | + | use super::*; | |
| 632 | + | ||
| 633 | + | /// What Azure's SDK sends as block ids: base64 of a 36-character uuid | |
| 634 | + | /// prefix and the index padded to 48 characters in all. | |
| 635 | + | fn azure_block_id(index: u32) -> String { | |
| 636 | + | let prefix = "4a2f0d2e-8a44-4f1b-9d55-6f1a2b3c4d5e"; | |
| 637 | + | let padded = format!("{prefix}{index:0>width$}", width = 48 - prefix.len()); | |
| 638 | + | STANDARD.encode(padded) | |
| 639 | + | } | |
| 640 | + | ||
| 641 | + | #[test] | |
| 642 | + | fn block_ids_give_their_index() { | |
| 643 | + | assert_eq!(block_index(&azure_block_id(0)), Some(0)); | |
| 644 | + | assert_eq!(block_index(&azure_block_id(17)), Some(17)); | |
| 645 | + | assert_eq!(block_index(&STANDARD.encode("no-digits")), None); | |
| 646 | + | assert_eq!(block_index("not base64!"), None); | |
| 647 | + | } | |
| 648 | + | ||
| 649 | + | #[test] | |
| 650 | + | fn a_block_list_is_read_in_order_and_matched_to_parts() { | |
| 651 | + | // As the SDK's commitBlockList sends it. | |
| 652 | + | let xml = format!( | |
| 653 | + | "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><BlockList><Latest>{}</Latest><Latest>{}</Latest></BlockList>", | |
| 654 | + | azure_block_id(0), | |
| 655 | + | azure_block_id(1) | |
| 656 | + | ); | |
| 657 | + | let ids = block_list(&xml); | |
| 658 | + | assert_eq!(ids, [azure_block_id(0), azure_block_id(1)]); | |
| 659 | + | // Sent out of order, as they are concurrently. | |
| 660 | + | let recorded = vec![ | |
| 661 | + | BlobPart { part: 2, etag: "b".into(), size: 3 }, | |
| 662 | + | BlobPart { part: 1, etag: "a".into(), size: 8 }, | |
| 663 | + | ]; | |
| 664 | + | let parts = parts_for(&ids, &recorded).unwrap(); | |
| 665 | + | assert_eq!(parts.iter().map(|p| p.etag.as_str()).collect::<Vec<_>>(), ["a", "b"]); | |
| 666 | + | // A block never sent, or listed out of order. | |
| 667 | + | assert!(parts_for(&[azure_block_id(0), azure_block_id(2)], &recorded).is_err()); | |
| 668 | + | assert!(parts_for(&[azure_block_id(1), azure_block_id(0)], &recorded).is_err()); | |
| 669 | + | assert!(block_list("<BlockList></BlockList>").is_empty()); | |
| 670 | + | } | |
| 671 | + | ||
| 672 | + | #[test] | |
| 673 | + | fn older_protocol_chunks_are_parts() { | |
| 674 | + | let mb32 = CACHE_PART_BYTES; | |
| 675 | + | assert_eq!(chunk_part(&format!("bytes 0-{}/*", mb32 - 1)), Some((1, mb32))); | |
| 676 | + | assert_eq!(chunk_part(&format!("bytes {}-{}/*", mb32 * 2, mb32 * 2 + 99)), Some((3, 100))); | |
| 677 | + | // Not on a chunk's boundary, too long, or not a range. | |
| 678 | + | assert_eq!(chunk_part("bytes 5-10/*"), None); | |
| 679 | + | assert_eq!(chunk_part(&format!("bytes 0-{}/*", mb32)), None); | |
| 680 | + | assert_eq!(chunk_part("0-10"), None); | |
| 681 | + | } | |
| 682 | + | ||
| 683 | + | /// The toolkit's requests, as `@actions/cache` 4 and `@actions/artifact` | |
| 684 | + | /// 2 send them (protobuf-ts, proto field names, no defaults). | |
| 685 | + | #[test] | |
| 686 | + | fn twirp_requests_read_in_the_toolkits_spelling() { | |
| 687 | + | let create_cache = json!({ "key": "node-cache-Linux-x64-npm-abc", "version": "a7f2c1e0" }); | |
| 688 | + | assert_eq!(text(&create_cache, "key"), "node-cache-Linux-x64-npm-abc"); | |
| 689 | + | let lookup = json!({ "key": "k", "restore_keys": ["k-", "x-"], "version": "v" }); | |
| 690 | + | assert_eq!(field(&lookup, "restore_keys").as_array().unwrap().len(), 2); | |
| 691 | + | let finalize = json!({ "key": "k", "size_bytes": "1048576", "version": "v" }); | |
| 692 | + | assert_eq!(number(&finalize, "size_bytes"), Some(1_048_576)); | |
| 693 | + | let create_artifact = json!({ | |
| 694 | + | "workflow_run_backend_id": "run_1", | |
| 695 | + | "workflow_job_run_backend_id": "job_1", | |
| 696 | + | "name": "dist", | |
| 697 | + | "expires_at": "2026-10-13T00:00:00Z", | |
| 698 | + | "version": 4 | |
| 699 | + | }); | |
| 700 | + | assert_eq!(text(&create_artifact, "workflow_job_run_backend_id"), "job_1"); | |
| 701 | + | let list = json!({ "workflow_run_backend_id": "run_1", "workflow_job_run_backend_id": "job_1", "id_filter": "42", "name_filter": "dist" }); | |
| 702 | + | assert_eq!(number(&list, "id_filter"), Some(42)); | |
| 703 | + | assert_eq!(text(&list, "name_filter"), "dist"); | |
| 704 | + | // A client writing JSON names instead reads the same. | |
| 705 | + | let camel = json!({ "workflowRunBackendId": "run_1", "sizeBytes": 3 }); | |
| 706 | + | assert_eq!(text(&camel, "workflow_run_backend_id"), "run_1"); | |
| 707 | + | assert_eq!(number(&camel, "size_bytes"), Some(3)); | |
| 708 | + | } | |
| 709 | + | ||
| 710 | + | #[test] | |
| 711 | + | fn the_runtime_token_names_its_run_and_job() { | |
| 712 | + | let payload = URL_SAFE_NO_PAD.encode(json!({ "job": "job_1", "run": "run_1" }).to_string()); | |
| 713 | + | let token = format!("h.{payload}.s"); | |
| 714 | + | assert_eq!(runtime_job(&token).as_deref(), Some("job_1")); | |
| 715 | + | assert_eq!(backend_ids(&token), ("run_1".to_owned(), "job_1".to_owned())); | |
| 716 | + | assert_eq!(runtime_job("deadbeef"), None); | |
| 717 | + | } | |
| 718 | + | ||
| 719 | + | #[test] | |
| 720 | + | fn a_job_is_told_where_the_toolkit_s_services_are() { | |
| 721 | + | let vars = runtime_variables("https://api.g1t.sh", "tok", false); | |
| 722 | + | // Twirp paths are resolved against the root of ACTIONS_RESULTS_URL. | |
| 723 | + | assert_eq!(vars["ACTIONS_RESULTS_URL"], "https://api.g1t.sh/"); | |
| 724 | + | // The older protocol appends `_apis/artifactcache/…`. | |
| 725 | + | assert_eq!(vars["ACTIONS_CACHE_URL"], "https://api.g1t.sh/actions/toolkit/"); | |
| 726 | + | assert_eq!(vars["ACTIONS_CACHE_SERVICE_V2"], "True"); | |
| 727 | + | assert!(vars.get("ACTIONS_ID_TOKEN_REQUEST_URL").is_none()); | |
| 728 | + | let vars = runtime_variables("https://api.g1t.sh", "tok", true); | |
| 729 | + | // core.getIDToken appends `&audience=…`. | |
| 730 | + | assert_eq!(vars["ACTIONS_ID_TOKEN_REQUEST_URL"], "https://api.g1t.sh/actions/oidc/token?api-version=2.0"); | |
| 731 | + | assert_eq!(vars["ACTIONS_ID_TOKEN_REQUEST_TOKEN"], "tok"); | |
| 732 | + | } | |
| 733 | + | ||
| 734 | + | #[test] | |
| 735 | + | fn artifacts_are_listed_as_the_toolkit_reads_them() { | |
| 736 | + | let artifact = Artifact { | |
| 737 | + | id: 7, | |
| 738 | + | name: "dist".into(), | |
| 739 | + | size: 10, | |
| 740 | + | digest: None, | |
| 741 | + | format: "zip".into(), | |
| 742 | + | run_id: "run_1".into(), | |
| 743 | + | job_id: "job_1".into(), | |
| 744 | + | repo_id: "repo_1".into(), | |
| 745 | + | expired: false, | |
| 746 | + | created_at: "2026-10-08T12:00:00.000Z".into(), | |
| 747 | + | updated_at: "2026-10-08T12:00:00.000Z".into(), | |
| 748 | + | expires_at: "2026-10-22T12:00:00.000Z".into(), | |
| 749 | + | head_branch: None, | |
| 750 | + | head_sha: None, | |
| 751 | + | }; | |
| 752 | + | let shown = listed(&artifact); | |
| 753 | + | assert_eq!(shown["database_id"], "7"); | |
| 754 | + | assert_eq!(shown["size"], "10"); | |
| 755 | + | assert_eq!(shown["workflow_job_run_backend_id"], "job_1"); | |
| 756 | + | } | |
| 757 | + | } |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.