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
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),