Replace listen-addr and server-url with a single port
The server's bind host was always 0.0.0.0 inside the container, and the
loopback URL handed to quire-ci is derivable from the port — two keys
that had to agree was operator footgun. quire-ci runs as a subprocess
of quire-server today, so http://127.0.0.1:{port} is the only URL the
API transport ever needs.
Transport::for_new_run becomes infallible (no missing-server-url to
guard) and Error::ApiTransportMissingServerUrl goes away. When remote
CI lands the host config grows back; today the simpler shape wins.
Assisted-by: Claude Opus 4.7 via Claude Code
diff --git a/docs/config.md b/docs/config.md
index 75639cf..a9ae633 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -11,6 +11,7 @@ Operator-created. Re-read on every call (no caching today).
| Key | Type | Required | Purpose |
|----------------|----------------|----------|----------------------------------------------------------|
+| `: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)`. |
diff --git a/quire-server/src/bin/quire/server.rs b/quire-server/src/bin/quire/server.rs
index 1368146..0feee9d 100644
--- a/quire-server/src/bin/quire/server.rs
+++ b/quire-server/src/bin/quire/server.rs
@@ -17,7 +17,8 @@ async fn index() -> String {
}
pub async fn run(quire: &Quire, ci_routes: axum::Router) -> Result<()> {
- let addr: SocketAddr = ([0, 0, 0, 0], 3000).into();
+ let config = quire.global_config()?;
+ let addr = SocketAddr::from(([0, 0, 0, 0], config.port));
// Set up event socket.
let socket_path = quire.socket_path();
diff --git a/quire-server/src/ci/error.rs b/quire-server/src/ci/error.rs
index bd93717..ca3238c 100644
--- a/quire-server/src/ci/error.rs
+++ b/quire-server/src/ci/error.rs
@@ -60,9 +60,6 @@ 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 3068146..668760a 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -166,19 +166,7 @@ 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;
- }
- };
+ let transport = Transport::for_new_run(config.ci.transport, config.port);
if let Err(e) = trigger_ref(
&repo,
&db_path,
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index 2765ed8..f9736c0 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -40,7 +40,7 @@ pub enum TransportMode {
}
/// Runtime transport for a single CI run. Built once per run from
-/// the config-shape [`TransportMode`] + the top-level `server_url`,
+/// the config-shape [`TransportMode`] + the server's listen port,
/// then passed to `Runs::create` and `Run::execute_via_quire_ci`.
/// The `Api` variant carries the shared [`ApiSession`] — quire-ci
/// receives a structurally identical value via its CLI flags.
@@ -53,22 +53,16 @@ pub enum Transport {
impl Transport {
/// Build a runtime transport for a new run. For `Api`, mints a
- /// fresh run ID and CSPRNG bearer token, pairs them with
- /// `server_url`, and bundles them into an [`ApiSession`]. Errors
- /// if `mode == Api` and `server_url` is missing.
- pub fn for_new_run(mode: TransportMode, server_url: Option<&str>) -> Result<Self> {
+ /// fresh run ID and CSPRNG bearer token, derives the loopback
+ /// server URL from `port`, and bundles them into an [`ApiSession`].
+ pub fn for_new_run(mode: TransportMode, port: u16) -> Self {
match mode {
- TransportMode::Filesystem => Ok(Transport::Filesystem),
- TransportMode::Api => {
- let server_url = server_url
- .ok_or(Error::ApiTransportMissingServerUrl)?
- .to_string();
- Ok(Transport::Api(ApiSession {
- run_id: uuid::Uuid::now_v7().to_string(),
- server_url,
- auth_token: mint_auth_token(),
- }))
- }
+ TransportMode::Filesystem => Transport::Filesystem,
+ TransportMode::Api => Transport::Api(ApiSession {
+ run_id: uuid::Uuid::now_v7().to_string(),
+ server_url: format!("http://127.0.0.1:{port}"),
+ auth_token: mint_auth_token(),
+ }),
}
}
}
@@ -923,8 +917,7 @@ mod tests {
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 transport = Transport::for_new_run(TransportMode::Api, 3000);
let run = runs.create(&test_meta(), &transport).expect("create");
let Transport::Api(api) = &transport else {
@@ -948,12 +941,11 @@ mod tests {
#[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 = Transport::for_new_run(TransportMode::Api, 4000);
let Transport::Api(api) = transport else {
panic!("expected Api transport");
};
- assert_eq!(api.server_url, "http://127.0.0.1:3000");
+ assert_eq!(api.server_url, "http://127.0.0.1:4000");
assert_eq!(api.auth_token.len(), 32);
assert!(
api.auth_token.chars().all(|c| c.is_ascii_alphanumeric()),
@@ -967,15 +959,6 @@ mod tests {
);
}
- #[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();
diff --git a/quire-server/src/quire/mod.rs b/quire-server/src/quire/mod.rs
index 150017d..7a050c2 100644
--- a/quire-server/src/quire/mod.rs
+++ b/quire-server/src/quire/mod.rs
@@ -23,17 +23,20 @@ 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>,
+ /// TCP port the HTTP server binds to on all interfaces (`0.0.0.0`).
+ /// The API transport derives `http://127.0.0.1:{port}` from this for
+ /// quire-ci's bootstrap URL.
+ #[serde(default = "default_port")]
+ pub port: u16,
/// CI configuration.
#[serde(default)]
pub ci: CiConfig,
}
+fn default_port() -> u16 {
+ 3000
+}
+
#[derive(serde::Deserialize, Debug, Default)]
pub struct CiConfig {
/// How the orchestrator dispatches CI runs. Defaults to shelling
@@ -418,23 +421,29 @@ mod tests {
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());
+ assert_eq!(config.port, 3000);
}
#[test]
- fn global_config_parses_api_transport_and_server_url() {
+ 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#"{:server-url "http://127.0.0.1:3000" :ci {:transport :api}}"#,
- )
- .expect("write");
+ 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);
- assert_eq!(config.server_url.as_deref(), Some("http://127.0.0.1:3000"));
+ }
+
+ #[test]
+ fn global_config_parses_custom_port() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let config_path = dir.path().join("config.fnl");
+ fs_err::write(&config_path, r#"{:port 4000}"#).expect("write");
+
+ let q = Quire::new(dir.path().to_path_buf());
+ let config = q.global_config().expect("global_config should load");
+ assert_eq!(config.port, 4000);
}
#[test]