Add opt-in `quire-ci` executor for CI dispatch
The orchestrator can now shell out to the `quire-ci` binary instead
of evaluating the pipeline in-process. Opt in by adding
`:executor :quire-ci` to `/var/quire/config.fnl`; default stays
`:host`. `global_config()` re-reads on every trigger, so the next
push after editing the config picks up the change without a server
restart.
`Run::execute_via_quire_ci` spawns `quire-ci run --workspace ...`
with combined stdout+stderr captured to `<run_dir>/quire-ci.log`.
The run finishes `complete` on exit 0 and `failed` otherwise.
Per-job and per-sh database records are not written in this path —
quire-ci doesn't yet emit a structured report for the orchestrator
to ingest. Plumbing only; the structured-report design is a
follow-up.
The orchestrator still compiles the ci.fnl before dispatching so
bad pipelines fail fast at trigger time. quire-ci recompiles inside
its own process; that redundant compile is cheap and worth the
early-fail.
Assisted-by: Claude Opus 4.7 (1M context) via Claude Code
diff --git a/quire-server/src/ci/error.rs b/quire-server/src/ci/error.rs
index 3edb624..5278b38 100644
--- a/quire-server/src/ci/error.rs
+++ b/quire-server/src/ci/error.rs
@@ -63,6 +63,9 @@ pub enum Error {
#[source]
source: std::io::Error,
},
+
+ #[error("quire-ci exited with status {exit:?}")]
+ QuireCiExit { exit: Option<i32> },
}
pub type Result<T> = std::result::Result<T, Error>;
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index 14abe06..c9ff2e1 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -13,7 +13,7 @@ pub use quire_core::ci::pipeline::{
};
pub use quire_core::ci::run::RunMeta;
pub use quire_core::ci::{mirror, pipeline, registration, runtime};
-pub use run::{Run, RunState, Runs, materialize_workspace, reconcile_orphans};
+pub use run::{Executor, Run, RunState, Runs, materialize_workspace, reconcile_orphans};
/// A resolved commit reference.
///
@@ -121,8 +121,8 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
}
};
- let secrets = match quire.global_config() {
- Ok(config) => config.secrets,
+ let config = match quire.global_config() {
+ Ok(config) => config,
Err(e) => {
tracing::error!(repo = %event.repo, error = %display_chain(&e), "failed to load global config");
return;
@@ -131,7 +131,14 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
let db_path = quire.db_path();
for push_ref in event.updated_refs() {
- if let Err(e) = trigger_ref(&repo, &db_path, event.pushed_at, push_ref, &secrets) {
+ if let Err(e) = trigger_ref(
+ &repo,
+ &db_path,
+ event.pushed_at,
+ push_ref,
+ &config.secrets,
+ config.executor,
+ ) {
tracing::error!(
repo = %event.repo,
sha = %push_ref.new_sha, // cov-excl-line
@@ -149,6 +156,7 @@ fn trigger_ref(
pushed_at: jiff::Timestamp,
push_ref: &PushRef,
secrets: &HashMap<String, quire_core::secret::SecretString>,
+ executor: Executor,
) -> error::Result<()> {
let ci = repo.ci();
@@ -182,7 +190,17 @@ fn trigger_ref(
let workspace = run.path().join("workspace");
run::materialize_workspace(&repo.path(), &push_ref.new_sha, &workspace)?;
- run.execute(pipeline, secrets.clone(), &repo.path(), &workspace)?;
+ match executor {
+ Executor::Host => {
+ run.execute(pipeline, secrets.clone(), &repo.path(), &workspace)?;
+ }
+ Executor::QuireCi => {
+ // The orchestrator already validated `pipeline` to fail-fast on
+ // bad ci.fnl; `quire-ci` recompiles inside its own process.
+ drop(pipeline);
+ run.execute_via_quire_ci(&workspace)?;
+ }
+ }
Ok(())
}
@@ -359,6 +377,7 @@ mod tests {
pushed_at,
&push_ref,
&HashMap::new(),
+ Executor::Host,
)
.expect("trigger_ref should succeed");
@@ -392,6 +411,7 @@ mod tests {
pushed_at,
&push_ref,
&HashMap::new(),
+ Executor::Host,
)
.expect("should succeed without ci.fnl");
}
@@ -415,6 +435,7 @@ mod tests {
pushed_at,
&push_ref,
&HashMap::new(),
+ Executor::Host,
);
assert!(result.is_err(), "invalid pipeline should fail");
}
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index e7562e4..225d02f 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -19,6 +19,20 @@ use quire_core::secret::SecretString;
pub use quire_core::ci::run::RunMeta;
+/// How a run dispatches its pipeline.
+///
+/// `Host` evaluates the Lua/Fennel pipeline in-process on the
+/// orchestrator. `QuireCi` shells out to the `quire-ci` binary,
+/// which compiles and runs the pipeline in a separate process.
+/// Selected by the `:executor` key in the global config.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum Executor {
+ #[default]
+ Host,
+ QuireCi,
+}
+
/// The state of a CI run.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RunState {
@@ -299,6 +313,52 @@ impl Run {
Ok(outputs)
}
+ /// Run the pipeline by shelling out to the `quire-ci` binary.
+ ///
+ /// Combined stdout+stderr is captured to `<run_dir>/quire-ci.log`.
+ /// Run finishes `Complete` on exit 0, `Failed` otherwise. Per-job
+ /// and per-sh database records are not written in this path —
+ /// quire-ci doesn't yet emit a structured report for the
+ /// orchestrator to ingest.
+ pub fn execute_via_quire_ci(mut self, workspace: &Path) -> Result<()> {
+ self.transition(RunState::Active)?;
+
+ let log_path = self.path().join("quire-ci.log");
+ // fs_err for the path-bearing IO error; unwrap to std::fs::File so
+ // it's convertible into Stdio.
+ let log = fs_err::File::create(&log_path)?.into_parts().0;
+ let log_clone = log.try_clone()?;
+
+ tracing::info!(
+ run_id = %self.id,
+ log = %log_path.display(),
+ "dispatching run to quire-ci",
+ );
+
+ let status = std::process::Command::new("quire-ci")
+ .arg("run")
+ .arg("--workspace")
+ .arg(workspace)
+ .stdout(std::process::Stdio::from(log))
+ .stderr(std::process::Stdio::from(log_clone))
+ .status()
+ .map_err(|source| Error::CommandSpawnFailed {
+ program: "quire-ci".to_string(),
+ cwd: workspace.to_path_buf(),
+ source,
+ })?;
+
+ if !status.success() {
+ self.transition(RunState::Failed)?;
+ return Err(Error::QuireCiExit {
+ exit: status.code(),
+ });
+ }
+
+ self.transition(RunState::Complete)?;
+ Ok(())
+ }
+
/// Write sh events to the database and per-sh CRI log files to
/// disk. Written before the final state transition so logs are
/// available for both successful and failed runs.
diff --git a/quire-server/src/quire/mod.rs b/quire-server/src/quire/mod.rs
index 0fb6ccb..8a9896c 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, Runs};
+use crate::ci::{Ci, Executor, Runs};
use crate::{Error, Result as AppResult};
use quire_core::fennel::Fennel;
use quire_core::secret::SecretString;
@@ -22,6 +22,11 @@ pub struct GlobalConfig {
/// Each value is a `SecretString` (plain literal or `{:file "..."}`).
#[serde(default)]
pub secrets: HashMap<String, SecretString>,
+ /// How the orchestrator dispatches CI runs. Defaults to in-process
+ /// host evaluation; set `:executor :quire-ci` to opt into shelling
+ /// out to the `quire-ci` binary.
+ #[serde(default)]
+ pub executor: Executor,
}
#[derive(serde::Deserialize, Debug)]