use serde::{Deserialize, Serialize}; use std::collections::HashMap; use thiserror::Error; #[derive(Debug, Error)] pub enum ManifestError { #[error("Invalid manifest: {0}")] Invalid(String), #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error("YAML parse error: {0}")] Yaml(#[from] serde_yaml::Error), } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppManifest { pub app: AppDefinition, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppDefinition { pub id: String, pub name: String, pub version: String, pub description: Option, #[serde(default)] pub container: ContainerConfig, #[serde(default)] pub dependencies: Vec, #[serde(default)] pub resources: ResourceLimits, #[serde(default)] pub security: SecurityPolicy, #[serde(default)] pub ports: Vec, #[serde(default)] pub volumes: Vec, #[serde(default)] pub environment: Vec, #[serde(default)] pub health_check: Option, #[serde(default)] pub devices: Vec, #[serde(flatten)] pub extensions: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ContainerConfig { /// Pull source. Mutually exclusive with `build`. Exactly one of the two must be present. #[serde(default)] pub image: Option, #[serde(default)] pub image_signature: Option, #[serde(default = "default_pull_policy")] pub pull_policy: String, /// Local build source. Mutually exclusive with `image`. #[serde(default)] pub build: Option, } 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 -f ` /// 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, } 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, }, /// Build locally. The resulting tag is the image reference for `podman create`. Build(BuildConfig), } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum Dependency { Storage { storage: String, }, App { app_id: String, version: Option, }, Simple(String), } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ResourceLimits { #[serde(default)] pub cpu_limit: Option, #[serde(default)] pub memory_limit: Option, #[serde(default)] pub disk_limit: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct SecurityPolicy { #[serde(default)] pub capabilities: Vec, #[serde(default = "default_true")] pub readonly_root: bool, #[serde(default = "default_network_policy")] pub network_policy: String, #[serde(default)] pub apparmor_profile: Option, } fn default_true() -> bool { true } fn default_network_policy() -> String { "isolated".to_string() } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PortMapping { pub host: u16, pub container: u16, #[serde(default)] pub protocol: String, } impl From<(u16, u16)> for PortMapping { fn from((host, container): (u16, u16)) -> Self { PortMapping { host, container, protocol: "tcp".to_string(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Volume { #[serde(rename = "type")] pub volume_type: String, pub source: String, pub target: String, #[serde(default)] pub options: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HealthCheck { #[serde(rename = "type")] pub check_type: String, pub endpoint: Option, pub path: Option, #[serde(default = "default_interval")] pub interval: String, #[serde(default = "default_timeout")] pub timeout: String, #[serde(default = "default_retries")] pub retries: u32, } fn default_interval() -> String { "30s".to_string() } fn default_timeout() -> String { "5s".to_string() } fn default_retries() -> u32 { 3 } impl AppManifest { pub fn from_file(path: &std::path::Path) -> Result { let content = std::fs::read_to_string(path)?; Self::parse(&content) } pub fn parse(content: &str) -> Result { let manifest: AppManifest = serde_yaml::from_str(content)?; manifest.validate()?; Ok(manifest) } pub fn validate(&self) -> Result<(), ManifestError> { if self.app.id.is_empty() { return Err(ManifestError::Invalid("app.id 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) if !self.app.version.chars().any(|c| c.is_ascii_digit()) { return Err(ManifestError::Invalid( "app.version must contain at least one digit".to_string(), )); } Ok(()) } } 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 { 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 { self.resolve().map(|r| match r { ResolvedSource::Pull { image, .. } => image, ResolvedSource::Build(b) => b.tag, }) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_manifest_parse() { let yaml = r#" app: id: test-app name: Test App version: 1.0.0 container: image: test/image:latest "#; let manifest = AppManifest::parse(yaml).unwrap(); assert_eq!(manifest.app.id, "test-app"); assert_eq!(manifest.app.name, "Test App"); assert_eq!(manifest.app.version, "1.0.0"); } #[test] fn test_manifest_validation() { let yaml = r#" app: id: "" name: Test version: 1.0.0 container: image: test/image:latest "#; 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 { .. } ); } }