Remove TransportMode and filesystem bootstrap fallback
`TransportMode` (Filesystem/Api) is gone — server-dispatched runs always
use API transport. quire-ci no longer accepts `--bootstrap` or
`QUIRE__TRANSPORT`; it detects local runs (no `QUIRE__SERVER_URL`) and
falls back to a bootstrap file only in that path. `write_bootstrap` and
the 0600 file handoff are removed from the `Some(transport)` arm;
`store_bootstrap_data` + `GET /api/run/bootstrap` is now the only path
for orchestrator-dispatched runs. `CiConfig.transport` is dropped from
the global config and docs.
https://claude.ai/code/session_01Ngf4zbFf87zJFUR5ee2Y8b
diff --git a/docs/config.md b/docs/config.md
index 25e7a3d..fd21568 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -14,7 +14,6 @@ Operator-created. Re-read on every call (no caching today).
| `:port` | integer | no | TCP port the HTTP server binds to (on `0.0.0.0`). Default: `3000`. |
| `:sentry :dsn` | `SecretString` | no | Sentry DSN for error reporting from both `quire` and `quire-ci`. Omit to disable. |
| `:secrets` | table | no | Named secrets exposed to `ci.fnl` jobs as `(secret :name)`. |
-| `:ci :transport` | string | no | How `quire-ci` receives secrets. `"filesystem"` (default) bakes revealed values into the bootstrap file; `"api"` serves them on demand via the HTTP API instead. See [Secret transport modes](#secret-transport-modes) below. |
Minimal (no Sentry, no secrets):
@@ -84,31 +83,6 @@ Limits worth knowing:
their *recorded* output redacted at record time.
- Tracing output is not yet redacted (tracked separately).
-## Secret transport modes
-
-The `:ci :transport` key controls how `quire-ci` obtains secret values at
-run time. The two modes are:
-
-**`"filesystem"` (default):** Secrets are revealed into the bootstrap JSON
-file (mode `0600`) before `quire-ci` is spawned. The subprocess reads and
-immediately unlinks the file.
-
-**`"api"`:** `quire-ci` ignores the secrets map in the bootstrap file and
-fetches each value on demand from `GET /api/run/secrets/{name}`
-using the per-run bearer token when a run-fn calls `(secret :name)`. The
-bootstrap file is still written and used for run metadata; only the secret
-values travel over the loopback HTTP channel instead of disk.
-
-To opt in:
-
-```fennel
-{:ci {:transport :api}}
-```
-
-The filesystem path remains the default. Use `:api` to validate the HTTP
-path before a future change stops writing secrets to the bootstrap file
-entirely.
-
## See also
- [`fennel.md`](fennel.md) — how Fennel files are loaded into Rust structs.
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 082a5bc..7a42e0e 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -14,7 +14,7 @@ use quire_core::ci::event::{Event, EventKind, JobOutcome, RunOutcome};
use quire_core::ci::pipeline::{self, Pipeline, RunFn};
use quire_core::ci::run::RunMeta;
use quire_core::ci::runtime::{Runtime, RuntimeError, RuntimeEvent, RuntimeHandle};
-use quire_core::ci::transport::{ApiSession, Transport, TransportMode};
+use quire_core::ci::transport::ApiSession;
use quire_core::fennel::FennelError;
use quire_core::secret::{Error as SecretError, Result as SecretResult, SecretRegistry};
use quire_core::telemetry::{self, FmtMode, MietteLayer};
@@ -49,8 +49,7 @@ struct Cli {
/// Transport credentials and telemetry settings for
/// orchestrator-dispatched runs, sourced from `QUIRE__*` env vars:
- /// `QUIRE__SERVER_URL`, `QUIRE__RUN_TOKEN`, `QUIRE__TRANSPORT`,
- /// `QUIRE__SENTRY_DSN`.
+ /// `QUIRE__SERVER_URL`, `QUIRE__RUN_TOKEN`, `QUIRE__SENTRY_DSN`.
#[facet(args::config, args::env_prefix = "QUIRE")]
quire: QuireConfig,
@@ -75,10 +74,6 @@ struct QuireConfig {
#[facet(sensitive, default)]
run_token: String,
- /// Transport mode (`QUIRE__TRANSPORT`).
- #[facet(default)]
- transport: TransportMode,
-
/// Sentry DSN for error reporting (`QUIRE__SENTRY_DSN`).
#[facet(default)]
sentry_dsn: Option<String>,
@@ -112,8 +107,8 @@ enum Commands {
out_dir: Option<PathBuf>,
/// Path to a JSON bootstrap file produced by the orchestrator.
- /// Required for `filesystem` transport; omitted for `api`
- /// transport, which fetches bootstrap via the server API instead.
+ /// Required for local runs (no `QUIRE__SERVER_URL`); omitted for
+ /// server-dispatched runs, which fetch bootstrap via the API.
#[facet(args::named, default)]
bootstrap: Option<PathBuf>,
},
@@ -317,20 +312,15 @@ fn main() -> Result<()> {
server_url: cli.quire.server_url,
run_token: cli.quire.run_token,
};
- let transport = Transport {
- session: session.clone(),
- mode: cli.quire.transport,
- };
- let client = RunClient::new(session);
-
- let (git_dir, meta, sentry_trace_id) = match transport.mode {
- TransportMode::Api => client.fetch_bootstrap()?,
- TransportMode::Filesystem => {
- let Some(path) = bootstrap else {
- bail!("--bootstrap is required for filesystem transport");
- };
- load_bootstrap(&path)?
- }
+ let client = RunClient::new(session.clone());
+
+ let (git_dir, meta, sentry_trace_id) = if session.server_url.is_empty() {
+ let Some(path) = bootstrap else {
+ bail!("--bootstrap is required for local runs (no QUIRE__SERVER_URL set)");
+ };
+ load_bootstrap(&path)?
+ } else {
+ client.fetch_bootstrap()?
};
// Drop order: `_sentry` flushes first (still inside the
@@ -351,7 +341,7 @@ fn main() -> Result<()> {
let registry = SecretRegistry::new(move |name| client.fetch_secret(name));
- run_pipeline(workspace, sink, log_dir, git_dir, meta, registry, transport)
+ run_pipeline(workspace, sink, log_dir, git_dir, meta, registry)
}
}
}
@@ -445,7 +435,6 @@ fn run_pipeline(
git_dir: PathBuf,
meta: RunMeta,
registry: SecretRegistry,
- _transport: Transport,
) -> Result<()> {
let pipeline = match compile_at(&workspace) {
Ok(p) => p,
diff --git a/quire-core/src/ci/bootstrap.rs b/quire-core/src/ci/bootstrap.rs
index 6e43157..8d23325 100644
--- a/quire-core/src/ci/bootstrap.rs
+++ b/quire-core/src/ci/bootstrap.rs
@@ -1,15 +1,11 @@
//! Wire format for handing off a run from the orchestrator to
//! `quire-ci`.
//!
-//! The orchestrator writes a [`Bootstrap`] as JSON to a file inside
-//! the run directory and passes the path via `--bootstrap`. `quire-ci`
-//! deserializes it on startup to recover push metadata. Standalone
-//! `quire-ci run` invocations skip the file entirely and fall back to
-//! placeholder values.
-//!
-//! The file is a one-shot handoff: `quire-ci` unlinks it as soon as
-//! it has read the bytes into memory, and the orchestrator
-//! best-effort unlinks after the subprocess exits as a safety net.
+//! For server-dispatched runs the orchestrator stores a [`Bootstrap`]
+//! in the database; `quire-ci` fetches it via `GET /api/run/bootstrap`
+//! using the per-run bearer token. For local dev runs without a server
+//! the orchestrator writes the JSON to a file and passes the path via
+//! `--bootstrap`.
use std::path::PathBuf;
diff --git a/quire-core/src/ci/transport.rs b/quire-core/src/ci/transport.rs
index 231183f..d5bbdf2 100644
--- a/quire-core/src/ci/transport.rs
+++ b/quire-core/src/ci/transport.rs
@@ -1,9 +1,8 @@
//! Shared transport types for CI ↔ server communication.
//!
//! The on-the-wire pairing both sides agree on. The orchestrator
-//! constructs a `Transport` per run (minting the auth token and
-//! using the run's UUID); quire-ci reconstructs it from the
-//! `QUIRE__*` environment variables.
+//! constructs a `Transport` per run (minting the auth token);
+//! quire-ci reconstructs it from the `QUIRE__*` environment variables.
/// Credentials and endpoint coordinates for a single CI run's API
/// channel. Holds everything quire-ci needs to call back to the
@@ -19,33 +18,9 @@ pub struct ApiSession {
pub run_token: String,
}
-/// Transport mode for CI ↔ server communication.
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, facet::Facet, serde::Deserialize)]
-#[facet(rename_all = "kebab-case")]
-#[serde(rename_all = "kebab-case")]
-#[repr(u8)]
-pub enum TransportMode {
- #[default]
- Filesystem,
- Api,
-}
-
-impl std::str::FromStr for TransportMode {
- type Err = String;
-
- fn from_str(s: &str) -> Result<Self, Self::Err> {
- match s {
- "filesystem" => Ok(Self::Filesystem),
- "api" => Ok(Self::Api),
- other => Err(format!("unknown transport mode: {other}")),
- }
- }
-}
-
/// Runtime transport for a single CI run.
/// Use `None` for local runs where no server is involved.
#[derive(Clone, Debug)]
pub struct Transport {
pub session: ApiSession,
- pub mode: TransportMode,
}
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index ac8a6a5..153df02 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -9,8 +9,7 @@ pub use quire_core::ci::pipeline::{
DefinitionError, Diagnostic, Job, Pipeline, PipelineError, StructureError,
};
pub use quire_core::ci::run::RunMeta;
-pub use quire_core::ci::transport::ApiSession;
-pub use quire_core::ci::transport::{Transport, TransportMode};
+pub use quire_core::ci::transport::{ApiSession, Transport};
pub use quire_core::ci::{pipeline, registration, runtime};
pub use run::{
Executor, Run, RunState, Runs, materialize_workspace, new_transport, reconcile_orphans,
@@ -103,7 +102,6 @@ impl Ci {
struct TriggerContext<'a> {
run: RunContext<'a>,
event_repo: &'a str,
- transport_mode: TransportMode,
port: u16,
sentry_dsn: Option<String>,
}
@@ -157,7 +155,6 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
executor: config.ci.executor,
},
event_repo: &event.repo,
- transport_mode: config.ci.transport,
port: config.port,
sentry_dsn,
};
@@ -184,7 +181,7 @@ fn run_ref(
trace_id: sentry::protocol::TraceId,
span_id: sentry::protocol::SpanId,
) {
- let transport = new_transport(ctx.transport_mode, ctx.port);
+ let transport = new_transport(ctx.port);
let sentry_trace_id = ctx.sentry_dsn.as_ref().map(|_| trace_id.to_string());
sentry::with_scope(
|scope| {
@@ -506,14 +503,7 @@ exit 0
let db_path = quire.db_path();
let ctx = run_ctx(&repo, &db_path);
let trigger_result = with_path(&fake_path, || {
- run_ref_inner(
- &ctx,
- pushed_at,
- &push_ref,
- &new_transport(TransportMode::Filesystem, 3000),
- None,
- None,
- )
+ run_ref_inner(&ctx, pushed_at, &push_ref, &new_transport(3000), None, None)
});
trigger_result.expect("trigger_ref should succeed with fake quire-ci");
@@ -561,14 +551,7 @@ exit 0
let db_path = quire.db_path();
let ctx = run_ctx(&repo, &db_path);
let trigger_result = with_path(&fake_path, || {
- run_ref_inner(
- &ctx,
- pushed_at,
- &push_ref,
- &new_transport(TransportMode::Filesystem, 3000),
- None,
- None,
- )
+ run_ref_inner(&ctx, pushed_at, &push_ref, &new_transport(3000), None, None)
});
let err = trigger_result.expect_err("should fail when quire-ci exits nonzero");
@@ -605,15 +588,8 @@ exit 0
let db_path = quire.db_path();
let ctx = run_ctx(&repo, &db_path);
- run_ref_inner(
- &ctx,
- pushed_at,
- &push_ref,
- &new_transport(TransportMode::Filesystem, 3000),
- None,
- None,
- )
- .expect("should succeed without ci.fnl");
+ run_ref_inner(&ctx, pushed_at, &push_ref, &new_transport(3000), None, None)
+ .expect("should succeed without ci.fnl");
}
fn push_event(repo: &str, sha: &str) -> PushEvent {
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index 04df1d9..f80b58f 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -15,7 +15,7 @@ use rand::{Rng, distr::Alphanumeric};
use super::error::{Error, Result};
pub use quire_core::ci::run::RunMeta;
-pub use quire_core::ci::transport::{Transport, TransportMode};
+pub use quire_core::ci::transport::Transport;
/// How a run dispatches its pipeline.
///
@@ -31,15 +31,13 @@ pub enum Executor {
}
/// Mint a fresh transport for a new orchestrator-dispatched run. Generates
-/// a UUIDv7 run ID and CSPRNG bearer token, deriving the loopback server
-/// URL from `port`.
-pub fn new_transport(mode: TransportMode, port: u16) -> Transport {
+/// a CSPRNG bearer token, deriving the loopback server URL from `port`.
+pub fn new_transport(port: u16) -> Transport {
Transport {
session: ApiSession {
server_url: format!("http://127.0.0.1:{port}"),
run_token: mint_run_token(),
},
- mode,
}
}
@@ -293,10 +291,6 @@ 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.
@@ -315,10 +309,7 @@ impl Run {
// 'active' in the DB before quire-ci connects, causing the endpoint
// to return 410 Gone. Update local state only so the later
// transition(Complete/Failed) call passes the state-machine check.
- let uses_api_transport = transport
- .map(|t| t.mode == TransportMode::Api)
- .unwrap_or(false);
- if uses_api_transport {
+ if transport.is_some() {
self.state = RunState::Active;
} else {
self.transition(RunState::Active, None)?;
@@ -355,15 +346,9 @@ impl Run {
cmd.arg("--bootstrap").arg(&bootstrap_path);
}
Some(t) => {
+ self.store_bootstrap_data(git_dir, sentry_trace_id)?;
cmd.env("QUIRE__SERVER_URL", &t.session.server_url);
cmd.env("QUIRE__RUN_TOKEN", &t.session.run_token);
- if t.mode == TransportMode::Api {
- self.store_bootstrap_data(git_dir, sentry_trace_id)?;
- cmd.env("QUIRE__TRANSPORT", "api");
- } else {
- write_bootstrap(&bootstrap_path, git_dir, meta, sentry_trace_id)?;
- cmd.arg("--bootstrap").arg(&bootstrap_path);
- }
}
}
if let Some(dsn) = sentry_dsn {
@@ -380,11 +365,11 @@ impl Run {
source,
})?;
- // quire-ci unlinks the bootstrap file after `load_bootstrap`;
- // this is a best-effort safety net for paths where it didn't
- // get that far (spawn failed mid-exec, arg parsing rejected
- // input, panic before read). `NotFound` is the expected case.
- if let Err(e) = fs_err::remove_file(&bootstrap_path)
+ // For local runs (None transport), quire-ci unlinks the bootstrap
+ // file after reading it. This is a best-effort safety net for
+ // paths where it didn't get that far. `NotFound` is expected.
+ if transport.is_none()
+ && let Err(e) = fs_err::remove_file(&bootstrap_path)
&& e.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!(
@@ -759,7 +744,7 @@ mod tests {
}
fn test_transport() -> Transport {
- new_transport(TransportMode::Filesystem, 3000)
+ new_transport(3000)
}
fn test_meta() -> RunMeta {
@@ -918,35 +903,10 @@ mod tests {
}
#[test]
- fn create_with_filesystem_persists_run_token() {
- let (_dir, quire) = tmp_quire();
- let runs = test_runs(&quire);
- let transport = new_transport(TransportMode::Filesystem, 3000);
- let run = runs.create(&test_meta(), Some(&transport)).expect("create");
-
- // Run ID is minted by the server, not taken from the transport.
- assert!(uuid::Uuid::parse_str(run.id()).is_ok());
-
- let conn = crate::db::open(&quire.db_path()).expect("db");
- let stored: Option<String> = conn
- .query_row(
- "SELECT run_token FROM runs WHERE id = ?1",
- rusqlite::params![run.id()],
- |row| row.get(0),
- )
- .expect("row");
- assert_eq!(
- stored.as_deref(),
- Some(transport.session.run_token.as_str()),
- "filesystem transport should persist its minted run token"
- );
- }
-
- #[test]
- fn create_with_api_persists_minted_run_token() {
+ fn create_persists_minted_run_token() {
let (_dir, quire) = tmp_quire();
let runs = test_runs(&quire);
- let transport = new_transport(TransportMode::Api, 3000);
+ let transport = new_transport(3000);
let run = runs.create(&test_meta(), Some(&transport)).expect("create");
// Run ID is minted by the server, not taken from the transport.
@@ -967,32 +927,19 @@ mod tests {
}
#[test]
- fn for_new_run_mints_alphanumeric_token() {
- for (transport, expected_url) in [
- (
- new_transport(TransportMode::Filesystem, 3000),
- "http://127.0.0.1:3000",
- ),
- (
- new_transport(TransportMode::Api, 4000),
- "http://127.0.0.1:4000",
- ),
- ] {
- assert_eq!(transport.session.server_url, expected_url);
- assert_eq!(transport.session.run_token.len(), 32);
- assert!(
- transport
- .session
- .run_token
- .chars()
- .all(|c| c.is_ascii_alphanumeric()),
- "token should be alphanumeric, got {:?}",
- transport.session.run_token
- );
- // Transport no longer carries a run_id — the server mints
- // the run ID at creation time and the token alone identifies
- // the run.
- }
+ fn new_transport_mints_alphanumeric_token() {
+ let transport = new_transport(3000);
+ assert_eq!(transport.session.server_url, "http://127.0.0.1:3000");
+ assert_eq!(transport.session.run_token.len(), 32);
+ assert!(
+ transport
+ .session
+ .run_token
+ .chars()
+ .all(|c| c.is_ascii_alphanumeric()),
+ "token should be alphanumeric, got {:?}",
+ transport.session.run_token
+ );
}
#[test]
diff --git a/quire-server/src/quire/mod.rs b/quire-server/src/quire/mod.rs
index 1d88b54..525b8ad 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, TransportMode};
+use crate::ci::{Ci, Executor, Runs};
use crate::{Error, Result as AppResult};
use quire_core::fennel::Fennel;
use quire_core::secret::SecretString;
@@ -43,13 +43,6 @@ pub struct CiConfig {
/// out to the `quire-ci` binary via `Executor::Process`.
#[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)]
@@ -412,29 +405,17 @@ mod tests {
}
#[test]
- fn global_config_ci_defaults_to_filesystem() {
+ fn global_config_ci_defaults() {
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::Process);
assert_eq!(config.port, 3000);
}
- #[test]
- fn global_config_parses_api_transport() {
- let dir = tempfile::tempdir().expect("tempdir");
- let config_path = dir.path().join("config.fnl");
- fs_err::write(&config_path, r#"{: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);
- }
-
#[test]
fn global_config_parses_custom_port() {
let dir = tempfile::tempdir().expect("tempdir");
diff --git a/quire-server/src/quire/web/api.rs b/quire-server/src/quire/web/api.rs
index 1d5f69e..ffee856 100644
--- a/quire-server/src/quire/web/api.rs
+++ b/quire-server/src/quire/web/api.rs
@@ -232,7 +232,7 @@ mod tests {
use tower::ServiceExt;
use crate::Quire;
- use crate::ci::{RunMeta, Runs, TransportMode, new_transport};
+ use crate::ci::{RunMeta, Runs, new_transport};
struct TestEnv {
_dir: tempfile::TempDir,
@@ -301,7 +301,7 @@ mod tests {
#[tokio::test]
async fn bootstrap_returns_401_without_auth() {
let env = TestEnv::new();
- let transport = new_transport(TransportMode::Api, 3000);
+ let transport = new_transport(3000);
create_run_with_bootstrap(&env, &transport, "/repos/test.git", None).await;
let resp = get(env.app(), "/run/bootstrap", None).await;
@@ -319,7 +319,7 @@ mod tests {
#[tokio::test]
async fn bootstrap_returns_payload_on_first_fetch() {
let env = TestEnv::new();
- let transport = new_transport(TransportMode::Api, 3000);
+ let transport = new_transport(3000);
create_run_with_bootstrap(&env, &transport, "/repos/test.git", None).await;
let resp = get(
@@ -339,7 +339,7 @@ mod tests {
#[tokio::test]
async fn bootstrap_returns_410_on_second_fetch() {
let env = TestEnv::new();
- let transport = new_transport(TransportMode::Api, 3000);
+ let transport = new_transport(3000);
create_run_with_bootstrap(&env, &transport, "/repos/test.git", None).await;
let token = &transport.session.run_token;
@@ -353,7 +353,7 @@ mod tests {
#[tokio::test]
async fn secret_returns_401_without_auth() {
let env = TestEnv::new();
- let transport = new_transport(TransportMode::Api, 3000);
+ let transport = new_transport(3000);
env.runs()
.create(&TestEnv::meta(), Some(&transport))
.expect("create");
@@ -370,7 +370,7 @@ mod tests {
r#"{:secrets {:my_token "hunter2"}}"#,
)
.expect("write config");
- let transport = new_transport(TransportMode::Api, 3000);
+ let transport = new_transport(3000);
env.runs()
.create(&TestEnv::meta(), Some(&transport))
.expect("create");