Skip to content

Commit 78fae34

Browse files
committed
jobs/index/archive: Authenticate archive push via GitHub App
The index SSH key used in production is a deploy key for the index repository, so it cannot push to the separate archive repository. Instead of minting a second SSH key plus user account with access to both repos, we registered a GitHub App scoped to both the index and archive repos. This job is the first consumer; the remaining index-writing jobs may switch from the deploy key to the app later. Mints an installation access token from `env.github_app` after fetching the snapshot branch, then pushes `FETCH_HEAD` to the archive repository over HTTPS via a temporary `archive` remote carrying `x-access-token` credentials. For URL schemes that do not accept userinfo (e.g. `file://` in tests), the job logs a warning and falls back to pushing without credentials. Fails loudly when `index_archive_url` is set but no GitHub App is configured, so a misconfigured worker does not silently skip the push.
1 parent 50f241d commit 78fae34

3 files changed

Lines changed: 87 additions & 4 deletions

File tree

src/config/server.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,10 @@ impl Server {
138138
/// by an operator (e.g. `/crates/{crate_id}/{version}/download`).
139139
/// - `DISABLE_TOKEN_CREATION`: If set to any non-empty value, disables API token creation
140140
/// and uses the value as the error message returned to users.
141-
/// - `GIT_ARCHIVE_REPO_URL`: URL of a git repository to mirror the crate index's snapshot
142-
/// branches to. If unset the `ArchiveIndexBranch` background job is a no-op.
141+
/// - `GIT_ARCHIVE_REPO_URL`: HTTPS URL (e.g. `https://github.com/<org>/<repo>.git`) of a git
142+
/// repository to mirror the crate index's snapshot branches to. Must be HTTPS because the
143+
/// `ArchiveIndexBranch` job authenticates via a GitHub App installation token; SSH remotes
144+
/// are not supported. If unset the job is a no-op.
143145
///
144146
/// # Panics
145147
///

src/tests/worker/archive_index_branch.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,33 @@ async fn archive_index_branch_without_url_configured() {
5757
app.run_pending_background_jobs().await;
5858
}
5959

60+
/// With an archive URL configured but no GitHub App wired into the
61+
/// environment, the job should fail loudly rather than push without
62+
/// authentication.
63+
#[tokio::test(flavor = "multi_thread")]
64+
async fn archive_index_branch_without_github_app() {
65+
let archive = UpstreamIndex::new().unwrap();
66+
let archive_url = archive.url();
67+
68+
let (app, _) = TestApp::full()
69+
.with_config(|c| c.index_archive_url = Some(archive_url))
70+
.with_github_app(None)
71+
.empty()
72+
.await;
73+
74+
let mut conn = app.db_conn().await;
75+
seed_snapshot_branch(app.upstream_index());
76+
77+
let job = jobs::ArchiveIndexBranch::new(SNAPSHOT_BRANCH);
78+
assert_ok!(job.enqueue(&conn).await);
79+
assert_snapshot!(app.try_run_pending_background_jobs().await.unwrap_err(), @"1 jobs failed");
80+
81+
diesel::delete(background_jobs::table)
82+
.execute(&mut conn)
83+
.await
84+
.unwrap();
85+
}
86+
6087
/// If the requested branch does not exist on origin, the job should fail so
6188
/// that an operator typo or a stale enqueue produces loud feedback rather
6289
/// than a silent success.

src/worker/jobs/index/archive.rs

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
use crate::tasks::spawn_blocking;
22
use crate::worker::Environment;
3+
use anyhow::anyhow;
34
use crates_io_worker::BackgroundJob;
5+
use secrecy::ExposeSecret;
46
use serde::{Deserialize, Serialize};
57
use std::process::Command;
68
use std::sync::Arc;
7-
use tracing::{info, instrument};
9+
use tracing::{info, instrument, warn};
10+
use url::Url;
11+
12+
const REMOTE_NAME: &str = "archive";
813

914
#[derive(Serialize, Deserialize)]
1015
pub struct ArchiveIndexBranch {
@@ -35,17 +40,36 @@ impl BackgroundJob for ArchiveIndexBranch {
3540
return Ok(());
3641
};
3742

43+
let Some(github_app) = env.github_app.as_ref() else {
44+
return Err(anyhow!(
45+
"`index_archive_url` is set but GitHub App is not configured"
46+
));
47+
};
48+
let github_app = github_app.clone();
49+
3850
info!(%archive_url, "Pushing snapshot to archive repository");
3951

4052
let branch = self.branch.clone();
53+
let handle = tokio::runtime::Handle::current();
54+
4155
spawn_blocking(move || {
4256
let repo = env.lock_index()?;
4357

4458
repo.run_command(Command::new("git").args(["fetch", "origin", &branch]))?;
4559

60+
let token = handle.block_on(github_app.installation_token())?;
61+
let push_url = match build_credentialed_url(&archive_url, token.expose_secret()) {
62+
Ok(url) => url,
63+
Err(()) => {
64+
warn!(%archive_url, "Archive URL does not support credentials; pushing without auth");
65+
archive_url.clone()
66+
}
67+
};
68+
69+
let _remote = repo.add_temporary_remote(REMOTE_NAME, &push_url)?;
4670
repo.run_command(Command::new("git").args([
4771
"push",
48-
archive_url.as_str(),
72+
REMOTE_NAME,
4973
&format!("FETCH_HEAD:refs/heads/{branch}"),
5074
]))?;
5175

@@ -55,3 +79,33 @@ impl BackgroundJob for ArchiveIndexBranch {
5579
.await?
5680
}
5781
}
82+
83+
/// Return a copy of `base` with `x-access-token` / `token` embedded as the
84+
/// HTTPS credentials git consumes when pushing. Returns `Err(())` when the
85+
/// URL scheme does not allow userinfo (e.g. `file://`).
86+
fn build_credentialed_url(base: &Url, token: &str) -> Result<Url, ()> {
87+
let mut url = base.clone();
88+
url.set_username("x-access-token")?;
89+
url.set_password(Some(token))?;
90+
Ok(url)
91+
}
92+
93+
#[cfg(test)]
94+
mod tests {
95+
use super::*;
96+
use claims::assert_err;
97+
use insta::assert_snapshot;
98+
99+
#[test]
100+
fn build_credentialed_url_https() {
101+
let url: Url = "https://github.com/rust-lang/archive.git".parse().unwrap();
102+
let credentialed = build_credentialed_url(&url, "tok").unwrap();
103+
assert_snapshot!(credentialed, @"https://x-access-token:tok@github.com/rust-lang/archive.git");
104+
}
105+
106+
#[test]
107+
fn build_credentialed_url_file_rejected() {
108+
let url: Url = "file:///tmp/archive".parse().unwrap();
109+
assert_err!(build_credentialed_url(&url, "tok"));
110+
}
111+
}

0 commit comments

Comments
 (0)