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
145impl Target {
146 pub fn parse(file: &str, bytes: &[u8]) -> Result<Target, CoreError> {
148 let target: Target = strict_parse(file, bytes)?;
149 target.validate(file)?;
150 Ok(target)
151 }
152
153 fn validate(&self, file: &str) -> Result<(), CoreError> {
154 let mut findings = Vec::new();
155 let mut push = |message: String| {
156 findings.push(DeployFinding {
157 file: file.to_string(),
158 line: None,
159 column: None,
160 message,
161 });
162 };
163
164 for (field, value) in [("gateway", &self.gateway), ("mode", &self.mode)] {
165 if let Some(why) = component_problem(value) {
166 push(format!("{field} {value:?}: {why}"));
167 }
168 }
169 for problem in list_problems(&self.exclude_tag_providers) {
170 push(problem);
171 }
172 if self.mode == "core" || self.mode == "local" {
176 push(format!(
177 "mode {:?}: names a collection the gateway reserves",
178 self.mode
179 ));
180 }
181 if self.mode == "projects" {
185 push(format!(
186 "mode {:?}: names a directory this tool reserves for project release records",
187 self.mode
188 ));
189 }
190 let backup = self.nodes.backup.as_ref().map(|n| ("nodes.backup", n));
191 for (label, node) in std::iter::once(("nodes.master", &self.nodes.master)).chain(backup) {
192 if node.profile.is_empty() {
193 push(format!("{label}.profile: must not be empty"));
194 }
195 if node.ssh.is_some() && node.local.is_some() {
196 push(format!("{label}: set ssh or local, not both"));
197 }
198 if let Some(local) = &node.local
199 && local.data_dir.is_empty()
200 {
201 push(format!("{label}.local.data_dir: must not be empty"));
202 }
203 if let Some(name) = &node.gateway_name {
204 if name.trim().is_empty() {
205 push(format!("{label}.gateway_name: must not be empty"));
206 } else if name.chars().any(char::is_control) {
207 push(format!(
208 "{label}.gateway_name: must not contain control characters"
209 ));
210 }
211 }
212 let Some(ssh) = &node.ssh else { continue };
213 for (field, value) in [
214 ("host", &ssh.host),
215 ("user", &ssh.user),
216 ("data_dir", &ssh.data_dir),
217 ] {
218 if value.is_empty() {
219 push(format!("{label}.ssh.{field}: must not be empty"));
220 }
221 }
222 if ssh.key_file.is_some() && ssh.key_env.is_some() {
223 push(format!(
224 "{label}.ssh: set key_file or key_env, not both (neither means agent auth)"
225 ));
226 }
227 for (field, value) in [("host", &ssh.host), ("user", &ssh.user)] {
232 if value.is_empty() {
233 continue;
234 }
235 if value.starts_with('-') {
236 push(format!(
237 "{label}.ssh.{field}: must not start with `-` (it would be read as an ssh option)"
238 ));
239 }
240 if value.chars().any(|c| c.is_whitespace() || c.is_control()) {
241 push(format!(
242 "{label}.ssh.{field}: must not contain whitespace or control characters"
243 ));
244 }
245 if value.contains('@') {
246 push(format!("{label}.ssh.{field}: must not contain `@`"));
247 }
248 }
249 if let Some(problem) = known_hosts_problem(&ssh.known_hosts) {
250 push(format!("{label}.ssh.known_hosts: {problem}"));
251 }
252 if let Some(n) = ssh.connect_timeout_s
253 && !CONNECT_TIMEOUT_RANGE_S.contains(&n)
254 {
255 push(format!(
256 "{label}.ssh.connect_timeout_s: {n} is outside {}..={} seconds",
257 CONNECT_TIMEOUT_RANGE_S.start(),
258 CONNECT_TIMEOUT_RANGE_S.end()
259 ));
260 }
261 if let Some(n) = ssh.op_timeout_s
262 && !OP_TIMEOUT_RANGE_S.contains(&n)
263 {
264 push(format!(
265 "{label}.ssh.op_timeout_s: {n} is outside {}..={} seconds",
266 OP_TIMEOUT_RANGE_S.start(),
267 OP_TIMEOUT_RANGE_S.end()
268 ));
269 }
270 }
271
272 if let Some(backup) = &self.nodes.backup {
279 let master = &self.nodes.master;
280 if !backup.profile.is_empty() && backup.profile == master.profile {
281 push(
282 "nodes.backup.profile: names the same profile as nodes.master.profile"
283 .to_string(),
284 );
285 }
286 match (&master.local, &master.ssh, &backup.local, &backup.ssh) {
289 (Some(m), None, Some(b), None)
290 if !m.data_dir.is_empty() && m.data_dir == b.data_dir =>
291 {
292 push(
293 "nodes.backup.local.data_dir: is the same data directory as nodes.master.local.data_dir"
294 .to_string(),
295 );
296 }
297 (None, Some(m), None, Some(b))
298 if !m.host.is_empty()
299 && m.host == b.host
300 && m.port.unwrap_or(22) == b.port.unwrap_or(22)
301 && m.data_dir == b.data_dir =>
302 {
303 push(
304 "nodes.backup.ssh: is the same host, port and data_dir as nodes.master.ssh"
305 .to_string(),
306 );
307 }
308 _ => {}
309 }
310 }
311
312 if findings.is_empty() {
313 Ok(())
314 } else {
315 Err(CoreError::deploy_findings(findings))
316 }
317 }
318}
319
320fn known_hosts_problem(path: &str) -> Option<String> {
326 if path.is_empty() {
327 return Some("must not be empty".to_string());
328 }
329 if path.starts_with('/') {
330 return Some("must be relative to the repo root, not absolute".to_string());
331 }
332 if path.contains('\\') {
333 return Some("must use `/` separators, not `\\`".to_string());
334 }
335 if path.as_bytes().get(1) == Some(&b':') {
336 return Some("must be relative to the repo root, not a drive path".to_string());
337 }
338 for part in path.split('/') {
339 if let Some(why) = component_problem(part) {
340 return Some(format!("component {part:?} {why}"));
341 }
342 }
343 None
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 fn findings(e: CoreError) -> Vec<DeployFinding> {
351 match e {
352 CoreError::DeployTreeInvalid(d) => d.findings,
353 other => panic!("expected deploy_tree_invalid, got {other}"),
354 }
355 }
356
357 #[test]
358 fn full_target_parses() {
359 let yaml = "schema_version: 1\ngateway: plant-a\nmode: prod\nnodes:\n master:\n profile: plant-a-master\n ssh:\n host: 10.0.0.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: 10.0.0.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";
360 let t = Target::parse("targets/prod.yaml", yaml.as_bytes()).expect("parses");
361 assert_eq!((t.gateway.as_str(), t.mode.as_str()), ("plant-a", "prod"));
362 let master = t.nodes.master.ssh.expect("master ssh");
363 assert_eq!(master.port, Some(2222));
364 assert_eq!(master.os, TargetOs::Windows);
365 assert_eq!(master.key_file.as_deref(), Some("/keys/id_ed25519"));
366 assert!(master.data_dir.starts_with("C:\\"));
367 let backup = t.nodes.backup.expect("backup");
368 assert_eq!(backup.profile, "plant-a-backup");
369 let bssh = backup.ssh.expect("backup ssh");
370 assert_eq!((bssh.os, bssh.port), (TargetOs::Linux, None));
371 assert_eq!(bssh.key_env.as_deref(), Some("PLANT_A_KEY"));
372 }
373
374 #[test]
375 fn reserved_collection_modes_are_refused() {
376 for mode in ["core", "local", "projects"] {
382 let yaml = format!(
383 "schema_version: 1\ngateway: g\nmode: {mode}\nnodes:\n master:\n profile: p\n"
384 );
385 let all: Vec<String> =
386 findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err(mode))
387 .into_iter()
388 .map(|f| f.message)
389 .collect();
390 assert_eq!(all.len(), 1, "{all:?}");
391 assert!(
392 all[0].contains(mode) && all[0].contains("reserves"),
393 "{all:?}"
394 );
395 }
396 }
397
398 #[test]
399 fn minimal_target_parses() {
400 let t = Target::parse(
401 "targets/dev.yaml",
402 b"schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n",
403 )
404 .expect("parses");
405 assert_eq!(t.nodes.backup, None);
406 assert_eq!(t.nodes.master.ssh, None);
407 assert_eq!(t.nodes.master.local, None);
408 }
409
410 #[test]
411 fn local_node_parses() {
412 let yaml = "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n local:\n data_dir: /var/ignition/data\n";
413 let t = Target::parse("targets/dev.yaml", yaml.as_bytes()).expect("parses");
414 let local = t.nodes.master.local.expect("local");
415 assert_eq!(local.data_dir, "/var/ignition/data");
416 }
417
418 #[test]
419 fn ssh_and_local_together_are_refused() {
420 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";
421 let all: Vec<String> =
422 findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("both"))
423 .into_iter()
424 .map(|f| f.message)
425 .collect();
426 assert_eq!(all, vec!["nodes.backup: set ssh or local, not both"]);
427 }
428
429 fn messages(yaml: &str) -> Vec<String> {
430 findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("refused"))
431 .into_iter()
432 .map(|f| f.message)
433 .collect()
434 }
435
436 #[test]
437 fn backup_profile_equal_to_master_profile_is_refused() {
438 let yaml = "schema_version: 1\ngateway: g\nmode: m\nnodes:\n master:\n profile: prod\n backup:\n profile: prod\n";
441 assert_eq!(
442 messages(yaml),
443 vec!["nodes.backup.profile: names the same profile as nodes.master.profile"]
444 );
445 let ok = "schema_version: 1\ngateway: g\nmode: m\nnodes:\n master:\n profile: prod-a\n backup:\n profile: prod-b\n";
447 Target::parse("targets/x.yaml", ok.as_bytes()).expect("distinct profiles parse");
448 }
449
450 #[test]
451 fn backup_on_the_masters_data_dir_is_refused() {
452 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";
453 assert_eq!(
454 messages(local),
455 vec![
456 "nodes.backup.local.data_dir: is the same data directory as nodes.master.local.data_dir"
457 ]
458 );
459 let ssh = |backup_host: &str, backup_port: &str, backup_dir: &str| {
460 format!(
461 "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"
462 )
463 };
464 assert_eq!(
465 messages(&ssh("h1", "port: 22", "/d")),
466 vec!["nodes.backup.ssh: is the same host, port and data_dir as nodes.master.ssh"]
467 );
468 Target::parse("targets/x.yaml", ssh("h2", "port: 22", "/d").as_bytes())
470 .expect("other host");
471 Target::parse("targets/x.yaml", ssh("h1", "port: 2222", "/d").as_bytes())
472 .expect("other port");
473 Target::parse("targets/x.yaml", ssh("h1", "port: 22", "/d2").as_bytes())
475 .expect("other data dir");
476 }
477
478 #[test]
479 fn empty_local_data_dir_is_refused() {
480 let yaml = "schema_version: 1\ngateway: g\nmode: m\nnodes:\n master:\n profile: p\n local:\n data_dir: ''\n";
481 let all: Vec<String> =
482 findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("empty"))
483 .into_iter()
484 .map(|f| f.message)
485 .collect();
486 assert_eq!(all, vec!["nodes.master.local.data_dir: must not be empty"]);
487 }
488
489 #[test]
490 fn target_unknown_ssh_field_is_positioned() {
491 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";
492 let f = findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("unknown"))
493 .remove(0);
494 assert_eq!((f.line, f.column), (Some(12), Some(7)));
495 assert!(f.message.contains("bogus"), "{}", f.message);
496 }
497
498 #[test]
499 fn target_os_rejects_unknown() {
500 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";
501 let f =
502 findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("os")).remove(0);
503 assert_eq!(f.line, Some(10));
504 assert!(f.column.is_some());
505 assert!(f.message.contains("unknown variant"), "{}", f.message);
506 assert!(!f.message.contains("macos"), "{}", f.message);
507 assert!(
508 f.message.contains("expected `windows` or `linux`"),
509 "{}",
510 f.message
511 );
512 }
513
514 #[test]
515 fn target_semantic_findings() {
516 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";
517 let found =
518 findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("invalid"));
519 let all: Vec<&str> = found.iter().map(|f| f.message.as_str()).collect();
520 let has = |needle: &str| all.iter().any(|m| m.contains(needle));
521 assert!(has("gateway") && has("path separator"), "{all:?}");
522 assert!(has("mode") && has("start with `.`"), "{all:?}");
523 assert!(has("nodes.master.profile"), "{all:?}");
524 assert!(has("key_file or key_env"), "{all:?}");
525 assert!(has("nodes.master.ssh.data_dir"), "{all:?}");
526 }
527
528 #[test]
529 fn gateway_name_is_optional_and_parses_on_either_node() {
530 let none = Target::parse(
531 "targets/dev.yaml",
532 b"schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n",
533 )
534 .expect("parses");
535 assert_eq!(none.nodes.master.gateway_name, None);
536
537 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";
538 let t = Target::parse("targets/dev.yaml", yaml.as_bytes()).expect("parses");
539 assert_eq!(t.nodes.master.gateway_name.as_deref(), Some("plant-a"));
540 assert_eq!(
541 t.nodes.backup.expect("backup").gateway_name.as_deref(),
542 Some("plant a backup")
543 );
544 }
545
546 #[test]
547 fn gateway_name_empty_or_with_control_characters_is_refused() {
548 for (value, label) in [
549 ("''", "nodes.master.gateway_name"),
550 ("' '", "nodes.master.gateway_name"),
551 ("\"a\\nb\"", "nodes.master.gateway_name"),
552 ("\"a\\u001bb\"", "nodes.master.gateway_name"),
553 ] {
554 let yaml = format!(
555 "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n gateway_name: {value}\n"
556 );
557 let all = messages(&yaml);
558 assert!(
559 all.iter().any(|m| m.starts_with(&format!("{label}:"))),
560 "{value}: {all:?}"
561 );
562 }
563 let yaml = "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n backup:\n profile: b\n gateway_name: ''\n";
564 assert!(
565 messages(yaml)
566 .iter()
567 .any(|m| m.starts_with("nodes.backup.gateway_name:"))
568 );
569 }
570
571 fn ssh_yaml(extra: &str) -> String {
572 format!(
573 "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}"
574 )
575 }
576
577 #[test]
578 fn target_ssh_fields() {
579 let f = findings(
581 Target::parse("targets/x.yaml", ssh_yaml("").as_bytes()).expect_err("missing"),
582 )
583 .remove(0);
584 assert!(f.message.contains("known_hosts"), "{}", f.message);
585
586 let t = Target::parse(
588 "targets/x.yaml",
589 ssh_yaml(" known_hosts: ssh/kh\n").as_bytes(),
590 )
591 .expect("parses");
592 let ssh = t.nodes.master.ssh.expect("ssh");
593 assert_eq!((ssh.connect_timeout_s, ssh.op_timeout_s), (None, None));
594 assert_eq!(DEFAULT_CONNECT_TIMEOUT_S, 10);
595 assert_eq!(DEFAULT_OP_TIMEOUT_S, 1050);
596 assert!(OP_TIMEOUT_RANGE_S.contains(&DEFAULT_OP_TIMEOUT_S));
597 let t = Target::parse(
598 "targets/x.yaml",
599 ssh_yaml(
600 " known_hosts: ssh/kh\n connect_timeout_s: 120\n op_timeout_s: 1\n",
601 )
602 .as_bytes(),
603 )
604 .expect("edges parse");
605 let ssh = t.nodes.master.ssh.expect("ssh");
606 assert_eq!(
607 (ssh.connect_timeout_s, ssh.op_timeout_s),
608 (Some(120), Some(1))
609 );
610
611 for bad in [
613 "/etc/ssh/kh",
614 "C:/kh",
615 "a\\\\b",
616 "../kh",
617 "a/../kh",
618 "a//kh",
619 ".hidden/kh",
620 "dir/.kh",
621 "''",
622 ] {
623 let all = messages(&ssh_yaml(&format!(" known_hosts: {bad}\n")));
624 assert!(
625 all.iter()
626 .any(|m| m.starts_with("nodes.master.ssh.known_hosts:")),
627 "{bad}: {all:?}"
628 );
629 }
630
631 for (field, value) in [
633 ("connect_timeout_s", 0),
634 ("connect_timeout_s", 121),
635 ("op_timeout_s", 0),
636 ("op_timeout_s", 3601),
637 ] {
638 let all = messages(&ssh_yaml(&format!(
639 " known_hosts: kh\n {field}: {value}\n"
640 )));
641 assert!(
642 all.iter()
643 .any(|m| m.starts_with(&format!("nodes.master.ssh.{field}:"))),
644 "{field}={value}: {all:?}"
645 );
646 }
647 }
648
649 #[test]
650 fn ssh_host_and_user_cannot_be_read_as_options() {
651 let cases = [
652 ("host", "-oProxyCommand=x"),
653 ("user", "-l"),
654 ("user", "a@b"),
655 ("host", "'a b'"),
656 ("host", "\"a\\tb\""),
657 ("user", "'a b'"),
658 ("host", "a@b"),
659 ];
660 for (field, value) in cases {
661 let other = if field == "host" {
662 "user: u"
663 } else {
664 "host: h"
665 };
666 let yaml = format!(
667 "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"
668 );
669 let all = messages(&yaml);
670 assert!(
671 all.iter()
672 .any(|m| m.starts_with(&format!("nodes.master.ssh.{field}:"))),
673 "{field}={value}: {all:?}"
674 );
675 }
676 }
677
678 const BAD_EXCLUDE_LISTS: [(&str, usize, &str); 6] = [
681 ("[\"\"]", 0, "empty"),
682 ("[\" \"]", 0, "blank"),
683 ("[\" MQTT Engine\"]", 0, "whitespace"),
684 ("[\"a/b\"]", 0, "separator"),
685 ("[\".x\"]", 0, "`.`"),
686 ("[\"MQTT Engine\", \"mqtt engine\"]", 1, "duplicate"),
687 ];
688
689 #[test]
690 fn exclude_tag_providers_is_optional_and_parses() {
691 let base =
692 "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n";
693 let t = Target::parse("targets/dev.yaml", base.as_bytes()).expect("absent");
694 assert!(t.exclude_tag_providers.is_empty());
695 let yaml = format!("{base}exclude_tag_providers: [\"MQTT Engine\"]\n");
696 let t = Target::parse("targets/dev.yaml", yaml.as_bytes()).expect("listed");
697 assert_eq!(t.exclude_tag_providers, ["MQTT Engine"]);
698 }
699
700 #[test]
701 fn exclude_tag_providers_bad_entries_are_refused() {
702 for (list, index, why) in BAD_EXCLUDE_LISTS {
703 let yaml = format!(
704 "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\nexclude_tag_providers: {list}\n"
705 );
706 let all = messages(&yaml);
707 assert_eq!(all.len(), 1, "{list}: {all:?}");
708 assert!(
709 all[0].starts_with(&format!("exclude_tag_providers[{index}] "))
710 && all[0].contains(why),
711 "{list}: {all:?}"
712 );
713 }
714 }
715
716 #[test]
717 fn exclude_tag_provider_misspelled_key_is_refused() {
718 let yaml = "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\nexclude_tag_provider: [x]\n";
719 let f = findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("unknown"))
720 .remove(0);
721 assert_eq!(f.line, Some(7));
722 assert!(f.message.contains("exclude_tag_provider"), "{}", f.message);
723 }
724}