Remove the in-process docker executor
The orchestrator no longer dispatches `(sh …)` via `docker exec` —
production has run host-only since the previous executor flip, and
the workspace-split plan replaces the in-process docker path with
`quire-ci eval` running inside the run container. Ripping the dead
path now decouples `runtime` from the orchestrator (no more
`DockerLifecycle` backref), unblocking the move of `runtime` and
`mirror` into `quire-core`.
What goes:
- `ci::docker` (the docker CLI wrapper module).
- `Executor` enum and the `--executor` CLI flag — there's only one
mode now.
- `ExecutorRuntime`, `DockerLifecycle`, `Cmd::wrap_in_docker_exec`,
and the per-run container build path in `Run::execute`.
- The `DockerUnavailable` / `DockerfileMissing` / `ImageBuildFailed`
/ `ContainerStartFailed` error variants.
- The docker-mode integration test and the docker-tag sanitization
helper.
What stays (handled in follow-ups):
- `(ci.image …)` registration parses but the result is unused until
stage 3 reintroduces the per-run container.
- The `runs` schema still has `image_tag` / `container_id` /
`*_at_ms` columns; rows just never populate them now. Worth a
cleanup migration once the new container shape is settled.
Assisted-by: Claude Opus 4.7 (1M context) via Claude Code
diff --git a/quire-server/src/bin/quire/commands/ci.rs b/quire-server/src/bin/quire/commands/ci.rs
index 5382cdb..193ab4f 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, Executor, RunMeta, Runs};
+use quire::ci::{Ci, CommitRef, RunMeta, Runs};
/// Validate a repo's ci.fnl without executing any jobs.
///
@@ -41,7 +41,7 @@ pub async fn validate(maybe_sha: Option<&str>) -> Result<()> {
/// default), creates a transient Run rooted at a tempdir, drives the
/// pipeline through it, and prints each job's `(ci.sh …)` output to
/// stdout. The tempdir is removed when the command exits.
-pub async fn run(quire: &Quire, maybe_sha: Option<&str>, executor: Executor) -> Result<()> {
+pub async fn run(quire: &Quire, maybe_sha: Option<&str>) -> Result<()> {
let repo_path = discover_repo()?;
let commit = resolve_commit(maybe_sha)?;
let ci = Ci::new(repo_path.clone());
@@ -88,13 +88,7 @@ pub async fn run(quire: &Quire, maybe_sha: Option<&str>, executor: Executor) ->
let workspace = tmp.path().join("workspace");
quire::ci::materialize_workspace(&repo_path.join(".git"), &commit.sha, &workspace)
.into_diagnostic()?;
- let exec_result = run.execute(
- pipeline,
- secrets,
- &repo_path.join(".git"),
- &workspace,
- executor,
- );
+ let exec_result = run.execute(pipeline, secrets, &repo_path.join(".git"), &workspace);
match exec_result {
Ok(outputs) => {
diff --git a/quire-server/src/bin/quire/main.rs b/quire-server/src/bin/quire/main.rs
index 3bf1c97..25e0c83 100644
--- a/quire-server/src/bin/quire/main.rs
+++ b/quire-server/src/bin/quire/main.rs
@@ -103,30 +103,9 @@ enum CiCommands {
/// Commit SHA to run. Defaults to the working-copy revision.
#[arg(short, long)]
sha: Option<String>,
-
- /// Where to run `(sh ...)` calls. `host` runs them locally in the
- /// materialized workspace; `docker` builds `.quire/Dockerfile` and
- /// routes commands through `docker exec`.
- #[arg(long, value_enum, default_value = "host")]
- executor: CliExecutor,
},
}
-#[derive(Clone, Debug, clap::ValueEnum)]
-enum CliExecutor {
- Host,
- Docker,
-}
-
-impl From<CliExecutor> for quire::ci::Executor {
- fn from(value: CliExecutor) -> Self {
- match value {
- CliExecutor::Host => quire::ci::Executor::Host,
- CliExecutor::Docker => quire::ci::Executor::Docker,
- }
- }
-}
-
/// Initialize Sentry if the global config provides a DSN.
///
/// Returns the guard if initialized, or None if Sentry is not configured.
@@ -237,9 +216,7 @@ async fn main() -> Result<()> {
},
Commands::Ci { command } => match command {
CiCommands::Validate { sha } => commands::ci::validate(sha.as_deref()).await?,
- CiCommands::Run { sha, executor } => {
- commands::ci::run(&quire, sha.as_deref(), executor.into()).await?
- }
+ CiCommands::Run { sha } => commands::ci::run(&quire, sha.as_deref()).await?,
},
}
diff --git a/quire-server/src/ci/docker.rs b/quire-server/src/ci/docker.rs
deleted file mode 100644
index e23d34a..0000000
--- a/quire-server/src/ci/docker.rs
+++ /dev/null
@@ -1,198 +0,0 @@
-//! Shell-out helpers for the docker subsystem.
-
-use std::path::Path;
-
-use super::error::{Error, Result};
-
-/// Returns true iff the docker daemon is reachable. Used to gate
-/// integration tests; calls `docker info` and treats any failure
-/// (binary missing, daemon down, permissions) as unavailable.
-pub(crate) fn is_available() -> bool {
- std::process::Command::new("docker")
- .arg("info")
- .stdout(std::process::Stdio::null())
- .stderr(std::process::Stdio::null())
- .status()
- .map(|s| s.success())
- .unwrap_or(false)
-}
-
-/// Build the per-run image. Runs `docker build --file <dockerfile>
-/// --tag <tag> <context>`. Failures (including a missing Dockerfile)
-/// surface as `Error::ImageBuildFailed` carrying docker's stderr.
-pub(crate) fn docker_build(dockerfile: &Path, context: &Path, tag: &str) -> Result<()> {
- let output = std::process::Command::new("docker")
- .arg("build")
- .arg("--file")
- .arg(dockerfile)
- .arg("--tag")
- .arg(tag)
- .arg(context)
- .output()
- .map_err(|e| Error::ImageBuildFailed { source: e })?;
- if !output.status.success() {
- return Err(Error::ImageBuildFailed {
- source: std::io::Error::other(String::from_utf8_lossy(&output.stderr).into_owned()),
- });
- }
- Ok(())
-}
-
-/// A running container owned by a quire run. Started via [`start`];
-/// stopped via [`Drop`]. The `--rm` flag on `docker run` removes the
-/// container record once stop completes, so callers don't need to
-/// manage `docker rm` separately.
-///
-/// `Drop` swallows errors from `docker stop` because `Drop` cannot
-/// return `Result`. Failures are logged via `tracing::error!`; orphan
-/// reconciliation handles anything that survives.
-pub(crate) struct ContainerSession {
- pub(crate) container_id: String,
- pub(crate) container_started_at: jiff::Timestamp,
-}
-
-impl ContainerSession {
- /// Start a long-lived container from `image_tag` with `mount_source`
- /// bind-mounted at `mount_target` inside the container, with
- /// `mount_target` as the working directory. Captures the container
- /// ID. Failures surface as `Error::ContainerStartFailed`.
- pub(crate) fn start(image_tag: &str, mount_source: &Path, mount_target: &str) -> Result<Self> {
- let mount = format!(
- "type=bind,src={},dst={}",
- mount_source.to_string_lossy(),
- mount_target,
- );
- let output = std::process::Command::new("docker")
- .args(["run", "--detach", "--rm", "--mount"])
- .arg(&mount)
- // 15-minute ceiling. Real runs end when
- // `ContainerSession::drop` calls `docker stop`; this is
- // the safety net for cases where Drop never fires (orphaned
- // process, killed parent).
- .args(["--workdir", mount_target, image_tag, "sleep", "15m"])
- .output()
- .map_err(|e| Error::ContainerStartFailed { source: e })?;
- if !output.status.success() {
- return Err(Error::ContainerStartFailed {
- source: std::io::Error::other(String::from_utf8_lossy(&output.stderr).into_owned()),
- });
- }
- let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
- Ok(Self {
- container_id,
- container_started_at: jiff::Timestamp::now(),
- })
- }
-}
-
-impl Drop for ContainerSession {
- fn drop(&mut self) {
- let result = std::process::Command::new("docker")
- .args(["stop", "--time", "5"])
- .arg(&self.container_id)
- .output();
- match result {
- Ok(out) if out.status.success() => {}
- Ok(out) => tracing::error!(
- container_id = %self.container_id,
- stderr = %String::from_utf8_lossy(&out.stderr),
- "docker stop returned non-zero"
- ),
- Err(e) => tracing::error!(
- container_id = %self.container_id,
- error = %e,
- "docker stop failed"
- ),
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- #[ignore = "requires docker"]
- fn docker_build_succeeds_with_minimal_dockerfile() {
- if !is_available() {
- return;
- }
- let dir = tempfile::tempdir().expect("tempdir");
- let context = dir.path();
- let dockerfile = context.join("Dockerfile");
- fs_err::write(&dockerfile, "FROM alpine:3.19\nRUN echo built\n").expect("write Dockerfile");
-
- let tag = "quire-ci/test-task5:test";
- docker_build(&dockerfile, context, tag).expect("build should succeed");
-
- // Cleanup — best effort; ignore failures.
- let _ = std::process::Command::new("docker")
- .args(["image", "rm", tag])
- .output();
- }
-
- #[test]
- #[ignore = "requires docker"]
- fn docker_build_errors_on_bad_dockerfile() {
- if !is_available() {
- return;
- }
- let dir = tempfile::tempdir().expect("tempdir");
- let context = dir.path();
- let dockerfile = context.join("Dockerfile");
- fs_err::write(&dockerfile, "GARBAGE\n").expect("write");
-
- let err = docker_build(&dockerfile, context, "quire-ci/test-task5-bad:test")
- .expect_err("should fail");
- assert!(matches!(err, Error::ImageBuildFailed { .. }));
- }
-
- #[test]
- #[ignore = "requires docker"]
- fn container_session_start_returns_id() {
- if !is_available() {
- return;
- }
- let dir = tempfile::tempdir().expect("tempdir");
-
- let session = ContainerSession::start("alpine:3.19", dir.path(), "/work")
- .expect("start should succeed");
-
- assert!(!session.container_id.is_empty());
- // Docker container IDs from `docker run` are 64-char SHA256 hex.
- assert_eq!(
- session.container_id.len(),
- 64,
- "got: {}",
- session.container_id
- );
- // session drops here; docker stop runs.
- }
-
- #[test]
- #[ignore = "requires docker"]
- fn container_session_drop_stops_container() {
- if !is_available() {
- return;
- }
- let dir = tempfile::tempdir().expect("tempdir");
- let id = {
- let session = ContainerSession::start("alpine:3.19", dir.path(), "/work")
- .expect("start should succeed");
- session.container_id.clone()
- }; // session drops here
-
- // Give the daemon a moment to settle the stop.
- std::thread::sleep(std::time::Duration::from_millis(500));
- let out = std::process::Command::new("docker")
- .args(["ps", "-a", "--quiet", "--filter"])
- .arg(format!("id={id}"))
- .output()
- .expect("docker ps");
- assert!(
- String::from_utf8_lossy(&out.stdout).trim().is_empty(),
- "container should be removed after drop, but ps shows: {}",
- String::from_utf8_lossy(&out.stdout),
- );
- }
-}
diff --git a/quire-server/src/ci/error.rs b/quire-server/src/ci/error.rs
index e35b4d5..e4675a1 100644
--- a/quire-server/src/ci/error.rs
+++ b/quire-server/src/ci/error.rs
@@ -34,30 +34,12 @@ pub enum Error {
source: Box<Error>,
},
- #[error("docker is not available — install docker and ensure the daemon is running")]
- DockerUnavailable,
-
- #[error("missing .quire/Dockerfile")]
- DockerfileMissing,
-
#[error("workspace materialization failed")]
WorkspaceMaterializationFailed {
#[source]
source: std::io::Error,
},
- #[error("image build failed")]
- ImageBuildFailed {
- #[source]
- source: std::io::Error,
- },
-
- #[error("container start failed")]
- ContainerStartFailed {
- #[source]
- source: std::io::Error,
- },
-
#[error("git error: {0}")]
Git(String),
diff --git a/quire-server/src/ci/mirror.rs b/quire-server/src/ci/mirror.rs
index 3fedcc4..a71097d 100644
--- a/quire-server/src/ci/mirror.rs
+++ b/quire-server/src/ci/mirror.rs
@@ -236,7 +236,7 @@ mod tests {
use crate::ci::pipeline::{Diagnostic, RustRunFn, compile};
use crate::ci::run::RunMeta;
- use crate::ci::runtime::{ExecutorRuntime, RuntimeHandle};
+ use crate::ci::runtime::RuntimeHandle;
use quire_core::secret::{Error as SecretError, SecretString};
/// Set up a bare git repo with one commit. Returns the tempdir,
@@ -446,7 +446,6 @@ mod tests {
meta,
git_dir,
std::env::current_dir().expect("cwd"),
- ExecutorRuntime::Host,
));
let _ = RuntimeHandle(runtime.clone())
.into_lua(runtime.lua())
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index 1f7a996..cf8f36b 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -2,7 +2,6 @@
use std::collections::HashMap;
-pub(crate) mod docker;
pub(crate) mod logs;
mod mirror;
mod pipeline;
@@ -14,7 +13,7 @@ pub(crate) mod error;
pub use error::{Error, Result};
pub use pipeline::{DefinitionError, Diagnostic, Job, Pipeline, PipelineError, StructureError};
-pub use run::{Executor, Run, RunMeta, RunState, Runs, materialize_workspace, reconcile_orphans};
+pub use run::{Run, RunMeta, RunState, Runs, materialize_workspace, reconcile_orphans};
/// A resolved commit reference.
///
@@ -132,14 +131,7 @@ 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,
- run::Executor::Host,
- ) {
+ if let Err(e) = trigger_ref(&repo, &db_path, event.pushed_at, push_ref, &secrets) {
tracing::error!(
repo = %event.repo,
sha = %push_ref.new_sha, // cov-excl-line
@@ -157,7 +149,6 @@ fn trigger_ref(
pushed_at: jiff::Timestamp,
push_ref: &PushRef,
secrets: &HashMap<String, quire_core::secret::SecretString>,
- executor: run::Executor,
) -> error::Result<()> {
let ci = repo.ci();
@@ -191,13 +182,7 @@ 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,
- executor,
- )?;
+ run.execute(pipeline, secrets.clone(), &repo.path(), &workspace)?;
Ok(())
}
@@ -374,7 +359,6 @@ mod tests {
pushed_at,
&push_ref,
&HashMap::new(),
- run::Executor::Host,
)
.expect("trigger_ref should succeed");
@@ -408,7 +392,6 @@ mod tests {
pushed_at,
&push_ref,
&HashMap::new(),
- run::Executor::Host,
)
.expect("should succeed without ci.fnl");
}
@@ -432,7 +415,6 @@ mod tests {
pushed_at,
&push_ref,
&HashMap::new(),
- run::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 00cc0f4..08c81e1 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -14,61 +14,9 @@ use mlua::IntoLua;
use super::error::{Error, Result};
use super::pipeline::{Pipeline, RunFn};
-use super::runtime::{ExecutorRuntime, Runtime, RuntimeHandle, ShOutput};
-use crate::display_chain;
+use super::runtime::{Runtime, RuntimeHandle, ShOutput};
use quire_core::secret::SecretString;
-/// The execution mode for a run. Host runs `sh` directly on the host.
-/// Docker materializes a container and routes `sh` through `docker exec`.
-#[derive(Debug, Clone)]
-pub enum Executor {
- Host,
- Docker,
-}
-
-/// Owns the per-run container session alongside the database path
-/// so [`Drop`] can stamp `container_stopped_at` *before* the session
-/// itself drops and fires `docker stop`.
-///
-/// Field declaration order matters: `session` is declared first so it
-/// drops first after this struct's custom `Drop` body returns. The
-/// effect is: write `container_stopped_at` → drop `session` →
-/// `docker stop`.
-pub(super) struct DockerLifecycle {
- pub(super) session: crate::ci::docker::ContainerSession,
- db_path: PathBuf,
- run_id: String,
- pub(super) work_dir: String,
-}
-
-impl Drop for DockerLifecycle {
- fn drop(&mut self) {
- // Stamp `container_stopped_at` before ContainerSession's Drop
- // (`docker stop`) fires. Errors are logged and swallowed —
- // Drop cannot return Result.
- match crate::db::open(&self.db_path) {
- Ok(conn) => {
- let now = Timestamp::now().as_millisecond();
- if let Err(e) = conn.execute(
- "UPDATE runs SET container_stopped_at_ms = ?1 WHERE id = ?2",
- rusqlite::params![now, &self.run_id],
- ) {
- tracing::error!(
- error = %display_chain(&Error::from(e)),
- "failed to write container_stopped_at"
- );
- }
- }
- Err(e) => tracing::error!(
- error = %display_chain(&e),
- "failed to open db before container stop"
- ),
- }
- // After this body returns, fields drop in declaration order:
- // `session` drops first → ContainerSession::Drop → docker stop.
- }
-}
-
/// The state of a CI run.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RunState {
@@ -268,33 +216,17 @@ impl Run {
secrets: HashMap<String, SecretString>,
git_dir: &std::path::Path,
workspace: &std::path::Path,
- executor: Executor,
) -> Result<HashMap<String, Vec<ShOutput>>> {
let meta = self.read_meta()?;
- // Transition to Active *before* building/starting the
- // container. The docker build can take a long time and
- // happens with the run in `active` so the database state
- // accurately reflects "this run is in progress." It also
- // means `container_id` is allowed (the CHECK constraint
- // permits it in `active`).
self.transition(RunState::Active)?;
- let executor_runtime = match self.build_executor_runtime(executor, workspace) {
- Ok(rt) => rt,
- Err(e) => {
- self.transition(RunState::Failed)?;
- return Err(e);
- }
- };
-
let runtime = Rc::new(Runtime::new(
pipeline,
secrets,
&meta,
git_dir,
workspace.to_path_buf(),
- executor_runtime,
));
let lua = runtime.lua();
@@ -377,64 +309,6 @@ impl Run {
Ok(outputs)
}
- /// Build the per-run container if `executor` is `Docker`, writing
- /// build and container timestamps to the database incrementally.
- /// Run must already be in `active` so `container_id` is permitted.
- fn build_executor_runtime(
- &self,
- executor: Executor,
- workspace: &std::path::Path,
- ) -> Result<ExecutorRuntime> {
- match executor {
- Executor::Host => Ok(ExecutorRuntime::Host),
- Executor::Docker => {
- if !crate::ci::docker::is_available() {
- return Err(Error::DockerUnavailable);
- }
-
- // Build phase.
- let now = Timestamp::now().as_millisecond();
- let db = crate::db::open(&self.db_path)?;
- db.execute(
- "UPDATE runs SET build_started_at_ms = ?1 WHERE id = ?2",
- rusqlite::params![now, &self.id],
- )?;
-
- let dockerfile = workspace.join(".quire/Dockerfile");
- if !dockerfile.exists() {
- return Err(Error::DockerfileMissing);
- }
- let tag = format!("quire-ci/{}:{}", repo_segment(&self.base_dir), self.id);
-
- crate::ci::docker::docker_build(&dockerfile, workspace, &tag)?;
-
- let build_finished = Timestamp::now().as_millisecond();
- db.execute(
- "UPDATE runs SET image_tag = ?1, build_finished_at_ms = ?2 WHERE id = ?3",
- rusqlite::params![&tag, build_finished, &self.id],
- )?;
-
- // Start phase.
- const WORK_DIR: &str = "/work";
- let session =
- crate::ci::docker::ContainerSession::start(&tag, workspace, WORK_DIR)?;
-
- let container_started = session.container_started_at.as_millisecond();
- db.execute(
- "UPDATE runs SET container_id = ?1, container_started_at_ms = ?2 WHERE id = ?3",
- rusqlite::params![&session.container_id, container_started, &self.id],
- )?;
-
- Ok(ExecutorRuntime::Docker(DockerLifecycle {
- session,
- db_path: self.db_path.clone(),
- run_id: self.id.clone(),
- work_dir: WORK_DIR.to_string(),
- }))
- }
- }
- }
-
/// 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.
@@ -582,34 +456,6 @@ impl Run {
/// Take the final path component of a runs base (`runs/<repo>/`) and
/// sanitize it for use as the tag segment in `quire-ci/<segment>:<id>`.
-/// Docker reference components match `[a-z0-9]+(?:[._-][a-z0-9]+)*`,
-/// so we lowercase, replace any other character with `_`, and strip
-/// leading non-alphanumerics (e.g. tempdir names like `.tmpXyZ`).
-/// Falls back to `repo` when the result would be empty.
-fn repo_segment(base: &Path) -> String {
- let Some(raw) = base.file_name().and_then(|s| s.to_str()) else {
- return "repo".to_string();
- };
- let sanitized: String = raw
- .to_lowercase()
- .chars()
- .map(|c| {
- if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
- c
- } else {
- '_'
- }
- })
- .collect::<String>()
- .trim_start_matches(|c: char| !c.is_ascii_alphanumeric())
- .to_string();
- if sanitized.is_empty() {
- "repo".to_string()
- } else {
- sanitized
- }
-}
-
/// Materialize a working tree at `sha` into `workspace` via
/// `git archive | tar -x`. Creates the workspace dir if needed.
pub fn materialize_workspace(git_dir: &Path, sha: &str, workspace: &Path) -> Result<()> {
@@ -1004,7 +850,6 @@ mod tests {
HashMap::new(),
std::path::Path::new("."),
&workspace,
- Executor::Host,
)
.expect("execute");
let pwd = &outputs["pwd"];
@@ -1035,7 +880,6 @@ mod tests {
HashMap::new(),
std::path::Path::new("."),
&test_workspace(&quire),
- Executor::Host,
)
.expect("execute");
@@ -1072,7 +916,6 @@ mod tests {
HashMap::new(),
std::path::Path::new("."),
&test_workspace(&quire),
- Executor::Host,
)
.expect("execute");
@@ -1099,7 +942,6 @@ mod tests {
HashMap::new(),
std::path::Path::new("."),
&test_workspace(&quire),
- Executor::Host,
)
.expect_err("expected failure");
assert!(matches!(err, Error::JobFailed { ref job, .. } if job == "a"));
@@ -1129,7 +971,6 @@ mod tests {
HashMap::new(),
std::path::Path::new("."),
&test_workspace(&quire),
- Executor::Host,
)
.expect("execute");
@@ -1159,7 +1000,6 @@ mod tests {
HashMap::new(),
std::path::Path::new("."),
&test_workspace(&quire),
- Executor::Host,
)
.expect("execute");
@@ -1185,7 +1025,6 @@ mod tests {
HashMap::new(),
std::path::Path::new("."),
&test_workspace(&quire),
- Executor::Host,
)
.expect_err("expected failure");
let Error::JobFailed { job, source } = err else {
@@ -1217,7 +1056,6 @@ mod tests {
HashMap::new(),
std::path::Path::new("."),
&test_workspace(&quire),
- Executor::Host,
)
.expect_err("expected failure");
let Error::JobFailed { source, .. } = err else {
@@ -1247,7 +1085,6 @@ mod tests {
HashMap::new(),
std::path::Path::new("."),
&test_workspace(&quire),
- Executor::Host,
)
.expect_err("expected failure");
let Error::JobFailed { source, .. } = err else {
@@ -1281,7 +1118,6 @@ mod tests {
HashMap::new(),
std::path::Path::new("."),
&test_workspace(&quire),
- Executor::Host,
)
.expect("execute");
let b = &outputs["b"];
@@ -1306,7 +1142,6 @@ mod tests {
HashMap::new(),
std::path::Path::new("."),
&test_workspace(&quire),
- Executor::Host,
)
.expect("execute");
@@ -1353,7 +1188,6 @@ mod tests {
HashMap::new(),
std::path::Path::new("."),
&test_workspace(&quire),
- Executor::Host,
);
let failed_dir = runs.base_dir.join(&run_id);
@@ -1389,7 +1223,6 @@ mod tests {
HashMap::new(),
std::path::Path::new("."),
&test_workspace(&quire),
- Executor::Host,
)
.expect_err("expected failure");
let Error::JobFailed { job, source } = err else {
@@ -1428,7 +1261,6 @@ mod tests {
HashMap::new(),
std::path::Path::new("."),
&test_workspace(&quire),
- Executor::Host,
)
.expect("execute should succeed");
assert!(called.get(), "rust run-fn should have been called");
@@ -1455,7 +1287,6 @@ mod tests {
HashMap::new(),
std::path::Path::new("."),
&test_workspace(&quire),
- Executor::Host,
)
.expect_err("expected failure");
let Error::JobFailed { job, source } = err else {
@@ -1467,145 +1298,4 @@ mod tests {
"expected source to surface rust error, got: {source}"
);
}
-
- #[test]
- fn repo_segment_returns_final_component() {
- assert_eq!(repo_segment(Path::new("runs/test.git")), "test.git");
- assert_eq!(
- repo_segment(Path::new("/var/lib/quire/runs/repo.git")),
- "repo.git"
- );
- assert_eq!(repo_segment(Path::new("")), "repo");
- }
-
- #[test]
- fn repo_segment_sanitizes_for_docker_tags() {
- assert_eq!(repo_segment(Path::new("/tmp/.tmpAbCdEf")), "tmpabcdef");
- assert_eq!(repo_segment(Path::new("MyRepo.git")), "myrepo.git");
- assert_eq!(
- repo_segment(Path::new("repo with spaces")),
- "repo_with_spaces"
- );
- }
-
- #[test]
- #[ignore = "requires docker"]
- fn execute_docker_mode_runs_jobs_in_container() {
- if !crate::ci::docker::is_available() {
- return;
- }
-
- let dir = tempfile::tempdir().expect("tempdir");
- let src_repo = dir.path().join("src");
- fs_err::create_dir_all(&src_repo).expect("mkdir src");
-
- let env_vars: [(&str, &str); 6] = [
- ("GIT_AUTHOR_NAME", "test"),
- ("GIT_AUTHOR_EMAIL", "test@test"),
- ("GIT_COMMITTER_NAME", "test"),
- ("GIT_COMMITTER_EMAIL", "test@test"),
- ("GIT_CONFIG_GLOBAL", "/dev/null"),
- ("GIT_CONFIG_SYSTEM", "/dev/null"),
- ];
- for cmd in [vec!["init", "-b", "main"]] {
- let out = std::process::Command::new("git")
- .args(&cmd)
- .current_dir(&src_repo)
- .envs(env_vars)
- .output()
- .expect("git");
- assert!(out.status.success());
- }
- fs_err::create_dir_all(src_repo.join(".quire")).expect("mkdir .quire");
- fs_err::write(src_repo.join(".quire/Dockerfile"), "FROM alpine:3.19\n")
- .expect("write Dockerfile");
- for cmd in [vec!["add", "."], vec!["commit", "-m", "initial"]] {
- let out = std::process::Command::new("git")
- .args(&cmd)
- .current_dir(&src_repo)
- .envs(env_vars)
- .output()
- .expect("git");
- assert!(out.status.success());
- }
- let sha = String::from_utf8(
- std::process::Command::new("git")
- .args(["rev-parse", "HEAD"])
- .current_dir(&src_repo)
- .envs(env_vars)
- .output()
- .expect("rev-parse")
- .stdout,
- )
- .unwrap()
- .trim()
- .to_string();
-
- let workspace = dir.path().join("ws");
- materialize_workspace(&src_repo.join(".git"), &sha, &workspace).expect("materialize");
-
- let (_qd, quire) = tmp_quire();
- let runs = test_runs(&quire);
- let meta = RunMeta {
- sha,
- r#ref: "refs/heads/main".to_string(),
- pushed_at: "2026-05-04T12:00:00Z".parse().unwrap(),
- };
- let run = runs.create(&meta).expect("create");
- let run_id = run.id().to_string();
-
- let pipeline = load(
- r#"(local ci (require :quire.ci))
-(ci.job :probe [:quire/push] (fn [{: sh}] (sh ["uname" "-s"])))"#,
- );
-
- let outputs = run
- .execute(
- pipeline,
- HashMap::new(),
- &src_repo.join(".git"),
- &workspace,
- Executor::Docker,
- )
- .expect("execute");
-
- let probe = &outputs["probe"];
- assert_eq!(probe.len(), 1);
- assert_eq!(
- probe[0].stdout.trim(),
- "Linux",
- "expected uname -s to return Linux from inside the container, got: {:?}",
- probe[0].stdout,
- );
-
- // Verify container metadata was written to the DB.
- let db = crate::db::open(&quire.db_path()).expect("open db");
- let image_tag: Option<String> = db
- .query_row(
- "SELECT image_tag FROM runs WHERE id = ?1",
- rusqlite::params![&run_id],
- |row| row.get(0),
- )
- .expect("query");
- assert!(image_tag.is_some(), "image_tag should be set");
-
- let container_stopped_ms: Option<i64> = db
- .query_row(
- "SELECT container_stopped_at_ms FROM runs WHERE id = ?1",
- rusqlite::params![&run_id],
- |row| row.get(0),
- )
- .expect("query");
- assert!(
- container_stopped_ms.is_some(),
- "container_stopped_at_ms should be set"
- );
-
- // Cleanup the image we built.
- if let Some(tag) = image_tag {
- let _ = std::process::Command::new("docker")
- .args(["image", "rm", &tag])
- .output();
- }
- }
}
diff --git a/quire-server/src/ci/runtime.rs b/quire-server/src/ci/runtime.rs
index 0833d86..98387e4 100644
--- a/quire-server/src/ci/runtime.rs
+++ b/quire-server/src/ci/runtime.rs
@@ -14,21 +14,11 @@ use jiff::Timestamp;
use mlua::{IntoLua, Lua, LuaSerdeExt};
use super::pipeline::{Job, Pipeline};
-use super::run::{DockerLifecycle, RunMeta};
+use super::run::RunMeta;
use quire_core::secret::{SecretRegistry, SecretString, redact};
/// Per-sh timing: (index, started_at, finished_at).
pub(super) type ShTimings = Vec<(usize, Timestamp, Timestamp)>;
-/// The runtime-side carrier for the chosen [`Executor`](super::run::Executor).
-/// `Host` runs `sh` directly on the host. `Docker` owns a
-/// [`DockerLifecycle`] whose Drop tears down the per-run container;
-/// `Runtime::sh` reads the variant's payload to wrap each command in
-/// `docker exec`.
-pub(super) enum ExecutorRuntime {
- Host,
- Docker(DockerLifecycle),
-}
-
/// Per-execution runtime: owns the Lua VM, holds the secrets exposed
/// to the job, the per-job `(jobs name)` views, the current-job
/// cursor, and the per-job captured `sh` outputs.
@@ -67,9 +57,6 @@ pub(super) struct Runtime {
/// The materialized workspace for this run. Every `(sh …)` call
/// runs here.
workspace: std::path::PathBuf,
- /// The chosen executor. `Host` is a no-op; `Docker` owns the
- /// per-run container's lifecycle (teardown on Drop).
- executor: ExecutorRuntime,
}
impl Runtime {
@@ -90,7 +77,6 @@ impl Runtime {
meta: &RunMeta,
git_dir: &std::path::Path,
workspace: std::path::PathBuf,
- executor: ExecutorRuntime,
) -> Self {
let transitive = pipeline.transitive_inputs();
let lua = pipeline.fennel().lua();
@@ -132,7 +118,6 @@ impl Runtime {
sh_timings: RefCell::new(HashMap::new()),
sh_counter: RefCell::new(HashMap::new()),
workspace,
- executor,
}
}
@@ -151,15 +136,6 @@ impl Runtime {
self.pipeline.job(id)
}
- /// In docker mode, return the `(container_id, work_dir)` used to
- /// route `(sh …)` through `docker exec`. Host mode returns `None`.
- pub(super) fn docker_target(&self) -> Option<(&str, &str)> {
- match &self.executor {
- ExecutorRuntime::Host => None,
- ExecutorRuntime::Docker(l) => Some((&l.session.container_id, &l.work_dir)),
- }
- }
-
/// Mark `id` as the currently executing job. `(sh …)` invocations
/// from this job's `run_fn` will record output under `id`, and
/// `(jobs …)` lookups will resolve against `id`'s view.
@@ -207,38 +183,16 @@ impl Runtime {
/// Run `cmd` with `opts` and record its output against the
/// current job (if one is active). Non-zero exits come back in
/// `:exit`, not as `Err`.
- ///
- /// In docker mode the command is wrapped in `docker exec` against
- /// the per-run container; the bind-mounted workspace is the
- /// container's working directory, and `opts.env` is forwarded as
- /// `-e KEY=VAL` flags.
pub(super) fn sh(&self, cmd: Cmd, opts: ShOpts) -> super::error::Result<ShOutput> {
let started_at = Timestamp::now();
- let output = match self.docker_target() {
- None => {
- let program = cmd.program().to_string();
- cmd.run(opts, &self.workspace).map_err(|e| {
- super::error::Error::CommandSpawnFailed {
- program,
- cwd: self.workspace.clone(),
- source: e,
- }
- })?
- }
- Some((container_id, work_dir)) => {
- let wrapped = Cmd::wrap_in_docker_exec(cmd, container_id, work_dir, &opts);
- let program = wrapped.program().to_string();
- // env was embedded into the docker exec argv; clear it
- // so it isn't also set on the host `docker` process.
- wrapped
- .run(ShOpts::default(), &self.workspace)
- .map_err(|e| super::error::Error::CommandSpawnFailed {
- program,
- cwd: self.workspace.clone(),
- source: e,
- })?
+ let program = cmd.program().to_string();
+ let output = cmd.run(opts, &self.workspace).map_err(|e| {
+ super::error::Error::CommandSpawnFailed {
+ program,
+ cwd: self.workspace.clone(),
+ source: e,
}
- };
+ })?;
let finished_at = Timestamp::now();
if let Some(job) = self.current_job.borrow().as_ref() {
let n = {
@@ -286,7 +240,6 @@ impl Runtime {
sh_timings: RefCell::new(HashMap::new()),
sh_counter: RefCell::new(HashMap::new()),
workspace: std::env::current_dir().expect("cwd"),
- executor: ExecutorRuntime::Host,
}
}
}
@@ -435,50 +388,6 @@ impl Cmd {
// Also revisit the `from_utf8_lossy` calls below — non-UTF-8 bytes
// are silently replaced with U+FFFD and `:stdout` / `:stderr` end
// up as mojibake with no signal that anything was lost.
- /// Build a new `Cmd` that invokes `docker exec` against the given
- /// container. Embeds `cwd` as `--workdir` and `opts.env` as
- /// repeated `-e KEY=VALUE` flags, so the caller's `ShOpts` after
- /// wrapping should be empty.
- pub(super) fn wrap_in_docker_exec(
- inner: Cmd,
- container_id: &str,
- work_dir: &str,
- opts: &ShOpts,
- ) -> Cmd {
- // `--interactive` keeps stdin attached. No `--tty` so stdout
- // and stderr stay as separate streams.
- let mut args: Vec<String> = vec![
- "exec".to_string(),
- "--interactive".to_string(),
- "--workdir".to_string(),
- work_dir.to_string(),
- ];
- for (k, v) in &opts.env {
- args.push("--env".to_string());
- args.push(format!("{k}={v}"));
- }
- args.push(container_id.to_string());
-
- match inner {
- Cmd::Argv {
- program,
- args: inner_args,
- } => {
- args.push(program);
- args.extend(inner_args);
- }
- Cmd::Shell(s) => {
- args.push("sh".to_string());
- args.push("-c".to_string());
- args.push(s);
- }
- }
- Cmd::Argv {
- program: "docker".to_string(),
- args,
- }
- }
-
pub(super) fn run(self, opts: ShOpts, cwd: &std::path::Path) -> std::io::Result<ShOutput> {
let cmd_str = format!("{self}");
let mut command: std::process::Command = self.into();
@@ -784,61 +693,6 @@ mod tests {
);
}
- #[test]
- fn cmd_wrap_in_docker_exec_argv_form() {
- let inner = Cmd::Argv {
- program: "echo".to_string(),
- args: vec!["hi".to_string()],
- };
- let mut opts = ShOpts::default();
- opts.env.insert("FOO".to_string(), "bar".to_string());
-
- let wrapped = Cmd::wrap_in_docker_exec(inner, "abc123", "/work", &opts);
- let Cmd::Argv { program, args } = wrapped else {
- panic!("expected argv form");
- };
- assert_eq!(program, "docker");
- assert_eq!(
- args,
- vec![
- "exec",
- "--interactive",
- "--workdir",
- "/work",
- "--env",
- "FOO=bar",
- "abc123",
- "echo",
- "hi",
- ]
- );
- }
-
- #[test]
- fn cmd_wrap_in_docker_exec_shell_form() {
- let inner = Cmd::Shell("echo hi | tr a-z A-Z".to_string());
- let opts = ShOpts::default();
-
- let wrapped = Cmd::wrap_in_docker_exec(inner, "abc123", "/work", &opts);
- let Cmd::Argv { program, args } = wrapped else {
- panic!("expected argv form");
- };
- assert_eq!(program, "docker");
- assert_eq!(
- args,
- vec![
- "exec",
- "--interactive",
- "--workdir",
- "/work",
- "abc123",
- "sh",
- "-c",
- "echo hi | tr a-z A-Z",
- ]
- );
- }
-
#[test]
fn sh_rejects_number_as_cmd() {
let (runtime, run_fn) = rt(