1use std::collections::BTreeSet;
8use std::io;
9use std::path::{Component, Path, PathBuf};
10
11use semver::Version;
12use serde::Serialize;
13
14use super::redact::{CommandPurpose, NativeProgram, RedactedUrl};
15use super::report::{
16 Capability, CapabilityStatus, DiagnosticDetails, DiagnosticEvidence, DiagnosticReport,
17 DiagnosticStatus, Endpoint, EndpointScope, Privilege, RuntimeKind,
18};
19use super::runtime::{ProbeCommand, RuntimeAdapter};
20use crate::process::{CaptureLimits, CommandOutcome, CommandRunner, CommandSpec};
21
22const MINIMUM_CONTAINERD_VERSION: Version = Version::new(1, 6, 0);
23#[cfg(not(windows))]
24const DEFAULT_ADDRESS: &str = "unix:///run/containerd/containerd.sock";
25#[cfg(windows)]
26const DEFAULT_ADDRESS: &str = "npipe:////./pipe/containerd-containerd";
27const DEFAULT_NAMESPACE: &str = "default";
28
29#[derive(Clone, Debug, Default, PartialEq, Eq)]
31pub struct ContainerdVersions {
32 pub containerd: Option<Version>,
33 pub ctr_client: Option<Version>,
34 pub ctr_server: Option<Version>,
35}
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
39#[serde(rename_all = "lowercase")]
40pub enum LegacyRegistryWarning {
41 Auths,
42 Configs,
43 Mirrors,
44}
45
46#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
49pub struct ContainerdDiagnosticDetails {
50 pub containerd_version: Option<String>,
51 pub ctr_client_version: Option<String>,
52 pub ctr_server_version: Option<String>,
53 pub config_version: Option<u32>,
54 pub config_path_configured: Option<bool>,
55 pub legacy_registry_settings: BTreeSet<LegacyRegistryWarning>,
56}
57
58#[derive(Clone, Debug, Default, PartialEq, Eq)]
60pub struct ContainerdConfig {
61 pub version: Option<u32>,
62 pub registry_config_path: Option<PathBuf>,
63 pub legacy_registry: BTreeSet<LegacyRegistryWarning>,
64}
65
66#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
68pub enum RegistryHostCapability {
69 Pull,
70 Resolve,
71 Push,
72 Unknown,
73}
74
75#[derive(Clone, Debug, Default, PartialEq, Eq)]
78pub struct RegistryHostTls {
79 pub skip_verify: bool,
80 pub ca_count: usize,
81 pub client_certificate_configured: bool,
82}
83
84#[derive(Clone, Debug, PartialEq, Eq)]
86pub struct RegistryHost {
87 pub endpoint: RedactedUrl,
88 pub capabilities: BTreeSet<RegistryHostCapability>,
89 pub override_path: bool,
90 pub tls: RegistryHostTls,
91}
92
93#[derive(Clone, Debug, Default, PartialEq, Eq)]
95pub struct RegistryHosts {
96 pub server: Option<RedactedUrl>,
97 pub hosts: Vec<RegistryHost>,
98}
99
100#[derive(Debug, thiserror::Error)]
102pub enum ContainerdParseError {
103 #[error("invalid containerd TOML")]
104 InvalidToml,
105 #[error("invalid containerd endpoint URL")]
106 InvalidEndpoint,
107 #[error("containerd endpoint selectors cannot contain credentials, query, or fragments")]
108 UnsafeEndpoint,
109 #[error("invalid containerd namespace")]
110 InvalidNamespace,
111 #[error("hosts path must explicitly name hosts.toml without parent traversal")]
112 UnsafeHostsPath,
113 #[error("could not read the explicitly selected hosts.toml")]
114 Read(#[source] io::Error),
115}
116
117#[derive(Clone, Debug, PartialEq, Eq)]
119pub struct ContainerdDiscovery {
120 pub report: DiagnosticReport,
121 pub versions: ContainerdVersions,
122 pub address: RedactedUrl,
123 pub namespace: String,
124 pub config: Option<ContainerdConfig>,
125}
126
127#[derive(Clone, PartialEq, Eq)]
130pub struct ContainerdAdapter {
131 address: String,
132 namespace: String,
133}
134
135impl std::fmt::Debug for ContainerdAdapter {
136 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 formatter
138 .debug_struct("ContainerdAdapter")
139 .field("address", &self.address().ok())
140 .field("namespace", &"[redacted]")
141 .finish()
142 }
143}
144
145impl Default for ContainerdAdapter {
146 fn default() -> Self {
147 Self {
148 address: DEFAULT_ADDRESS.to_owned(),
149 namespace: DEFAULT_NAMESPACE.to_owned(),
150 }
151 }
152}
153
154impl ContainerdAdapter {
155 pub fn new(
158 address: impl Into<String>,
159 namespace: impl Into<String>,
160 ) -> Result<Self, ContainerdParseError> {
161 let address = address.into();
162 let parsed =
163 reqwest::Url::parse(&address).map_err(|_| ContainerdParseError::InvalidEndpoint)?;
164 RedactedUrl::parse(&address).map_err(|_| ContainerdParseError::InvalidEndpoint)?;
165 if !parsed.username().is_empty()
166 || parsed.password().is_some()
167 || parsed.query().is_some()
168 || parsed.fragment().is_some()
169 {
170 return Err(ContainerdParseError::UnsafeEndpoint);
171 }
172 let namespace = namespace.into();
173 if namespace.is_empty()
174 || namespace.starts_with('-')
175 || namespace.len() > 128
176 || !namespace
177 .bytes()
178 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
179 {
180 return Err(ContainerdParseError::InvalidNamespace);
181 }
182 Ok(Self { address, namespace })
183 }
184
185 pub fn address(&self) -> Result<RedactedUrl, ContainerdParseError> {
186 RedactedUrl::parse(&self.address).map_err(|_| ContainerdParseError::InvalidEndpoint)
187 }
188
189 pub fn namespace(&self) -> &str {
190 &self.namespace
191 }
192
193 pub fn inspect(
196 &self,
197 runner: &dyn CommandRunner,
198 limits: CaptureLimits,
199 ) -> ContainerdDiscovery {
200 let address = self.address().expect("validated containerd address");
201 let mut report = DiagnosticReport::new(RuntimeKind::Containerd, DiagnosticStatus::Degraded)
202 .with_privilege(Privilege::CurrentUser);
203 let endpoint = endpoint_from_url(address.clone());
204 report.add_endpoint(endpoint.clone());
205 report.add_evidence(DiagnosticEvidence::Endpoint(endpoint.clone()));
206
207 let daemon_probe = probe(
208 NativeProgram::Containerd,
209 CommandPurpose::Version,
210 CommandSpec::new("containerd").arg("--version"),
211 );
212 report.add_evidence(DiagnosticEvidence::Command(daemon_probe.evidence().clone()));
213 let daemon_outcome = daemon_probe.execute(runner, limits);
214
215 let ctr_probe = probe(
216 NativeProgram::Ctr,
217 CommandPurpose::Version,
218 self.ctr_command().arg("version"),
219 );
220 report.add_evidence(DiagnosticEvidence::Command(ctr_probe.evidence().clone()));
221 let ctr_outcome = ctr_probe.execute(runner, limits);
222
223 let mut versions = ContainerdVersions {
224 containerd: successful_stdout(&daemon_outcome).and_then(parse_first_version),
225 ctr_client: None,
226 ctr_server: None,
227 };
228 if let Some(stdout) = successful_stdout(&ctr_outcome) {
229 let parsed = parse_ctr_versions(stdout);
230 versions.ctr_client = parsed.0;
231 versions.ctr_server = parsed.1;
232 }
233
234 let (config_outcome, config) = if endpoint.scope == EndpointScope::Local {
235 let config_probe = probe(
236 NativeProgram::Containerd,
237 CommandPurpose::RuntimeInfo,
238 CommandSpec::new("containerd").args(["config", "dump"]),
239 );
240 report.add_evidence(DiagnosticEvidence::Command(config_probe.evidence().clone()));
241 let outcome = config_probe.execute(runner, limits);
242 let config =
243 successful_stdout(&outcome).and_then(|bytes| parse_effective_config(bytes).ok());
244 (Some(outcome), config)
245 } else {
246 (None, None)
249 };
250
251 let ctr_installed = !matches!(ctr_outcome, CommandOutcome::NotInstalled);
252 let daemon_installed = !matches!(daemon_outcome, CommandOutcome::NotInstalled);
253 let server_reached = versions.ctr_server.is_some();
254 let unsupported = [
255 &versions.containerd,
256 &versions.ctr_client,
257 &versions.ctr_server,
258 ]
259 .into_iter()
260 .flatten()
261 .any(|version| version < &MINIMUM_CONTAINERD_VERSION);
262 report.status = classify_status(
263 daemon_installed,
264 ctr_installed,
265 server_reached,
266 unsupported,
267 [&daemon_outcome, &ctr_outcome]
268 .into_iter()
269 .chain(config_outcome.as_ref())
270 .collect::<Vec<_>>(),
271 );
272 if report.status == DiagnosticStatus::Healthy
273 && matches!(
274 config_outcome.as_ref(),
275 Some(CommandOutcome::Exited { status, .. }) if status.success()
276 )
277 && config.is_none()
278 {
279 report.status = DiagnosticStatus::Degraded;
280 }
281
282 report.set_capability(
283 Capability::Client,
284 if versions.ctr_client.is_some() {
285 CapabilityStatus::Supported
286 } else {
287 CapabilityStatus::Unavailable
288 },
289 );
290 for capability in [
291 Capability::Daemon,
292 Capability::RuntimeInfo,
293 Capability::Pull,
294 ] {
295 report.set_capability(
296 capability,
297 if server_reached {
298 CapabilityStatus::Supported
299 } else {
300 CapabilityStatus::Unavailable
301 },
302 );
303 }
304 for capability in [Capability::CacheStatus, Capability::CachePrune] {
309 report.set_capability(capability, CapabilityStatus::Unsupported);
310 }
311 report.set_capability(
312 Capability::RegistryHostMapping,
313 if config
314 .as_ref()
315 .and_then(|value| value.registry_config_path.as_ref())
316 .is_some()
317 {
318 CapabilityStatus::Supported
319 } else if server_reached {
320 CapabilityStatus::Unknown
321 } else {
322 CapabilityStatus::Unavailable
323 },
324 );
325 report.set_capability(
326 Capability::RegistryMirrors,
327 *report
328 .capabilities
329 .get(&Capability::RegistryHostMapping)
330 .unwrap_or(&CapabilityStatus::Unknown),
331 );
332 report.set_details(DiagnosticDetails::Containerd(ContainerdDiagnosticDetails {
333 containerd_version: versions.containerd.as_ref().map(ToString::to_string),
334 ctr_client_version: versions.ctr_client.as_ref().map(ToString::to_string),
335 ctr_server_version: versions.ctr_server.as_ref().map(ToString::to_string),
336 config_version: config.as_ref().and_then(|config| config.version),
337 config_path_configured: config
338 .as_ref()
339 .map(|config| config.registry_config_path.is_some()),
340 legacy_registry_settings: config
341 .as_ref()
342 .map(|config| config.legacy_registry.clone())
343 .unwrap_or_default(),
344 }));
345
346 ContainerdDiscovery {
347 report,
348 versions,
349 address,
350 namespace: self.namespace.clone(),
351 config,
352 }
353 }
354
355 fn ctr_command(&self) -> CommandSpec {
356 CommandSpec::new("ctr").args([
357 "--address",
358 self.address.as_str(),
359 "--namespace",
360 self.namespace.as_str(),
361 ])
362 }
363}
364
365impl RuntimeAdapter for ContainerdAdapter {
366 fn kind(&self) -> RuntimeKind {
367 RuntimeKind::Containerd
368 }
369
370 fn diagnose(&self, runner: &dyn CommandRunner, limits: CaptureLimits) -> DiagnosticReport {
371 self.inspect(runner, limits).report
372 }
373}
374
375pub fn parse_effective_config(input: &[u8]) -> Result<ContainerdConfig, ContainerdParseError> {
378 let text = std::str::from_utf8(input).map_err(|_| ContainerdParseError::InvalidToml)?;
379 let root: toml::Value = toml::from_str(text).map_err(|_| ContainerdParseError::InvalidToml)?;
380 let version = root
381 .get("version")
382 .and_then(toml::Value::as_integer)
383 .and_then(|value| u32::try_from(value).ok());
384 let plugins = root.get("plugins").and_then(toml::Value::as_table);
385 let legacy_registry = plugins
386 .and_then(|plugins| plugins.get("io.containerd.grpc.v1.cri"))
387 .and_then(|value| value.get("registry"));
388 let current_registry = plugins
389 .and_then(|plugins| plugins.get("io.containerd.cri.v1.images"))
390 .and_then(|value| value.get("registry"))
391 .or_else(|| {
392 plugins
393 .and_then(|plugins| plugins.get("io.containerd.cri.v1.images"))
394 .and_then(|value| value.get("containerd"))
395 .and_then(|value| value.get("registry"))
396 });
397 let registry_config_path = current_registry
398 .and_then(|value| value.get("config_path"))
399 .and_then(toml::Value::as_str)
400 .filter(|value| !value.trim().is_empty())
401 .or_else(|| {
402 legacy_registry
403 .and_then(|value| value.get("config_path"))
404 .and_then(toml::Value::as_str)
405 .filter(|value| !value.trim().is_empty())
406 })
407 .map(PathBuf::from);
408 let mut legacy_warnings = BTreeSet::new();
409 for (name, warning) in [
410 ("mirrors", LegacyRegistryWarning::Mirrors),
411 ("configs", LegacyRegistryWarning::Configs),
412 ("auths", LegacyRegistryWarning::Auths),
413 ] {
414 if legacy_registry.and_then(|value| value.get(name)).is_some()
415 || current_registry.and_then(|value| value.get(name)).is_some()
416 {
417 legacy_warnings.insert(warning);
418 }
419 }
420 Ok(ContainerdConfig {
421 version,
422 registry_config_path,
423 legacy_registry: legacy_warnings,
424 })
425}
426
427pub fn parse_hosts_toml(input: &str) -> Result<RegistryHosts, ContainerdParseError> {
430 let root: toml::Value = toml::from_str(input).map_err(|_| ContainerdParseError::InvalidToml)?;
431 let ordered: toml_edit::DocumentMut = input
432 .parse()
433 .map_err(|_| ContainerdParseError::InvalidToml)?;
434 let server = root
435 .get("server")
436 .and_then(toml::Value::as_str)
437 .map(parse_url)
438 .transpose()?;
439 let mut hosts = Vec::new();
440 if let Some(entries) = ordered.get("host").and_then(toml_edit::Item::as_table) {
441 for (raw_endpoint, value) in entries.iter() {
442 let Some(table) = value.as_table_like() else {
443 continue;
444 };
445 let endpoint = parse_url(raw_endpoint)?;
446 let mut capabilities = BTreeSet::new();
447 if let Some(values) = table
448 .get("capabilities")
449 .and_then(toml_edit::Item::as_array)
450 {
451 for value in values.iter().filter_map(toml_edit::Value::as_str) {
452 capabilities.insert(match value.to_ascii_lowercase().as_str() {
453 "pull" => RegistryHostCapability::Pull,
454 "resolve" => RegistryHostCapability::Resolve,
455 "push" => RegistryHostCapability::Push,
456 _ => RegistryHostCapability::Unknown,
457 });
458 }
459 }
460 let tls = RegistryHostTls {
461 skip_verify: table
462 .get("skip_verify")
463 .and_then(toml_edit::Item::as_bool)
464 .unwrap_or(false),
465 ca_count: path_value_count(table.get("ca")),
466 client_certificate_configured: path_value_count(table.get("client")) > 0,
467 };
468 hosts.push(RegistryHost {
469 endpoint,
470 capabilities,
471 override_path: table
472 .get("override_path")
473 .and_then(toml_edit::Item::as_bool)
474 .unwrap_or(false),
475 tls,
476 });
477 }
478 }
479 Ok(RegistryHosts { server, hosts })
480}
481
482pub fn read_hosts_toml(path: &Path) -> Result<RegistryHosts, ContainerdParseError> {
485 if path.file_name().and_then(|name| name.to_str()) != Some("hosts.toml")
486 || path
487 .components()
488 .any(|component| component == Component::ParentDir)
489 {
490 return Err(ContainerdParseError::UnsafeHostsPath);
491 }
492 let text = std::fs::read_to_string(path).map_err(ContainerdParseError::Read)?;
493 parse_hosts_toml(&text)
494}
495
496fn probe(program: NativeProgram, purpose: CommandPurpose, command: CommandSpec) -> ProbeCommand {
497 ProbeCommand::new(program, purpose, command)
498}
499
500fn endpoint_from_url(address: RedactedUrl) -> Endpoint {
501 let (transport, scope) = super::classify_endpoint(address.as_str());
502 Endpoint::new(transport, scope, address)
503}
504
505fn successful_stdout(outcome: &CommandOutcome) -> Option<&[u8]> {
506 match outcome {
507 CommandOutcome::Exited { status, output }
508 if status.success() && !output.stdout_truncated =>
509 {
510 Some(&output.stdout)
511 }
512 _ => None,
513 }
514}
515
516fn parse_first_version(input: &[u8]) -> Option<Version> {
517 let text = std::str::from_utf8(input).ok()?;
518 text.split(|character: char| character.is_whitespace() || character == ',' || character == ':')
519 .find_map(super::parse_vendor_version)
520}
521
522fn parse_ctr_versions(input: &[u8]) -> (Option<Version>, Option<Version>) {
523 let Ok(text) = std::str::from_utf8(input) else {
524 return (None, None);
525 };
526 let mut section = None;
527 let mut client = None;
528 let mut server = None;
529 for line in text.lines() {
530 match line
531 .trim()
532 .trim_end_matches(':')
533 .to_ascii_lowercase()
534 .as_str()
535 {
536 "client" => section = Some(false),
537 "server" => section = Some(true),
538 _ if line
539 .trim_start()
540 .to_ascii_lowercase()
541 .starts_with("version:") =>
542 {
543 let version = line
544 .split_once(':')
545 .and_then(|pair| super::parse_vendor_version(pair.1.trim()));
546 match section {
547 Some(false) => client = version,
548 Some(true) => server = version,
549 None => {}
550 }
551 }
552 _ => {}
553 }
554 }
555 (client, server)
556}
557
558fn parse_url(raw: &str) -> Result<RedactedUrl, ContainerdParseError> {
559 RedactedUrl::parse(raw).map_err(|_| ContainerdParseError::InvalidEndpoint)
560}
561
562fn path_value_count(value: Option<&toml_edit::Item>) -> usize {
563 match value {
564 Some(value) if value.as_str().is_some() => usize::from(!value.as_str().unwrap().is_empty()),
565 Some(value) if value.as_array().is_some() => value.as_array().unwrap().len(),
566 _ => 0,
567 }
568}
569
570fn classify_status(
571 daemon_installed: bool,
572 ctr_installed: bool,
573 server_reached: bool,
574 unsupported: bool,
575 outcomes: Vec<&CommandOutcome>,
576) -> DiagnosticStatus {
577 if outcomes
578 .iter()
579 .any(|outcome| matches!(outcome, CommandOutcome::PermissionDenied))
580 || outcomes
581 .iter()
582 .any(|outcome| output_contains(outcome, &["permission denied", "access is denied"]))
583 {
584 DiagnosticStatus::PermissionDenied
585 } else if !daemon_installed && !ctr_installed {
586 DiagnosticStatus::NotInstalled
587 } else if unsupported {
588 DiagnosticStatus::UnsupportedVersion
589 } else if outcomes.iter().any(|outcome| {
590 matches!(outcome, CommandOutcome::TimedOut { .. })
591 || output_contains(
592 outcome,
593 &[
594 "connection refused",
595 "deadline exceeded",
596 "transport is closing",
597 "failed to dial",
598 "context deadline",
599 ],
600 )
601 }) {
602 DiagnosticStatus::Unreachable
603 } else if server_reached {
604 DiagnosticStatus::Healthy
605 } else if ctr_installed {
606 DiagnosticStatus::ClientOnly
607 } else {
608 DiagnosticStatus::Degraded
609 }
610}
611
612fn output_contains(outcome: &CommandOutcome, needles: &[&str]) -> bool {
613 let Some(output) = outcome.output() else {
614 return false;
615 };
616 let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
617 let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
618 needles
619 .iter()
620 .any(|needle| stdout.contains(needle) || stderr.contains(needle))
621}
622
623#[cfg(test)]
624mod tests {
625 use std::collections::VecDeque;
626 use std::process::ExitStatus;
627 use std::sync::Mutex;
628
629 use crate::process::CapturedOutput;
630
631 use super::*;
632
633 struct FakeRunner {
634 outcomes: Mutex<VecDeque<CommandOutcome>>,
635 commands: Mutex<Vec<Vec<String>>>,
636 }
637
638 impl FakeRunner {
639 fn new(outcomes: Vec<CommandOutcome>) -> Self {
640 Self {
641 outcomes: Mutex::new(outcomes.into()),
642 commands: Mutex::new(Vec::new()),
643 }
644 }
645 }
646
647 impl CommandRunner for FakeRunner {
648 fn run_captured(&self, command: &CommandSpec, _limits: CaptureLimits) -> CommandOutcome {
649 self.commands.lock().unwrap().push(
650 std::iter::once(command.program().to_string_lossy().into_owned())
651 .chain(
652 command
653 .arguments()
654 .iter()
655 .map(|value| value.to_string_lossy().into_owned()),
656 )
657 .collect(),
658 );
659 self.outcomes.lock().unwrap().pop_front().unwrap()
660 }
661
662 fn run_foreground(&self, _command: &CommandSpec) -> io::Result<ExitStatus> {
663 panic!("discovery must not run foreground commands")
664 }
665 }
666
667 fn success(stdout: &str) -> CommandOutcome {
668 CommandOutcome::Exited {
669 status: success_status(),
670 output: CapturedOutput {
671 stdout: stdout.as_bytes().to_vec(),
672 ..CapturedOutput::default()
673 },
674 }
675 }
676
677 #[cfg(unix)]
678 fn success_status() -> ExitStatus {
679 use std::os::unix::process::ExitStatusExt;
680 ExitStatus::from_raw(0)
681 }
682
683 #[cfg(windows)]
684 fn success_status() -> ExitStatus {
685 use std::os::windows::process::ExitStatusExt;
686 ExitStatus::from_raw(0)
687 }
688
689 #[test]
690 fn parses_v1_and_v2_cri_config_paths_and_legacy_keys() {
691 let v1 = parse_effective_config(
692 br#"
693version = 2
694[plugins."io.containerd.grpc.v1.cri".registry]
695 config_path = "/etc/containerd/certs.d"
696 [plugins."io.containerd.grpc.v1.cri".registry.mirrors]
697"#,
698 )
699 .unwrap();
700 assert_eq!(v1.version, Some(2));
701 assert_eq!(
702 v1.registry_config_path,
703 Some(PathBuf::from("/etc/containerd/certs.d"))
704 );
705 assert!(v1.legacy_registry.contains(&LegacyRegistryWarning::Mirrors));
706
707 let v2 = parse_effective_config(
708 br#"
709version = 3
710[plugins."io.containerd.cri.v1.images".registry]
711config_path = "/etc/containerd/certs-v2.d"
712"#,
713 )
714 .unwrap();
715 assert_eq!(
716 v2.registry_config_path,
717 Some(PathBuf::from("/etc/containerd/certs-v2.d"))
718 );
719 }
720
721 #[test]
722 fn current_config_path_wins_while_legacy_warnings_are_unioned() {
723 let config = parse_effective_config(
724 br#"
725version = 3
726[plugins."io.containerd.grpc.v1.cri".registry]
727config_path = "/legacy"
728[plugins."io.containerd.grpc.v1.cri".registry.mirrors]
729[plugins."io.containerd.cri.v1.images".registry]
730config_path = "/current"
731[plugins."io.containerd.cri.v1.images".registry.configs]
732"#,
733 )
734 .unwrap();
735
736 assert_eq!(config.registry_config_path, Some(PathBuf::from("/current")));
737 assert_eq!(
738 config.legacy_registry,
739 BTreeSet::from([
740 LegacyRegistryWarning::Mirrors,
741 LegacyRegistryWarning::Configs,
742 ])
743 );
744 }
745
746 #[test]
747 fn parses_hosts_without_retaining_tls_paths_or_endpoint_secrets() {
748 let parsed = parse_hosts_toml(
749 r#"
750server = "https://user:password@registry.example/private?token=secret"
751[host."https://mirror.example/prefix?key=secret"]
752capabilities = ["pull", "resolve"]
753override_path = true
754skip_verify = true
755ca = ["/secret/ca.pem", "/other/ca.pem"]
756client = [["/secret/cert.pem", "/secret/key.pem"]]
757"#,
758 )
759 .unwrap();
760 assert_eq!(
761 parsed.server.as_ref().unwrap().as_str(),
762 "https://registry.example/[redacted]?redacted"
763 );
764 assert_eq!(parsed.hosts.len(), 1);
765 let host = &parsed.hosts[0];
766 assert!(host.capabilities.contains(&RegistryHostCapability::Pull));
767 assert!(host.capabilities.contains(&RegistryHostCapability::Resolve));
768 assert!(host.override_path);
769 assert!(host.tls.skip_verify);
770 assert_eq!(host.tls.ca_count, 2);
771 assert!(host.tls.client_certificate_configured);
772 assert!(!format!("{parsed:?}").contains("secret"));
773 }
774
775 #[test]
776 fn hosts_toml_preserves_declared_host_priority() {
777 let parsed = parse_hosts_toml(
778 r#"
779[host."https://z-first.example"]
780capabilities = ["pull"]
781[host."https://a-second.example"]
782capabilities = ["pull"]
783"#,
784 )
785 .unwrap();
786
787 assert_eq!(
788 parsed
789 .hosts
790 .iter()
791 .map(|host| host.endpoint.as_str())
792 .collect::<Vec<_>>(),
793 ["https://z-first.example/", "https://a-second.example/"]
794 );
795 }
796
797 #[test]
798 fn discovery_uses_explicit_selectors_and_reports_healthy_server() {
799 let runner = FakeRunner::new(vec![
800 success("containerd github.com/containerd/containerd v1.7.22 abc"),
801 success("Client:\n Version: v1.7.22\nServer:\n Version: v1.7.22\n"),
802 success("version = 2\n[plugins.\"io.containerd.grpc.v1.cri\".registry]\nconfig_path = \"/etc/containerd/certs.d\"\n"),
803 ]);
804 let adapter = ContainerdAdapter::new("unix:///custom/containerd.sock", "k8s.io").unwrap();
805 let discovery = adapter.inspect(&runner, CaptureLimits::default());
806 assert_eq!(discovery.report.status, DiagnosticStatus::Healthy);
807 assert_eq!(discovery.namespace, "k8s.io");
808 assert_eq!(discovery.versions.ctr_server, Some(Version::new(1, 7, 22)));
809 assert_eq!(
810 discovery.report.capabilities.get(&Capability::CacheStatus),
811 Some(&CapabilityStatus::Unsupported)
812 );
813 assert_eq!(
814 discovery.report.capabilities.get(&Capability::CachePrune),
815 Some(&CapabilityStatus::Unsupported)
816 );
817 let commands = runner.commands.lock().unwrap();
818 assert_eq!(
819 commands[1],
820 [
821 "ctr",
822 "--address",
823 "unix:///custom/containerd.sock",
824 "--namespace",
825 "k8s.io",
826 "version"
827 ]
828 );
829 }
830
831 #[test]
832 fn remote_address_does_not_probe_or_associate_local_config() {
833 let runner = FakeRunner::new(vec![
834 success("containerd github.com/containerd/containerd v1.7.22 abc"),
835 success("Client:\n Version: v1.7.22\nServer:\n Version: v1.7.22\n"),
836 ]);
837 let adapter = ContainerdAdapter::new("tcp://192.0.2.10:1234", "default").unwrap();
838
839 let discovery = adapter.inspect(&runner, CaptureLimits::default());
840
841 assert_eq!(discovery.report.status, DiagnosticStatus::Healthy);
842 assert!(discovery.config.is_none());
843 assert_eq!(
844 discovery
845 .report
846 .capabilities
847 .get(&Capability::RegistryHostMapping),
848 Some(&CapabilityStatus::Unknown)
849 );
850 assert_eq!(runner.commands.lock().unwrap().len(), 2);
851 }
852
853 #[test]
854 fn loopback_tcp_address_is_local_and_may_probe_local_config() {
855 let runner = FakeRunner::new(vec![
856 success("containerd github.com/containerd/containerd v1.7.22 abc"),
857 success("Client:\n Version: v1.7.22\nServer:\n Version: v1.7.22\n"),
858 success("version = 2"),
859 ]);
860 let adapter = ContainerdAdapter::new("tcp://127.0.0.1:1234", "default").unwrap();
861
862 let discovery = adapter.inspect(&runner, CaptureLimits::default());
863
864 assert_eq!(
865 discovery.report.endpoints.iter().next().unwrap().scope,
866 EndpointScope::Local
867 );
868 assert!(discovery.config.is_some());
869 assert_eq!(runner.commands.lock().unwrap().len(), 3);
870 }
871
872 #[test]
873 fn failed_ctr_stdout_does_not_establish_server_reachability() {
874 let runner = FakeRunner::new(vec![
875 success("containerd github.com/containerd/containerd v1.7.22 abc"),
876 CommandOutcome::Exited {
877 status: failure_status(),
878 output: CapturedOutput {
879 stdout: b"Client:\n Version: v1.7.22\nServer:\n Version: v1.7.22\n".to_vec(),
880 stderr: b"failed to dial: connection refused".to_vec(),
881 ..CapturedOutput::default()
882 },
883 },
884 success("version = 2"),
885 ]);
886
887 let discovery = ContainerdAdapter::default().inspect(&runner, CaptureLimits::default());
888
889 assert_eq!(discovery.report.status, DiagnosticStatus::Unreachable);
890 assert!(discovery.versions.ctr_client.is_none());
891 assert!(discovery.versions.ctr_server.is_none());
892 assert_eq!(
893 discovery.report.capabilities.get(&Capability::Daemon),
894 Some(&CapabilityStatus::Unavailable)
895 );
896 }
897
898 #[test]
899 fn containerd_versions_use_shared_vendor_parser() {
900 assert_eq!(
901 parse_first_version(b"containerd V1.7.22~ds1-1 abc"),
902 Some(Version::new(1, 7, 22))
903 );
904 assert_eq!(parse_first_version(b"containerd 1.2.3.4 abc"), None);
905 assert_eq!(
906 parse_ctr_versions(b"Client:\n Version: V1.7\nServer:\n Version: v1.7.22~ds1-1\n"),
907 (Some(Version::new(1, 7, 0)), Some(Version::new(1, 7, 22)))
908 );
909 }
910
911 #[test]
912 fn platform_default_address_uses_native_transport() {
913 let adapter = ContainerdAdapter::default();
914 #[cfg(windows)]
915 assert_eq!(adapter.address, "npipe:////./pipe/containerd-containerd");
916 #[cfg(not(windows))]
917 assert_eq!(adapter.address, "unix:///run/containerd/containerd.sock");
918 }
919
920 #[test]
921 fn windows_named_pipe_is_a_valid_direct_ctr_selector() {
922 let adapter =
923 ContainerdAdapter::new("npipe:////./pipe/containerd-containerd", "default").unwrap();
924 let command = adapter.ctr_command().arg("version");
925 let arguments = command
926 .arguments()
927 .iter()
928 .map(|value| value.to_string_lossy().into_owned())
929 .collect::<Vec<_>>();
930
931 assert_eq!(
932 arguments,
933 [
934 "--address",
935 "npipe:////./pipe/containerd-containerd",
936 "--namespace",
937 "default",
938 "version",
939 ]
940 );
941 assert_eq!(adapter.address().unwrap().as_str(), "npipe:///[redacted]");
942 }
943
944 #[test]
945 fn rejects_namespace_values_that_could_be_cli_options() {
946 assert!(matches!(
947 ContainerdAdapter::new("unix:///run/containerd/containerd.sock", "--help"),
948 Err(ContainerdParseError::InvalidNamespace)
949 ));
950 assert!(ContainerdAdapter::new("unix:///run/containerd/containerd.sock", "k8s.io").is_ok());
951 assert!(matches!(
952 ContainerdAdapter::new(
953 "tcp://user:secret@host.example:1234/socket?token=x",
954 "default"
955 ),
956 Err(ContainerdParseError::UnsafeEndpoint)
957 ));
958 }
959
960 #[test]
961 fn missing_tools_and_unreachable_daemon_are_distinct() {
962 let missing = FakeRunner::new(vec![
963 CommandOutcome::NotInstalled,
964 CommandOutcome::NotInstalled,
965 CommandOutcome::NotInstalled,
966 ]);
967 assert_eq!(
968 ContainerdAdapter::default()
969 .inspect(&missing, CaptureLimits::default())
970 .report
971 .status,
972 DiagnosticStatus::NotInstalled
973 );
974
975 let unreachable = FakeRunner::new(vec![
976 success("containerd github.com/containerd/containerd v1.7.22 abc"),
977 CommandOutcome::Exited {
978 status: failure_status(),
979 output: CapturedOutput {
980 stderr: b"failed to dial: connection refused".to_vec(),
981 ..CapturedOutput::default()
982 },
983 },
984 success("version = 2"),
985 ]);
986 assert_eq!(
987 ContainerdAdapter::default()
988 .inspect(&unreachable, CaptureLimits::default())
989 .report
990 .status,
991 DiagnosticStatus::Unreachable
992 );
993
994 let timed_out = FakeRunner::new(vec![
995 success("containerd github.com/containerd/containerd v1.7.22 abc"),
996 CommandOutcome::TimedOut {
997 output: CapturedOutput::default(),
998 termination: crate::process::TerminationStatus::Requested,
999 },
1000 success("version = 2"),
1001 ]);
1002 assert_eq!(
1003 ContainerdAdapter::default()
1004 .inspect(&timed_out, CaptureLimits::default())
1005 .report
1006 .status,
1007 DiagnosticStatus::Unreachable
1008 );
1009 }
1010
1011 #[cfg(unix)]
1012 fn failure_status() -> ExitStatus {
1013 use std::os::unix::process::ExitStatusExt;
1014 ExitStatus::from_raw(256)
1015 }
1016
1017 #[cfg(windows)]
1018 fn failure_status() -> ExitStatus {
1019 use std::os::windows::process::ExitStatusExt;
1020 ExitStatus::from_raw(1)
1021 }
1022}