1use std::fmt;
7use std::path::PathBuf;
8
9use crate::model::Target;
10use crate::profile::ProfileProblem;
11use crate::syntax::Problem;
12
13#[derive(Debug, thiserror::Error)]
15#[non_exhaustive]
16pub enum Error {
17 #[error("no isoloom.yml in {}", .0.display())]
19 NotFound(PathBuf),
20 #[error("both isoloom.yml and isoloom.yaml exist in {}; keep one", .0.display())]
22 Ambiguous(PathBuf),
23 #[error("can't read {}: {source}", path.display())]
25 Read {
26 path: PathBuf,
28 #[source]
30 source: std::io::Error,
31 },
32 #[error("{0}")]
35 Yaml(#[from] serde_yaml_ng::Error),
36 #[error("{}", Rendered { file, problems })]
38 Invalid {
39 file: String,
41 problems: Vec<Problem>,
43 },
44 #[error("`-s {set}`: expected key=value, like -s machines.web.vm.os=ubuntu@24.04")]
46 SetShape {
47 set: String,
49 },
50 #[error("`-s {set}`: the key is a dotted path, like cloud.aws.region")]
52 SetKey {
53 set: String,
55 },
56 #[error("`{value}` isn't a valid {what}: {rule}")]
58 Name {
59 what: &'static str,
61 value: String,
63 rule: &'static str,
65 },
66 #[error("unknown OS `{text}`; write `<family>@<version>` with one of: {known}")]
68 UnknownOs {
69 text: String,
71 known: String,
73 },
74 #[error("{family} has no version `{version}`; use one of: {known} (`{family}` alone is {family}@{default})")]
76 UnknownOsVersion {
77 family: &'static str,
79 version: String,
81 known: String,
83 default: &'static str,
85 },
86 #[error("`{{{{ {path} }}}}` isn't a placeholder Isoloom knows: {why}")]
88 Placeholder {
89 path: String,
91 why: &'static str,
93 },
94 #[error("a `{{{{` without its `}}}}`")]
96 Unclosed,
97 #[error("`{{{{ {reference} }}}}`: {why}")]
99 NotHere {
100 reference: crate::syntax::placeholder::Reference,
102 why: &'static str,
104 },
105 #[error("`{0}` isn't a file mode: octal digits up to 0777, like \"0444\"")]
107 Mode(String),
108 #[error("`service` and `app` are both given: a machine is a catalogue service or a custom app")]
110 ServiceAndApp,
111 #[error("`with` sets a catalogue service's settings: it goes with `service`")]
113 WithWithoutService,
114 #[error("`config` is an appliance's: it goes with `appliance`")]
116 ConfigWithoutAppliance,
117 #[error("`image` and `build` are both given: a container comes from one")]
119 ImageAndBuild,
120 #[error("an appliance comes from its `image` (or `firmware`), not a `build`")]
122 ApplianceBuild,
123 #[error("`firmware` is `cisco-dynamips`'s: it goes with `appliance: cisco-dynamips`")]
125 FirmwareWithoutDynamips,
126 #[error("an app needs a recipe: `image`, `build`, `steps` or `appliance`")]
128 NoRecipe,
129 #[error("`cisco-dynamips` boots its `firmware`; Isoloom builds its image, so no `image`")]
131 DynamipsImage,
132 #[error("`cisco-dynamips` needs the `firmware` it boots (the IOS `.bin`)")]
134 DynamipsFirmware,
135 #[error("`firmware` is `cisco-dynamips`'s; this appliance runs its `image`")]
137 ApplianceFirmware,
138 #[error("an appliance runs its `image` (`vrnetlab/cisco_iol:<version>`)")]
140 ApplianceImage,
141 #[error("`test` and `http` are both given: a healthcheck is a `test` or an `http` page")]
143 TestAndHttp,
144 #[error("a healthcheck says what to try: `{{ test: <command> }}` or `{{ http: {{ port, path }} }}`")]
146 NoHealthProbe,
147 #[error("a file needs one of `source`, `content`, `input` and `secret`")]
149 NoFileSource,
150 #[error("`{}` are {} given: keep one source", .0.join("` and `"), if .0.len() == 2 { "both" } else { "all" })]
152 FileSources(Vec<&'static str>),
153 #[error("write one of `{{ input: NAME }}`")]
155 EnvReference,
156 #[error("`gpus.count`: a whole number or `all`, not {0}")]
158 GpuCount(String),
159 #[error("`gpus`: a number, `all` or a mapping, not `{0}`")]
161 GpusWord(String),
162 #[error("`{0}`: a device is `host[:guest[:permissions]]`")]
164 Device(String),
165 #[error("{}: {source}", path.display())]
167 Layer {
168 path: PathBuf,
170 #[source]
172 source: serde_yaml_ng::Error,
173 },
174 #[error("defaults ({sources}): {source}")]
176 Defaults {
177 sources: String,
179 #[source]
181 source: serde_yaml_ng::Error,
182 },
183 #[error("{0}")]
185 Images(#[source] serde_yaml_ng::Error),
186 #[error("profile `{0}`: a profile name is kebab-case (lowercase letters, digits, dashes)")]
188 ProfileName(String),
189 #[error("no profile `{name}` ({})", Known { folder, known })]
191 NoProfile {
192 name: String,
194 folder: PathBuf,
196 known: Vec<String>,
198 },
199 #[error("both {}/{name}.yml and {}/{name}.yaml exist; keep one", crate::profile::DIR, crate::profile::DIR)]
201 TwoProfiles {
202 name: String,
204 },
205 #[error("profile `{name}` ({}): {source}", path.display())]
207 ProfileYaml {
208 name: String,
210 path: PathBuf,
212 #[source]
214 source: serde_yaml_ng::Error,
215 },
216 #[error("profile `{name}` ({}): `{key}` is a mapping", path.display())]
218 ProfileMapping {
219 name: String,
221 path: PathBuf,
223 key: &'static str,
225 },
226 #[error("profile `{name}` ({}): input `{input}` takes a single value (text, a number or a boolean)", path.display())]
228 ProfileInput {
229 name: String,
231 path: PathBuf,
233 input: String,
235 },
236 #[error("{}", Lines(problems))]
238 ProfileMismatch {
239 problems: Vec<ProfileProblem>,
241 },
242 #[error("profile `{profile}` runs on `{}`, not `{asked}`: drop the target or choose another profile", target.id())]
244 ProfileTarget {
245 profile: String,
247 target: Target,
249 asked: String,
251 },
252 #[error("an instance number is 1 to {}", crate::instance::MAX)]
254 InstanceNumber(u8),
255 #[error("`{0}` is too long for an instance name (at most 55 characters with the suffix)")]
257 InstanceName(String),
258 #[error("`{0}` isn't an IPv4 block: write the network's own address, `/` and the prefix length (`10.20.0.0/24`)")]
260 Cidr(String),
261 #[error("`{0}` isn't an IPv6 block: write a unique local or global network address and `/64` (`fd00:20::/64`)")]
263 Cidr6(String),
264 #[error("machines.{machine}.ports: published port {port} leaves no room for instance {instance} (ports shift by 100 per instance)")]
266 InstancePort {
267 machine: String,
269 port: u16,
271 instance: u8,
273 },
274 #[error("networks.{network}.ipv6.cidr: {cidr} leaves no room for instance {instance}")]
276 InstanceIpv6 {
277 network: String,
279 cidr: String,
281 instance: u8,
283 },
284}
285
286pub type Result<T, E = Error> = std::result::Result<T, E>;
288
289struct Rendered<'a> {
291 file: &'a str,
292 problems: &'a [Problem],
293}
294
295impl fmt::Display for Rendered<'_> {
296 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
297 for (i, p) in self.problems.iter().enumerate() {
298 if i > 0 {
299 f.write_str("\n")?;
300 }
301 f.write_str(&p.render(self.file))?;
302 }
303 Ok(())
304 }
305}
306
307struct Lines<'a, T>(&'a [T]);
309
310impl<T: fmt::Display> fmt::Display for Lines<'_, T> {
311 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
312 for (i, item) in self.0.iter().enumerate() {
313 if i > 0 {
314 f.write_str("\n")?;
315 }
316 write!(f, "{item}")?;
317 }
318 Ok(())
319 }
320}
321
322struct Known<'a> {
324 folder: &'a PathBuf,
325 known: &'a [String],
326}
327
328impl fmt::Display for Known<'_> {
329 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
330 if self.known.is_empty() {
331 write!(f, "no profiles in {}", self.folder.display())
332 } else {
333 write!(f, "profiles here: {}", self.known.join(", "))
334 }
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use crate::syntax::{Code, FieldPath};
342
343 #[test]
344 fn a_specs_problems_read_one_after_the_other() {
345 let problems = vec![
346 Problem::new(Code::MissingField, FieldPath::new("version"), "a spec says its format's version"),
347 Problem::new(Code::UnknownField, FieldPath::new("nme"), "`nme` isn't a field here"),
348 ];
349 let invalid = Error::Invalid {
350 file: "isoloom.yaml".into(),
351 problems,
352 };
353 assert_eq!(
354 invalid.to_string(),
355 "error[missing-field] version\n a spec says its format's version\nerror[unknown-field] nme\n `nme` isn't a field here"
356 );
357 }
358
359 #[test]
360 fn a_missing_profile_lists_the_profiles_there_are() {
361 let missing = |known: &[&str]| Error::NoProfile {
362 name: "c".into(),
363 folder: PathBuf::from("/lab/profiles"),
364 known: known.iter().map(|k| (*k).to_owned()).collect(),
365 };
366 assert_eq!(missing(&[]).to_string(), "no profile `c` (no profiles in /lab/profiles)");
367 assert_eq!(missing(&["a", "b"]).to_string(), "no profile `c` (profiles here: a, b)");
368 }
369
370 #[test]
371 fn a_profiles_problems_read_one_per_line() {
372 let problems = vec![
373 ProfileProblem::Input {
374 profile: "p".into(),
375 input: "A".into(),
376 },
377 ProfileProblem::Input {
378 profile: "p".into(),
379 input: "B".into(),
380 },
381 ];
382 assert_eq!(
383 Error::ProfileMismatch { problems }.to_string(),
384 "profile `p`: input `A` isn't one of the spec's `inputs`\nprofile `p`: input `B` isn't one of the spec's `inputs`"
385 );
386 }
387
388 #[test]
389 fn several_sources_of_one_file_are_named_together() {
390 assert_eq!(
391 Error::FileSources(vec!["source", "input"]).to_string(),
392 "`source` and `input` are both given: keep one source"
393 );
394 assert_eq!(
395 Error::FileSources(vec!["source", "content", "input"]).to_string(),
396 "`source` and `content` and `input` are all given: keep one source"
397 );
398 }
399}