use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; 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 files: 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, // ── Step 8b.0 additions ────────────────────────────────────────── // // Fields the Rust orchestrator needs to faithfully port containers // from the legacy `scripts/container-specs.sh` registry. See // `docs/STEP-8B-PORT-AUDIT.md` for the full justification per field. // // All are optional with `#[serde(default)]` so every existing manifest // in `apps/` continues to parse unchanged. /// Podman `--network` value. `Some("archy-net")` joins the shared /// Archipelago bridge. `Some("host")` uses host networking. /// `None` (the default) falls back to podman's default isolated /// network — equivalent to today's rootless default. /// /// `SecurityPolicy::network_policy` remains a policy knob (what the /// firewall layer does); this field is literally the CLI flag value. #[serde(default)] pub network: Option, /// Extra positional arguments appended to the container command /// after the image. Mirrors `SPEC_CUSTOM_ARGS` in /// `scripts/container-specs.sh` (bitcoin-knots prune/dbcache flags, /// filebrowser `--config /data/.filebrowser.json`, etc). #[serde(default)] pub custom_args: Vec, /// Entrypoint override (`podman run --entrypoint …`). When present, /// replaces the image's default entrypoint. Mirrors `SPEC_ENTRYPOINT` /// for fedimint-gateway's LND-aware invocation. #[serde(default)] pub entrypoint: Option>, /// Environment keys whose values are rendered from a small /// allow-list of host facts (`HOST_IP`, `HOST_MDNS`, `DISK_GB`). /// Resolved by `ContainerConfig::resolve_derived_env` at apply time /// — never hard-coded into the manifest. /// /// Example: `- { key: FM_P2P_URL, template: "fedimint://{{HOST_MDNS}}:8173" }` #[serde(default)] pub derived_env: Vec, /// Environment keys whose values are read from files in /// `/var/lib/archipelago/secrets/`. Never logged. /// Resolved by `ContainerConfig::resolve_secret_env` at apply time. /// /// Example: `- { key: FM_BITCOIND_PASSWORD, secret_file: bitcoin-rpc-password }` #[serde(default)] pub secret_env: Vec, /// Rootless-mapped UID:GID applied to the container's data directory /// (the `bind`-mounted host path with `target` inside the container's /// data root) before creation. Mirrors `SPEC_DATA_UID`. /// /// Example: `"100070:100070"` for Postgres' mapped subuid. #[serde(default)] pub data_uid: Option, } /// Derived-env entry. The template is rendered against `HostFacts` at /// apply time; exactly one `{{PLACEHOLDER}}` occurrence per supported /// fact name is allowed (host_ip, host_mdns, disk_gb). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct DerivedEnv { pub key: String, pub template: String, } /// Secret-env entry. `secret_file` is resolved against a /// `SecretsProvider` (in prod, `/var/lib/archipelago/secrets/`). /// /// `secret_file` is restricted to a bare filename — no `/`, no `..`. /// Validated at `AppManifest::validate` time. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct SecretEnv { pub key: String, pub secret_file: String, } 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_true")] pub no_new_privileges: 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, #[serde(default)] pub source: String, pub target: String, #[serde(default)] pub options: Vec, /// For `type: tmpfs` only. Comma-separated mount options /// (e.g. `"rw,noexec,nosuid,size=256m"`). Ignored for bind/volume. #[serde(default)] pub tmpfs_options: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct GeneratedFile { pub path: String, pub content: String, #[serde(default)] pub overwrite: bool, } #[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 !is_valid_app_id(&self.app.id) { return Err(ManifestError::Invalid( "app.id must be lowercase ASCII letters, digits, or single hyphens".to_string(), )); } if self.app.name.trim().is_empty() { return Err(ManifestError::Invalid( "app.name 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(), )); } // ── Step 8b.0 field validation ──────────────────────────────── // network: allow any non-empty string; podman itself is the // final authority (named networks, "host", "bridge", "none", // "container:", etc). Reject only the empty-string case // so "network:" with no value is a loud error instead of a // silent default. if let Some(n) = &self.app.container.network { if n.is_empty() { return Err(ManifestError::Invalid( "container.network cannot be empty (omit the field to use default)".to_string(), )); } if is_dangerous_network_mode(n) { return Err(ManifestError::Invalid(format!( "container.network '{n}' is not allowed in app manifests" ))); } } // custom_args: no empty strings (would inject literal "" into // the podman command line and confuse downstream parsing). for (i, a) in self.app.container.custom_args.iter().enumerate() { if a.is_empty() { return Err(ManifestError::Invalid(format!( "container.custom_args[{i}] cannot be empty" ))); } } // entrypoint: present ⇒ non-empty vec, no empty elements. if let Some(ep) = &self.app.container.entrypoint { if ep.is_empty() { return Err(ManifestError::Invalid( "container.entrypoint must contain at least one element when set".to_string(), )); } for (i, a) in ep.iter().enumerate() { if a.is_empty() { return Err(ManifestError::Invalid(format!( "container.entrypoint[{i}] cannot be empty" ))); } } } // derived_env: non-empty keys, unique keys, templates reference // only known host-fact placeholders. { let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); for (i, e) in self.app.container.derived_env.iter().enumerate() { if e.key.is_empty() { return Err(ManifestError::Invalid(format!( "container.derived_env[{i}].key cannot be empty" ))); } if !seen.insert(e.key.as_str()) { return Err(ManifestError::Invalid(format!( "container.derived_env has duplicate key '{}'", e.key ))); } validate_derived_template(&e.key, &e.template)?; } } // secret_env: non-empty keys, unique keys, secret_file is a // bare filename (no '/', no '..'). { let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); for (i, e) in self.app.container.secret_env.iter().enumerate() { if e.key.is_empty() { return Err(ManifestError::Invalid(format!( "container.secret_env[{i}].key cannot be empty" ))); } if !seen.insert(e.key.as_str()) { return Err(ManifestError::Invalid(format!( "container.secret_env has duplicate key '{}'", e.key ))); } if e.secret_file.is_empty() || e.secret_file.contains('/') || e.secret_file.contains("..") { return Err(ManifestError::Invalid(format!( "container.secret_env[{}].secret_file must be a bare filename (no '/', no '..'), got '{}'", i, e.secret_file ))); } } } // data_uid: if set, must look like "NNNNN:NNNNN". if let Some(u) = &self.app.container.data_uid { let parts: Vec<&str> = u.split(':').collect(); let valid = parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() && parts[0].chars().all(|c| c.is_ascii_digit()) && parts[1].chars().all(|c| c.is_ascii_digit()); if !valid { return Err(ManifestError::Invalid(format!( "container.data_uid must be 'UID:GID' with numeric parts, got '{}'", u ))); } } validate_security(&self.app.security)?; validate_ports(&self.app.ports)?; validate_environment(&self.app.environment)?; validate_devices(&self.app.devices)?; // Volume tmpfs_options: only meaningful for type: tmpfs. for (i, v) in self.app.volumes.iter().enumerate() { if v.volume_type == "tmpfs" { if v.target.is_empty() { return Err(ManifestError::Invalid(format!( "volumes[{i}] (tmpfs) must set target" ))); } if !v.source.is_empty() { return Err(ManifestError::Invalid(format!( "volumes[{i}] (tmpfs) must not set source" ))); } } else if v.tmpfs_options.is_some() { return Err(ManifestError::Invalid(format!( "volumes[{i}] sets tmpfs_options but type is '{}', not 'tmpfs'", v.volume_type ))); } else { if v.volume_type != "bind" && v.volume_type != "volume" { return Err(ManifestError::Invalid(format!( "volumes[{i}].type must be bind, volume, or tmpfs" ))); } if v.source.is_empty() { return Err(ManifestError::Invalid(format!( "volumes[{i}] ({}) must set source", v.volume_type ))); } if v.target.is_empty() { return Err(ManifestError::Invalid(format!( "volumes[{i}] ({}) must set target", v.volume_type ))); } if v.volume_type == "bind" { validate_bind_source(i, &v.source)?; } else if !is_valid_named_volume(&v.source) { return Err(ManifestError::Invalid(format!( "volumes[{i}].source must be a safe named volume" ))); } validate_container_path(i, &v.target)?; validate_volume_options(i, &v.options)?; } } for (i, f) in self.app.files.iter().enumerate() { if f.path.is_empty() { return Err(ManifestError::Invalid(format!( "files[{i}].path cannot be empty" ))); } if !std::path::Path::new(&f.path).is_absolute() { return Err(ManifestError::Invalid(format!( "files[{i}].path must be absolute" ))); } if f.content.is_empty() { return Err(ManifestError::Invalid(format!( "files[{i}].content cannot be empty" ))); } let file_path = std::path::Path::new(&f.path); let under_bind_mount = self .app .volumes .iter() .filter(|v| v.volume_type != "tmpfs" && !v.source.is_empty()) .any(|v| file_path.starts_with(std::path::Path::new(&v.source))); if !under_bind_mount { return Err(ManifestError::Invalid(format!( "files[{i}].path must live under a bind-mounted volume source" ))); } } Ok(()) } } fn is_valid_app_id(id: &str) -> bool { if id.is_empty() || id.starts_with('-') || id.ends_with('-') || id.contains("--") { return false; } id.chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') } fn is_dangerous_network_mode(mode: &str) -> bool { mode.starts_with("container:") || mode.starts_with("ns:") } fn validate_security(policy: &SecurityPolicy) -> Result<(), ManifestError> { let allowed_network_policies = ["isolated", "bridge", "host"]; if !policy.network_policy.is_empty() && !allowed_network_policies.contains(&policy.network_policy.as_str()) { return Err(ManifestError::Invalid(format!( "security.network_policy must be one of {}", allowed_network_policies.join(", ") ))); } let allowed_caps = [ "CHOWN", "DAC_OVERRIDE", "FOWNER", "NET_ADMIN", "NET_BIND_SERVICE", "NET_RAW", "SETGID", "SETUID", "SYS_ADMIN", ]; let mut seen = HashSet::new(); for cap in &policy.capabilities { if !allowed_caps.contains(&cap.as_str()) { return Err(ManifestError::Invalid(format!( "security.capabilities contains unsupported capability '{cap}'" ))); } if !seen.insert(cap.as_str()) { return Err(ManifestError::Invalid(format!( "security.capabilities contains duplicate capability '{cap}'" ))); } } Ok(()) } fn validate_ports(ports: &[PortMapping]) -> Result<(), ManifestError> { let mut seen_host = HashSet::new(); for (i, port) in ports.iter().enumerate() { if port.host == 0 || port.container == 0 { return Err(ManifestError::Invalid(format!( "ports[{i}].host and ports[{i}].container must be non-zero" ))); } let protocol = if port.protocol.is_empty() { "tcp" } else { port.protocol.as_str() }; if protocol != "tcp" && protocol != "udp" { return Err(ManifestError::Invalid(format!( "ports[{i}].protocol must be tcp or udp" ))); } if !seen_host.insert((port.host, protocol.to_string())) { return Err(ManifestError::Invalid(format!( "ports contains duplicate host binding {}/{}", port.host, protocol ))); } } Ok(()) } fn validate_environment(env: &[String]) -> Result<(), ManifestError> { let mut seen = HashSet::new(); for (i, entry) in env.iter().enumerate() { let Some((key, _)) = entry.split_once('=') else { return Err(ManifestError::Invalid(format!( "environment[{i}] must be KEY=VALUE" ))); }; if !is_valid_env_key(key) { return Err(ManifestError::Invalid(format!( "environment[{i}] has invalid key '{key}'" ))); } if !seen.insert(key) { return Err(ManifestError::Invalid(format!( "environment contains duplicate key '{key}'" ))); } } Ok(()) } fn is_valid_env_key(key: &str) -> bool { let mut chars = key.chars(); match chars.next() { Some(c) if c.is_ascii_alphabetic() || c == '_' => {} _ => return false, } chars.all(|c| c.is_ascii_alphanumeric() || c == '_') } fn validate_devices(devices: &[String]) -> Result<(), ManifestError> { let mut seen = HashSet::new(); for (i, device) in devices.iter().enumerate() { if !device.starts_with("/dev/") || device.contains("..") { return Err(ManifestError::Invalid(format!( "devices[{i}] must be an absolute /dev path" ))); } if !seen.insert(device.as_str()) { return Err(ManifestError::Invalid(format!( "devices contains duplicate entry '{device}'" ))); } } Ok(()) } fn validate_bind_source(index: usize, source: &str) -> Result<(), ManifestError> { let path = std::path::Path::new(source); if !path.is_absolute() { if is_valid_named_volume(source) { return Ok(()); } return Err(ManifestError::Invalid(format!( "volumes[{index}].source must be absolute for host bind mounts or a safe named volume" ))); } if source.contains("..") { return Err(ManifestError::Invalid(format!( "volumes[{index}].source must not contain '..'" ))); } if source.starts_with("/var/lib/archipelago/") || is_reviewed_host_bind_exception(source) { return Ok(()); } Err(ManifestError::Invalid(format!( "volumes[{index}].source must be under /var/lib/archipelago or a reviewed host-bind exception" ))) } fn is_reviewed_host_bind_exception(source: &str) -> bool { source == "/run/user/1000/podman/podman.sock" || source == "/var/run/dbus" } fn is_valid_named_volume(source: &str) -> bool { if source.is_empty() || source.contains('/') || source.contains("..") { return false; } source .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') } fn validate_container_path(index: usize, target: &str) -> Result<(), ManifestError> { if !std::path::Path::new(target).is_absolute() || target.contains("..") { return Err(ManifestError::Invalid(format!( "volumes[{index}].target must be an absolute container path without '..'" ))); } Ok(()) } fn validate_volume_options(index: usize, options: &[String]) -> Result<(), ManifestError> { let allowed = ["rw", "ro", "z", "Z", "shared", "rshared", "slave", "rslave"]; let mut seen = HashSet::new(); for option in options { if !allowed.contains(&option.as_str()) { return Err(ManifestError::Invalid(format!( "volumes[{index}].options contains unsupported option '{option}'" ))); } if !seen.insert(option.as_str()) { return Err(ManifestError::Invalid(format!( "volumes[{index}].options contains duplicate option '{option}'" ))); } } Ok(()) } /// Host facts available to `derived_env` templates at apply time. /// /// Mirrors the values `scripts/container-specs.sh:detect_environment()` /// computed before each reconcile pass. The Rust orchestrator computes /// these once per reconcile tick and passes them to /// `ContainerConfig::resolve_derived_env`. #[derive(Debug, Clone)] pub struct HostFacts { /// Primary host IPv4 (e.g. from `hostname -I | awk '{print $1}'`). /// Falls back to `127.0.0.1` on detection failure. pub host_ip: String, /// mDNS hostname (`.local`). Survives DHCP churn and /// reinstall-on-different-IP. Requires avahi-daemon on the node. pub host_mdns: String, /// Usable disk size in gigabytes at `/var/lib/archipelago` (or /// `/` if the data partition is not yet mounted). Drives the /// prune-vs-full-node decision in bitcoin-knots custom_args. pub disk_gb: u64, } impl HostFacts { /// Test-only constant fixture; do not use in production paths. #[cfg(test)] pub fn sample() -> Self { Self { host_ip: "192.168.1.116".to_string(), host_mdns: "archi-thinkpad.local".to_string(), disk_gb: 2000, } } } /// Supported placeholder names in `DerivedEnv::template`. Keep in sync /// with `HostFacts`. Centralized so validation and rendering agree. const DERIVED_PLACEHOLDERS: &[&str] = &["HOST_IP", "HOST_MDNS", "DISK_GB"]; fn validate_derived_template(key: &str, template: &str) -> Result<(), ManifestError> { // Walk `{{NAME}}` occurrences and ensure each NAME is recognized. // Unbalanced braces are a user error. let bytes = template.as_bytes(); let mut i = 0; while i + 1 < bytes.len() { if bytes[i] == b'{' && bytes[i + 1] == b'{' { let rest = &template[i + 2..]; let close = rest.find("}}").ok_or_else(|| { ManifestError::Invalid(format!( "container.derived_env['{key}'].template has unbalanced '{{{{' — no closing '}}}}'" )) })?; let name = &rest[..close]; if !DERIVED_PLACEHOLDERS.contains(&name) { return Err(ManifestError::Invalid(format!( "container.derived_env['{key}'].template references unknown placeholder '{{{{{name}}}}}' (supported: {})", DERIVED_PLACEHOLDERS.join(", ") ))); } i = i + 2 + close + 2; } else { i += 1; } } Ok(()) } /// A source of named secrets. In prod this is a directory on disk /// (`/var/lib/archipelago/secrets/`); in tests, a HashMap. pub trait SecretsProvider { /// Read the named secret and return its value with trailing /// whitespace trimmed (so `echo "…" > secret-file` works without /// injecting a newline into env). fn read(&self, name: &str) -> Result; } 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, }) } /// Render every `derived_env` entry's template against the given /// host facts. Returns `"KEY=VALUE"` strings ready to concatenate /// with `environment:`. /// /// Assumes `AppManifest::validate()` has already accepted the /// manifest — placeholder names are not re-checked here. pub fn resolve_derived_env(&self, facts: &HostFacts) -> Vec { self.derived_env .iter() .map(|e| { let value = e .template .replace("{{HOST_IP}}", &facts.host_ip) .replace("{{HOST_MDNS}}", &facts.host_mdns) .replace("{{DISK_GB}}", &facts.disk_gb.to_string()); format!("{}={}", e.key, value) }) .collect() } /// Read every `secret_env` entry's value from the provider and /// return `"KEY=VALUE"` strings. Propagates the provider error on /// the first missing/unreadable secret — partial resolution is not /// useful because it silently produces a misconfigured container. pub fn resolve_secret_env( &self, provider: &dyn SecretsProvider, ) -> Result, ManifestError> { let mut out = Vec::with_capacity(self.secret_env.len()); for e in &self.secret_env { let v = provider.read(&e.secret_file)?; // An empty secret produces e.g. `-rpcpassword=` and crashes // the container on auth before logs are useful. Fail loud. if v.trim().is_empty() { return Err(ManifestError::Invalid(format!( "secret_env {} resolved to empty value (file: {})", e.key, e.secret_file ))); } out.push(format!("{}={}", e.key, v)); } Ok(out) } } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; #[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 { .. } ); } #[test] fn generated_files_must_live_under_bind_mounts() { let yaml = r#" app: id: test-app name: Test App version: 1.0.0 container: image: test/image:latest volumes: - type: bind source: /var/lib/archipelago/test-app target: /data files: - path: /var/lib/archipelago/test-app/config.yaml content: | key: value "#; let manifest = AppManifest::parse(yaml).unwrap(); assert_eq!(manifest.app.files.len(), 1); let bad = yaml.replace( "/var/lib/archipelago/test-app/config.yaml", "/etc/test-app/config.yaml", ); let err = AppManifest::parse(&bad).unwrap_err(); assert!( format!("{err}").contains("bind-mounted volume source"), "unexpected error: {err}" ); } #[test] fn empty_custom_arg_is_rejected() { let yaml = r#" app: id: x name: X version: 1.0.0 container: image: foo:latest custom_args: [""] "#; let err = AppManifest::parse(yaml).unwrap_err(); let msg = format!("{err}"); assert!(msg.contains("custom_args[0]"), "unexpected error: {msg}"); } #[test] fn empty_entrypoint_vec_is_rejected() { let yaml = r#" app: id: x name: X version: 1.0.0 container: image: foo:latest entrypoint: [] "#; let err = AppManifest::parse(yaml).unwrap_err(); let msg = format!("{err}"); assert!(msg.contains("entrypoint"), "unexpected error: {msg}"); } #[test] fn empty_entrypoint_element_is_rejected() { let yaml = r#" app: id: x name: X version: 1.0.0 container: image: foo:latest entrypoint: ["gatewayd", ""] "#; let err = AppManifest::parse(yaml).unwrap_err(); let msg = format!("{err}"); assert!(msg.contains("entrypoint[1]"), "unexpected error: {msg}"); } #[test] fn duplicate_derived_env_keys_are_rejected() { let yaml = r#" app: id: fedimint name: Fedimint version: 0.10.0 container: image: fedimintd:v0.10.0 derived_env: - key: FM_API_URL template: "ws://{{HOST_MDNS}}:8174" - key: FM_API_URL template: "ws://{{HOST_IP}}:8174" "#; let err = AppManifest::parse(yaml).unwrap_err(); let msg = format!("{err}"); assert!(msg.contains("duplicate key"), "unexpected error: {msg}"); } #[test] fn unknown_derived_placeholder_is_rejected() { let yaml = r#" app: id: fedimint name: Fedimint version: 0.10.0 container: image: fedimintd:v0.10.0 derived_env: - key: FM_API_URL template: "ws://{{HOSTNAME}}:8174" "#; let err = AppManifest::parse(yaml).unwrap_err(); let msg = format!("{err}"); assert!( msg.contains("unknown placeholder"), "unexpected error: {msg}" ); } #[test] fn path_traversal_secret_file_is_rejected() { let yaml = r#" app: id: fedimint name: Fedimint version: 0.10.0 container: image: fedimintd:v0.10.0 secret_env: - key: FM_BITCOIND_PASSWORD secret_file: "../bitcoin-rpc-password" "#; let err = AppManifest::parse(yaml).unwrap_err(); let msg = format!("{err}"); assert!(msg.contains("bare filename"), "unexpected error: {msg}"); } #[test] fn resolve_derived_env_renders_host_facts() { let c = ContainerConfig { image: Some("x:latest".to_string()), image_signature: None, pull_policy: "if-not-present".to_string(), build: None, network: None, custom_args: vec![], entrypoint: None, derived_env: vec![ DerivedEnv { key: "FM_API_URL".to_string(), template: "ws://{{HOST_MDNS}}:8174".to_string(), }, DerivedEnv { key: "INFO".to_string(), template: "{{HOST_IP}}-{{DISK_GB}}".to_string(), }, ], secret_env: vec![], data_uid: None, }; let facts = HostFacts { host_ip: "192.168.1.116".to_string(), host_mdns: "archi-thinkpad.local".to_string(), disk_gb: 2000, }; let out = c.resolve_derived_env(&facts); assert_eq!(out[0], "FM_API_URL=ws://archi-thinkpad.local:8174"); assert_eq!(out[1], "INFO=192.168.1.116-2000"); } struct MapSecretsProvider { data: HashMap, } impl SecretsProvider for MapSecretsProvider { fn read(&self, name: &str) -> Result { self.data .get(name) .cloned() .ok_or_else(|| ManifestError::Invalid(format!("missing secret: {name}"))) } } #[test] fn resolve_secret_env_reads_from_provider() { let c = ContainerConfig { image: Some("x:latest".to_string()), image_signature: None, pull_policy: "if-not-present".to_string(), build: None, network: None, custom_args: vec![], entrypoint: None, derived_env: vec![], secret_env: vec![ SecretEnv { key: "FM_BITCOIND_PASSWORD".to_string(), secret_file: "bitcoin-rpc-password".to_string(), }, SecretEnv { key: "FM_GATEWAY_PASSWORD".to_string(), secret_file: "fedimint-gateway-password".to_string(), }, ], data_uid: None, }; let p = MapSecretsProvider { data: HashMap::from([ ( "bitcoin-rpc-password".to_string(), "supersecret1".to_string(), ), ( "fedimint-gateway-password".to_string(), "supersecret2".to_string(), ), ]), }; let out = c.resolve_secret_env(&p).unwrap(); assert_eq!(out[0], "FM_BITCOIND_PASSWORD=supersecret1"); assert_eq!(out[1], "FM_GATEWAY_PASSWORD=supersecret2"); } #[test] fn resolve_secret_env_rejects_empty_value() { let c = ContainerConfig { image: Some("x:latest".to_string()), image_signature: None, pull_policy: "if-not-present".to_string(), build: None, network: None, custom_args: vec![], entrypoint: None, derived_env: vec![], secret_env: vec![SecretEnv { key: "BITCOIN_RPC_PASS".to_string(), secret_file: "bitcoin-rpc-password".to_string(), }], data_uid: None, }; let p = MapSecretsProvider { data: HashMap::from([("bitcoin-rpc-password".to_string(), " \n".to_string())]), }; let err = c.resolve_secret_env(&p).unwrap_err(); match err { ManifestError::Invalid(msg) => assert!( msg.contains("BITCOIN_RPC_PASS") && msg.contains("bitcoin-rpc-password"), "msg should name the env key + file: {msg}" ), other => panic!("expected Invalid, got {other:?}"), } } #[test] fn unsafe_manifest_values_are_rejected() { let cases = [ ( "bad app id", r#" app: id: Bad_App name: Bad version: 1.0.0 container: image: test/image:latest "#, "app.id", ), ( "unsupported capability", r#" app: id: bad-cap name: Bad version: 1.0.0 container: image: test/image:latest security: capabilities: [SYS_MODULE] "#, "unsupported capability", ), ( "docker socket bind", r#" app: id: bad-bind name: Bad version: 1.0.0 container: image: test/image:latest volumes: - type: bind source: /var/run/docker.sock target: /var/run/docker.sock "#, "reviewed host-bind exception", ), ( "path-like relative bind source", r#" app: id: bad-bind name: Bad version: 1.0.0 container: image: test/image:latest volumes: - type: bind source: data/cache target: /data "#, "absolute for host bind mounts", ), ( "bad environment key", r#" app: id: bad-env name: Bad version: 1.0.0 container: image: test/image:latest environment: - 1BAD=value "#, "invalid key", ), ( "duplicate host port", r#" app: id: bad-port name: Bad version: 1.0.0 container: image: test/image:latest ports: - { host: 8080, container: 80, protocol: tcp } - { host: 8080, container: 81, protocol: tcp } "#, "duplicate host binding", ), ( "bad device", r#" app: id: bad-device name: Bad version: 1.0.0 container: image: test/image:latest devices: - /tmp/fake-device "#, "absolute /dev path", ), ( "container network namespace", r#" app: id: bad-network name: Bad version: 1.0.0 container: image: test/image:latest network: container:host "#, "not allowed", ), ]; for (name, yaml, expected) in cases { let err = AppManifest::parse(yaml).unwrap_err(); let msg = format!("{err}"); assert!( msg.contains(expected), "case {name} expected '{expected}', got: {msg}" ); } } #[test] fn reviewed_host_bind_exceptions_parse() { let yaml = r#" app: id: reviewed-binds name: Reviewed Binds version: 1.0.0 container: image: test/image:latest volumes: - type: bind source: /run/user/1000/podman/podman.sock target: /var/run/docker.sock options: [rw] - type: bind source: /var/run/dbus target: /var/run/dbus options: [ro] "#; AppManifest::parse(yaml).unwrap(); } #[test] fn parse_every_real_manifest() { let app_manifests = list_repo_manifests(); assert!( !app_manifests.is_empty(), "no apps/*/manifest.yml files found" ); let mut failures: Vec = Vec::new(); let mut modern_count = 0usize; for path in app_manifests { let content = fs::read_to_string(&path).expect("read manifest"); let parsed_yaml: serde_yaml::Value = match serde_yaml::from_str(&content) { Ok(v) => v, Err(err) => { failures.push(format!("{}: YAML parse error: {err}", path.display())); continue; } }; let is_modern = parsed_yaml .as_mapping() .map(|m| m.contains_key(serde_yaml::Value::String("app".to_string()))) .unwrap_or(false); if is_modern { modern_count += 1; if let Err(err) = AppManifest::parse(&content) { failures.push(format!("{}: {err}", path.display())); } } else { failures.push(format!( "{}: expected modern app-schema manifest", path.display() )); } } assert!(modern_count > 0, "no modern app-schema manifests found"); assert!( failures.is_empty(), "manifest parse failures:\n{}", failures.join("\n") ); } fn list_repo_manifests() -> Vec { let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join(".."); let apps_dir = repo_root.join("apps"); let mut out = Vec::new(); let Ok(entries) = fs::read_dir(apps_dir) else { return out; }; for entry in entries.flatten() { let path = entry.path(); if !path.is_dir() { continue; } let manifest = path.join("manifest.yml"); if manifest.exists() { out.push(manifest); } } out.sort(); out } }