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:
parent
3767c2670c
commit
34af4d9d4e
@ -1,4 +1,4 @@
|
|||||||
use crate::manifest::AppManifest;
|
use crate::manifest::{AppManifest, BuildConfig};
|
||||||
use crate::podman_client::{ContainerState, ContainerStatus, PodmanClient};
|
use crate::podman_client::{ContainerState, ContainerStatus, PodmanClient};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use async_trait::async_trait;
|
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_status(&self, name: &str) -> Result<ContainerStatus>;
|
||||||
async fn get_container_logs(&self, name: &str, lines: u32) -> Result<Vec<String>>;
|
async fn get_container_logs(&self, name: &str, lines: u32) -> Result<Vec<String>>;
|
||||||
async fn list_containers(&self) -> Result<Vec<ContainerStatus>>;
|
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 {
|
pub struct PodmanRuntime {
|
||||||
@ -32,6 +48,17 @@ impl PodmanRuntime {
|
|||||||
client: PodmanClient::new(user),
|
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]
|
#[async_trait]
|
||||||
@ -79,6 +106,64 @@ impl ContainerRuntime for PodmanRuntime {
|
|||||||
async fn list_containers(&self) -> Result<Vec<ContainerStatus>> {
|
async fn list_containers(&self) -> Result<Vec<ContainerStatus>> {
|
||||||
self.client.list_containers().await
|
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 {
|
pub struct DockerRuntime {
|
||||||
@ -188,7 +273,13 @@ impl ContainerRuntime for DockerRuntime {
|
|||||||
cmd.arg("--cap-add").arg(cap);
|
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")?;
|
let output = cmd.output().await.context("Failed to create container")?;
|
||||||
|
|
||||||
@ -344,6 +435,42 @@ impl ContainerRuntime for DockerRuntime {
|
|||||||
|
|
||||||
Ok(result)
|
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 {
|
pub struct AutoRuntime {
|
||||||
@ -415,7 +542,91 @@ impl ContainerRuntime for AutoRuntime {
|
|||||||
async fn list_containers(&self) -> Result<Vec<ContainerStatus>> {
|
async fn list_containers(&self) -> Result<Vec<ContainerStatus>> {
|
||||||
self.runtime.list_containers().await
|
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
|
// Runtime factory functions will be provided by the archipelago crate
|
||||||
// that imports this library and has access to Config
|
// 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user