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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user