diff --git a/core/container/src/runtime.rs b/core/container/src/runtime.rs index 7f748899..61e7f801 100644 --- a/core/container/src/runtime.rs +++ b/core/container/src/runtime.rs @@ -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; async fn get_container_logs(&self, name: &str, lines: u32) -> Result>; async fn list_containers(&self) -> Result>; + + /// Check whether an image reference exists in local storage. + /// + /// The reconciler calls this before deciding to build. `true` means + /// `image inspect ` 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; + + /// Build a local image from a `BuildConfig`. + /// + /// Equivalent to `podman build -t -f [--build-arg K=V ...] `. + /// 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 `, 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 { + 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> { self.client.list_containers().await } + + async fn image_exists(&self, image_ref: &str) -> Result { + // `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 { + let mut args: Vec = 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 { + // `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> { self.runtime.list_containers().await } + + async fn image_exists(&self, image_ref: &str) -> Result { + 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::>(), + } + } + + #[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"); + } +}