Add bootstrap endpoint and API transport support
- Migration 0004: adds git_dir, sentry_dsn, sentry_trace_id, bootstrap_fetched columns to runs
- quire-server: store_bootstrap_data writes bootstrap fields to DB in API transport mode; GET /api/runs/:id/bootstrap serves one-shot payload (410 Gone after first fetch)
- quire-ci: ApiClient wraps reqwest::blocking::Client with default Authorization header and per-run base URL; fetch_bootstrap and fetch_secret use error_for_status(); blocking calls spawned on OS threads to avoid Tokio runtime conflict

https://claude.ai/code/session_01GfccpUReesMY5Rb4FhajhT
change
commit 4b6ab380ba13b33e7770c3853e8e918f0eefeaa1
author Claude <noreply@anthropic.com>
date
parent 7707a118
diff --git a/quire-ci/Cargo.toml b/quire-ci/Cargo.toml
index 8c4db26..5e7328a 100644
--- a/quire-ci/Cargo.toml
+++ b/quire-ci/Cargo.toml
@@ -11,7 +11,7 @@ jiff = { workspace = true }
 miette = { workspace = true, features = ["fancy"] }
 mlua = { workspace = true }
 quire-core = { path = "../quire-core" }
-reqwest = { version = "*", features = ["rustls"], default-features = false }
+reqwest = { version = "*", features = ["blocking", "json", "rustls"], default-features = false }
 sentry = { workspace = true }
 serde = { workspace = true }
 serde_json = { workspace = true }
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 77cdb5a..3703716 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -196,16 +196,44 @@ impl std::str::FromStr for EventsTarget {
     }
 }
 
-/// Thin wrapper around [`ApiSession`] for calling the quire-server API.
-/// Owns its session data so it can be moved into the `'static`
-/// closures that server-backed resources require.
+/// HTTP client for quire-server's CI API.
+///
+/// Wraps `reqwest::blocking::Client` with the `Authorization` header
+/// pre-configured and the per-run base URL baked in. Cloning is cheap
+/// (the underlying connection pool is reference-counted).
 struct ApiClient {
-    session: ApiSession,
+    client: reqwest::blocking::Client,
+    base_url: String,
 }
 
 impl ApiClient {
     fn new(session: ApiSession) -> Self {
-        Self { session }
+        use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
+        let mut headers = HeaderMap::new();
+        let mut auth = HeaderValue::from_str(&format!("Bearer {}", session.auth_token))
+            .expect("auth token contains only ASCII");
+        auth.set_sensitive(true);
+        headers.insert(AUTHORIZATION, auth);
+        Self {
+            client: reqwest::blocking::Client::builder()
+                .default_headers(headers)
+                .build()
+                .expect("failed to build HTTP client"),
+            base_url: format!("{}/api/runs/{}", session.server_url, session.run_id),
+        }
+    }
+
+    /// Send an authenticated GET to `{base_url}/{path}` and return the response.
+    ///
+    /// Spawns a dedicated OS thread so `reqwest::blocking` works regardless of
+    /// whether the calling thread holds a Tokio runtime guard (which it does
+    /// during pipeline execution due to Sentry's runtime requirement).
+    fn get(&self, path: &str) -> reqwest::Result<reqwest::blocking::Response> {
+        let url = format!("{}/{}", self.base_url, path);
+        let client = self.client.clone();
+        std::thread::spawn(move || client.get(&url).send())
+            .join()
+            .expect("HTTP thread panicked")
     }
 
     /// Fetch the bootstrap payload from the server API.
@@ -213,64 +241,29 @@ impl ApiClient {
     /// One-shot: the server marks the bootstrap as fetched after the first
     /// successful call and returns 410 on any subsequent call.
     fn fetch_bootstrap(&self) -> Result<(PathBuf, RunMeta, Option<String>)> {
-        let url = format!(
-            "{}/api/runs/{}/bootstrap",
-            self.session.server_url, self.session.run_id
-        );
-        let token = self.session.auth_token.clone();
-        tokio::runtime::Handle::current().block_on(async move {
-            let resp = reqwest::Client::new()
-                .get(&url)
-                .bearer_auth(&token)
-                .send()
-                .await
-                .map_err(|e| miette!("bootstrap request failed: {e}"))?;
-
-            let status = resp.status();
-            if !status.is_success() {
-                miette::bail!("bootstrap API returned {status}");
-            }
-            let bootstrap: Bootstrap = resp
-                .json()
-                .await
-                .map_err(|e| miette!("failed to parse bootstrap response: {e}"))?;
-            Ok((bootstrap.git_dir, bootstrap.meta, bootstrap.sentry_trace_id))
-        })
+        let bootstrap: Bootstrap = self
+            .get("bootstrap")
+            .into_diagnostic()?
+            .error_for_status()
+            .into_diagnostic()?
+            .json()
+            .into_diagnostic()?;
+        Ok((bootstrap.git_dir, bootstrap.meta, bootstrap.sentry_trace_id))
     }
 
     /// Fetch a single secret by name from the server.
     fn fetch_secret(&self, name: &str) -> SecretResult<String> {
-        let url = format!(
-            "{}/api/runs/{}/secrets/{}",
-            self.session.server_url, self.session.run_id, name
-        );
-        let token = self.session.auth_token.clone();
-        let name_owned = name.to_string();
-        tokio::runtime::Handle::current().block_on(async move {
-            let resp = match reqwest::Client::new()
-                .get(&url)
-                .bearer_auth(&token)
-                .send()
-                .await
-            {
-                Ok(resp) => resp,
-                Err(e) => return Err(SecretError::Resolve(e.to_string())),
-            };
-            let status = resp.status();
-            if status.is_success() {
-                let body = match resp.json::<SecretResponse>().await {
-                    Ok(body) => body,
-                    Err(e) => return Err(SecretError::Resolve(e.to_string())),
-                };
-                Ok(body.value)
-            } else if status == reqwest::StatusCode::NOT_FOUND {
-                Err(SecretError::UnknownSecret(name_owned))
-            } else {
-                Err(SecretError::Resolve(format!(
-                    "secret API returned {status} for {name_owned:?}"
-                )))
-            }
-        })
+        let resp = self
+            .get(&format!("secrets/{name}"))
+            .map_err(|e| SecretError::Resolve(e.to_string()))?;
+        if resp.status() == reqwest::StatusCode::NOT_FOUND {
+            return Err(SecretError::UnknownSecret(name.to_string()));
+        }
+        resp.error_for_status()
+            .map_err(|e| SecretError::Resolve(e.to_string()))?
+            .json::<SecretResponse>()
+            .map(|r| r.value)
+            .map_err(|e| SecretError::Resolve(e.to_string()))
     }
 }
 
diff --git a/quire-server/src/quire/web/api.rs b/quire-server/src/quire/web/api.rs
index 655a14e..ab23c53 100644
--- a/quire-server/src/quire/web/api.rs
+++ b/quire-server/src/quire/web/api.rs
@@ -115,17 +115,12 @@ async fn verify_bearer(
     };
 
     tokio::task::spawn_blocking(move || -> Result<(), ApiError> {
-        let db = quire
-            .db_pool()
-            .lock()
-            .map_err(|_| crate::Error::Io(std::io::Error::other("db mutex poisoned")))?;
-        let stored: Option<String> = db
-            .query_row(
-                "SELECT auth_token FROM runs WHERE id = ?1",
-                rusqlite::params![run_id],
-                |row| row.get(0),
-            )
-            .map_err(ApiError::from)?;
+        let db = quire.db_pool().lock().expect("db mutex poisoned");
+        let stored: Option<String> = db.query_row(
+            "SELECT auth_token FROM runs WHERE id = ?1",
+            rusqlite::params![run_id],
+            |row| row.get(0),
+        )?;
         match stored {
             Some(ref t) if t == &token => Ok(()),
             _ => Err(ApiError::Unauthorized),