1use serde::Deserialize;
12
13use super::component_problem;
14use super::exclude::list_problems;
15use super::yamlio::{SchemaVersion, strict_parse};
16use crate::error::{CoreError, DeployFinding};
17
18#[derive(Debug, Clone, PartialEq, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct Target {
22 pub schema_version: SchemaVersion,
24 pub gateway: String,
27 pub mode: String,
30 pub nodes: Nodes,
32 #[serde(default)]
39 pub exclude_tag_providers: Vec<String>,
40}
41
42#[derive(Debug, Clone, PartialEq, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct Nodes {
46 pub master: NodeTarget,
48 #[serde(default)]
50 pub backup: Option<NodeTarget>,
51}
52
53#[derive(Debug, Clone, PartialEq, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct NodeTarget {
57 pub profile: String,
60 #[serde(default)]
62 pub ssh: Option<Ssh>,
63 #[serde(default)]
66 pub local: Option<Local>,
67 #[serde(default)]
72 pub gateway_name: Option<String>,
73}
74
75#[derive(Debug, Clone, PartialEq, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct Local {
79 pub data_dir: String,
82}
83
84#[derive(Debug, Clone, PartialEq, Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct Ssh {
88 pub host: String,
90 #[serde(default)]
92 pub port: Option<u16>,
93 pub user: String,
95 #[serde(default)]
97 pub key_file: Option<String>,
98 #[serde(default)]
101 pub key_env: Option<String>,
102 pub os: TargetOs,
104 pub data_dir: String,
106 pub known_hosts: String,
111 #[serde(default)]
114 pub connect_timeout_s: Option<u32>,
115 #[serde(default)]
118 pub op_timeout_s: Option<u32>,
119}
120
121pub const DEFAULT_CONNECT_TIMEOUT_S: u32 = 10;
124pub const DEFAULT_OP_TIMEOUT_S: u32 = 1050;
130pub const CONNECT_TIMEOUT_RANGE_S: std::ops::RangeInclusive<u32> = 1..=120;
132pub const OP_TIMEOUT_RANGE_S: std::ops::RangeInclusive<u32> = 1..=3600;
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
137#[serde(rename_all = "lowercase")]
138pub enum TargetOs {
139 Windows,
141 Linux,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum Redundancy {
155 Standalone,
157 Pair,
159}
160
161impl Redundancy {
162 pub fn of(target: &Target) -> Self {
164 if target.nodes.backup.is_some() {
165 Redundancy::Pair
166 } else {
167 Redundancy::Standalone
168 }
169 }
170}
171
172impl Target {
173 pub fn parse(file: &str, bytes: &[u8]) -> Result<Target, CoreError> {
175 let target: Target = strict_parse(file, bytes)?;
176 target.validate(file)?;
177 Ok(target)
178 }
179
180 fn validate(&self, file: &str) -> Result<(), CoreError> {
181 let mut findings = Vec::new();
182 let mut push = |message: String| {
183 findings.push(DeployFinding {
184 file: file.to_string(),
185 line: None,
186 column: None,
187 message,
188 });
189 };
190
191 for (field, value) in [("gateway", &self.gateway), ("mode", &self.mode)] {
192 if let Some(why) = component_problem(value) {
193 push(format!("{field} {value:?}: {why}"));
194 }
195 }
196 for problem in list_problems(&self.exclude_tag_providers) {
197 push(problem);
198 }
199 if self.mode == "core" || self.mode == "local" {
203 push(format!(
204 "mode {:?}: names a collection the gateway reserves",
205 self.mode
206 ));
207 }
208 if self.mode == "projects" {
212 push(format!(
213 "mode {:?}: names a directory this tool reserves for project release records",
214 self.mode
215 ));
216 }
217 let backup = self.nodes.backup.as_ref().map(|n| ("nodes.backup", n));
218 for (label, node) in std::iter::once(("nodes.master", &self.nodes.master)).chain(backup) {
219 if node.profile.is_empty() {
220 push(format!("{label}.profile: must not be empty"));
221 }
222 if node.ssh.is_some() && node.local.is_some() {
223 push(format!("{label}: set ssh or local, not both"));
224 }
225 if let Some(local) = &node.local
226 && local.data_dir.is_empty()
227 {
228 push(format!("{label}.local.data_dir: must not be empty"));
229 }
230 if let Some(name) = &node.gateway_name {
231 if name.trim().is_empty() {
232 push(format!("{label}.gateway_name: must not be empty"));
233 } else if name.chars().any(char::is_control) {
234 push(format!(
235 "{label}.gateway_name: must not contain control characters"
236 ));
237 }
238 }
239 let Some(ssh) = &node.ssh else { continue };
240 for (field, value) in [
241 ("host", &ssh.host),
242 ("user", &ssh.user),
243 ("data_dir", &ssh.data_dir),
244 ] {
245 if value.is_empty() {
246 push(format!("{label}.ssh.{field}: must not be empty"));
247 }
248 }
249 if ssh.key_file.is_some() && ssh.key_env.is_some() {
250 push(format!(
251 "{label}.ssh: set key_file or key_env, not both (neither means agent auth)"
252 ));
253 }
254 for (field, value) in [("host", &ssh.host), ("user", &ssh.user)] {
259 if value.is_empty() {
260 continue;
261 }
262 if value.starts_with('-') {
263 push(format!(
264 "{label}.ssh.{field}: must not start with `-` (it would be read as an ssh option)"
265 ));
266 }
267 if value.chars().any(|c| c.is_whitespace() || c.is_control()) {
268 push(format!(
269 "{label}.ssh.{field}: must not contain whitespace or control characters"
270 ));
271 }
272 if value.contains('@') {
273 push(format!("{label}.ssh.{field}: must not contain `@`"));
274 }
275 }
276 if let Some(problem) = known_hosts_problem(&ssh.known_hosts) {
277 push(format!("{label}.ssh.known_hosts: {problem}"));
278 }
279 if let Some(n) = ssh.connect_timeout_s
280 && !CONNECT_TIMEOUT_RANGE_S.contains(&n)
281 {
282 push(format!(
283 "{label}.ssh.connect_timeout_s: {n} is outside {}..={} seconds",
284 CONNECT_TIMEOUT_RANGE_S.start(),
285 CONNECT_TIMEOUT_RANGE_S.end()
286 ));
287 }
288 if let Some(n) = ssh.op_timeout_s
289 && !OP_TIMEOUT_RANGE_S.contains(&n)
290 {
291 push(format!(
292 "{label}.ssh.op_timeout_s: {n} is outside {}..={} seconds",
293 OP_TIMEOUT_RANGE_S.start(),
294 OP_TIMEOUT_RANGE_S.end()
295 ));
296 }
297 }
298
299 if let Some(backup) = &self.nodes.backup {
306 let master = &self.nodes.master;
307 if !backup.profile.is_empty() && backup.profile == master.profile {
308 push(
309 "nodes.backup.profile: names the same profile as nodes.master.profile"
310 .to_string(),
311 );
312 }
313 match (&master.local, &master.ssh, &backup.local, &backup.ssh) {
316 (Some(m), None, Some(b), None)
317 if !m.data_dir.is_empty() && m.data_dir == b.data_dir =>
318 {
319 push(
320 "nodes.backup.local.data_dir: is the same data directory as nodes.master.local.data_dir"
321 .to_string(),
322 );
323 }
324 (None, Some(m), None, Some(b))
325 if !m.host.is_empty()
326 && m.host == b.host
327 && m.port.unwrap_or(22) == b.port.unwrap_or(22)
328 && m.data_dir == b.data_dir =>
329 {
330 push(
331 "nodes.backup.ssh: is the same host, port and data_dir as nodes.master.ssh"
332 .to_string(),
333 );
334 }
335 _ => {}
336 }
337 }
338
339 if findings.is_empty() {
340 Ok(())
341 } else {
342 Err(CoreError::deploy_findings(findings))
343 }
344 }
345}
346
347fn known_hosts_problem(path: &str) -> Option<String> {
353 if path.is_empty() {
354 return Some("must not be empty".to_string());
355 }
356 if path.starts_with('/') {
357 return Some("must be relative to the repo root, not absolute".to_string());
358 }
359 if path.contains('\\') {
360 return Some("must use `/` separators, not `\\`".to_string());
361 }
362 if path.as_bytes().get(1) == Some(&b':') {
363 return Some("must be relative to the repo root, not a drive path".to_string());
364 }
365 for part in path.split('/') {
366 if let Some(why) = component_problem(part) {
367 return Some(format!("component {part:?} {why}"));
368 }
369 }
370 None
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376
377 fn findings(e: CoreError) -> Vec<DeployFinding> {
378 match e {
379 CoreError::DeployTreeInvalid(d) => d.findings,
380 other => panic!("expected deploy_tree_invalid, got {other}"),
381 }
382 }
383
384 #[test]
385 fn full_target_parses() {
386 let yaml = "schema_version: 1\ngateway: plant-a\nmode: prod\nnodes:\n master:\n profile: plant-a-master\n ssh:\n host: 192.0.2.5\n port: 2222\n user: svc\n key_file: /keys/id_ed25519\n os: windows\n data_dir: 'C:\\Program Files\\Inductive Automation\\Ignition\\data'\n known_hosts: ssh/plant-a.known_hosts\n backup:\n profile: plant-a-backup\n ssh:\n host: 192.0.2.6\n user: svc\n key_env: PLANT_A_KEY\n os: linux\n data_dir: /usr/local/bin/ignition/data\n known_hosts: ssh/plant-a.known_hosts\n";
387 let t = Target::parse("targets/prod.yaml", yaml.as_bytes()).expect("parses");
388 assert_eq!((t.gateway.as_str(), t.mode.as_str()), ("plant-a", "prod"));
389 let master = t.nodes.master.ssh.expect("master ssh");
390 assert_eq!(master.port, Some(2222));
391 assert_eq!(master.os, TargetOs::Windows);
392 assert_eq!(master.key_file.as_deref(), Some("/keys/id_ed25519"));
393 assert!(master.data_dir.starts_with("C:\\"));
394 let backup = t.nodes.backup.expect("backup");
395 assert_eq!(backup.profile, "plant-a-backup");
396 let bssh = backup.ssh.expect("backup ssh");
397 assert_eq!((bssh.os, bssh.port), (TargetOs::Linux, None));
398 assert_eq!(bssh.key_env.as_deref(), Some("PLANT_A_KEY"));
399 }
400
401 #[test]
402 fn reserved_collection_modes_are_refused() {
403 for mode in ["core", "local", "projects"] {
409 let yaml = format!(
410 "schema_version: 1\ngateway: g\nmode: {mode}\nnodes:\n master:\n profile: p\n"
411 );
412 let all: Vec<String> =
413 findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err(mode))
414 .into_iter()
415 .map(|f| f.message)
416 .collect();
417 assert_eq!(all.len(), 1, "{all:?}");
418 assert!(
419 all[0].contains(mode) && all[0].contains("reserves"),
420 "{all:?}"
421 );
422 }
423 }
424
425 #[test]
426 fn minimal_target_parses() {
427 let t = Target::parse(
428 "targets/dev.yaml",
429 b"schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n",
430 )
431 .expect("parses");
432 assert_eq!(t.nodes.backup, None);
433 assert_eq!(t.nodes.master.ssh, None);
434 assert_eq!(t.nodes.master.local, None);
435 }
436
437 #[test]
438 fn local_node_parses() {
439 let yaml = "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n local:\n data_dir: /var/ignition/data\n";
440 let t = Target::parse("targets/dev.yaml", yaml.as_bytes()).expect("parses");
441 let local = t.nodes.master.local.expect("local");
442 assert_eq!(local.data_dir, "/var/ignition/data");
443 }
444
445 #[test]
446 fn ssh_and_local_together_are_refused() {
447 let yaml = "schema_version: 1\ngateway: g\nmode: m\nnodes:\n master:\n profile: p\n local:\n data_dir: /d\n backup:\n profile: q\n local:\n data_dir: /d\n ssh:\n host: h\n user: u\n os: linux\n data_dir: /d\n known_hosts: kh\n";
448 let all: Vec<String> =
449 findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("both"))
450 .into_iter()
451 .map(|f| f.message)
452 .collect();
453 assert_eq!(all, vec!["nodes.backup: set ssh or local, not both"]);
454 }
455
456 fn messages(yaml: &str) -> Vec<String> {
457 findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("refused"))
458 .into_iter()
459 .map(|f| f.message)
460 .collect()
461 }
462
463 #[test]
464 fn backup_profile_equal_to_master_profile_is_refused() {
465 let yaml = "schema_version: 1\ngateway: g\nmode: m\nnodes:\n master:\n profile: prod\n backup:\n profile: prod\n";
468 assert_eq!(
469 messages(yaml),
470 vec!["nodes.backup.profile: names the same profile as nodes.master.profile"]
471 );
472 let ok = "schema_version: 1\ngateway: g\nmode: m\nnodes:\n master:\n profile: prod-a\n backup:\n profile: prod-b\n";
474 Target::parse("targets/x.yaml", ok.as_bytes()).expect("distinct profiles parse");
475 }
476
477 #[test]
478 fn backup_on_the_masters_data_dir_is_refused() {
479 let local = "schema_version: 1\ngateway: g\nmode: m\nnodes:\n master:\n profile: a\n local:\n data_dir: /d\n backup:\n profile: b\n local:\n data_dir: /d\n";
480 assert_eq!(
481 messages(local),
482 vec![
483 "nodes.backup.local.data_dir: is the same data directory as nodes.master.local.data_dir"
484 ]
485 );
486 let ssh = |backup_host: &str, backup_port: &str, backup_dir: &str| {
487 format!(
488 "schema_version: 1\ngateway: g\nmode: m\nnodes:\n master:\n profile: a\n ssh:\n host: h1\n user: u\n os: linux\n data_dir: /d\n known_hosts: kh\n backup:\n profile: b\n ssh:\n host: {backup_host}\n {backup_port}\n user: u\n os: linux\n data_dir: {backup_dir}\n known_hosts: kh\n"
489 )
490 };
491 assert_eq!(
492 messages(&ssh("h1", "port: 22", "/d")),
493 vec!["nodes.backup.ssh: is the same host, port and data_dir as nodes.master.ssh"]
494 );
495 Target::parse("targets/x.yaml", ssh("h2", "port: 22", "/d").as_bytes())
497 .expect("other host");
498 Target::parse("targets/x.yaml", ssh("h1", "port: 2222", "/d").as_bytes())
499 .expect("other port");
500 Target::parse("targets/x.yaml", ssh("h1", "port: 22", "/d2").as_bytes())
502 .expect("other data dir");
503 }
504
505 #[test]
506 fn empty_local_data_dir_is_refused() {
507 let yaml = "schema_version: 1\ngateway: g\nmode: m\nnodes:\n master:\n profile: p\n local:\n data_dir: ''\n";
508 let all: Vec<String> =
509 findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("empty"))
510 .into_iter()
511 .map(|f| f.message)
512 .collect();
513 assert_eq!(all, vec!["nodes.master.local.data_dir: must not be empty"]);
514 }
515
516 #[test]
517 fn target_unknown_ssh_field_is_positioned() {
518 let yaml = "schema_version: 1\ngateway: g\nmode: m\nnodes:\n master:\n profile: p\n ssh:\n host: h\n user: u\n os: linux\n data_dir: /d\n bogus: 1\n known_hosts: kh\n";
519 let f = findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("unknown"))
520 .remove(0);
521 assert_eq!((f.line, f.column), (Some(12), Some(7)));
522 assert!(f.message.contains("bogus"), "{}", f.message);
523 }
524
525 #[test]
526 fn target_os_rejects_unknown() {
527 let yaml = "schema_version: 1\ngateway: g\nmode: m\nnodes:\n master:\n profile: p\n ssh:\n host: h\n user: u\n os: macos\n data_dir: /d\n known_hosts: kh\n";
528 let f =
529 findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("os")).remove(0);
530 assert_eq!(f.line, Some(10));
531 assert!(f.column.is_some());
532 assert!(f.message.contains("unknown variant"), "{}", f.message);
533 assert!(!f.message.contains("macos"), "{}", f.message);
534 assert!(
535 f.message.contains("expected `windows` or `linux`"),
536 "{}",
537 f.message
538 );
539 }
540
541 #[test]
542 fn target_semantic_findings() {
543 let yaml = "schema_version: 1\ngateway: ../x\nmode: .hidden\nnodes:\n master:\n profile: ''\n ssh:\n host: h\n user: u\n key_file: /k\n key_env: K\n os: linux\n data_dir: ''\n known_hosts: kh\n";
544 let found =
545 findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("invalid"));
546 let all: Vec<&str> = found.iter().map(|f| f.message.as_str()).collect();
547 let has = |needle: &str| all.iter().any(|m| m.contains(needle));
548 assert!(has("gateway") && has("path separator"), "{all:?}");
549 assert!(has("mode") && has("start with `.`"), "{all:?}");
550 assert!(has("nodes.master.profile"), "{all:?}");
551 assert!(has("key_file or key_env"), "{all:?}");
552 assert!(has("nodes.master.ssh.data_dir"), "{all:?}");
553 }
554
555 #[test]
556 fn gateway_name_is_optional_and_parses_on_either_node() {
557 let none = Target::parse(
558 "targets/dev.yaml",
559 b"schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n",
560 )
561 .expect("parses");
562 assert_eq!(none.nodes.master.gateway_name, None);
563
564 let yaml = "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n gateway_name: plant-a\n backup:\n profile: dev2\n gateway_name: 'plant a backup'\n";
565 let t = Target::parse("targets/dev.yaml", yaml.as_bytes()).expect("parses");
566 assert_eq!(t.nodes.master.gateway_name.as_deref(), Some("plant-a"));
567 assert_eq!(
568 t.nodes.backup.expect("backup").gateway_name.as_deref(),
569 Some("plant a backup")
570 );
571 }
572
573 #[test]
574 fn gateway_name_empty_or_with_control_characters_is_refused() {
575 for (value, label) in [
576 ("''", "nodes.master.gateway_name"),
577 ("' '", "nodes.master.gateway_name"),
578 ("\"a\\nb\"", "nodes.master.gateway_name"),
579 ("\"a\\u001bb\"", "nodes.master.gateway_name"),
580 ] {
581 let yaml = format!(
582 "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n gateway_name: {value}\n"
583 );
584 let all = messages(&yaml);
585 assert!(
586 all.iter().any(|m| m.starts_with(&format!("{label}:"))),
587 "{value}: {all:?}"
588 );
589 }
590 let yaml = "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n backup:\n profile: b\n gateway_name: ''\n";
591 assert!(
592 messages(yaml)
593 .iter()
594 .any(|m| m.starts_with("nodes.backup.gateway_name:"))
595 );
596 }
597
598 fn ssh_yaml(extra: &str) -> String {
599 format!(
600 "schema_version: 1\ngateway: g\nmode: m\nnodes:\n master:\n profile: p\n ssh:\n host: h\n user: u\n os: linux\n data_dir: /d\n{extra}"
601 )
602 }
603
604 #[test]
605 fn target_ssh_fields() {
606 let f = findings(
608 Target::parse("targets/x.yaml", ssh_yaml("").as_bytes()).expect_err("missing"),
609 )
610 .remove(0);
611 assert!(f.message.contains("known_hosts"), "{}", f.message);
612
613 let t = Target::parse(
615 "targets/x.yaml",
616 ssh_yaml(" known_hosts: ssh/kh\n").as_bytes(),
617 )
618 .expect("parses");
619 let ssh = t.nodes.master.ssh.expect("ssh");
620 assert_eq!((ssh.connect_timeout_s, ssh.op_timeout_s), (None, None));
621 assert_eq!(DEFAULT_CONNECT_TIMEOUT_S, 10);
622 assert_eq!(DEFAULT_OP_TIMEOUT_S, 1050);
623 assert!(OP_TIMEOUT_RANGE_S.contains(&DEFAULT_OP_TIMEOUT_S));
624 let t = Target::parse(
625 "targets/x.yaml",
626 ssh_yaml(
627 " known_hosts: ssh/kh\n connect_timeout_s: 120\n op_timeout_s: 1\n",
628 )
629 .as_bytes(),
630 )
631 .expect("edges parse");
632 let ssh = t.nodes.master.ssh.expect("ssh");
633 assert_eq!(
634 (ssh.connect_timeout_s, ssh.op_timeout_s),
635 (Some(120), Some(1))
636 );
637
638 for bad in [
640 "/etc/ssh/kh",
641 "C:/kh",
642 "a\\\\b",
643 "../kh",
644 "a/../kh",
645 "a//kh",
646 ".hidden/kh",
647 "dir/.kh",
648 "''",
649 ] {
650 let all = messages(&ssh_yaml(&format!(" known_hosts: {bad}\n")));
651 assert!(
652 all.iter()
653 .any(|m| m.starts_with("nodes.master.ssh.known_hosts:")),
654 "{bad}: {all:?}"
655 );
656 }
657
658 for (field, value) in [
660 ("connect_timeout_s", 0),
661 ("connect_timeout_s", 121),
662 ("op_timeout_s", 0),
663 ("op_timeout_s", 3601),
664 ] {
665 let all = messages(&ssh_yaml(&format!(
666 " known_hosts: kh\n {field}: {value}\n"
667 )));
668 assert!(
669 all.iter()
670 .any(|m| m.starts_with(&format!("nodes.master.ssh.{field}:"))),
671 "{field}={value}: {all:?}"
672 );
673 }
674 }
675
676 #[test]
677 fn ssh_host_and_user_cannot_be_read_as_options() {
678 let cases = [
679 ("host", "-oProxyCommand=x"),
680 ("user", "-l"),
681 ("user", "a@b"),
682 ("host", "'a b'"),
683 ("host", "\"a\\tb\""),
684 ("user", "'a b'"),
685 ("host", "a@b"),
686 ];
687 for (field, value) in cases {
688 let other = if field == "host" {
689 "user: u"
690 } else {
691 "host: h"
692 };
693 let yaml = format!(
694 "schema_version: 1\ngateway: g\nmode: m\nnodes:\n master:\n profile: p\n ssh:\n {field}: {value}\n {other}\n os: linux\n data_dir: /d\n known_hosts: kh\n"
695 );
696 let all = messages(&yaml);
697 assert!(
698 all.iter()
699 .any(|m| m.starts_with(&format!("nodes.master.ssh.{field}:"))),
700 "{field}={value}: {all:?}"
701 );
702 }
703 }
704
705 const BAD_EXCLUDE_LISTS: [(&str, usize, &str); 6] = [
708 ("[\"\"]", 0, "empty"),
709 ("[\" \"]", 0, "blank"),
710 ("[\" MQTT Engine\"]", 0, "whitespace"),
711 ("[\"a/b\"]", 0, "separator"),
712 ("[\".x\"]", 0, "`.`"),
713 ("[\"MQTT Engine\", \"mqtt engine\"]", 1, "duplicate"),
714 ];
715
716 #[test]
717 fn exclude_tag_providers_is_optional_and_parses() {
718 let base =
719 "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n";
720 let t = Target::parse("targets/dev.yaml", base.as_bytes()).expect("absent");
721 assert!(t.exclude_tag_providers.is_empty());
722 let yaml = format!("{base}exclude_tag_providers: [\"MQTT Engine\"]\n");
723 let t = Target::parse("targets/dev.yaml", yaml.as_bytes()).expect("listed");
724 assert_eq!(t.exclude_tag_providers, ["MQTT Engine"]);
725 }
726
727 #[test]
728 fn exclude_tag_providers_bad_entries_are_refused() {
729 for (list, index, why) in BAD_EXCLUDE_LISTS {
730 let yaml = format!(
731 "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\nexclude_tag_providers: {list}\n"
732 );
733 let all = messages(&yaml);
734 assert_eq!(all.len(), 1, "{list}: {all:?}");
735 assert!(
736 all[0].starts_with(&format!("exclude_tag_providers[{index}] "))
737 && all[0].contains(why),
738 "{list}: {all:?}"
739 );
740 }
741 }
742
743 #[test]
744 fn exclude_tag_provider_misspelled_key_is_refused() {
745 let yaml = "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\nexclude_tag_provider: [x]\n";
746 let f = findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("unknown"))
747 .remove(0);
748 assert_eq!(f.line, Some(7));
749 assert!(f.message.contains("exclude_tag_provider"), "{}", f.message);
750 }
751}