use std::fmt;
use std::path::PathBuf;
use crate::model::Target;
use crate::profile::ProfileProblem;
use crate::syntax::Problem;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("no isoloom.yml in {}", .0.display())]
NotFound(PathBuf),
#[error("both isoloom.yml and isoloom.yaml exist in {}; keep one", .0.display())]
Ambiguous(PathBuf),
#[error("can't read {}: {source}", path.display())]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("{0}")]
Yaml(#[from] serde_yaml_ng::Error),
#[error("{}", Rendered { file, problems })]
Invalid {
file: String,
problems: Vec<Problem>,
},
#[error("`-s {set}`: expected key=value, like -s machines.web.vm.os=ubuntu@24.04")]
SetShape {
set: String,
},
#[error("`-s {set}`: the key is a dotted path, like cloud.aws.region")]
SetKey {
set: String,
},
#[error("`{value}` isn't a valid {what}: {rule}")]
Name {
what: &'static str,
value: String,
rule: &'static str,
},
#[error("unknown OS `{text}`; write `<family>@<version>` with one of: {known}")]
UnknownOs {
text: String,
known: String,
},
#[error("{family} has no version `{version}`; use one of: {known} (`{family}` alone is {family}@{default})")]
UnknownOsVersion {
family: &'static str,
version: String,
known: String,
default: &'static str,
},
#[error("`{{{{ {path} }}}}` isn't a placeholder Isoloom knows: {why}")]
Placeholder {
path: String,
why: &'static str,
},
#[error("a `{{{{` without its `}}}}`")]
Unclosed,
#[error("`{{{{ {reference} }}}}`: {why}")]
NotHere {
reference: crate::syntax::placeholder::Reference,
why: &'static str,
},
#[error("`{0}` isn't a file mode: octal digits up to 0777, like \"0444\"")]
Mode(String),
#[error("`service` and `app` are both given: a machine is a catalogue service or a custom app")]
ServiceAndApp,
#[error("`with` sets a catalogue service's settings: it goes with `service`")]
WithWithoutService,
#[error("`config` is an appliance's: it goes with `appliance`")]
ConfigWithoutAppliance,
#[error("`image` and `build` are both given: a container comes from one")]
ImageAndBuild,
#[error("an appliance comes from its `image` (or `firmware`), not a `build`")]
ApplianceBuild,
#[error("`firmware` is `cisco-dynamips`'s: it goes with `appliance: cisco-dynamips`")]
FirmwareWithoutDynamips,
#[error("an app needs a recipe: `image`, `build`, `steps` or `appliance`")]
NoRecipe,
#[error("`cisco-dynamips` boots its `firmware`; Isoloom builds its image, so no `image`")]
DynamipsImage,
#[error("`cisco-dynamips` needs the `firmware` it boots (the IOS `.bin`)")]
DynamipsFirmware,
#[error("`firmware` is `cisco-dynamips`'s; this appliance runs its `image`")]
ApplianceFirmware,
#[error("an appliance runs its `image` (`vrnetlab/cisco_iol:<version>`)")]
ApplianceImage,
#[error("`test` and `http` are both given: a healthcheck is a `test` or an `http` page")]
TestAndHttp,
#[error("a healthcheck says what to try: `{{ test: <command> }}` or `{{ http: {{ port, path }} }}`")]
NoHealthProbe,
#[error("a file needs one of `source`, `content`, `input` and `secret`")]
NoFileSource,
#[error("`{}` are {} given: keep one source", .0.join("` and `"), if .0.len() == 2 { "both" } else { "all" })]
FileSources(Vec<&'static str>),
#[error("write one of `{{ input: NAME }}`")]
EnvReference,
#[error("`gpus.count`: a whole number or `all`, not {0}")]
GpuCount(String),
#[error("`gpus`: a number, `all` or a mapping, not `{0}`")]
GpusWord(String),
#[error("`{0}`: a device is `host[:guest[:permissions]]`")]
Device(String),
#[error("{}: {source}", path.display())]
Layer {
path: PathBuf,
#[source]
source: serde_yaml_ng::Error,
},
#[error("defaults ({sources}): {source}")]
Defaults {
sources: String,
#[source]
source: serde_yaml_ng::Error,
},
#[error("{0}")]
Images(#[source] serde_yaml_ng::Error),
#[error("profile `{0}`: a profile name is kebab-case (lowercase letters, digits, dashes)")]
ProfileName(String),
#[error("no profile `{name}` ({})", Known { folder, known })]
NoProfile {
name: String,
folder: PathBuf,
known: Vec<String>,
},
#[error("both {}/{name}.yml and {}/{name}.yaml exist; keep one", crate::profile::DIR, crate::profile::DIR)]
TwoProfiles {
name: String,
},
#[error("profile `{name}` ({}): {source}", path.display())]
ProfileYaml {
name: String,
path: PathBuf,
#[source]
source: serde_yaml_ng::Error,
},
#[error("profile `{name}` ({}): `{key}` is a mapping", path.display())]
ProfileMapping {
name: String,
path: PathBuf,
key: &'static str,
},
#[error("profile `{name}` ({}): input `{input}` takes a single value (text, a number or a boolean)", path.display())]
ProfileInput {
name: String,
path: PathBuf,
input: String,
},
#[error("{}", Lines(problems))]
ProfileMismatch {
problems: Vec<ProfileProblem>,
},
#[error("profile `{profile}` runs on `{}`, not `{asked}`: drop the target or choose another profile", target.id())]
ProfileTarget {
profile: String,
target: Target,
asked: String,
},
#[error("an instance number is 1 to {}", crate::instance::MAX)]
InstanceNumber(u8),
#[error("`{0}` is too long for an instance name (at most 55 characters with the suffix)")]
InstanceName(String),
#[error("`{0}` isn't an IPv4 block: write the network's own address, `/` and the prefix length (`10.20.0.0/24`)")]
Cidr(String),
#[error("`{0}` isn't an IPv6 block: write a unique local or global network address and `/64` (`fd00:20::/64`)")]
Cidr6(String),
#[error("machines.{machine}.ports: published port {port} leaves no room for instance {instance} (ports shift by 100 per instance)")]
InstancePort {
machine: String,
port: u16,
instance: u8,
},
#[error("networks.{network}.ipv6.cidr: {cidr} leaves no room for instance {instance}")]
InstanceIpv6 {
network: String,
cidr: String,
instance: u8,
},
}
pub type Result<T, E = Error> = std::result::Result<T, E>;
struct Rendered<'a> {
file: &'a str,
problems: &'a [Problem],
}
impl fmt::Display for Rendered<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (i, p) in self.problems.iter().enumerate() {
if i > 0 {
f.write_str("\n")?;
}
f.write_str(&p.render(self.file))?;
}
Ok(())
}
}
struct Lines<'a, T>(&'a [T]);
impl<T: fmt::Display> fmt::Display for Lines<'_, T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (i, item) in self.0.iter().enumerate() {
if i > 0 {
f.write_str("\n")?;
}
write!(f, "{item}")?;
}
Ok(())
}
}
struct Known<'a> {
folder: &'a PathBuf,
known: &'a [String],
}
impl fmt::Display for Known<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.known.is_empty() {
write!(f, "no profiles in {}", self.folder.display())
} else {
write!(f, "profiles here: {}", self.known.join(", "))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::syntax::{Code, FieldPath};
#[test]
fn a_specs_problems_read_one_after_the_other() {
let problems = vec![
Problem::new(Code::MissingField, FieldPath::new("version"), "a spec says its format's version"),
Problem::new(Code::UnknownField, FieldPath::new("nme"), "`nme` isn't a field here"),
];
let invalid = Error::Invalid {
file: "isoloom.yaml".into(),
problems,
};
assert_eq!(
invalid.to_string(),
"error[missing-field] version\n a spec says its format's version\nerror[unknown-field] nme\n `nme` isn't a field here"
);
}
#[test]
fn a_missing_profile_lists_the_profiles_there_are() {
let missing = |known: &[&str]| Error::NoProfile {
name: "c".into(),
folder: PathBuf::from("/lab/profiles"),
known: known.iter().map(|k| (*k).to_owned()).collect(),
};
assert_eq!(missing(&[]).to_string(), "no profile `c` (no profiles in /lab/profiles)");
assert_eq!(missing(&["a", "b"]).to_string(), "no profile `c` (profiles here: a, b)");
}
#[test]
fn a_profiles_problems_read_one_per_line() {
let problems = vec![
ProfileProblem::Input {
profile: "p".into(),
input: "A".into(),
},
ProfileProblem::Input {
profile: "p".into(),
input: "B".into(),
},
];
assert_eq!(
Error::ProfileMismatch { problems }.to_string(),
"profile `p`: input `A` isn't one of the spec's `inputs`\nprofile `p`: input `B` isn't one of the spec's `inputs`"
);
}
#[test]
fn several_sources_of_one_file_are_named_together() {
assert_eq!(
Error::FileSources(vec!["source", "input"]).to_string(),
"`source` and `input` are both given: keep one source"
);
assert_eq!(
Error::FileSources(vec!["source", "content", "input"]).to_string(),
"`source` and `content` and `input` are all given: keep one source"
);
}
}