feat(container): add build source to manifest schema
ContainerConfig.image is now Option<String>, mutually exclusive with a new optional ContainerConfig.build: Option<BuildConfig>. Exactly one of image or build must be present, enforced in AppManifest::validate. Adds ResolvedSource enum (Pull | Build) and ContainerConfig::resolve + ::image_ref helpers so the orchestrator can treat pull and build uniformly. All 26 existing pull-only manifests continue to parse unchanged (covered by existing_pull_only_manifests_still_parse test). Call sites updated: podman_client, runtime::DockerRuntime, dev_orchestrator. Dev orchestrator errors out cleanly on Build sources until Step 2 lands build_image support on the runtime trait. Step 1 of docs/rust-orchestrator-migration.md. 10 new unit tests, all pass. Also includes: docs/rust-orchestrator-migration.md (design spec) and docs/STATUS.md resume section for the next session.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
use anyhow::{Context, Result};
|
||||
use archipelago_container::{
|
||||
AppManifest, BitcoinSimulationMode, BitcoinSimulator,
|
||||
ContainerRuntime as ContainerRuntimeTrait, ContainerStatus, PortManager,
|
||||
ContainerRuntime as ContainerRuntimeTrait, ContainerStatus, PortManager, ResolvedSource,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -103,14 +103,30 @@ impl DevContainerOrchestrator {
|
||||
volume.source = dev_path.to_string_lossy().to_string();
|
||||
}
|
||||
|
||||
// Pull image
|
||||
self.runtime
|
||||
.pull_image(
|
||||
&manifest.app.container.image,
|
||||
manifest.app.container.image_signature.as_deref(),
|
||||
)
|
||||
.await
|
||||
.context("Failed to pull image")?;
|
||||
// Resolve pull-or-build. Dev orchestrator currently only supports pull;
|
||||
// Build support lands in Step 2 of the rust-orchestrator migration.
|
||||
match manifest
|
||||
.app
|
||||
.container
|
||||
.resolve()
|
||||
.ok_or_else(|| anyhow::anyhow!("manifest container config invalid (neither image nor build)"))?
|
||||
{
|
||||
ResolvedSource::Pull {
|
||||
image,
|
||||
image_signature,
|
||||
..
|
||||
} => {
|
||||
self.runtime
|
||||
.pull_image(&image, image_signature.as_deref())
|
||||
.await
|
||||
.context("Failed to pull image")?;
|
||||
}
|
||||
ResolvedSource::Build(_) => {
|
||||
anyhow::bail!(
|
||||
"dev orchestrator does not yet support local image builds (see rust-orchestrator-migration.md Step 2)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create container with port offset
|
||||
let port_offset = if self.config.dev_mode {
|
||||
|
||||
@@ -213,9 +213,10 @@ mod tests {
|
||||
version: "1.0.0".to_string(),
|
||||
description: None,
|
||||
container: ContainerConfig {
|
||||
image: format!("test/{}:latest", id),
|
||||
image: Some(format!("test/{}:latest", id)),
|
||||
image_signature: None,
|
||||
pull_policy: "if-not-present".to_string(),
|
||||
build: None,
|
||||
},
|
||||
dependencies: deps,
|
||||
resources: Default::default(),
|
||||
|
||||
@@ -9,7 +9,10 @@ pub mod runtime;
|
||||
pub use bitcoin_simulator::{BitcoinSimulationMode, BitcoinSimulator};
|
||||
pub use dependency_resolver::DependencyResolver;
|
||||
pub use health_monitor::HealthMonitor;
|
||||
pub use manifest::{AppManifest, Dependency, HealthCheck, ResourceLimits, SecurityPolicy};
|
||||
pub use manifest::{
|
||||
AppManifest, BuildConfig, Dependency, HealthCheck, ResolvedSource, ResourceLimits,
|
||||
SecurityPolicy,
|
||||
};
|
||||
pub use podman_client::{ContainerState, ContainerStatus, PodmanClient};
|
||||
pub use port_manager::{PortError, PortManager};
|
||||
pub use runtime::{AutoRuntime, ContainerRuntime, DockerRuntime, PodmanRuntime};
|
||||
|
||||
@@ -57,17 +57,60 @@ pub struct AppDefinition {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ContainerConfig {
|
||||
pub image: String,
|
||||
/// Pull source. Mutually exclusive with `build`. Exactly one of the two must be present.
|
||||
#[serde(default)]
|
||||
pub image: Option<String>,
|
||||
#[serde(default)]
|
||||
pub image_signature: Option<String>,
|
||||
#[serde(default = "default_pull_policy")]
|
||||
pub pull_policy: String,
|
||||
/// Local build source. Mutually exclusive with `image`.
|
||||
#[serde(default)]
|
||||
pub build: Option<BuildConfig>,
|
||||
}
|
||||
|
||||
fn default_pull_policy() -> String {
|
||||
"if-not-present".to_string()
|
||||
}
|
||||
|
||||
/// Build a container image locally from a Dockerfile rather than pulling from a registry.
|
||||
///
|
||||
/// When present on `ContainerConfig`, the orchestrator runs `podman build -t <tag> -f <dockerfile> <context>`
|
||||
/// before starting the container. The resulting local image is referenced by `tag`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct BuildConfig {
|
||||
/// Build context directory (absolute path or relative to the manifest location).
|
||||
pub context: String,
|
||||
/// Dockerfile path relative to `context`. Defaults to `Dockerfile`.
|
||||
#[serde(default = "default_dockerfile")]
|
||||
pub dockerfile: String,
|
||||
/// Tag applied to the built image. Used as the container's image reference.
|
||||
pub tag: String,
|
||||
/// Optional `--build-arg KEY=VALUE` pairs passed to the build.
|
||||
#[serde(default)]
|
||||
pub build_args: HashMap<String, String>,
|
||||
}
|
||||
|
||||
fn default_dockerfile() -> String {
|
||||
"Dockerfile".to_string()
|
||||
}
|
||||
|
||||
/// Resolved pull-or-build decision after manifest validation.
|
||||
///
|
||||
/// `ContainerConfig::resolve()` produces this. The orchestrator matches on it
|
||||
/// to decide whether to pull a registry image or invoke a local build.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ResolvedSource {
|
||||
/// Pull `image` from a registry using `pull_policy` semantics.
|
||||
Pull {
|
||||
image: String,
|
||||
pull_policy: String,
|
||||
image_signature: Option<String>,
|
||||
},
|
||||
/// Build locally. The resulting tag is the image reference for `podman create`.
|
||||
Build(BuildConfig),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Dependency {
|
||||
@@ -182,10 +225,33 @@ impl AppManifest {
|
||||
return Err(ManifestError::Invalid("app.id cannot be empty".to_string()));
|
||||
}
|
||||
|
||||
if self.app.container.image.is_empty() {
|
||||
return Err(ManifestError::Invalid(
|
||||
"container.image cannot be empty".to_string(),
|
||||
));
|
||||
// Exactly one of container.image or container.build must be set. We can't
|
||||
// default either side, because an empty-string image or an empty build block
|
||||
// would be silently wrong downstream.
|
||||
match (&self.app.container.image, &self.app.container.build) {
|
||||
(Some(img), None) if !img.is_empty() => {}
|
||||
(None, Some(b)) => {
|
||||
if b.context.is_empty() {
|
||||
return Err(ManifestError::Invalid(
|
||||
"container.build.context cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if b.tag.is_empty() {
|
||||
return Err(ManifestError::Invalid(
|
||||
"container.build.tag cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
(Some(_), Some(_)) => {
|
||||
return Err(ManifestError::Invalid(
|
||||
"container.image and container.build are mutually exclusive".to_string(),
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
return Err(ManifestError::Invalid(
|
||||
"container must specify either image or build".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate version format (semantic versioning)
|
||||
@@ -199,6 +265,37 @@ impl AppManifest {
|
||||
}
|
||||
}
|
||||
|
||||
impl ContainerConfig {
|
||||
/// Collapse the (image, build) pair into a single resolved source.
|
||||
///
|
||||
/// Returns `None` if the config is in an invalid state (e.g. neither field set
|
||||
/// or both set). Callers should have already run `AppManifest::validate()` to
|
||||
/// surface a user-facing error; this method is for internal orchestrator use
|
||||
/// after validation has passed.
|
||||
pub fn resolve(&self) -> Option<ResolvedSource> {
|
||||
match (&self.image, &self.build) {
|
||||
(Some(img), None) if !img.is_empty() => Some(ResolvedSource::Pull {
|
||||
image: img.clone(),
|
||||
pull_policy: self.pull_policy.clone(),
|
||||
image_signature: self.image_signature.clone(),
|
||||
}),
|
||||
(None, Some(b)) => Some(ResolvedSource::Build(b.clone())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The image reference used to create/inspect a container for this config.
|
||||
///
|
||||
/// For Pull sources this is the registry image. For Build sources this is
|
||||
/// the locally-built tag. Returns `None` only for an invalid config.
|
||||
pub fn image_ref(&self) -> Option<String> {
|
||||
self.resolve().map(|r| match r {
|
||||
ResolvedSource::Pull { image, .. } => image,
|
||||
ResolvedSource::Build(b) => b.tag,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -234,4 +331,196 @@ app:
|
||||
let result = AppManifest::parse(yaml);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_source_resolves_to_pull() {
|
||||
let yaml = r#"
|
||||
app:
|
||||
id: test-app
|
||||
name: Test
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: docker.io/library/nginx:1.27
|
||||
pull_policy: always
|
||||
"#;
|
||||
let m = AppManifest::parse(yaml).unwrap();
|
||||
let src = m.app.container.resolve().unwrap();
|
||||
match src {
|
||||
ResolvedSource::Pull {
|
||||
image, pull_policy, ..
|
||||
} => {
|
||||
assert_eq!(image, "docker.io/library/nginx:1.27");
|
||||
assert_eq!(pull_policy, "always");
|
||||
}
|
||||
_ => panic!("expected Pull"),
|
||||
}
|
||||
assert_eq!(
|
||||
m.app.container.image_ref().as_deref(),
|
||||
Some("docker.io/library/nginx:1.27")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_source_resolves_to_build() {
|
||||
let yaml = r#"
|
||||
app:
|
||||
id: bitcoin-ui
|
||||
name: Bitcoin UI
|
||||
version: 1.0.0
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/bitcoin-ui
|
||||
dockerfile: Dockerfile
|
||||
tag: archy-bitcoin-ui:local
|
||||
build_args:
|
||||
NGINX_VERSION: "1.27"
|
||||
"#;
|
||||
let m = AppManifest::parse(yaml).unwrap();
|
||||
let src = m.app.container.resolve().unwrap();
|
||||
match src {
|
||||
ResolvedSource::Build(b) => {
|
||||
assert_eq!(b.context, "/opt/archipelago/docker/bitcoin-ui");
|
||||
assert_eq!(b.dockerfile, "Dockerfile");
|
||||
assert_eq!(b.tag, "archy-bitcoin-ui:local");
|
||||
assert_eq!(b.build_args.get("NGINX_VERSION").unwrap(), "1.27");
|
||||
}
|
||||
_ => panic!("expected Build"),
|
||||
}
|
||||
assert_eq!(
|
||||
m.app.container.image_ref().as_deref(),
|
||||
Some("archy-bitcoin-ui:local")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dockerfile_defaults_to_dockerfile() {
|
||||
let yaml = r#"
|
||||
app:
|
||||
id: x
|
||||
name: X
|
||||
version: 1.0.0
|
||||
container:
|
||||
build:
|
||||
context: /tmp
|
||||
tag: x:local
|
||||
"#;
|
||||
let m = AppManifest::parse(yaml).unwrap();
|
||||
match m.app.container.resolve().unwrap() {
|
||||
ResolvedSource::Build(b) => assert_eq!(b.dockerfile, "Dockerfile"),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_and_build_both_set_is_rejected() {
|
||||
let yaml = r#"
|
||||
app:
|
||||
id: x
|
||||
name: X
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: foo:latest
|
||||
build:
|
||||
context: /tmp
|
||||
tag: x:local
|
||||
"#;
|
||||
let err = AppManifest::parse(yaml).unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(
|
||||
msg.contains("mutually exclusive"),
|
||||
"unexpected error: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neither_image_nor_build_is_rejected() {
|
||||
let yaml = r#"
|
||||
app:
|
||||
id: x
|
||||
name: X
|
||||
version: 1.0.0
|
||||
container: {}
|
||||
"#;
|
||||
let err = AppManifest::parse(yaml).unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(
|
||||
msg.contains("either image or build"),
|
||||
"unexpected error: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_image_string_is_rejected() {
|
||||
let yaml = r#"
|
||||
app:
|
||||
id: x
|
||||
name: X
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: ""
|
||||
"#;
|
||||
let err = AppManifest::parse(yaml).unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(
|
||||
msg.contains("either image or build"),
|
||||
"unexpected error: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_build_context_is_rejected() {
|
||||
let yaml = r#"
|
||||
app:
|
||||
id: x
|
||||
name: X
|
||||
version: 1.0.0
|
||||
container:
|
||||
build:
|
||||
context: ""
|
||||
tag: x:local
|
||||
"#;
|
||||
let err = AppManifest::parse(yaml).unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("context"), "unexpected error: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_build_tag_is_rejected() {
|
||||
let yaml = r#"
|
||||
app:
|
||||
id: x
|
||||
name: X
|
||||
version: 1.0.0
|
||||
container:
|
||||
build:
|
||||
context: /tmp
|
||||
tag: ""
|
||||
"#;
|
||||
let err = AppManifest::parse(yaml).unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("tag"), "unexpected error: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_pull_only_manifests_still_parse() {
|
||||
// Backwards-compat smoke: the shape every file in apps/*/manifest.yml uses today.
|
||||
let yaml = r#"
|
||||
app:
|
||||
id: legacy
|
||||
name: Legacy App
|
||||
version: 0.1.0
|
||||
description: existing shape
|
||||
container:
|
||||
image: registry.example.com/legacy:1.2.3
|
||||
image_signature: sha256:abc
|
||||
ports:
|
||||
- { host: 8080, container: 80 }
|
||||
"#;
|
||||
let m = AppManifest::parse(yaml).unwrap();
|
||||
assert_eq!(m.app.container.pull_policy, "if-not-present");
|
||||
matches!(
|
||||
m.app.container.resolve().unwrap(),
|
||||
ResolvedSource::Pull { .. }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,9 +306,16 @@ impl PodmanClient {
|
||||
let cap_add: Vec<String> = manifest.app.security.capabilities.clone();
|
||||
let cap_drop = vec!["ALL".to_string()];
|
||||
|
||||
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
|
||||
)
|
||||
})?;
|
||||
|
||||
let body = serde_json::json!({
|
||||
"name": name,
|
||||
"image": manifest.app.container.image,
|
||||
"image": image_ref,
|
||||
"portmappings": port_mappings,
|
||||
"mounts": mounts,
|
||||
"env": env_map,
|
||||
|
||||
Reference in New Issue
Block a user