karapace/crates/karapace-schema/src/manifest.rs
Marco Allegretti b73fd9eaec schema: include manifest path in read errors
Manifest read failures now include the attempted path, making missing
./karapace.toml errors actionable.
2026-02-23 19:07:11 +01:00

223 lines
5.4 KiB
Rust

use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::{error::Error as StdError, fmt};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ManifestError {
#[error("failed to read manifest file: {0}")]
Io(#[from] std::io::Error),
#[error("failed to parse manifest: {0}")]
ParseToml(#[from] toml::de::Error),
#[error("unsupported manifest_version: {0}, expected 1")]
UnsupportedVersion(u32),
#[error("base.image must not be empty")]
EmptyBaseImage,
#[error("base.image is not pinned: '{0}' (expected http(s)://...)")]
UnpinnedBaseImage(String),
#[error("mount label must not be empty")]
EmptyMountLabel,
#[error("invalid mount declaration for '{label}': '{spec}', expected '<host>:<container>'")]
InvalidMount { label: String, spec: String },
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ManifestV1 {
pub manifest_version: u32,
pub base: BaseSection,
#[serde(default)]
pub system: SystemSection,
#[serde(default)]
pub gui: GuiSection,
#[serde(default)]
pub hardware: HardwareSection,
#[serde(default)]
pub mounts: MountsSection,
#[serde(default)]
pub runtime: RuntimeSection,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct BaseSection {
pub image: String,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct SystemSection {
#[serde(default)]
pub packages: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct GuiSection {
#[serde(default)]
pub apps: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct HardwareSection {
#[serde(default)]
pub gpu: bool,
#[serde(default)]
pub audio: bool,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
pub struct MountsSection {
#[serde(flatten)]
pub entries: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RuntimeSection {
#[serde(default = "default_backend")]
pub backend: String,
#[serde(default)]
pub network_isolation: bool,
#[serde(default)]
pub resource_limits: ResourceLimits,
}
impl Default for RuntimeSection {
fn default() -> Self {
Self {
backend: default_backend(),
network_isolation: false,
resource_limits: ResourceLimits::default(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ResourceLimits {
#[serde(default)]
pub cpu_shares: Option<u64>,
#[serde(default)]
pub memory_limit_mb: Option<u64>,
}
fn default_backend() -> String {
"namespace".to_owned()
}
#[derive(Debug)]
struct ManifestIoWithPath {
path: PathBuf,
source: std::io::Error,
}
impl fmt::Display for ManifestIoWithPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.path.display(), self.source)
}
}
impl StdError for ManifestIoWithPath {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
Some(&self.source)
}
}
pub fn parse_manifest_str(input: &str) -> Result<ManifestV1, ManifestError> {
Ok(toml::from_str(input)?)
}
pub fn parse_manifest_file(path: impl AsRef<Path>) -> Result<ManifestV1, ManifestError> {
let path = path.as_ref().to_path_buf();
let content = fs::read_to_string(&path).map_err(|e| {
let kind = e.kind();
ManifestError::Io(std::io::Error::new(
kind,
ManifestIoWithPath {
path: path.clone(),
source: e,
},
))
})?;
parse_manifest_str(&content)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_full_manifest() {
let input = r#"
manifest_version = 1
[base]
image = "rolling"
[system]
packages = ["clang", "cmake", "git"]
[gui]
apps = ["ide", "debugger"]
[hardware]
gpu = true
audio = true
[mounts]
workspace = "./:/workspace"
[runtime]
backend = "oci"
network_isolation = true
[runtime.resource_limits]
cpu_shares = 1024
memory_limit_mb = 4096
"#;
let manifest = parse_manifest_str(input).expect("should parse");
assert_eq!(manifest.manifest_version, 1);
assert_eq!(manifest.base.image, "rolling");
assert_eq!(manifest.system.packages.len(), 3);
assert_eq!(manifest.runtime.backend, "oci");
assert!(manifest.runtime.network_isolation);
assert_eq!(manifest.runtime.resource_limits.cpu_shares, Some(1024));
}
#[test]
fn parses_minimal_manifest() {
let input = r#"
manifest_version = 1
[base]
image = "rolling"
"#;
let manifest = parse_manifest_str(input).expect("should parse");
assert_eq!(manifest.runtime.backend, "namespace");
assert!(!manifest.runtime.network_isolation);
}
#[test]
fn rejects_unknown_fields() {
let input = r#"
manifest_version = 1
[base]
image = "rolling"
unknown_field = true
"#;
assert!(parse_manifest_str(input).is_err());
}
#[test]
fn rejects_missing_base() {
let input = r"
manifest_version = 1
";
assert!(parse_manifest_str(input).is_err());
}
}