1use serde::Deserialize;
12
13use super::component_problem;
14use super::yamlio::{SchemaVersion, strict_parse};
15use crate::error::{CoreError, DeployFinding};
16
17#[derive(Debug, Clone, PartialEq, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct Target {
21 pub schema_version: SchemaVersion,
23 pub gateway: String,
26 pub mode: String,
29 pub nodes: Nodes,
31}
32
33#[derive(Debug, Clone, PartialEq, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct Nodes {
37 pub master: NodeTarget,
39 #[serde(default)]
41 pub backup: Option<NodeTarget>,
42}
43
44#[derive(Debug, Clone, PartialEq, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct NodeTarget {
48 pub profile: String,
51 #[serde(default)]
53 pub ssh: Option<Ssh>,
54 #[serde(default)]
57 pub local: Option<Local>,
58 #[serde(default)]
63 pub gateway_name: Option<String>,
64}
65
66#[derive(Debug, Clone, PartialEq, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct Local {
70 pub data_dir: String,
73}
74
75#[derive(Debug, Clone, PartialEq, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct Ssh {
79 pub host: String,
81 #[serde(default)]
83 pub port: Option<u16>,
84 pub user: String,
86 #[serde(default)]
88 pub key_file: Option<String>,
89 #[serde(default)]
92 pub key_env: Option<String>,
93 pub os: TargetOs,
95 pub data_dir: String,
97 pub known_hosts: String,
102 #[serde(default)]
105 pub connect_timeout_s: Option<u32>,
106 #[serde(default)]
109 pub op_timeout_s: Option<u32>,
110}
111
112pub const DEFAULT_CONNECT_TIMEOUT_S: u32 = 10;
115pub const DEFAULT_OP_TIMEOUT_S: u32 = 1050;
121pub const CONNECT_TIMEOUT_RANGE_S: std::ops::RangeInclusive<u32> = 1..=120;
123pub const OP_TIMEOUT_RANGE_S: std::ops::RangeInclusive<u32> = 1..=3600;
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
128#[serde(rename_all = "lowercase")]
129pub enum TargetOs {
130 Windows,
132 Linux,
134}
135
136impl Target {
137 pub fn parse(file: &str, bytes: &[u8]) -> Result<Target, CoreError> {
139 let target: Target = strict_parse(file, bytes)?;
140 target.validate(file)?;
141 Ok(target)
142 }
143
144 fn validate(&self, file: &str) -> Result<(), CoreError> {
145 let mut findings = Vec::new();
146 let mut push = |message: String| {
147 findings.push(DeployFinding {
148 file: file.to_string(),
149 line: None,
150 column: None,
151 message,
152 });
153 };
154
155 for (field, value) in [("gateway", &self.gateway), ("mode", &self.mode)] {
156 if let Some(why) = component_problem(value) {
157 push(format!("{field} {value:?}: {why}"));
158 }
159 }
160 if self.mode == "core" || self.mode == "local" {
164 push(format!(
165 "mode {:?}: names a collection the gateway reserves",
166 self.mode
167 ));
168 }
169 if self.mode == "projects" {
173 push(format!(
174 "mode {:?}: names a directory this tool reserves for project release records",
175 self.mode
176 ));
177 }
178 let backup = self.nodes.backup.as_ref().map(|n| ("nodes.backup", n));
179 for (label, node) in std::iter::once(("nodes.master", &self.nodes.master)).chain(backup) {
180 if node.profile.is_empty() {
181 push(format!("{label}.profile: must not be empty"));
182 }
183 if node.ssh.is_some() && node.local.is_some() {
184 push(format!("{label}: set ssh or local, not both"));
185 }
186 if let Some(local) = &node.local
187 && local.data_dir.is_empty()
188 {
189 push(format!("{label}.local.data_dir: must not be empty"));
190 }
191 if let Some(name) = &node.gateway_name {
192 if name.trim().is_empty() {
193 push(format!("{label}.gateway_name: must not be empty"));
194 } else if name.chars().any(char::is_control) {
195 push(format!(
196 "{label}.gateway_name: must not contain control characters"
197 ));
198 }
199 }
200 let Some(ssh) = &node.ssh else { continue };
201 for (field, value) in [
202 ("host", &ssh.host),
203 ("user", &ssh.user),
204 ("data_dir", &ssh.data_dir),
205 ] {
206 if value.is_empty() {
207 push(format!("{label}.ssh.{field}: must not be empty"));
208 }
209 }
210 if ssh.key_file.is_some() && ssh.key_env.is_some() {
211 push(format!(
212 "{label}.ssh: set key_file or key_env, not both (neither means agent auth)"
213 ));
214 }
215 for (field, value) in [("host", &ssh.host), ("user", &ssh.user)] {
220 if value.is_empty() {
221 continue;
222 }
223 if value.starts_with('-') {
224 push(format!(
225 "{label}.ssh.{field}: must not start with `-` (it would be read as an ssh option)"
226 ));
227 }
228 if value.chars().any(|c| c.is_whitespace() || c.is_control()) {
229 push(format!(
230 "{label}.ssh.{field}: must not contain whitespace or control characters"
231 ));
232 }
233 if value.contains('@') {
234 push(format!("{label}.ssh.{field}: must not contain `@`"));
235 }
236 }
237 if let Some(problem) = known_hosts_problem(&ssh.known_hosts) {
238 push(format!("{label}.ssh.known_hosts: {problem}"));
239 }
240 if let Some(n) = ssh.connect_timeout_s
241 && !CONNECT_TIMEOUT_RANGE_S.contains(&n)
242 {
243 push(format!(
244 "{label}.ssh.connect_timeout_s: {n} is outside {}..={} seconds",
245 CONNECT_TIMEOUT_RANGE_S.start(),
246 CONNECT_TIMEOUT_RANGE_S.end()
247 ));
248 }
249 if let Some(n) = ssh.op_timeout_s
250 && !OP_TIMEOUT_RANGE_S.contains(&n)
251 {
252 push(format!(
253 "{label}.ssh.op_timeout_s: {n} is outside {}..={} seconds",
254 OP_TIMEOUT_RANGE_S.start(),
255 OP_TIMEOUT_RANGE_S.end()
256 ));
257 }
258 }
259
260 if let Some(backup) = &self.nodes.backup {
267 let master = &self.nodes.master;
268 if !backup.profile.is_empty() && backup.profile == master.profile {
269 push(
270 "nodes.backup.profile: names the same profile as nodes.master.profile"
271 .to_string(),
272 );
273 }
274 match (&master.local, &master.ssh, &backup.local, &backup.ssh) {
277 (Some(m), None, Some(b), None)
278 if !m.data_dir.is_empty() && m.data_dir == b.data_dir =>
279 {
280 push(
281 "nodes.backup.local.data_dir: is the same data directory as nodes.master.local.data_dir"
282 .to_string(),
283 );
284 }
285 (None, Some(m), None, Some(b))
286 if !m.host.is_empty()
287 && m.host == b.host
288 && m.port.unwrap_or(22) == b.port.unwrap_or(22)
289 && m.data_dir == b.data_dir =>
290 {
291 push(
292 "nodes.backup.ssh: is the same host, port and data_dir as nodes.master.ssh"
293 .to_string(),
294 );
295 }
296 _ => {}
297 }
298 }
299
300 if findings.is_empty() {
301 Ok(())
302 } else {
303 Err(CoreError::deploy_findings(findings))
304 }
305 }
306}
307
308fn known_hosts_problem(path: &str) -> Option<String> {
314 if path.is_empty() {
315 return Some("must not be empty".to_string());
316 }
317 if path.starts_with('/') {
318 return Some("must be relative to the repo root, not absolute".to_string());
319 }
320 if path.contains('\\') {
321 return Some("must use `/` separators, not `\\`".to_string());
322 }
323 if path.as_bytes().get(1) == Some(&b':') {
324 return Some("must be relative to the repo root, not a drive path".to_string());
325 }
326 for part in path.split('/') {
327 if let Some(why) = component_problem(part) {
328 return Some(format!("component {part:?} {why}"));
329 }
330 }
331 None
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 fn findings(e: CoreError) -> Vec<DeployFinding> {
339 match e {
340 CoreError::DeployTreeInvalid(d) => d.findings,
341 other => panic!("expected deploy_tree_invalid, got {other}"),
342 }
343 }
344
345 #[test]
346 fn full_target_parses() {
347 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";
348 let t = Target::parse("targets/prod.yaml", yaml.as_bytes()).expect("parses");
349 assert_eq!((t.gateway.as_str(), t.mode.as_str()), ("plant-a", "prod"));
350 let master = t.nodes.master.ssh.expect("master ssh");
351 assert_eq!(master.port, Some(2222));
352 assert_eq!(master.os, TargetOs::Windows);
353 assert_eq!(master.key_file.as_deref(), Some("/keys/id_ed25519"));
354 assert!(master.data_dir.starts_with("C:\\"));
355 let backup = t.nodes.backup.expect("backup");
356 assert_eq!(backup.profile, "plant-a-backup");
357 let bssh = backup.ssh.expect("backup ssh");
358 assert_eq!((bssh.os, bssh.port), (TargetOs::Linux, None));
359 assert_eq!(bssh.key_env.as_deref(), Some("PLANT_A_KEY"));
360 }
361
362 #[test]
363 fn reserved_collection_modes_are_refused() {
364 for mode in ["core", "local", "projects"] {
370 let yaml = format!(
371 "schema_version: 1\ngateway: g\nmode: {mode}\nnodes:\n master:\n profile: p\n"
372 );
373 let all: Vec<String> =
374 findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err(mode))
375 .into_iter()
376 .map(|f| f.message)
377 .collect();
378 assert_eq!(all.len(), 1, "{all:?}");
379 assert!(
380 all[0].contains(mode) && all[0].contains("reserves"),
381 "{all:?}"
382 );
383 }
384 }
385
386 #[test]
387 fn minimal_target_parses() {
388 let t = Target::parse(
389 "targets/dev.yaml",
390 b"schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n",
391 )
392 .expect("parses");
393 assert_eq!(t.nodes.backup, None);
394 assert_eq!(t.nodes.master.ssh, None);
395 assert_eq!(t.nodes.master.local, None);
396 }
397
398 #[test]
399 fn local_node_parses() {
400 let yaml = "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n local:\n data_dir: /var/ignition/data\n";
401 let t = Target::parse("targets/dev.yaml", yaml.as_bytes()).expect("parses");
402 let local = t.nodes.master.local.expect("local");
403 assert_eq!(local.data_dir, "/var/ignition/data");
404 }
405
406 #[test]
407 fn ssh_and_local_together_are_refused() {
408 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";
409 let all: Vec<String> =
410 findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("both"))
411 .into_iter()
412 .map(|f| f.message)
413 .collect();
414 assert_eq!(all, vec!["nodes.backup: set ssh or local, not both"]);
415 }
416
417 fn messages(yaml: &str) -> Vec<String> {
418 findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("refused"))
419 .into_iter()
420 .map(|f| f.message)
421 .collect()
422 }
423
424 #[test]
425 fn backup_profile_equal_to_master_profile_is_refused() {
426 let yaml = "schema_version: 1\ngateway: g\nmode: m\nnodes:\n master:\n profile: prod\n backup:\n profile: prod\n";
429 assert_eq!(
430 messages(yaml),
431 vec!["nodes.backup.profile: names the same profile as nodes.master.profile"]
432 );
433 let ok = "schema_version: 1\ngateway: g\nmode: m\nnodes:\n master:\n profile: prod-a\n backup:\n profile: prod-b\n";
435 Target::parse("targets/x.yaml", ok.as_bytes()).expect("distinct profiles parse");
436 }
437
438 #[test]
439 fn backup_on_the_masters_data_dir_is_refused() {
440 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";
441 assert_eq!(
442 messages(local),
443 vec![
444 "nodes.backup.local.data_dir: is the same data directory as nodes.master.local.data_dir"
445 ]
446 );
447 let ssh = |backup_host: &str, backup_port: &str, backup_dir: &str| {
448 format!(
449 "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"
450 )
451 };
452 assert_eq!(
453 messages(&ssh("h1", "port: 22", "/d")),
454 vec!["nodes.backup.ssh: is the same host, port and data_dir as nodes.master.ssh"]
455 );
456 Target::parse("targets/x.yaml", ssh("h2", "port: 22", "/d").as_bytes())
458 .expect("other host");
459 Target::parse("targets/x.yaml", ssh("h1", "port: 2222", "/d").as_bytes())
460 .expect("other port");
461 Target::parse("targets/x.yaml", ssh("h1", "port: 22", "/d2").as_bytes())
463 .expect("other data dir");
464 }
465
466 #[test]
467 fn empty_local_data_dir_is_refused() {
468 let yaml = "schema_version: 1\ngateway: g\nmode: m\nnodes:\n master:\n profile: p\n local:\n data_dir: ''\n";
469 let all: Vec<String> =
470 findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("empty"))
471 .into_iter()
472 .map(|f| f.message)
473 .collect();
474 assert_eq!(all, vec!["nodes.master.local.data_dir: must not be empty"]);
475 }
476
477 #[test]
478 fn target_unknown_ssh_field_is_positioned() {
479 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";
480 let f = findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("unknown"))
481 .remove(0);
482 assert_eq!((f.line, f.column), (Some(12), Some(7)));
483 assert!(f.message.contains("bogus"), "{}", f.message);
484 }
485
486 #[test]
487 fn target_os_rejects_unknown() {
488 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";
489 let f =
490 findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("os")).remove(0);
491 assert_eq!(f.line, Some(10));
492 assert!(f.column.is_some());
493 assert!(f.message.contains("unknown variant"), "{}", f.message);
494 assert!(!f.message.contains("macos"), "{}", f.message);
495 assert!(
496 f.message.contains("expected `windows` or `linux`"),
497 "{}",
498 f.message
499 );
500 }
501
502 #[test]
503 fn target_semantic_findings() {
504 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";
505 let found =
506 findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("invalid"));
507 let all: Vec<&str> = found.iter().map(|f| f.message.as_str()).collect();
508 let has = |needle: &str| all.iter().any(|m| m.contains(needle));
509 assert!(has("gateway") && has("path separator"), "{all:?}");
510 assert!(has("mode") && has("start with `.`"), "{all:?}");
511 assert!(has("nodes.master.profile"), "{all:?}");
512 assert!(has("key_file or key_env"), "{all:?}");
513 assert!(has("nodes.master.ssh.data_dir"), "{all:?}");
514 }
515
516 #[test]
517 fn gateway_name_is_optional_and_parses_on_either_node() {
518 let none = Target::parse(
519 "targets/dev.yaml",
520 b"schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n",
521 )
522 .expect("parses");
523 assert_eq!(none.nodes.master.gateway_name, None);
524
525 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";
526 let t = Target::parse("targets/dev.yaml", yaml.as_bytes()).expect("parses");
527 assert_eq!(t.nodes.master.gateway_name.as_deref(), Some("plant-a"));
528 assert_eq!(
529 t.nodes.backup.expect("backup").gateway_name.as_deref(),
530 Some("plant a backup")
531 );
532 }
533
534 #[test]
535 fn gateway_name_empty_or_with_control_characters_is_refused() {
536 for (value, label) in [
537 ("''", "nodes.master.gateway_name"),
538 ("' '", "nodes.master.gateway_name"),
539 ("\"a\\nb\"", "nodes.master.gateway_name"),
540 ("\"a\\u001bb\"", "nodes.master.gateway_name"),
541 ] {
542 let yaml = format!(
543 "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n gateway_name: {value}\n"
544 );
545 let all = messages(&yaml);
546 assert!(
547 all.iter().any(|m| m.starts_with(&format!("{label}:"))),
548 "{value}: {all:?}"
549 );
550 }
551 let yaml = "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n master:\n profile: dev\n backup:\n profile: b\n gateway_name: ''\n";
552 assert!(
553 messages(yaml)
554 .iter()
555 .any(|m| m.starts_with("nodes.backup.gateway_name:"))
556 );
557 }
558
559 fn ssh_yaml(extra: &str) -> String {
560 format!(
561 "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}"
562 )
563 }
564
565 #[test]
566 fn target_ssh_fields() {
567 let f = findings(
569 Target::parse("targets/x.yaml", ssh_yaml("").as_bytes()).expect_err("missing"),
570 )
571 .remove(0);
572 assert!(f.message.contains("known_hosts"), "{}", f.message);
573
574 let t = Target::parse(
576 "targets/x.yaml",
577 ssh_yaml(" known_hosts: ssh/kh\n").as_bytes(),
578 )
579 .expect("parses");
580 let ssh = t.nodes.master.ssh.expect("ssh");
581 assert_eq!((ssh.connect_timeout_s, ssh.op_timeout_s), (None, None));
582 assert_eq!(DEFAULT_CONNECT_TIMEOUT_S, 10);
583 assert_eq!(DEFAULT_OP_TIMEOUT_S, 1050);
584 assert!(OP_TIMEOUT_RANGE_S.contains(&DEFAULT_OP_TIMEOUT_S));
585 let t = Target::parse(
586 "targets/x.yaml",
587 ssh_yaml(
588 " known_hosts: ssh/kh\n connect_timeout_s: 120\n op_timeout_s: 1\n",
589 )
590 .as_bytes(),
591 )
592 .expect("edges parse");
593 let ssh = t.nodes.master.ssh.expect("ssh");
594 assert_eq!(
595 (ssh.connect_timeout_s, ssh.op_timeout_s),
596 (Some(120), Some(1))
597 );
598
599 for bad in [
601 "/etc/ssh/kh",
602 "C:/kh",
603 "a\\\\b",
604 "../kh",
605 "a/../kh",
606 "a//kh",
607 ".hidden/kh",
608 "dir/.kh",
609 "''",
610 ] {
611 let all = messages(&ssh_yaml(&format!(" known_hosts: {bad}\n")));
612 assert!(
613 all.iter()
614 .any(|m| m.starts_with("nodes.master.ssh.known_hosts:")),
615 "{bad}: {all:?}"
616 );
617 }
618
619 for (field, value) in [
621 ("connect_timeout_s", 0),
622 ("connect_timeout_s", 121),
623 ("op_timeout_s", 0),
624 ("op_timeout_s", 3601),
625 ] {
626 let all = messages(&ssh_yaml(&format!(
627 " known_hosts: kh\n {field}: {value}\n"
628 )));
629 assert!(
630 all.iter()
631 .any(|m| m.starts_with(&format!("nodes.master.ssh.{field}:"))),
632 "{field}={value}: {all:?}"
633 );
634 }
635 }
636
637 #[test]
638 fn ssh_host_and_user_cannot_be_read_as_options() {
639 let cases = [
640 ("host", "-oProxyCommand=x"),
641 ("user", "-l"),
642 ("user", "a@b"),
643 ("host", "'a b'"),
644 ("host", "\"a\\tb\""),
645 ("user", "'a b'"),
646 ("host", "a@b"),
647 ];
648 for (field, value) in cases {
649 let other = if field == "host" {
650 "user: u"
651 } else {
652 "host: h"
653 };
654 let yaml = format!(
655 "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"
656 );
657 let all = messages(&yaml);
658 assert!(
659 all.iter()
660 .any(|m| m.starts_with(&format!("nodes.master.ssh.{field}:"))),
661 "{field}={value}: {all:?}"
662 );
663 }
664 }
665}