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 #[serde(default)]
47 pub read_only_tag_providers: Vec<String>,
48}
49
50#[derive(Debug, Clone, PartialEq, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct Nodes {
54 pub master: NodeTarget,
56 #[serde(default)]
58 pub backup: Option<NodeTarget>,
59}
60
61#[derive(Debug, Clone, PartialEq, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct NodeTarget {
65 pub profile: String,
68 #[serde(default)]
70 pub ssh: Option<Ssh>,
71 #[serde(default)]
74 pub local: Option<Local>,
75 #[serde(default)]
80 pub gateway_name: Option<String>,
81}
82
83#[derive(Debug, Clone, PartialEq, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct Local {
87 pub data_dir: String,
90}
91
92#[derive(Debug, Clone, PartialEq, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct Ssh {
96 pub host: String,
98 #[serde(default)]
100 pub port: Option<u16>,
101 pub user: String,
103 #[serde(default)]
105 pub key_file: Option<String>,
106 #[serde(default)]
109 pub key_env: Option<String>,
110 pub os: TargetOs,
112 pub data_dir: String,
114 pub known_hosts: String,
119 #[serde(default)]
122 pub connect_timeout_s: Option<u32>,
123 #[serde(default)]
126 pub op_timeout_s: Option<u32>,
127}
128
129pub const DEFAULT_CONNECT_TIMEOUT_S: u32 = 10;
132pub const DEFAULT_OP_TIMEOUT_S: u32 = 1050;
138pub const CONNECT_TIMEOUT_RANGE_S: std::ops::RangeInclusive<u32> = 1..=120;
140pub const OP_TIMEOUT_RANGE_S: std::ops::RangeInclusive<u32> = 1..=3600;
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
145#[serde(rename_all = "lowercase")]
146pub enum TargetOs {
147 Windows,
149 Linux,
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum Redundancy {
163 Standalone,
165 Pair,
167}
168
169impl Redundancy {
170 pub fn of(target: &Target) -> Self {
172 if target.nodes.backup.is_some() {
173 Redundancy::Pair
174 } else {
175 Redundancy::Standalone
176 }
177 }
178}
179
180impl Target {
181 pub fn parse(file: &str, bytes: &[u8]) -> Result<Target, CoreError> {
183 let target: Target = strict_parse(file, bytes)?;
184 target.validate(file)?;
185 Ok(target)
186 }
187
188 fn validate(&self, file: &str) -> Result<(), CoreError> {
189 let mut findings = Vec::new();
190 let mut push = |message: String| {
191 findings.push(DeployFinding {
192 file: file.to_string(),
193 line: None,
194 column: None,
195 message,
196 });
197 };
198
199 for (field, value) in [("gateway", &self.gateway), ("mode", &self.mode)] {
200 if let Some(why) = component_problem(value) {
201 push(format!("{field} {value:?}: {why}"));
202 }
203 }
204 for problem in list_problems("exclude_tag_providers", &self.exclude_tag_providers)
205 .into_iter()
206 .chain(list_problems(
207 "read_only_tag_providers",
208 &self.read_only_tag_providers,
209 ))
210 {
211 push(problem);
212 }
213 if self.mode == "core" || self.mode == "local" {
217 push(format!(
218 "mode {:?}: names a collection the gateway reserves",
219 self.mode
220 ));
221 }
222 if self.mode == "projects" {
226 push(format!(
227 "mode {:?}: names a directory this tool reserves for project release records",
228 self.mode
229 ));
230 }
231 if self.mode == super::ledger::TAG_RECORDS_DIR {
235 push(format!(
236 "mode {:?}: names a directory this tool reserves for tag records",
237 self.mode
238 ));
239 }
240 let backup = self.nodes.backup.as_ref().map(|n| ("nodes.backup", n));
241 for (label, node) in std::iter::once(("nodes.master", &self.nodes.master)).chain(backup) {
242 if node.profile.is_empty() {
243 push(format!("{label}.profile: must not be empty"));
244 }
245 if node.ssh.is_some() && node.local.is_some() {
246 push(format!("{label}: set ssh or local, not both"));
247 }
248 if let Some(local) = &node.local
249 && local.data_dir.is_empty()
250 {
251 push(format!("{label}.local.data_dir: must not be empty"));
252 }
253 if let Some(name) = &node.gateway_name {
254 if name.trim().is_empty() {
255 push(format!("{label}.gateway_name: must not be empty"));
256 } else if name.chars().any(char::is_control) {
257 push(format!(
258 "{label}.gateway_name: must not contain control characters"
259 ));
260 }
261 }
262 let Some(ssh) = &node.ssh else { continue };
263 for (field, value) in [
264 ("host", &ssh.host),
265 ("user", &ssh.user),
266 ("data_dir", &ssh.data_dir),
267 ] {
268 if value.is_empty() {
269 push(format!("{label}.ssh.{field}: must not be empty"));
270 }
271 }
272 if ssh.key_file.is_some() && ssh.key_env.is_some() {
273 push(format!(
274 "{label}.ssh: set key_file or key_env, not both (neither means agent auth)"
275 ));
276 }
277 for (field, value) in [("host", &ssh.host), ("user", &ssh.user)] {
282 if value.is_empty() {
283 continue;
284 }
285 if value.starts_with('-') {
286 push(format!(
287 "{label}.ssh.{field}: must not start with `-` (it would be read as an ssh option)"
288 ));
289 }
290 if value.chars().any(|c| c.is_whitespace() || c.is_control()) {
291 push(format!(
292 "{label}.ssh.{field}: must not contain whitespace or control characters"
293 ));
294 }
295 if value.contains('@') {
296 push(format!("{label}.ssh.{field}: must not contain `@`"));
297 }
298 }
299 if let Some(problem) = known_hosts_problem(&ssh.known_hosts) {
300 push(format!("{label}.ssh.known_hosts: {problem}"));
301 }
302 if let Some(n) = ssh.connect_timeout_s
303 && !CONNECT_TIMEOUT_RANGE_S.contains(&n)
304 {
305 push(format!(
306 "{label}.ssh.connect_timeout_s: {n} is outside {}..={} seconds",
307 CONNECT_TIMEOUT_RANGE_S.start(),
308 CONNECT_TIMEOUT_RANGE_S.end()
309 ));
310 }
311 if let Some(n) = ssh.op_timeout_s
312 && !OP_TIMEOUT_RANGE_S.contains(&n)
313 {
314 push(format!(
315 "{label}.ssh.op_timeout_s: {n} is outside {}..={} seconds",
316 OP_TIMEOUT_RANGE_S.start(),
317 OP_TIMEOUT_RANGE_S.end()
318 ));
319 }
320 }
321
322 if let Some(backup) = &self.nodes.backup {
329 let master = &self.nodes.master;
330 if !backup.profile.is_empty() && backup.profile == master.profile {
331 push(
332 "nodes.backup.profile: names the same profile as nodes.master.profile"
333 .to_string(),
334 );
335 }
336 match (&master.local, &master.ssh, &backup.local, &backup.ssh) {
339 (Some(m), None, Some(b), None)
340 if !m.data_dir.is_empty() && m.data_dir == b.data_dir =>
341 {
342 push(
343 "nodes.backup.local.data_dir: is the same data directory as nodes.master.local.data_dir"
344 .to_string(),
345 );
346 }
347 (None, Some(m), None, Some(b))
348 if !m.host.is_empty()
349 && m.host == b.host
350 && m.port.unwrap_or(22) == b.port.unwrap_or(22)
351 && m.data_dir == b.data_dir =>
352 {
353 push(
354 "nodes.backup.ssh: is the same host, port and data_dir as nodes.master.ssh"
355 .to_string(),
356 );
357 }
358 _ => {}
359 }
360 }
361
362 if findings.is_empty() {
363 Ok(())
364 } else {
365 Err(CoreError::deploy_findings(findings))
366 }
367 }
368}
369
370fn known_hosts_problem(path: &str) -> Option<String> {
376 if path.is_empty() {
377 return Some("must not be empty".to_string());
378 }
379 if path.starts_with('/') {
380 return Some("must be relative to the repo root, not absolute".to_string());
381 }
382 if path.contains('\\') {
383 return Some("must use `/` separators, not `\\`".to_string());
384 }
385 if path.as_bytes().get(1) == Some(&b':') {
386 return Some("must be relative to the repo root, not a drive path".to_string());
387 }
388 for part in path.split('/') {
389 if let Some(why) = component_problem(part) {
390 return Some(format!("component {part:?} {why}"));
391 }
392 }
393 None
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 fn findings(e: CoreError) -> Vec<DeployFinding> {
401 match e {
402 CoreError::DeployTreeInvalid(d) => d.findings,
403 other => panic!("expected deploy_tree_invalid, got {other}"),
404 }
405 }
406
407 #[test]
408 fn full_target_parses() {
409 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";
410 let t = Target::parse("targets/prod.yaml", yaml.as_bytes()).expect("parses");
411 assert_eq!((t.gateway.as_str(), t.mode.as_str()), ("plant-a", "prod"));
412 let master = t.nodes.master.ssh.expect("master ssh");
413 assert_eq!(master.port, Some(2222));
414 assert_eq!(master.os, TargetOs::Windows);
415 assert_eq!(master.key_file.as_deref(), Some("/keys/id_ed25519"));
416 assert!(master.data_dir.starts_with("C:\\"));
417 let backup = t.nodes.backup.expect("backup");
418 assert_eq!(backup.profile, "plant-a-backup");
419 let bssh = backup.ssh.expect("backup ssh");
420 assert_eq!((bssh.os, bssh.port), (TargetOs::Linux, None));
421 assert_eq!(bssh.key_env.as_deref(), Some("PLANT_A_KEY"));
422 }
423
424 #[test]
425 fn reserved_collection_modes_are_refused() {
426 for mode in ["core", "local", "projects", "tag-providers"] {
433 let yaml = format!(
434 "schema_version: 1\ngateway: g\nmode: {mode}\nnodes:\n master:\n profile: p\n"
435 );
436 let all: Vec<String> =
437 findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err(mode))
438 .into_iter()
439 .map(|f| f.message)
440 .collect();
441 assert_eq!(all.len(), 1, "{all:?}");
442 assert!(
443 all[0].contains(mode) && all[0].contains("reserves"),
444 "{all:?}"
445 );
446 }
447 }
448
449 #[test]
450 fn minimal_target_parses() {
451 let t = Target::parse(
452 "targets/dev.yaml",
453 b"schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n",
454 )
455 .expect("parses");
456 assert_eq!(t.nodes.backup, None);
457 assert_eq!(t.nodes.master.ssh, None);
458 assert_eq!(t.nodes.master.local, None);
459 }
460
461 #[test]
462 fn local_node_parses() {
463 let yaml = "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n local:\n data_dir: /var/ignition/data\n";
464 let t = Target::parse("targets/dev.yaml", yaml.as_bytes()).expect("parses");
465 let local = t.nodes.master.local.expect("local");
466 assert_eq!(local.data_dir, "/var/ignition/data");
467 }
468
469 #[test]
470 fn ssh_and_local_together_are_refused() {
471 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";
472 let all: Vec<String> =
473 findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("both"))
474 .into_iter()
475 .map(|f| f.message)
476 .collect();
477 assert_eq!(all, vec!["nodes.backup: set ssh or local, not both"]);
478 }
479
480 fn messages(yaml: &str) -> Vec<String> {
481 findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("refused"))
482 .into_iter()
483 .map(|f| f.message)
484 .collect()
485 }
486
487 #[test]
488 fn backup_profile_equal_to_master_profile_is_refused() {
489 let yaml = "schema_version: 1\ngateway: g\nmode: m\nnodes:\n master:\n profile: prod\n backup:\n profile: prod\n";
492 assert_eq!(
493 messages(yaml),
494 vec!["nodes.backup.profile: names the same profile as nodes.master.profile"]
495 );
496 let ok = "schema_version: 1\ngateway: g\nmode: m\nnodes:\n master:\n profile: prod-a\n backup:\n profile: prod-b\n";
498 Target::parse("targets/x.yaml", ok.as_bytes()).expect("distinct profiles parse");
499 }
500
501 #[test]
502 fn backup_on_the_masters_data_dir_is_refused() {
503 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";
504 assert_eq!(
505 messages(local),
506 vec![
507 "nodes.backup.local.data_dir: is the same data directory as nodes.master.local.data_dir"
508 ]
509 );
510 let ssh = |backup_host: &str, backup_port: &str, backup_dir: &str| {
511 format!(
512 "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"
513 )
514 };
515 assert_eq!(
516 messages(&ssh("h1", "port: 22", "/d")),
517 vec!["nodes.backup.ssh: is the same host, port and data_dir as nodes.master.ssh"]
518 );
519 Target::parse("targets/x.yaml", ssh("h2", "port: 22", "/d").as_bytes())
521 .expect("other host");
522 Target::parse("targets/x.yaml", ssh("h1", "port: 2222", "/d").as_bytes())
523 .expect("other port");
524 Target::parse("targets/x.yaml", ssh("h1", "port: 22", "/d2").as_bytes())
526 .expect("other data dir");
527 }
528
529 #[test]
530 fn empty_local_data_dir_is_refused() {
531 let yaml = "schema_version: 1\ngateway: g\nmode: m\nnodes:\n master:\n profile: p\n local:\n data_dir: ''\n";
532 let all: Vec<String> =
533 findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("empty"))
534 .into_iter()
535 .map(|f| f.message)
536 .collect();
537 assert_eq!(all, vec!["nodes.master.local.data_dir: must not be empty"]);
538 }
539
540 #[test]
541 fn target_unknown_ssh_field_is_positioned() {
542 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";
543 let f = findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("unknown"))
544 .remove(0);
545 assert_eq!((f.line, f.column), (Some(12), Some(7)));
546 assert!(f.message.contains("bogus"), "{}", f.message);
547 }
548
549 #[test]
550 fn target_os_rejects_unknown() {
551 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";
552 let f =
553 findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("os")).remove(0);
554 assert_eq!(f.line, Some(10));
555 assert!(f.column.is_some());
556 assert!(f.message.contains("unknown variant"), "{}", f.message);
557 assert!(!f.message.contains("macos"), "{}", f.message);
558 assert!(
559 f.message.contains("expected `windows` or `linux`"),
560 "{}",
561 f.message
562 );
563 }
564
565 #[test]
566 fn target_semantic_findings() {
567 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";
568 let found =
569 findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("invalid"));
570 let all: Vec<&str> = found.iter().map(|f| f.message.as_str()).collect();
571 let has = |needle: &str| all.iter().any(|m| m.contains(needle));
572 assert!(has("gateway") && has("path separator"), "{all:?}");
573 assert!(has("mode") && has("start with `.`"), "{all:?}");
574 assert!(has("nodes.master.profile"), "{all:?}");
575 assert!(has("key_file or key_env"), "{all:?}");
576 assert!(has("nodes.master.ssh.data_dir"), "{all:?}");
577 }
578
579 #[test]
580 fn gateway_name_is_optional_and_parses_on_either_node() {
581 let none = Target::parse(
582 "targets/dev.yaml",
583 b"schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n",
584 )
585 .expect("parses");
586 assert_eq!(none.nodes.master.gateway_name, None);
587
588 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";
589 let t = Target::parse("targets/dev.yaml", yaml.as_bytes()).expect("parses");
590 assert_eq!(t.nodes.master.gateway_name.as_deref(), Some("plant-a"));
591 assert_eq!(
592 t.nodes.backup.expect("backup").gateway_name.as_deref(),
593 Some("plant a backup")
594 );
595 }
596
597 #[test]
598 fn gateway_name_empty_or_with_control_characters_is_refused() {
599 for (value, label) in [
600 ("''", "nodes.master.gateway_name"),
601 ("' '", "nodes.master.gateway_name"),
602 ("\"a\\nb\"", "nodes.master.gateway_name"),
603 ("\"a\\u001bb\"", "nodes.master.gateway_name"),
604 ] {
605 let yaml = format!(
606 "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n gateway_name: {value}\n"
607 );
608 let all = messages(&yaml);
609 assert!(
610 all.iter().any(|m| m.starts_with(&format!("{label}:"))),
611 "{value}: {all:?}"
612 );
613 }
614 let yaml = "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n backup:\n profile: b\n gateway_name: ''\n";
615 assert!(
616 messages(yaml)
617 .iter()
618 .any(|m| m.starts_with("nodes.backup.gateway_name:"))
619 );
620 }
621
622 fn ssh_yaml(extra: &str) -> String {
623 format!(
624 "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}"
625 )
626 }
627
628 #[test]
629 fn target_ssh_fields() {
630 let f = findings(
632 Target::parse("targets/x.yaml", ssh_yaml("").as_bytes()).expect_err("missing"),
633 )
634 .remove(0);
635 assert!(f.message.contains("known_hosts"), "{}", f.message);
636
637 let t = Target::parse(
639 "targets/x.yaml",
640 ssh_yaml(" known_hosts: ssh/kh\n").as_bytes(),
641 )
642 .expect("parses");
643 let ssh = t.nodes.master.ssh.expect("ssh");
644 assert_eq!((ssh.connect_timeout_s, ssh.op_timeout_s), (None, None));
645 assert_eq!(DEFAULT_CONNECT_TIMEOUT_S, 10);
646 assert_eq!(DEFAULT_OP_TIMEOUT_S, 1050);
647 assert!(OP_TIMEOUT_RANGE_S.contains(&DEFAULT_OP_TIMEOUT_S));
648 let t = Target::parse(
649 "targets/x.yaml",
650 ssh_yaml(
651 " known_hosts: ssh/kh\n connect_timeout_s: 120\n op_timeout_s: 1\n",
652 )
653 .as_bytes(),
654 )
655 .expect("edges parse");
656 let ssh = t.nodes.master.ssh.expect("ssh");
657 assert_eq!(
658 (ssh.connect_timeout_s, ssh.op_timeout_s),
659 (Some(120), Some(1))
660 );
661
662 for bad in [
664 "/etc/ssh/kh",
665 "C:/kh",
666 "a\\\\b",
667 "../kh",
668 "a/../kh",
669 "a//kh",
670 ".hidden/kh",
671 "dir/.kh",
672 "''",
673 ] {
674 let all = messages(&ssh_yaml(&format!(" known_hosts: {bad}\n")));
675 assert!(
676 all.iter()
677 .any(|m| m.starts_with("nodes.master.ssh.known_hosts:")),
678 "{bad}: {all:?}"
679 );
680 }
681
682 for (field, value) in [
684 ("connect_timeout_s", 0),
685 ("connect_timeout_s", 121),
686 ("op_timeout_s", 0),
687 ("op_timeout_s", 3601),
688 ] {
689 let all = messages(&ssh_yaml(&format!(
690 " known_hosts: kh\n {field}: {value}\n"
691 )));
692 assert!(
693 all.iter()
694 .any(|m| m.starts_with(&format!("nodes.master.ssh.{field}:"))),
695 "{field}={value}: {all:?}"
696 );
697 }
698 }
699
700 #[test]
701 fn ssh_host_and_user_cannot_be_read_as_options() {
702 let cases = [
703 ("host", "-oProxyCommand=x"),
704 ("user", "-l"),
705 ("user", "a@b"),
706 ("host", "'a b'"),
707 ("host", "\"a\\tb\""),
708 ("user", "'a b'"),
709 ("host", "a@b"),
710 ];
711 for (field, value) in cases {
712 let other = if field == "host" {
713 "user: u"
714 } else {
715 "host: h"
716 };
717 let yaml = format!(
718 "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"
719 );
720 let all = messages(&yaml);
721 assert!(
722 all.iter()
723 .any(|m| m.starts_with(&format!("nodes.master.ssh.{field}:"))),
724 "{field}={value}: {all:?}"
725 );
726 }
727 }
728
729 const BAD_EXCLUDE_LISTS: [(&str, usize, &str); 6] = [
732 ("[\"\"]", 0, "empty"),
733 ("[\" \"]", 0, "blank"),
734 ("[\" MQTT Engine\"]", 0, "whitespace"),
735 ("[\"a/b\"]", 0, "separator"),
736 ("[\".x\"]", 0, "`.`"),
737 ("[\"MQTT Engine\", \"mqtt engine\"]", 1, "duplicate"),
738 ];
739
740 #[test]
741 fn exclude_tag_providers_is_optional_and_parses() {
742 let base =
743 "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n";
744 let t = Target::parse("targets/dev.yaml", base.as_bytes()).expect("absent");
745 assert!(t.exclude_tag_providers.is_empty());
746 let yaml = format!("{base}exclude_tag_providers: [\"MQTT Engine\"]\n");
747 let t = Target::parse("targets/dev.yaml", yaml.as_bytes()).expect("listed");
748 assert_eq!(t.exclude_tag_providers, ["MQTT Engine"]);
749 }
750
751 #[test]
752 fn exclude_tag_providers_bad_entries_are_refused() {
753 for (list, index, why) in BAD_EXCLUDE_LISTS {
754 let yaml = format!(
755 "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\nexclude_tag_providers: {list}\n"
756 );
757 let all = messages(&yaml);
758 assert_eq!(all.len(), 1, "{list}: {all:?}");
759 assert!(
760 all[0].starts_with(&format!("exclude_tag_providers[{index}] "))
761 && all[0].contains(why),
762 "{list}: {all:?}"
763 );
764 }
765 }
766
767 #[test]
770 fn read_only_tag_providers_is_optional_and_parses() {
771 let base =
772 "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n";
773 let t = Target::parse("targets/dev.yaml", base.as_bytes()).expect("absent");
774 assert!(t.read_only_tag_providers.is_empty());
775 let yaml =
776 format!("{base}read_only_tag_providers: [\"Broker Transmission\", EdgeGateway01]\n");
777 let t = Target::parse("targets/dev.yaml", yaml.as_bytes()).expect("listed");
778 assert_eq!(
779 t.read_only_tag_providers,
780 ["Broker Transmission", "EdgeGateway01"]
781 );
782 }
783
784 #[test]
785 fn read_only_tag_providers_bad_entries_are_refused() {
786 for (list, index, why) in BAD_EXCLUDE_LISTS {
787 let yaml = format!(
788 "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\nread_only_tag_providers: {list}\n"
789 );
790 let all = messages(&yaml);
791 assert_eq!(all.len(), 1, "{list}: {all:?}");
792 assert!(
793 all[0].starts_with(&format!("read_only_tag_providers[{index}] "))
794 && all[0].contains(why),
795 "{list}: {all:?}"
796 );
797 }
798 }
799
800 #[test]
801 fn exclude_tag_provider_misspelled_key_is_refused() {
802 let yaml = "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\nexclude_tag_provider: [x]\n";
803 let f = findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("unknown"))
804 .remove(0);
805 assert_eq!(f.line, Some(7));
806 assert!(f.message.contains("exclude_tag_provider"), "{}", f.message);
807 }
808}