Add transport foundation for quire-ci ↔ quire-server API
Thread --run-id, --server-url, and QUIRE_CI_TOKEN through the spawn
path. Add TransportMode enum (filesystem/api) to GlobalConfig under
:ci, default filesystem. Mint a per-run auth token stored in the DB
when transport is api. Add reqwest to quire-ci. Separate config
(TransportMode) from runtime (Transport) enums. Use clap::ValueEnum
for quire-ci CLI transport arg. No behavior change yet — filesystem
path is still the default.
Assisted-by: Owl Alpha via pi
diff --git a/Cargo.lock b/Cargo.lock
index 5888d7a..cab18e2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2133,6 +2133,7 @@ dependencies = [
"miette",
"mlua",
"quire-core",
+ "reqwest",
"sentry",
"serde",
"serde_json",
@@ -2180,6 +2181,7 @@ dependencies = [
"petgraph",
"predicates",
"quire-core",
+ "rand",
"regex",
"rusqlite",
"rusqlite_migration",
diff --git a/quire-ci/Cargo.toml b/quire-ci/Cargo.toml
index 2392f00..41e3bfc 100644
--- a/quire-ci/Cargo.toml
+++ b/quire-ci/Cargo.toml
@@ -10,6 +10,7 @@ jiff = { workspace = true }
miette = { workspace = true, features = ["fancy"] }
mlua = { workspace = true }
quire-core = { path = "../quire-core" }
+reqwest = { version = "*", features = ["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 9e05335..bd4fbc5 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -82,9 +82,52 @@ enum Commands {
/// secrets).
#[arg(long)]
dispatch: Option<PathBuf>,
+
+ #[command(flatten)]
+ transport: TransportFlags,
},
}
+/// CLI flags for the CI ↔ server transport. Grouped so the related
+/// args travel together and `Transport == Api` can pull its required
+/// peers in via `required_if_eq`.
+#[derive(clap::Args, Debug)]
+struct TransportFlags {
+ /// Transport for CI ↔ server communication.
+ #[arg(long, default_value = "filesystem", value_enum)]
+ transport: Transport,
+
+ /// Run ID assigned by the orchestrator.
+ /// Required when `--transport api`.
+ #[arg(long, required_if_eq("transport", "api"))]
+ run_id: Option<String>,
+
+ /// Base URL of quire-server (e.g. `http://127.0.0.1:3000`).
+ /// Required when `--transport api`.
+ #[arg(long, required_if_eq("transport", "api"))]
+ server_url: Option<String>,
+}
+
+impl TransportFlags {
+ /// Promote into the resolved [`TransportArgs`], folding in the
+ /// `QUIRE_CI_TOKEN` env var. clap's `required_if_eq` guarantees
+ /// the unwraps are safe for `Transport::Api`.
+ fn resolve(self, auth_token: Option<String>) -> TransportArgs {
+ match self.transport {
+ Transport::Filesystem => TransportArgs::Filesystem,
+ Transport::Api => TransportArgs::Api {
+ run_id: self
+ .run_id
+ .expect("clap requires --run-id when --transport api"),
+ server_url: self
+ .server_url
+ .expect("clap requires --server-url when --transport api"),
+ auth_token,
+ },
+ }
+ }
+}
+
/// RAII wrapper around the tempdir that holds a `quire-ci run`'s
/// captured sh logs when no `--out-dir` was passed. On drop, prints
/// each log file's contents to stdout, then lets the underlying
@@ -157,6 +200,31 @@ fn parse_events_target(s: &str) -> Result<EventsTarget, String> {
}
}
+/// Transport for CI ↔ server communication.
+///
+/// CLI-shape only; the resolved variant (with run_id/server_url
+/// promoted out of their Options) is `TransportArgs`.
+#[derive(Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
+pub enum Transport {
+ Filesystem,
+ Api,
+}
+
+/// Resolved transport produced by [`TransportFlags::resolve`].
+///
+/// The `Api` variant's fields are wired up ahead of the HTTP client
+/// that will consume them.
+#[derive(Debug)]
+#[allow(dead_code)] // fields read by the upcoming API client
+enum TransportArgs {
+ Filesystem,
+ Api {
+ run_id: String,
+ server_url: String,
+ auth_token: Option<String>,
+ },
+}
+
fn main() -> miette::Result<()> {
miette::set_panic_hook();
let cli = Cli::parse();
@@ -166,6 +234,7 @@ fn main() -> miette::Result<()> {
events,
out_dir,
dispatch,
+ transport,
} => {
let sink: Box<dyn EventSink> = match events {
EventsTarget::Null => Box::new(NullSink),
@@ -186,6 +255,8 @@ fn main() -> miette::Result<()> {
(path, Some(DumpLogsOnDrop { dir }))
}
};
+ let auth_token = std::env::var("QUIRE_CI_TOKEN").ok();
+ let transport = transport.resolve(auth_token);
let (git_dir, meta, secrets, sentry_handoff) = match dispatch {
Some(path) => load_dispatch(&path)?,
None => (
@@ -219,7 +290,15 @@ fn main() -> miette::Result<()> {
let miette_layer = MietteLayer::new();
telemetry::init_tracing(miette_layer, FmtMode::Plain)?;
- run_pipeline(cli.workspace, sink, log_dir, git_dir, meta, secrets)
+ run_pipeline(
+ cli.workspace,
+ sink,
+ log_dir,
+ git_dir,
+ meta,
+ secrets,
+ transport,
+ )
}
}
}
@@ -349,6 +428,7 @@ fn run_pipeline(
git_dir: PathBuf,
meta: RunMeta,
secrets: HashMap<String, quire_core::secret::SecretString>,
+ _transport: TransportArgs,
) -> miette::Result<()> {
let pipeline = compile_at(&workspace)?;
diff --git a/quire-server/Cargo.toml b/quire-server/Cargo.toml
index 7ddf103..fd36998 100644
--- a/quire-server/Cargo.toml
+++ b/quire-server/Cargo.toml
@@ -34,6 +34,7 @@ askama = "*"
axum = "*"
clap = { workspace = true }
clap_complete = "*"
+rand = "*"
rusqlite = { version = "*", features = ["bundled"] }
rusqlite_migration = "*"
sentry = { workspace = true }
diff --git a/quire-server/migrations/0003_ci_api.sql b/quire-server/migrations/0003_ci_api.sql
new file mode 100644
index 0000000..fea5121
--- /dev/null
+++ b/quire-server/migrations/0003_ci_api.sql
@@ -0,0 +1 @@
+ALTER TABLE runs ADD COLUMN auth_token TEXT;
diff --git a/quire-server/src/bin/quire/commands/ci.rs b/quire-server/src/bin/quire/commands/ci.rs
index dbffad0..9aee15b 100644
--- a/quire-server/src/bin/quire/commands/ci.rs
+++ b/quire-server/src/bin/quire/commands/ci.rs
@@ -2,7 +2,7 @@ use std::path::PathBuf;
use miette::{IntoDiagnostic, Result};
use quire::Quire;
-use quire::ci::{Ci, CommitRef, RunMeta, Runs};
+use quire::ci::{Ci, CommitRef, RunMeta, Runs, Transport};
/// Validate a repo's ci.fnl without executing any jobs.
///
@@ -74,7 +74,10 @@ pub async fn run(quire: &Quire, maybe_sha: Option<&str>) -> Result<()> {
pushed_at: jiff::Timestamp::now(),
};
- let run = runs.create(&meta)?;
+ // Local `quire ci run` always uses filesystem transport; it never
+ // talks to the server, so no server_url is needed.
+ let transport = Transport::Filesystem;
+ let run = runs.create(&meta, &transport)?;
let run_id = run.id().to_string();
println!(
"Run {}: executing at {} ({})",
@@ -86,8 +89,14 @@ pub async fn run(quire: &Quire, maybe_sha: Option<&str>) -> Result<()> {
let workspace = tmp.path().join("workspace");
quire::ci::materialize_workspace(&repo_path.join(".git"), &commit.sha, &workspace)
.into_diagnostic()?;
- let exec_result =
- run.execute_via_quire_ci(&repo_path.join(".git"), &workspace, &meta, &secrets, None);
+ let exec_result = run.execute_via_quire_ci(
+ &repo_path.join(".git"),
+ &workspace,
+ &meta,
+ &secrets,
+ None,
+ &transport,
+ );
// Print the combined quire-ci log regardless of outcome.
let log_path = tmp.path().join(&run_id).join("quire-ci.log");
diff --git a/quire-server/src/ci/error.rs b/quire-server/src/ci/error.rs
index ca3238c..bd93717 100644
--- a/quire-server/src/ci/error.rs
+++ b/quire-server/src/ci/error.rs
@@ -60,6 +60,9 @@ pub enum Error {
#[error("quire-ci exited with status {exit:?}")]
QuireCiExit { exit: Option<i32> },
+ #[error("ci.transport=api requires ci.server-url to be set in config.fnl")]
+ ApiTransportMissingServerUrl,
+
#[error("failed to parse quire-ci event stream at {}: {source}", path.display())]
EventStreamParse {
path: std::path::PathBuf,
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index 559a0c9..5ee76bb 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -12,7 +12,10 @@ pub use quire_core::ci::pipeline::{
};
pub use quire_core::ci::run::RunMeta;
pub use quire_core::ci::{pipeline, registration, runtime};
-pub use run::{Executor, Run, RunState, Runs, materialize_workspace, reconcile_orphans};
+pub use run::{
+ ApiTransport, Executor, Run, RunState, Runs, Transport, TransportMode, materialize_workspace,
+ reconcile_orphans,
+};
/// A resolved commit reference.
///
@@ -162,13 +165,27 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
);
},
|| {
+ let transport =
+ match Transport::for_new_run(config.ci.transport, config.server_url.as_deref())
+ {
+ Ok(t) => t,
+ Err(e) => {
+ tracing::error!(
+ repo = %event.repo,
+ error = &e as &(dyn std::error::Error + 'static),
+ "CI transport misconfigured",
+ );
+ return;
+ }
+ };
if let Err(e) = trigger_ref(
&repo,
&db_path,
event.pushed_at,
push_ref,
&config.secrets,
- config.executor,
+ config.ci.executor,
+ &transport,
sentry_handoff.as_ref(),
) {
// QuireCiExit means quire-ci itself ran and reported a
@@ -199,6 +216,7 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
}
/// Create and run CI for a single updated ref.
+#[allow(clippy::too_many_arguments)]
fn trigger_ref(
repo: &Repo,
db_path: &Path,
@@ -206,6 +224,7 @@ fn trigger_ref(
push_ref: &PushRef,
secrets: &HashMap<String, quire_core::secret::SecretString>,
executor: Executor,
+ transport: &Transport,
sentry: Option<&quire_core::ci::dispatch::SentryHandoff>,
) -> error::Result<()> {
let ci = repo.ci();
@@ -220,7 +239,7 @@ fn trigger_ref(
pushed_at,
};
- let run = repo.runs(db_path).create(&meta)?;
+ let run = repo.runs(db_path).create(&meta, transport)?;
tracing::info!(
run_id = %run.id(), // cov-excl-line
@@ -235,7 +254,7 @@ fn trigger_ref(
Executor::QuireCi => {
// Compilation happens inside quire-ci so a malformed ci.fnl is
// reported once, with the worker's trace context.
- run.execute_via_quire_ci(&repo.path(), &workspace, &meta, secrets, sentry)?;
+ run.execute_via_quire_ci(&repo.path(), &workspace, &meta, secrets, sentry, transport)?;
}
}
Ok(())
@@ -472,6 +491,7 @@ mod tests {
&push_ref,
&HashMap::new(),
Executor::QuireCi,
+ &Transport::Filesystem,
None,
)
});
@@ -526,6 +546,7 @@ mod tests {
&push_ref,
&HashMap::new(),
Executor::QuireCi,
+ &Transport::Filesystem,
None,
)
});
@@ -569,6 +590,7 @@ mod tests {
&push_ref,
&HashMap::new(),
Executor::QuireCi,
+ &Transport::Filesystem,
None,
)
.expect("should succeed without ci.fnl");
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index 7cf6de3..f8790fb 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -10,6 +10,7 @@ use std::path::{Path, PathBuf};
use jiff::Timestamp;
use quire_core::secret::SecretString;
+use rand::{Rng, distr::Alphanumeric};
use super::error::{Error, Result};
@@ -28,6 +29,54 @@ pub enum Executor {
QuireCi,
}
+/// Transport for CI ↔ server communication.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum TransportMode {
+ #[default]
+ Filesystem,
+ Api,
+}
+
+/// Runtime transport for a single CI run. Built once per run from
+/// the config-shape [`TransportMode`] + the top-level `server_url`,
+/// then passed to `Runs::create` and `Run::execute_via_quire_ci`.
+#[derive(Clone, Debug, Default)]
+pub enum Transport {
+ #[default]
+ Filesystem,
+ Api(ApiTransport),
+}
+
+impl Transport {
+ /// Build a runtime transport for a new run. For `Api`, mints a
+ /// fresh CSPRNG bearer token and pairs it with `server_url`.
+ /// Errors if `mode == Api` and `server_url` is missing.
+ pub fn for_new_run(mode: TransportMode, server_url: Option<&str>) -> Result<Self> {
+ match mode {
+ TransportMode::Filesystem => Ok(Transport::Filesystem),
+ TransportMode::Api => {
+ let server_url = server_url
+ .ok_or(Error::ApiTransportMissingServerUrl)?
+ .to_string();
+ Ok(Transport::Api(ApiTransport {
+ server_url,
+ auth_token: mint_auth_token(),
+ }))
+ }
+ }
+ }
+}
+
+/// Runtime config for the API transport, produced at run creation.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ApiTransport {
+ /// Base URL of quire-server (e.g. `http://127.0.0.1:3000`).
+ pub server_url: String,
+ /// Bearer token for this run, minted at creation time.
+ pub auth_token: String,
+}
+
/// The state of a CI run.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RunState {
@@ -96,9 +145,17 @@ impl Runs {
///
/// Inserts a row into `runs` and creates the run directory for
/// workspace materialization and log storage.
- pub fn create(&self, meta: &RunMeta) -> Result<Run> {
+ ///
+ /// `transport` is built by the caller via [`Transport::for_new_run`];
+ /// its `Api` auth_token (if any) is persisted in the `auth_token`
+ /// column so server-side request handlers can look it up.
+ pub fn create(&self, meta: &RunMeta, transport: &Transport) -> Result<Run> {
let id = uuid::Uuid::now_v7().to_string();
let workspace_path = self.base_dir.join(&id).join("workspace");
+ let auth_token_str = match transport {
+ Transport::Filesystem => None,
+ Transport::Api(api) => Some(api.auth_token.as_str()),
+ };
let db = crate::db::open(&self.db_path)?;
@@ -108,8 +165,8 @@ impl Runs {
self.supersede_existing(&db, &meta.r#ref)?;
db.execute(
- "INSERT INTO runs (id, repo, ref_name, sha, pushed_at_ms, state, queued_at_ms, workspace_path)
- VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?7)",
+ "INSERT INTO runs (id, repo, ref_name, sha, pushed_at_ms, state, queued_at_ms, workspace_path, auth_token)
+ VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?7, ?8)",
rusqlite::params![
&id,
&self.repo,
@@ -121,6 +178,7 @@ impl Runs {
std::io::ErrorKind::InvalidData,
"workspace path is not valid UTF-8",
))?,
+ auth_token_str,
],
)?;
@@ -266,6 +324,10 @@ impl Run {
/// * `jobs/<job>/sh-<n>.log` — per-sh CRI logs, written by quire-ci
/// via `--out-dir`.
///
+ /// When `transport` is `Api`, passes `--run-id`, `--server-url`,
+ /// and `QUIRE_CI_TOKEN` to the subprocess instead of the
+ /// filesystem-based flags.
+ ///
/// Run finishes `Complete` on exit 0, `Failed` otherwise. The DB
/// rows are written even on failure so the web UI can render
/// partial progress.
@@ -276,6 +338,7 @@ impl Run {
meta: &RunMeta,
secrets: &HashMap<String, SecretString>,
sentry: Option<&quire_core::ci::dispatch::SentryHandoff>,
+ transport: &Transport,
) -> Result<()> {
self.transition(RunState::Active, None)?;
@@ -297,16 +360,28 @@ impl Run {
"dispatching run to quire-ci",
);
- let status = std::process::Command::new("quire-ci")
- .arg("run")
- .arg("--workspace")
- .arg(workspace)
- .arg("--out-dir")
- .arg(&run_dir)
- .arg("--events")
- .arg(&events_path)
- .arg("--dispatch")
- .arg(&dispatch_path)
+ let mut cmd = std::process::Command::new("quire-ci");
+ cmd.arg("run").arg("--workspace").arg(workspace);
+
+ match transport {
+ Transport::Filesystem => {
+ cmd.arg("--out-dir")
+ .arg(&run_dir)
+ .arg("--events")
+ .arg(&events_path)
+ .arg("--dispatch")
+ .arg(&dispatch_path);
+ }
+ Transport::Api(api) => {
+ cmd.arg("--run-id")
+ .arg(&self.id)
+ .arg("--server-url")
+ .arg(&api.server_url);
+ cmd.env("QUIRE_CI_TOKEN", &api.auth_token);
+ }
+ }
+
+ let status = cmd
.stdout(std::process::Stdio::from(log))
.stderr(std::process::Stdio::from(log_clone))
.status()
@@ -630,6 +705,19 @@ pub fn materialize_workspace(git_dir: &Path, sha: &str, workspace: &Path) -> Res
Ok(())
}
+/// Mint a 32-character alphanumeric bearer token from the OS CSPRNG.
+///
+/// ~190 bits of entropy, opaque to the holder. Used as the per-run
+/// auth secret for the API transport; stored in the `runs.auth_token`
+/// column and passed to quire-ci via `QUIRE_CI_TOKEN`.
+fn mint_auth_token() -> String {
+ rand::rng()
+ .sample_iter(&Alphanumeric)
+ .take(32)
+ .map(char::from)
+ .collect()
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -805,16 +893,97 @@ mod tests {
fn create_generates_uuidv7_id() {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
- let run = runs.create(&test_meta()).expect("create");
+ let run = runs
+ .create(&test_meta(), &Transport::Filesystem)
+ .expect("create");
let parsed = uuid::Uuid::parse_str(run.id()).expect("should be valid UUID");
assert_eq!(parsed.get_version(), Some(uuid::Version::SortRand));
}
+ #[test]
+ fn create_with_filesystem_leaves_auth_token_null() {
+ let (_dir, quire) = tmp_quire();
+ let runs = test_runs(&quire);
+ let run = runs
+ .create(&test_meta(), &Transport::Filesystem)
+ .expect("create");
+
+ let conn = crate::db::open(&quire.db_path()).expect("db");
+ let token: Option<String> = conn
+ .query_row(
+ "SELECT auth_token FROM runs WHERE id = ?1",
+ rusqlite::params![run.id()],
+ |row| row.get(0),
+ )
+ .expect("row");
+ assert!(
+ token.is_none(),
+ "filesystem transport should not mint a token"
+ );
+ }
+
+ #[test]
+ fn create_with_api_persists_minted_auth_token() {
+ let (_dir, quire) = tmp_quire();
+ let runs = test_runs(&quire);
+ let transport = Transport::for_new_run(TransportMode::Api, Some("http://127.0.0.1:3000"))
+ .expect("for_new_run");
+ let run = runs.create(&test_meta(), &transport).expect("create");
+
+ let Transport::Api(api) = &transport else {
+ panic!("expected Api transport");
+ };
+
+ let conn = crate::db::open(&quire.db_path()).expect("db");
+ let stored: Option<String> = conn
+ .query_row(
+ "SELECT auth_token FROM runs WHERE id = ?1",
+ rusqlite::params![run.id()],
+ |row| row.get(0),
+ )
+ .expect("row");
+ assert_eq!(stored.as_deref(), Some(api.auth_token.as_str()));
+ }
+
+ #[test]
+ fn for_new_run_mints_alphanumeric_token() {
+ let transport = Transport::for_new_run(TransportMode::Api, Some("http://127.0.0.1:3000"))
+ .expect("for_new_run");
+ let Transport::Api(api) = transport else {
+ panic!("expected Api transport");
+ };
+ assert_eq!(api.server_url, "http://127.0.0.1:3000");
+ assert_eq!(api.auth_token.len(), 32);
+ assert!(
+ api.auth_token.chars().all(|c| c.is_ascii_alphanumeric()),
+ "token should be alphanumeric, got {:?}",
+ api.auth_token
+ );
+ }
+
+ #[test]
+ fn for_new_run_rejects_api_without_server_url() {
+ match Transport::for_new_run(TransportMode::Api, None) {
+ Ok(_) => panic!("for_new_run should fail without server_url"),
+ Err(Error::ApiTransportMissingServerUrl) => {}
+ Err(other) => panic!("unexpected error: {other}"),
+ }
+ }
+
+ #[test]
+ fn mint_auth_token_returns_unique_values() {
+ let a = mint_auth_token();
+ let b = mint_auth_token();
+ assert_ne!(a, b, "two mints should not collide");
+ }
+
#[test]
fn create_writes_row_in_pending_state() {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
- let run = runs.create(&test_meta()).expect("create");
+ let run = runs
+ .create(&test_meta(), &Transport::Filesystem)
+ .expect("create");
assert_eq!(run.state(), RunState::Pending);
@@ -837,7 +1006,9 @@ mod tests {
fn transition_updates_state_in_db() {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
- let mut run = runs.create(&test_meta()).expect("create");
+ let mut run = runs
+ .create(&test_meta(), &Transport::Filesystem)
+ .expect("create");
let id = run.id().to_string();
run.transition(RunState::Active, None).expect("transition");
@@ -858,7 +1029,9 @@ mod tests {
fn transition_stamps_started_at_on_active() {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
- let mut run = runs.create(&test_meta()).expect("create");
+ let mut run = runs
+ .create(&test_meta(), &Transport::Filesystem)
+ .expect("create");
run.transition(RunState::Active, None).expect("to active");
let started = run.read_started_at().expect("read started_at");
@@ -871,7 +1044,9 @@ mod tests {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
- let mut completed = runs.create(&test_meta()).expect("create");
+ let mut completed = runs
+ .create(&test_meta(), &Transport::Filesystem)
+ .expect("create");
completed
.transition(RunState::Active, None)
.expect("to active");
@@ -880,7 +1055,9 @@ mod tests {
.expect("to complete");
assert!(completed.read_finished_at().expect("read").is_some());
- let mut failed = runs.create(&test_meta()).expect("create");
+ let mut failed = runs
+ .create(&test_meta(), &Transport::Filesystem)
+ .expect("create");
failed
.transition(RunState::Active, None)
.expect("to active");
@@ -894,7 +1071,9 @@ mod tests {
fn transition_records_failure_kind_on_failed() {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
- let mut run = runs.create(&test_meta()).expect("create");
+ let mut run = runs
+ .create(&test_meta(), &Transport::Filesystem)
+ .expect("create");
let id = run.id().to_string();
run.transition(RunState::Active, None).expect("to active");
@@ -916,7 +1095,9 @@ mod tests {
fn transition_skips_failure_kind_when_none() {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
- let mut run = runs.create(&test_meta()).expect("create");
+ let mut run = runs
+ .create(&test_meta(), &Transport::Filesystem)
+ .expect("create");
let id = run.id().to_string();
run.transition(RunState::Active, None).expect("to active");
@@ -939,11 +1120,15 @@ mod tests {
let runs = test_runs(&quire);
// Pending -> Failed is not allowed (must go via Active).
- let mut run = runs.create(&test_meta()).expect("create");
+ let mut run = runs
+ .create(&test_meta(), &Transport::Filesystem)
+ .expect("create");
assert!(run.transition(RunState::Failed, None).is_err());
// Terminal -> anything is not allowed.
- let mut completed = runs.create(&test_meta()).expect("create");
+ let mut completed = runs
+ .create(&test_meta(), &Transport::Filesystem)
+ .expect("create");
completed
.transition(RunState::Active, None)
.expect("to active");
@@ -958,7 +1143,9 @@ mod tests {
fn transition_preserves_started_at_through_completion() {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
- let mut run = runs.create(&test_meta()).expect("create");
+ let mut run = runs
+ .create(&test_meta(), &Transport::Filesystem)
+ .expect("create");
run.transition(RunState::Active, None).expect("to active");
let started = run.read_started_at().expect("read started_at");
@@ -976,7 +1163,9 @@ mod tests {
fn transition_full_lifecycle() {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
- let mut run = runs.create(&test_meta()).expect("create");
+ let mut run = runs
+ .create(&test_meta(), &Transport::Filesystem)
+ .expect("create");
run.transition(RunState::Active, None).expect("to active");
run.transition(RunState::Complete, None)
@@ -989,7 +1178,9 @@ mod tests {
fn reconcile_fails_pending_orphans() {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
- let run = runs.create(&test_meta()).expect("create");
+ let run = runs
+ .create(&test_meta(), &Transport::Filesystem)
+ .expect("create");
let id = run.id().to_string();
reconcile_orphans(&quire.db_path()).expect("reconcile");
@@ -1002,7 +1193,9 @@ mod tests {
fn reconcile_fails_active_orphans() {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
- let mut run = runs.create(&test_meta()).expect("create");
+ let mut run = runs
+ .create(&test_meta(), &Transport::Filesystem)
+ .expect("create");
run.transition(RunState::Active, None).expect("to active");
let id = run.id().to_string();
@@ -1016,7 +1209,9 @@ mod tests {
fn reconcile_leaves_complete_runs_alone() {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
- let mut run = runs.create(&test_meta()).expect("create");
+ let mut run = runs
+ .create(&test_meta(), &Transport::Filesystem)
+ .expect("create");
run.transition(RunState::Active, None).expect("to active");
run.transition(RunState::Complete, None)
.expect("to complete");
@@ -1040,7 +1235,9 @@ mod tests {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
- let run = runs.create(&test_meta()).expect("create");
+ let run = runs
+ .create(&test_meta(), &Transport::Filesystem)
+ .expect("create");
let run_id = run.id().to_string();
let events = vec![
@@ -1145,7 +1342,9 @@ mod tests {
fn ingest_events_treats_missing_file_as_empty() {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
- let run = runs.create(&test_meta()).expect("create");
+ let run = runs
+ .create(&test_meta(), &Transport::Filesystem)
+ .expect("create");
let missing = run.path().join("events.jsonl");
run.ingest_events(&missing)
@@ -1168,7 +1367,9 @@ mod tests {
let runs = test_runs(&quire);
// Create first run.
- let run1 = runs.create(&test_meta()).expect("create run1");
+ let run1 = runs
+ .create(&test_meta(), &Transport::Filesystem)
+ .expect("create run1");
let run1_id = run1.id().to_string();
assert_eq!(run1.state(), RunState::Pending);
@@ -1178,7 +1379,9 @@ mod tests {
r#ref: "refs/heads/main".to_string(),
pushed_at: "2026-04-28T13:00:00Z".parse().unwrap(),
};
- let run2 = runs.create(&meta2).expect("create run2");
+ let run2 = runs
+ .create(&meta2, &Transport::Filesystem)
+ .expect("create run2");
assert_eq!(run2.state(), RunState::Pending);
// First run should now be superseded.
@@ -1196,7 +1399,9 @@ mod tests {
let runs = test_runs(&quire);
// Create and activate first run.
- let mut run1 = runs.create(&test_meta()).expect("create run1");
+ let mut run1 = runs
+ .create(&test_meta(), &Transport::Filesystem)
+ .expect("create run1");
let run1_id = run1.id().to_string();
run1.transition(RunState::Active, None).expect("to active");
@@ -1206,7 +1411,9 @@ mod tests {
r#ref: "refs/heads/main".to_string(),
pushed_at: "2026-04-28T13:00:00Z".parse().unwrap(),
};
- let run2 = runs.create(&meta2).expect("create run2");
+ let run2 = runs
+ .create(&meta2, &Transport::Filesystem)
+ .expect("create run2");
assert_eq!(run2.state(), RunState::Pending);
// First run should be superseded.
@@ -1224,7 +1431,9 @@ mod tests {
let runs = test_runs(&quire);
// Create run for main.
- let run1 = runs.create(&test_meta()).expect("create run1");
+ let run1 = runs
+ .create(&test_meta(), &Transport::Filesystem)
+ .expect("create run1");
let run1_id = run1.id().to_string();
// Create run for a different ref.
@@ -1233,7 +1442,9 @@ mod tests {
r#ref: "refs/heads/feature".to_string(),
pushed_at: "2026-04-28T13:00:00Z".parse().unwrap(),
};
- let _run2 = runs.create(&meta2).expect("create run2");
+ let _run2 = runs
+ .create(&meta2, &Transport::Filesystem)
+ .expect("create run2");
// First run should still be pending.
let reopened = Run::open(quire.db_path(), run1_id, runs.base_dir.clone()).expect("reopen");
@@ -1246,7 +1457,9 @@ mod tests {
let runs = test_runs(&quire);
// Create and complete first run.
- let mut run1 = runs.create(&test_meta()).expect("create run1");
+ let mut run1 = runs
+ .create(&test_meta(), &Transport::Filesystem)
+ .expect("create run1");
let run1_id = run1.id().to_string();
run1.transition(RunState::Active, None).expect("to active");
run1.transition(RunState::Complete, None)
@@ -1258,7 +1471,9 @@ mod tests {
r#ref: "refs/heads/main".to_string(),
pushed_at: "2026-04-28T13:00:00Z".parse().unwrap(),
};
- let _run2 = runs.create(&meta2).expect("create run2");
+ let _run2 = runs
+ .create(&meta2, &Transport::Filesystem)
+ .expect("create run2");
// First run should still be complete.
let reopened = Run::open(quire.db_path(), run1_id, runs.base_dir.clone()).expect("reopen");
@@ -1269,7 +1484,9 @@ mod tests {
fn transition_allows_pending_to_superseded() {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
- let mut run = runs.create(&test_meta()).expect("create");
+ let mut run = runs
+ .create(&test_meta(), &Transport::Filesystem)
+ .expect("create");
run.transition(RunState::Superseded, None)
.expect("to superseded");
assert_eq!(run.state(), RunState::Superseded);
@@ -1279,7 +1496,9 @@ mod tests {
fn transition_allows_active_to_superseded() {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
- let mut run = runs.create(&test_meta()).expect("create");
+ let mut run = runs
+ .create(&test_meta(), &Transport::Filesystem)
+ .expect("create");
run.transition(RunState::Active, None).expect("to active");
run.transition(RunState::Superseded, None)
.expect("to superseded");
@@ -1290,7 +1509,9 @@ mod tests {
fn supersede_sets_finished_at() {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
- let mut run = runs.create(&test_meta()).expect("create");
+ let mut run = runs
+ .create(&test_meta(), &Transport::Filesystem)
+ .expect("create");
run.transition(RunState::Active, None).expect("to active");
assert!(
diff --git a/quire-server/src/db.rs b/quire-server/src/db.rs
index 7fca1ca..0b8770b 100644
--- a/quire-server/src/db.rs
+++ b/quire-server/src/db.rs
@@ -14,6 +14,7 @@ static MIGRATIONS: std::sync::LazyLock<Migrations<'static>> = std::sync::LazyLoc
Migrations::new(vec![
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")),
])
});
diff --git a/quire-server/src/quire/mod.rs b/quire-server/src/quire/mod.rs
index 8a6e0a7..150017d 100644
--- a/quire-server/src/quire/mod.rs
+++ b/quire-server/src/quire/mod.rs
@@ -6,7 +6,7 @@ use miette::{Context, IntoDiagnostic, Result, ensure};
pub mod web;
-use crate::ci::{Ci, Executor, Runs};
+use crate::ci::{Ci, Executor, Runs, TransportMode};
use crate::{Error, Result as AppResult};
use quire_core::fennel::Fennel;
use quire_core::secret::SecretString;
@@ -15,6 +15,7 @@ use quire_core::secret::SecretString;
///
/// Top-level stays open for future keys (notifications defaults, SMTP, etc.).
#[derive(serde::Deserialize, Debug)]
+#[serde(rename_all = "kebab-case")]
pub struct GlobalConfig {
#[serde(default)]
pub sentry: Option<SentryConfig>,
@@ -22,10 +23,30 @@ pub struct GlobalConfig {
/// Each value is a `SecretString` (plain literal or `{:file "..."}`).
#[serde(default)]
pub secrets: HashMap<String, SecretString>,
+ /// Base URL the orchestrator advertises to quire-ci over the API
+ /// transport (e.g. `http://127.0.0.1:3000`). Top-level because it
+ /// describes the server itself, not CI; once the filesystem
+ /// transport is retired it stays put.
+ #[serde(default)]
+ pub server_url: Option<String>,
+ /// CI configuration.
+ #[serde(default)]
+ pub ci: CiConfig,
+}
+
+#[derive(serde::Deserialize, Debug, Default)]
+pub struct CiConfig {
/// How the orchestrator dispatches CI runs. Defaults to shelling
/// out to the `quire-ci` binary via `Executor::QuireCi`.
#[serde(default)]
pub executor: Executor,
+ /// Transport for CI ↔ server communication.
+ ///
+ /// `"filesystem"` (default) writes dispatch/events/logs to disk;
+ /// `"api"` uses the HTTP API with bearer-token auth (requires
+ /// the top-level `server-url`).
+ #[serde(default)]
+ pub transport: TransportMode,
}
#[derive(serde::Deserialize, Debug)]
@@ -387,6 +408,35 @@ mod tests {
assert!(q.repo_from_path(&path).is_err());
}
+ #[test]
+ fn global_config_ci_defaults_to_filesystem() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let config_path = dir.path().join("config.fnl");
+ fs_err::write(&config_path, "{}").expect("write");
+
+ let q = Quire::new(dir.path().to_path_buf());
+ let config = q.global_config().expect("global_config should load");
+ assert_eq!(config.ci.transport, TransportMode::Filesystem);
+ assert_eq!(config.ci.executor, Executor::QuireCi);
+ assert!(config.server_url.is_none());
+ }
+
+ #[test]
+ fn global_config_parses_api_transport_and_server_url() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let config_path = dir.path().join("config.fnl");
+ fs_err::write(
+ &config_path,
+ r#"{:server-url "http://127.0.0.1:3000" :ci {:transport :api}}"#,
+ )
+ .expect("write");
+
+ let q = Quire::new(dir.path().to_path_buf());
+ let config = q.global_config().expect("global_config should load");
+ assert_eq!(config.ci.transport, TransportMode::Api);
+ assert_eq!(config.server_url.as_deref(), Some("http://127.0.0.1:3000"));
+ }
+
#[test]
fn global_config_loads_from_fennel_file() {
let dir = tempfile::tempdir().expect("tempdir");