Add GET /api/runs/:id/bootstrap endpoint for quire-ci API transport
Implements slice 2 of the quire-ci ↔ quire-server API migration
(see docs/plans/2026-05-14-quire-ci-server-api-design.md).
Server changes:
- Migration 0004: adds git_dir, sentry_dsn, sentry_trace_id, and
bootstrap_fetched columns to the runs table
- Run::store_bootstrap_data persists bootstrap fields to the DB when
the API transport is active, called from execute() before spawning
- execute() skips writing bootstrap.json and omits --bootstrap in API
mode; quire-ci fetches bootstrap via the new endpoint instead
- GET /api/runs/:id/bootstrap returns the Bootstrap payload (meta,
git_dir, sentry); one-shot: sets bootstrap_fetched=1 and returns
410 Gone on any subsequent call; 404 if git_dir was never stored
quire-ci changes:
- --bootstrap is now optional; filesystem transport still requires it,
API transport omits it and calls fetch_bootstrap() instead
- ApiClient::fetch_bootstrap() calls GET /api/runs/:id/bootstrap and
deserializes the Bootstrap response
- main() dispatches bootstrap loading based on transport.mode
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index d09d4b6..77cdb5a 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -7,7 +7,7 @@ use std::rc::Rc;
use facet::Facet;
use figue::{self as args, Driver, FigueBuiltins};
-use miette::{IntoDiagnostic, Result};
+use miette::{IntoDiagnostic, Result, miette};
use quire_core::api::SecretResponse;
use quire_core::ci::bootstrap::Bootstrap;
use quire_core::ci::event::{Event, EventKind, JobOutcome, RunOutcome};
@@ -114,9 +114,10 @@ enum Commands {
out_dir: Option<PathBuf>,
/// Path to a JSON bootstrap file produced by the orchestrator.
- /// Carries push metadata and the Sentry trace id.
- #[facet(args::named)]
- bootstrap: PathBuf,
+ /// Required for `filesystem` transport; omitted for `api`
+ /// transport, which fetches bootstrap via the server API instead.
+ #[facet(args::named, default)]
+ bootstrap: Option<PathBuf>,
},
}
@@ -207,6 +208,36 @@ impl ApiClient {
Self { session }
}
+ /// Fetch the bootstrap payload from the server API.
+ ///
+ /// 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))
+ })
+ }
+
/// Fetch a single secret by name from the server.
fn fetch_secret(&self, name: &str) -> SecretResult<String> {
let url = format!(
@@ -282,9 +313,6 @@ fn main() -> Result<()> {
(path, Some(DumpLogsOnDrop { dir }))
}
};
-
- let (git_dir, meta, sentry_trace_id) = load_bootstrap(&bootstrap)?;
-
// Sentry's reqwest transport spawns Tokio tasks for HTTP
// sends, so the client must be constructed and dropped from
// within a runtime context. A single worker thread is
@@ -297,6 +325,27 @@ fn main() -> Result<()> {
.into_diagnostic()?;
let _enter = rt.enter();
+ let session = ApiSession {
+ run_id: cli.quire.run_id,
+ server_url: cli.quire.server_url,
+ auth_token: cli.quire.auth_token,
+ };
+ let transport = Transport {
+ session: session.clone(),
+ mode: cli.quire.transport,
+ };
+ let client = ApiClient::new(session);
+
+ let (git_dir, meta, sentry_trace_id) = match transport.mode {
+ TransportMode::Api => client.fetch_bootstrap()?,
+ TransportMode::Filesystem => {
+ let path = bootstrap.ok_or_else(|| {
+ miette!("--bootstrap is required for filesystem transport")
+ })?;
+ load_bootstrap(&path)?
+ }
+ };
+
// Drop order: `_sentry` flushes first (still inside the
// runtime), then `_enter`, then `rt`.
let _sentry = cli
@@ -313,17 +362,6 @@ fn main() -> Result<()> {
let miette_layer = MietteLayer::new();
telemetry::init_tracing(miette_layer, FmtMode::Plain)?;
- let session = ApiSession {
- run_id: cli.quire.run_id,
- server_url: cli.quire.server_url,
- auth_token: cli.quire.auth_token,
- };
- let transport = Transport {
- session: session.clone(),
- mode: cli.quire.transport,
- };
-
- let client = ApiClient::new(session);
let registry = SecretRegistry::new(move |name| client.fetch_secret(name));
run_pipeline(workspace, sink, log_dir, git_dir, meta, registry, transport)
diff --git a/quire-server/migrations/0004_bootstrap_api.sql b/quire-server/migrations/0004_bootstrap_api.sql
new file mode 100644
index 0000000..07ca49a
--- /dev/null
+++ b/quire-server/migrations/0004_bootstrap_api.sql
@@ -0,0 +1,2 @@
+ALTER TABLE runs ADD COLUMN git_dir TEXT;
+ALTER TABLE runs ADD COLUMN sentry_trace_id TEXT;
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index 9c23569..ad1cbdd 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -321,8 +321,6 @@ impl Run {
let log = fs_err::File::create(&log_path)?.into_parts().0;
let log_clone = log.try_clone()?;
- write_bootstrap(&bootstrap_path, git_dir, meta, sentry_trace_id)?;
-
tracing::info!(
run_id = %self.id,
log = %log_path.display(),
@@ -337,17 +335,27 @@ impl Run {
.arg("--out-dir")
.arg(&run_dir)
.arg("--events")
- .arg(&events_path)
- .arg("--bootstrap")
- .arg(&bootstrap_path);
-
- if let Some(t) = transport {
- cmd.env("QUIRE__RUN_ID", &t.session.run_id);
- cmd.env("QUIRE__SERVER_URL", &t.session.server_url);
- cmd.env("QUIRE__AUTH_TOKEN", &t.session.auth_token);
- if t.mode == TransportMode::Api {
+ .arg(&events_path);
+
+ match transport {
+ None => {
+ write_bootstrap(&bootstrap_path, git_dir, meta, sentry_trace_id)?;
+ cmd.arg("--bootstrap").arg(&bootstrap_path);
+ }
+ Some(t) if t.mode == TransportMode::Api => {
+ self.store_bootstrap_data(git_dir, sentry_trace_id)?;
+ cmd.env("QUIRE__RUN_ID", &t.session.run_id);
+ cmd.env("QUIRE__SERVER_URL", &t.session.server_url);
+ cmd.env("QUIRE__AUTH_TOKEN", &t.session.auth_token);
cmd.env("QUIRE__TRANSPORT", "api");
}
+ Some(t) => {
+ write_bootstrap(&bootstrap_path, git_dir, meta, sentry_trace_id)?;
+ cmd.arg("--bootstrap").arg(&bootstrap_path);
+ cmd.env("QUIRE__RUN_ID", &t.session.run_id);
+ cmd.env("QUIRE__SERVER_URL", &t.session.server_url);
+ cmd.env("QUIRE__AUTH_TOKEN", &t.session.auth_token);
+ }
}
if let Some(dsn) = sentry_dsn {
cmd.env("QUIRE__SENTRY_DSN", dsn);
@@ -501,6 +509,30 @@ impl Run {
Ok(run_outcome)
}
+ /// Persist bootstrap data in the DB so the API endpoint can serve it.
+ ///
+ /// Called by `execute` when the API transport is active, before spawning
+ /// quire-ci. quire-ci fetches this via `GET /api/runs/:id/bootstrap`
+ /// instead of reading a file.
+ fn store_bootstrap_data(
+ &self,
+ git_dir: &Path,
+ sentry_trace_id: Option<&str>,
+ ) -> Result<()> {
+ let git_dir_str = git_dir.to_str().ok_or_else(|| {
+ std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ "git_dir path is not valid UTF-8",
+ )
+ })?;
+ let db = crate::db::open(&self.db_path)?;
+ db.execute(
+ "UPDATE runs SET git_dir = ?1, sentry_trace_id = ?2 WHERE id = ?3",
+ rusqlite::params![git_dir_str, sentry_trace_id, &self.id],
+ )?;
+ Ok(())
+ }
+
/// Transition the run from its current state to a new state.
///
/// Allowed edges (see `docs/CI-STATE.md`):
diff --git a/quire-server/src/db.rs b/quire-server/src/db.rs
index 0b8770b..8bc3f9f 100644
--- a/quire-server/src/db.rs
+++ b/quire-server/src/db.rs
@@ -15,6 +15,7 @@ static MIGRATIONS: std::sync::LazyLock<Migrations<'static>> = std::sync::LazyLoc
M::up(include_str!("../migrations/0001_initial.sql")),
M::up(include_str!("../migrations/0002_sh_events.sql")),
M::up(include_str!("../migrations/0003_ci_api.sql")),
+ M::up(include_str!("../migrations/0004_bootstrap_api.sql")),
])
});
diff --git a/quire-server/src/quire/web/api.rs b/quire-server/src/quire/web/api.rs
index bf9f37e..253ad57 100644
--- a/quire-server/src/quire/web/api.rs
+++ b/quire-server/src/quire/web/api.rs
@@ -5,6 +5,7 @@
//! is created and scoped to that run's ID.
use std::collections::HashMap;
+use std::path::PathBuf;
use axum::extract::{FromRequestParts, Path as AxumPath, State};
use axum::http::StatusCode;
@@ -13,6 +14,8 @@ use axum::response::{IntoResponse, Response, Result};
use axum_extra::TypedHeader;
use axum_extra::headers::Authorization;
use axum_extra::headers::authorization::Bearer;
+use quire_core::ci::bootstrap::Bootstrap;
+use quire_core::ci::run::RunMeta;
use crate::Quire;
@@ -23,6 +26,7 @@ use crate::Quire;
/// Intended to be mounted at `/api` via `Router::nest`.
pub fn router(quire: Quire) -> axum::Router {
let run_routes = axum::Router::new()
+ .route("/bootstrap", axum::routing::get(get_bootstrap))
.route("/secrets/{name}", axum::routing::get(get_secret))
.layer(axum::middleware::from_fn_with_state(
quire.clone(),
@@ -40,6 +44,8 @@ enum ApiError {
NotFound,
#[error("unauthorized")]
Unauthorized,
+ #[error("gone")]
+ Gone,
#[error(transparent)]
Db(rusqlite::Error),
#[error(transparent)]
@@ -62,6 +68,7 @@ impl IntoResponse for ApiError {
match self {
ApiError::NotFound => StatusCode::NOT_FOUND.into_response(),
ApiError::Unauthorized => StatusCode::UNAUTHORIZED.into_response(),
+ ApiError::Gone => StatusCode::GONE.into_response(),
e => {
tracing::error!(error = %e, "api error");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
@@ -130,6 +137,81 @@ async fn verify_bearer(
Ok(next.run(req).await)
}
+/// `GET /api/runs/:run_id/bootstrap`
+///
+/// Returns the bootstrap payload for a run. One-shot: the server marks
+/// bootstrap as fetched on the first successful read and returns 410 on
+/// any subsequent call. Auth is handled by [`verify_bearer`] middleware.
+///
+/// Returns 404 if the run does not have API bootstrap data (e.g. the run
+/// was created with filesystem transport and `store_bootstrap_data` was
+/// never called).
+async fn get_bootstrap(
+ State(quire): State<Quire>,
+ AxumPath(params): AxumPath<HashMap<String, String>>,
+) -> Result<axum::Json<Bootstrap>, ApiError> {
+ let run_id = params.get("run_id").cloned().ok_or(ApiError::NotFound)?;
+
+ let bootstrap =
+ tokio::task::spawn_blocking(move || -> std::result::Result<Bootstrap, ApiError> {
+ let db = crate::db::open(&quire.db_path()).map_err(ApiError::Db)?;
+
+ let (sha, ref_name, pushed_at_ms, git_dir_opt, sentry_trace_id, state): (
+ String,
+ String,
+ i64,
+ Option<String>,
+ Option<String>,
+ String,
+ ) = db
+ .query_row(
+ "SELECT sha, ref_name, pushed_at_ms, git_dir, sentry_trace_id, state
+ FROM runs WHERE id = ?1",
+ rusqlite::params![run_id],
+ |row| {
+ Ok((
+ row.get(0)?,
+ row.get(1)?,
+ row.get(2)?,
+ row.get(3)?,
+ row.get(4)?,
+ row.get(5)?,
+ ))
+ },
+ )
+ .map_err(ApiError::from)?;
+
+ if state != "pending" {
+ return Err(ApiError::Gone);
+ }
+
+ let git_dir: PathBuf = git_dir_opt.map(PathBuf::from).ok_or(ApiError::NotFound)?;
+
+ let meta = RunMeta {
+ sha,
+ r#ref: ref_name,
+ pushed_at: jiff::Timestamp::from_millisecond(pushed_at_ms)
+ .expect("db stores valid timestamps"),
+ };
+
+ let started_at_ms = jiff::Timestamp::now().as_millisecond();
+ db.execute(
+ "UPDATE runs SET state = 'active', started_at_ms = ?1 WHERE id = ?2",
+ rusqlite::params![started_at_ms, run_id],
+ )?;
+
+ Ok(Bootstrap {
+ meta,
+ git_dir,
+ sentry_trace_id,
+ })
+ })
+ .await
+ .expect("blocking task panicked")?;
+
+ Ok(axum::Json(bootstrap))
+}
+
/// `GET /api/runs/:run_id/secrets/:name`
///
/// Returns the plain-text value of a named secret from the global config.
@@ -260,6 +342,147 @@ mod tests {
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
+ async fn create_run_with_bootstrap(
+ env: &TestEnv,
+ transport: &crate::ci::Transport,
+ git_dir: &str,
+ sentry_trace_id: Option<&str>,
+ ) -> String {
+ let run = env
+ .runs()
+ .create(&TestEnv::meta(), Some(transport))
+ .expect("create run");
+ let run_id = run.id().to_string();
+
+ let db = crate::db::open(&env.quire.db_path()).expect("db open");
+ db.execute(
+ "UPDATE runs SET git_dir = ?1, sentry_trace_id = ?2 WHERE id = ?3",
+ rusqlite::params![git_dir, sentry_trace_id, &run_id],
+ )
+ .expect("update bootstrap data");
+ run_id
+ }
+
+ #[tokio::test]
+ async fn bootstrap_returns_401_without_auth() {
+ let env = TestEnv::new();
+ let transport = new_transport(TransportMode::Api, 3000);
+ let run_id =
+ create_run_with_bootstrap(&env, &transport, "/repos/test.git", None).await;
+ let url = format!("/runs/{run_id}/bootstrap");
+
+ let resp = get(env.app(), &url, None).await;
+ assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
+ }
+
+ #[tokio::test]
+ async fn bootstrap_returns_404_for_unknown_run() {
+ let env = TestEnv::new();
+ let resp = get(
+ env.app(),
+ "/runs/00000000-0000-0000-0000-000000000001/bootstrap",
+ Some("token"),
+ )
+ .await;
+ assert_eq!(resp.status(), StatusCode::NOT_FOUND);
+ }
+
+ #[tokio::test]
+ async fn bootstrap_returns_404_when_git_dir_not_stored() {
+ let env = TestEnv::new();
+ let transport = new_transport(TransportMode::Api, 3000);
+ env.runs()
+ .create(&TestEnv::meta(), Some(&transport))
+ .expect("create run");
+ let url = format!("/runs/{}/bootstrap", transport.session.run_id);
+
+ let resp = get(env.app(), &url, Some(&transport.session.auth_token)).await;
+ assert_eq!(resp.status(), StatusCode::NOT_FOUND);
+ }
+
+ #[tokio::test]
+ async fn bootstrap_returns_payload_on_first_fetch() {
+ let env = TestEnv::new();
+ let transport = new_transport(TransportMode::Api, 3000);
+ let run_id =
+ create_run_with_bootstrap(&env, &transport, "/repos/test.git", None).await;
+ let url = format!("/runs/{run_id}/bootstrap");
+
+ let resp = get(env.app(), &url, Some(&transport.session.auth_token)).await;
+ assert_eq!(resp.status(), StatusCode::OK);
+
+ use http_body_util::BodyExt;
+ let body = resp.into_body().collect().await.unwrap().to_bytes();
+ let parsed: serde_json::Value = serde_json::from_slice(&body).expect("json body");
+ assert_eq!(parsed["git_dir"], "/repos/test.git");
+ assert_eq!(parsed["meta"]["sha"], "abc1".repeat(10));
+ assert_eq!(parsed["meta"]["ref"], "refs/heads/main");
+ assert!(parsed["sentry_trace_id"].is_null());
+ }
+
+ #[tokio::test]
+ async fn bootstrap_returns_sentry_trace_id_when_stored() {
+ let env = TestEnv::new();
+ let transport = new_transport(TransportMode::Api, 3000);
+ let run_id = create_run_with_bootstrap(
+ &env,
+ &transport,
+ "/repos/test.git",
+ Some("aaaabbbbccccdddd0000111122223333"),
+ )
+ .await;
+ let url = format!("/runs/{run_id}/bootstrap");
+
+ let resp = get(env.app(), &url, Some(&transport.session.auth_token)).await;
+ assert_eq!(resp.status(), StatusCode::OK);
+
+ use http_body_util::BodyExt;
+ let body = resp.into_body().collect().await.unwrap().to_bytes();
+ let parsed: serde_json::Value = serde_json::from_slice(&body).expect("json body");
+ assert_eq!(
+ parsed["sentry_trace_id"],
+ "aaaabbbbccccdddd0000111122223333"
+ );
+ }
+
+ #[tokio::test]
+ async fn bootstrap_returns_410_on_second_fetch() {
+ let env = TestEnv::new();
+ let transport = new_transport(TransportMode::Api, 3000);
+ let run_id =
+ create_run_with_bootstrap(&env, &transport, "/repos/test.git", None).await;
+ let url = format!("/runs/{run_id}/bootstrap");
+ let token = &transport.session.auth_token;
+
+ let first = get(env.app(), &url, Some(token)).await;
+ assert_eq!(first.status(), StatusCode::OK);
+
+ let second = get(env.app(), &url, Some(token)).await;
+ assert_eq!(second.status(), StatusCode::GONE);
+ }
+
+ #[tokio::test]
+ async fn bootstrap_transitions_run_to_active() {
+ let env = TestEnv::new();
+ let transport = new_transport(TransportMode::Api, 3000);
+ let run_id =
+ create_run_with_bootstrap(&env, &transport, "/repos/test.git", None).await;
+ let url = format!("/runs/{run_id}/bootstrap");
+
+ get(env.app(), &url, Some(&transport.session.auth_token)).await;
+
+ let db = crate::db::open(&env.quire.db_path()).expect("db open");
+ let (state, started_at_ms): (String, Option<i64>) = db
+ .query_row(
+ "SELECT state, started_at_ms FROM runs WHERE id = ?1",
+ rusqlite::params![run_id],
+ |row| Ok((row.get(0)?, row.get(1)?)),
+ )
+ .expect("query");
+ assert_eq!(state, "active");
+ assert!(started_at_ms.is_some());
+ }
+
#[tokio::test]
async fn secret_returns_plaintext_value() {
let env = TestEnv::new();