feat(container): runtime trait gains image_exists + build_image

Adds two methods to ContainerRuntime so the upcoming ProdContainerOrchestrator
can inspect local image storage and build images from BuildConfig:

- image_exists(image_ref) -> Result<bool>: local-storage check only, does
  not consult registries. Distinguishes exit 0 (present) from exit 1
  (absent) from other failures (environment error).
- build_image(&BuildConfig) -> Result<()>: shells out to podman/docker
  build with -t, -f, deterministically-sorted --build-arg pairs, and the
  context path last.

Implemented on all three runtimes:
- PodmanRuntime: new podman_cli helper shells out alongside the existing
  HTTP API calls (build and image inspect are awkward over the HTTP API)
- DockerRuntime: native docker CLI, same exit-code semantics
- AutoRuntime: delegates to the selected inner runtime

Argv construction extracted into pure build_args_for_podman helper so it
can be unit-tested without a real podman. 4 new tests cover minimal args,
custom Dockerfile path, deterministic build-arg sorting (guards against
HashMap iteration non-determinism), and context-is-last (positional arg
placement is load-bearing for podman build).

Step 2 of docs/rust-orchestrator-migration.md. 25/25 tests pass.
This commit is contained in:
archipelago 2026-04-22 17:46:47 -04:00
parent 919055f3f1
commit 56af57a6f8

View File

@ -1,4 +1,4 @@
use crate::manifest::AppManifest;
use crate::manifest::{AppManifest, BuildConfig};
use crate::podman_client::{ContainerState, ContainerStatus, PodmanClient};
use anyhow::{Context, Result};
use async_trait::async_trait;
@ -20,6 +20,22 @@ pub trait ContainerRuntime: Send + Sync {
async fn get_container_status(&self, name: &str) -> Result<ContainerStatus>;
async fn get_container_logs(&self, name: &str, lines: u32) -> Result<Vec<String>>;
async fn list_containers(&self) -> Result<Vec<ContainerStatus>>;
/// Check whether an image reference exists in local storage.
///
/// The reconciler calls this before deciding to build. `true` means
/// `image inspect <image_ref>` succeeded (or equivalent); `false` means
/// the image is not present. Registry/network state is explicitly NOT
/// consulted — this is a local-storage check only.
async fn image_exists(&self, image_ref: &str) -> Result<bool>;
/// Build a local image from a `BuildConfig`.
///
/// Equivalent to `podman build -t <tag> -f <dockerfile> [--build-arg K=V ...] <context>`.
/// The resulting image is referenceable by `config.tag` for subsequent
/// `create_container` / `image_exists` calls. Stdout/stderr are collected
/// and included in the error on failure; on success they are discarded.
async fn build_image(&self, config: &BuildConfig) -> Result<()>;
}
pub struct PodmanRuntime {
@ -32,6 +48,17 @@ impl PodmanRuntime {
client: PodmanClient::new(user),
}
}
/// Run `podman <args>`, returning an error with captured stderr on non-zero
/// exit. Used for operations (build, image inspect) that are awkward over the
/// HTTP API. The daemon runs as the target user already, so no sudo hop.
async fn podman_cli(&self, args: &[&str]) -> Result<std::process::Output> {
let mut cmd = TokioCommand::new("podman");
cmd.args(args);
cmd.output()
.await
.with_context(|| format!("failed to execute podman {}", args.join(" ")))
}
}
#[async_trait]
@ -79,6 +106,64 @@ impl ContainerRuntime for PodmanRuntime {
async fn list_containers(&self) -> Result<Vec<ContainerStatus>> {
self.client.list_containers().await
}
async fn image_exists(&self, image_ref: &str) -> Result<bool> {
// `podman image exists` returns 0 if present, 1 if absent. Any other
// exit code is an environment failure we should surface.
let output = self.podman_cli(&["image", "exists", image_ref]).await?;
match output.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
Some(code) => {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(anyhow::anyhow!(
"podman image exists {image_ref} exited with {code}: {stderr}"
))
}
None => Err(anyhow::anyhow!(
"podman image exists {image_ref} terminated by signal"
)),
}
}
async fn build_image(&self, config: &BuildConfig) -> Result<()> {
let args = build_args_for_podman(config);
let borrowed: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let output = self.podman_cli(&borrowed).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
return Err(anyhow::anyhow!(
"podman build -t {} failed: {stderr}{}{stdout}",
config.tag,
if stderr.is_empty() || stdout.is_empty() { "" } else { "\n---stdout---\n" }
));
}
Ok(())
}
}
/// Build the argv for `podman build` from a BuildConfig.
///
/// Extracted so it can be unit-tested without actually invoking podman.
/// Order is fixed for deterministic tests: subcommand, -t, -f, build-args
/// (sorted by key), context.
fn build_args_for_podman(config: &BuildConfig) -> Vec<String> {
let mut args: Vec<String> = vec![
"build".to_string(),
"-t".to_string(),
config.tag.clone(),
"-f".to_string(),
config.dockerfile.clone(),
];
let mut kv: Vec<(&String, &String)> = config.build_args.iter().collect();
kv.sort_by(|a, b| a.0.cmp(b.0));
for (k, v) in kv {
args.push("--build-arg".to_string());
args.push(format!("{k}={v}"));
}
args.push(config.context.clone());
args
}
pub struct DockerRuntime {
@ -188,7 +273,13 @@ impl ContainerRuntime for DockerRuntime {
cmd.arg("--cap-add").arg(cap);
}
cmd.arg(&manifest.app.container.image);
let image_ref = manifest.app.container.image_ref().ok_or_else(|| {
anyhow::anyhow!(
"container config for {} has neither a valid image nor build source",
manifest.app.id
)
})?;
cmd.arg(&image_ref);
let output = cmd.output().await.context("Failed to create container")?;
@ -344,6 +435,42 @@ impl ContainerRuntime for DockerRuntime {
Ok(result)
}
async fn image_exists(&self, image_ref: &str) -> Result<bool> {
// `docker image inspect` exits 1 when the image is absent. Any message
// to stderr in that case is informational; we swallow it.
let mut cmd = self.docker_async();
cmd.arg("image").arg("inspect").arg(image_ref);
let output = cmd.output().await.context("failed to execute docker image inspect")?;
match output.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
Some(code) => {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(anyhow::anyhow!(
"docker image inspect {image_ref} exited with {code}: {stderr}"
))
}
None => Err(anyhow::anyhow!(
"docker image inspect {image_ref} terminated by signal"
)),
}
}
async fn build_image(&self, config: &BuildConfig) -> Result<()> {
let mut cmd = self.docker_async();
cmd.arg("build").arg("-t").arg(&config.tag).arg("-f").arg(&config.dockerfile);
for (k, v) in &config.build_args {
cmd.arg("--build-arg").arg(format!("{k}={v}"));
}
cmd.arg(&config.context);
let output = cmd.output().await.context("failed to execute docker build")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("docker build -t {} failed: {stderr}", config.tag));
}
Ok(())
}
}
pub struct AutoRuntime {
@ -415,7 +542,91 @@ impl ContainerRuntime for AutoRuntime {
async fn list_containers(&self) -> Result<Vec<ContainerStatus>> {
self.runtime.list_containers().await
}
async fn image_exists(&self, image_ref: &str) -> Result<bool> {
self.runtime.image_exists(image_ref).await
}
async fn build_image(&self, config: &BuildConfig) -> Result<()> {
self.runtime.build_image(config).await
}
}
// Runtime factory functions will be provided by the archipelago crate
// that imports this library and has access to Config
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn cfg(context: &str, tag: &str, dockerfile: &str, args: &[(&str, &str)]) -> BuildConfig {
BuildConfig {
context: context.to_string(),
dockerfile: dockerfile.to_string(),
tag: tag.to_string(),
build_args: args
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect::<HashMap<_, _>>(),
}
}
#[test]
fn build_args_minimal() {
let c = cfg("/tmp/ctx", "archy-bitcoin-ui:local", "Dockerfile", &[]);
assert_eq!(
build_args_for_podman(&c),
vec![
"build",
"-t",
"archy-bitcoin-ui:local",
"-f",
"Dockerfile",
"/tmp/ctx",
]
);
}
#[test]
fn build_args_custom_dockerfile() {
let c = cfg("/opt/archy/bitcoin-ui", "x:local", "Dockerfile.prod", &[]);
let got = build_args_for_podman(&c);
assert_eq!(got[3], "-f");
assert_eq!(got[4], "Dockerfile.prod");
assert_eq!(got.last().unwrap(), "/opt/archy/bitcoin-ui");
}
#[test]
fn build_args_are_sorted_deterministically() {
// HashMap iteration order is nondeterministic; the runtime sorts so that
// equivalent BuildConfigs produce identical commands (easier to debug,
// cache-friendly if we ever layer build-cache keys on top).
let c = cfg(
"/c",
"t",
"Dockerfile",
&[("BAR", "2"), ("FOO", "1"), ("BAZ", "3")],
);
let args = build_args_for_podman(&c);
let flat: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
// Build args appear as pairs of --build-arg K=V; locate them:
let mut pairs: Vec<&str> = Vec::new();
for w in flat.windows(2) {
if w[0] == "--build-arg" {
pairs.push(w[1]);
}
}
assert_eq!(pairs, vec!["BAR=2", "BAZ=3", "FOO=1"]);
}
#[test]
fn build_args_context_is_last() {
// Context MUST be the final positional argument — podman treats any
// stray trailing arg after build-args as the context, so placement
// matters. Regression guard.
let c = cfg("/final/context", "t", "Dockerfile", &[("K", "V")]);
let args = build_args_for_podman(&c);
assert_eq!(args.last().unwrap(), "/final/context");
}
}