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, // ── 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_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)] 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(), )); } // ── 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(), )); } } // 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 ))); } } // 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.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 ))); } } } 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)?; 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 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 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; let mut legacy_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 { legacy_count += 1; } } assert!(modern_count > 0, "no modern app-schema manifests found"); assert!( legacy_count > 0, "expected at least one legacy manifest shape" ); 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 } }