1use std::collections::BTreeSet;
9
10use semver::Version;
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14use super::plan::Fingerprint;
15use super::redact::{CommandPurpose, NativeProgram, RedactedOrigin};
16use super::report::{
17 Capability, CapabilityStatus, DiagnosticDetails, DiagnosticEvidence, DiagnosticReport,
18 DiagnosticStatus, Endpoint, RuntimeKind,
19};
20use super::runtime::{ProbeCommand, RuntimeAdapter};
21use crate::process::{CaptureLimits, CommandOutcome, CommandRunner, CommandSpec};
22
23const MINIMUM_BUILDX_VERSION: Version = Version::new(0, 10, 0);
24
25#[derive(Clone, Debug, Default, PartialEq, Eq)]
28pub enum BuildxBuilderSelector {
29 #[default]
30 Auto,
31 Named(String),
32}
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
35#[error("invalid Buildx builder selector")]
36pub struct BuildxBuilderSelectorError;
37
38impl BuildxBuilderSelector {
39 pub fn named(value: impl Into<String>) -> Result<Self, BuildxBuilderSelectorError> {
40 let value = value.into();
41 if is_safe_builder_name(&value) && value != "auto" {
42 Ok(Self::Named(value))
43 } else {
44 Err(BuildxBuilderSelectorError)
45 }
46 }
47
48 pub fn as_name(&self) -> Option<&str> {
49 match self {
50 Self::Auto => None,
51 Self::Named(name) => Some(name),
52 }
53 }
54}
55
56impl std::str::FromStr for BuildxBuilderSelector {
57 type Err = BuildxBuilderSelectorError;
58
59 fn from_str(value: &str) -> Result<Self, Self::Err> {
60 if value == "auto" {
61 Ok(Self::Auto)
62 } else {
63 Self::named(value)
64 }
65 }
66}
67
68impl std::fmt::Display for BuildxBuilderSelector {
69 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 match self {
71 Self::Auto => formatter.write_str("auto"),
72 Self::Named(name) => formatter.write_str(name),
73 }
74 }
75}
76
77impl Serialize for BuildxBuilderSelector {
78 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
79 where
80 S: serde::Serializer,
81 {
82 serializer.serialize_str(&self.to_string())
83 }
84}
85
86impl<'de> Deserialize<'de> for BuildxBuilderSelector {
87 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
88 where
89 D: serde::Deserializer<'de>,
90 {
91 let value = String::deserialize(deserializer)?;
92 value.parse().map_err(serde::de::Error::custom)
93 }
94}
95
96fn is_safe_builder_name(value: &str) -> bool {
97 value.len() <= 128
98 && value
99 .bytes()
100 .next()
101 .is_some_and(|byte| byte.is_ascii_alphanumeric())
102 && value
103 .bytes()
104 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
105}
106
107#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
109#[serde(rename_all = "kebab-case")]
110pub enum BuilderDriver {
111 Docker,
112 DockerContainer,
113 Kubernetes,
114 Remote,
115 Cloud,
116 Unknown,
117}
118
119impl BuilderDriver {
120 fn parse(value: &str) -> Self {
121 match value.trim().to_ascii_lowercase().as_str() {
122 "docker" => Self::Docker,
123 "docker-container" => Self::DockerContainer,
124 "kubernetes" => Self::Kubernetes,
125 "remote" => Self::Remote,
126 "cloud" => Self::Cloud,
127 _ => Self::Unknown,
128 }
129 }
130
131 fn identity_name(&self) -> &'static str {
132 match self {
133 Self::Docker => "docker",
134 Self::DockerContainer => "docker-container",
135 Self::Kubernetes => "kubernetes",
136 Self::Remote => "remote",
137 Self::Cloud => "cloud",
138 Self::Unknown => "unknown",
139 }
140 }
141}
142
143#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
145#[serde(rename_all = "kebab-case")]
146pub enum BuilderNodeStatus {
147 Running,
148 Stopped,
149 Inactive,
150 Error,
151 Unknown,
152}
153
154impl BuilderNodeStatus {
155 fn parse(value: &str) -> Self {
156 match value.trim().to_ascii_lowercase().as_str() {
157 "running" => Self::Running,
158 "stopped" => Self::Stopped,
159 "inactive" => Self::Inactive,
160 "error" => Self::Error,
161 _ => Self::Unknown,
162 }
163 }
164}
165
166#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
168pub struct BuildPlatform {
169 pub os: String,
170 pub architecture: String,
171 pub variant: Option<String>,
172}
173
174#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
177pub struct BuildkitNodeDiagnosticDetails {
178 pub ordinal: usize,
179 pub status: BuilderNodeStatus,
180 pub version: Option<String>,
181 pub endpoint: Option<RedactedOrigin>,
182 pub platforms: Vec<String>,
183}
184
185#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
187pub struct BuildkitDiagnosticDetails {
188 pub buildx_version: Option<String>,
189 pub driver: Option<BuilderDriver>,
190 pub nodes: Vec<BuildkitNodeDiagnosticDetails>,
191}
192
193impl BuildPlatform {
194 fn parse(raw: &str) -> Option<Self> {
195 let raw = raw.trim().trim_end_matches('*');
196 let mut components = raw.split('/');
197 let os = components.next()?.trim();
198 let architecture = components.next()?.trim();
199 let variant = components.next().map(str::trim);
200 if !is_safe_platform_component(os)
201 || !is_safe_platform_component(architecture)
202 || components.next().is_some()
203 || variant.is_some_and(|value| !is_safe_platform_component(value))
204 {
205 return None;
206 }
207 Some(Self {
208 os: os.to_ascii_lowercase(),
209 architecture: architecture.to_ascii_lowercase(),
210 variant: variant.map(str::to_ascii_lowercase),
211 })
212 }
213}
214
215fn is_safe_platform_component(value: &str) -> bool {
216 !value.is_empty()
217 && value.len() <= 64
218 && value.bytes().all(|byte| {
219 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'.' | b'-')
220 })
221}
222
223#[derive(Clone, Debug, PartialEq, Eq)]
225pub struct BuilderNode {
226 pub name: String,
227 pub endpoint: Option<Endpoint>,
228 pub endpoint_fingerprint: Option<Fingerprint>,
231 pub status: BuilderNodeStatus,
232 pub buildkit_version: Option<Version>,
233 pub platforms: BTreeSet<BuildPlatform>,
234}
235
236#[derive(Clone, Debug, PartialEq, Eq)]
238pub struct SelectedBuilder {
239 pub name: String,
240 pub driver: BuilderDriver,
241 pub nodes: Vec<BuilderNode>,
242 pub has_error: bool,
243}
244
245impl SelectedBuilder {
246 pub fn topology_fingerprint(&self) -> Result<Option<Fingerprint>, super::plan::PlanError> {
252 if self.nodes.is_empty()
253 || self
254 .nodes
255 .iter()
256 .any(|node| node.endpoint_fingerprint.is_none())
257 {
258 return Ok(None);
259 }
260 let mut nodes = self
261 .nodes
262 .iter()
263 .map(|node| BuilderNodeIdentity {
264 name: node.name.as_str(),
265 endpoint_fingerprint: node.endpoint_fingerprint.as_ref().expect("checked above"),
266 })
267 .collect::<Vec<_>>();
268 nodes.sort_by(|left, right| {
269 left.name
270 .cmp(right.name)
271 .then_with(|| left.endpoint_fingerprint.cmp(right.endpoint_fingerprint))
272 });
273 Fingerprint::for_canonical(&BuilderTopologyIdentity {
274 schema_version: 1,
275 driver: self.driver.identity_name(),
276 nodes,
277 })
278 .map(Some)
279 }
280}
281
282#[derive(Serialize)]
283struct BuilderTopologyIdentity<'a> {
284 schema_version: u32,
285 driver: &'static str,
286 nodes: Vec<BuilderNodeIdentity<'a>>,
287}
288
289#[derive(Serialize)]
290struct BuilderNodeIdentity<'a> {
291 name: &'a str,
292 endpoint_fingerprint: &'a Fingerprint,
293}
294
295#[derive(Clone, Debug, PartialEq, Eq)]
297pub struct BuildkitDiscovery {
298 pub report: DiagnosticReport,
299 pub buildx_version: Option<Version>,
300 pub selected_builder: Option<SelectedBuilder>,
301}
302
303#[derive(Clone, Debug, Default)]
305pub struct BuildkitAdapter {
306 selector: BuildxBuilderSelector,
307}
308
309impl BuildkitAdapter {
310 pub fn new(selector: BuildxBuilderSelector) -> Self {
311 Self { selector }
312 }
313
314 pub fn selector(&self) -> &BuildxBuilderSelector {
315 &self.selector
316 }
317
318 pub fn inspect(&self, runner: &dyn CommandRunner, limits: CaptureLimits) -> BuildkitDiscovery {
321 let mut report = DiagnosticReport::new(RuntimeKind::Buildkit, DiagnosticStatus::Degraded);
322 let version_probe = probe(
323 CommandPurpose::Version,
324 CommandSpec::new("docker").args(["buildx", "version"]),
325 );
326 report.add_evidence(DiagnosticEvidence::Command(
327 version_probe.evidence().clone(),
328 ));
329 let version_outcome = version_probe.execute(runner, limits);
330 let version = match successful_stdout(&version_outcome) {
331 Some(stdout) => parse_buildx_version(stdout),
332 None => {
333 report.status = classify_outcome(&version_outcome, DiagnosticStatus::NotInstalled);
334 set_unavailable_capabilities(&mut report);
335 return BuildkitDiscovery {
336 report,
337 buildx_version: None,
338 selected_builder: None,
339 };
340 }
341 };
342 report.set_capability(Capability::Client, CapabilityStatus::Supported);
343 if version
344 .as_ref()
345 .is_none_or(|value| value < &MINIMUM_BUILDX_VERSION)
346 {
347 report.status = DiagnosticStatus::UnsupportedVersion;
348 report.set_capability(Capability::BuilderInspection, CapabilityStatus::Unsupported);
349 return BuildkitDiscovery {
350 report,
351 buildx_version: version,
352 selected_builder: None,
353 };
354 }
355
356 let list_probe = probe(
357 CommandPurpose::BuilderInspect,
358 CommandSpec::new("docker").args(["buildx", "ls", "--format", "{{json .}}"]),
359 );
360 report.add_evidence(DiagnosticEvidence::Command(list_probe.evidence().clone()));
361 let list_outcome = list_probe.execute(runner, limits);
362 let Some(list_stdout) = successful_stdout(&list_outcome) else {
363 report.status = classify_outcome(&list_outcome, DiagnosticStatus::ClientOnly);
364 report.set_capability(Capability::BuilderInspection, CapabilityStatus::Unavailable);
365 return BuildkitDiscovery {
366 report,
367 buildx_version: version,
368 selected_builder: None,
369 };
370 };
371 let Some(mut selected) = parse_selected_builder(list_stdout, &self.selector) else {
372 report.status =
373 classify_text(&output_text(&list_outcome), DiagnosticStatus::ClientOnly);
374 report.set_capability(Capability::BuilderInspection, CapabilityStatus::Unavailable);
375 return BuildkitDiscovery {
376 report,
377 buildx_version: version,
378 selected_builder: None,
379 };
380 };
381
382 let inspect_probe = probe(
385 CommandPurpose::BuilderInspect,
386 CommandSpec::new("docker").args(["buildx", "inspect", selected.name.as_str()]),
387 );
388 report.add_evidence(DiagnosticEvidence::Command(
389 inspect_probe.evidence().clone(),
390 ));
391 let inspect_outcome = inspect_probe.execute(runner, limits);
392 let inspect_failure = if let Some(stdout) = successful_stdout(&inspect_outcome) {
393 if merge_inspect_text(&mut selected, stdout) {
394 None
395 } else {
396 selected.has_error = true;
397 Some(DiagnosticStatus::Degraded)
398 }
399 } else {
400 selected.has_error = true;
401 Some(classify_outcome(
402 &inspect_outcome,
403 DiagnosticStatus::Degraded,
404 ))
405 };
406
407 for node in &selected.nodes {
408 if let Some(endpoint) = &node.endpoint {
409 report.add_endpoint(endpoint.clone());
410 }
411 }
412 let combined = format!(
413 "{} {}",
414 output_text(&list_outcome),
415 output_text(&inspect_outcome)
416 );
417 report.status = if let Some(
418 status @ (DiagnosticStatus::PermissionDenied | DiagnosticStatus::Unreachable),
419 ) = inspect_failure
420 {
421 status
422 } else if selected.has_error {
423 classify_text(&combined, DiagnosticStatus::Degraded)
424 } else if selected
425 .nodes
426 .iter()
427 .any(|node| node.status == BuilderNodeStatus::Running)
428 {
429 DiagnosticStatus::Healthy
430 } else {
431 DiagnosticStatus::Degraded
432 };
433 report.set_capability(
434 Capability::BuilderInspection,
435 if report.status == DiagnosticStatus::Healthy {
436 CapabilityStatus::Supported
437 } else {
438 CapabilityStatus::Unavailable
439 },
440 );
441 report.set_capability(
442 Capability::PlatformSelection,
443 if selected.nodes.iter().any(|node| !node.platforms.is_empty()) {
444 CapabilityStatus::Supported
445 } else {
446 CapabilityStatus::Unknown
447 },
448 );
449
450 BuildkitDiscovery {
451 report,
452 buildx_version: version,
453 selected_builder: Some(selected),
454 }
455 }
456
457 fn details(discovery: &BuildkitDiscovery) -> BuildkitDiagnosticDetails {
458 let selected = discovery.selected_builder.as_ref();
459 BuildkitDiagnosticDetails {
460 buildx_version: discovery.buildx_version.as_ref().map(ToString::to_string),
461 driver: selected.map(|builder| builder.driver.clone()),
462 nodes: selected
463 .into_iter()
464 .flat_map(|builder| builder.nodes.iter())
465 .enumerate()
466 .map(|(ordinal, node)| BuildkitNodeDiagnosticDetails {
467 ordinal,
468 status: node.status.clone(),
469 version: node.buildkit_version.as_ref().map(ToString::to_string),
470 endpoint: node
471 .endpoint
472 .as_ref()
473 .map(|endpoint| RedactedOrigin::from(endpoint.address.clone())),
474 platforms: node.platforms.iter().map(build_platform_string).collect(),
475 })
476 .collect(),
477 }
478 }
479}
480
481impl RuntimeAdapter for BuildkitAdapter {
482 fn kind(&self) -> RuntimeKind {
483 RuntimeKind::Buildkit
484 }
485
486 fn diagnose(&self, runner: &dyn CommandRunner, limits: CaptureLimits) -> DiagnosticReport {
487 let mut discovery = self.inspect(runner, limits);
488 let details = Self::details(&discovery);
489 discovery
490 .report
491 .set_details(DiagnosticDetails::Buildkit(details));
492 discovery.report
493 }
494}
495
496fn build_platform_string(platform: &BuildPlatform) -> String {
497 let mut value = format!("{}/{}", platform.os, platform.architecture);
498 if let Some(variant) = &platform.variant {
499 value.push('/');
500 value.push_str(variant);
501 }
502 value
503}
504
505fn probe(purpose: CommandPurpose, command: CommandSpec) -> ProbeCommand {
506 ProbeCommand::new(NativeProgram::Buildx, purpose, command)
507}
508
509fn successful_stdout(outcome: &CommandOutcome) -> Option<&[u8]> {
510 match outcome {
511 CommandOutcome::Exited { status, output }
512 if status.success() && !output.stdout_truncated =>
513 {
514 Some(&output.stdout)
515 }
516 _ => None,
517 }
518}
519
520fn output_text(outcome: &CommandOutcome) -> String {
521 outcome
522 .output()
523 .map(|output| {
524 let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
525 text.push(' ');
526 text.push_str(&String::from_utf8_lossy(&output.stderr));
527 text
528 })
529 .unwrap_or_default()
530}
531
532fn classify_outcome(outcome: &CommandOutcome, fallback: DiagnosticStatus) -> DiagnosticStatus {
533 match outcome {
534 CommandOutcome::NotInstalled => DiagnosticStatus::NotInstalled,
535 CommandOutcome::PermissionDenied => DiagnosticStatus::PermissionDenied,
536 CommandOutcome::TimedOut { .. } => DiagnosticStatus::Unreachable,
537 _ => classify_text(&output_text(outcome), fallback),
538 }
539}
540
541fn classify_text(text: &str, fallback: DiagnosticStatus) -> DiagnosticStatus {
542 let text = text.to_ascii_lowercase();
543 if text.contains("permission denied") || text.contains("access is denied") {
544 DiagnosticStatus::PermissionDenied
545 } else if text.contains("cannot connect")
546 || text.contains("connection refused")
547 || text.contains("connection error")
548 || text.contains("deadline exceeded")
549 || text.contains("timed out")
550 {
551 DiagnosticStatus::Unreachable
552 } else if text.contains("is not a docker command")
553 || text.contains("unknown command \"buildx\"")
554 || text.contains("docker-buildx: executable file not found")
555 {
556 DiagnosticStatus::NotInstalled
557 } else {
558 fallback
559 }
560}
561
562fn set_unavailable_capabilities(report: &mut DiagnosticReport) {
563 report.set_capability(Capability::Client, CapabilityStatus::Unavailable);
564 report.set_capability(Capability::BuilderInspection, CapabilityStatus::Unavailable);
565 report.set_capability(Capability::PlatformSelection, CapabilityStatus::Unknown);
566}
567
568fn parse_buildx_version(bytes: &[u8]) -> Option<Version> {
569 String::from_utf8_lossy(bytes)
570 .split_whitespace()
571 .filter_map(super::parse_vendor_version)
572 .next()
573}
574
575fn parse_selected_builder(
576 bytes: &[u8],
577 selector: &BuildxBuilderSelector,
578) -> Option<SelectedBuilder> {
579 let matches = String::from_utf8_lossy(bytes)
580 .lines()
581 .filter_map(|line| serde_json::from_str::<Value>(line.trim()).ok())
582 .filter(|value| match selector {
583 BuildxBuilderSelector::Auto => json_bool(value, "Current").unwrap_or(false),
584 BuildxBuilderSelector::Named(name) => json_str(value, "Name") == Some(name),
585 })
586 .filter_map(parse_builder_json)
587 .collect::<Vec<_>>();
588 (matches.len() == 1).then(|| matches.into_iter().next().unwrap())
589}
590
591fn parse_builder_json(value: Value) -> Option<SelectedBuilder> {
592 let name = json_str(&value, "Name")?.trim().to_owned();
593 if !is_safe_builder_name(&name) {
594 return None;
595 }
596 let driver = BuilderDriver::parse(json_str(&value, "Driver").unwrap_or_default());
597 let has_error = json_str(&value, "Err").is_some_and(|error| !error.trim().is_empty());
598 let nodes = value
599 .get("Nodes")
600 .and_then(Value::as_array)
601 .into_iter()
602 .flatten()
603 .map(parse_node_json)
604 .collect();
605 Some(SelectedBuilder {
606 name,
607 driver,
608 nodes,
609 has_error,
610 })
611}
612
613fn parse_node_json(value: &Value) -> BuilderNode {
614 let platforms = value
615 .get("Platforms")
616 .map(platform_values)
617 .unwrap_or_default();
618 let raw_endpoint = json_str(value, "Endpoint").map(str::trim);
619 let endpoint = raw_endpoint.and_then(parse_endpoint);
620 BuilderNode {
621 name: json_str(value, "Name")
622 .unwrap_or_default()
623 .trim()
624 .to_owned(),
625 endpoint_fingerprint: endpoint
626 .as_ref()
627 .and(raw_endpoint)
628 .map(builder_endpoint_fingerprint),
629 endpoint,
630 status: BuilderNodeStatus::parse(json_str(value, "Status").unwrap_or_default()),
631 buildkit_version: json_str(value, "Buildkit").and_then(super::parse_vendor_version),
632 platforms,
633 }
634}
635
636fn json_str<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
637 value.get(key).and_then(Value::as_str)
638}
639
640fn json_bool(value: &Value, key: &str) -> Option<bool> {
641 value.get(key).and_then(Value::as_bool)
642}
643
644fn platform_values(value: &Value) -> BTreeSet<BuildPlatform> {
645 match value {
646 Value::String(raw) => parse_platforms(raw),
647 Value::Array(values) => values
648 .iter()
649 .filter_map(Value::as_str)
650 .filter_map(BuildPlatform::parse)
651 .collect(),
652 _ => BTreeSet::new(),
653 }
654}
655
656fn parse_platforms(raw: &str) -> BTreeSet<BuildPlatform> {
657 raw.split(',').filter_map(BuildPlatform::parse).collect()
658}
659
660fn parse_endpoint(raw: &str) -> Option<Endpoint> {
661 let address = super::redact::RedactedUrl::parse(raw.trim()).ok()?;
662 let (transport, scope) = super::classify_endpoint(raw.trim());
663 Some(Endpoint::new(transport, scope, address))
664}
665
666fn merge_inspect_text(builder: &mut SelectedBuilder, bytes: &[u8]) -> bool {
667 let text = String::from_utf8_lossy(bytes);
668 let top_level_name = text.lines().find_map(|line| {
669 let (key, value) = line.trim().split_once(':')?;
670 (key.trim() == "Name").then(|| value.trim())
671 });
672 if top_level_name != Some(builder.name.as_str()) {
673 return false;
674 }
675
676 let mut current_node: Option<usize> = None;
677 let mut consumed_top_level_name = false;
678 for line in text.lines() {
679 let trimmed = line.trim();
680 let Some((key, value)) = trimmed.split_once(':') else {
681 continue;
682 };
683 let key = key.trim();
684 let value = value.trim();
685 match key {
686 "Name" if !consumed_top_level_name => {
687 consumed_top_level_name = true;
688 }
689 "Driver" if current_node.is_none() => builder.driver = BuilderDriver::parse(value),
690 "Error" => builder.has_error |= !value.is_empty(),
691 "Name" => {
692 let index = builder
693 .nodes
694 .iter()
695 .position(|node| node.name == value)
696 .unwrap_or_else(|| {
697 builder.nodes.push(empty_node(value));
698 builder.nodes.len() - 1
699 });
700 current_node = Some(index);
701 }
702 "Endpoint" => {
703 if let Some(node) = current_node.and_then(|index| builder.nodes.get_mut(index)) {
704 node.endpoint = parse_endpoint(value);
705 node.endpoint_fingerprint = node
706 .endpoint
707 .as_ref()
708 .map(|_| builder_endpoint_fingerprint(value));
709 }
710 }
711 "Status" => {
712 if let Some(node) = current_node.and_then(|index| builder.nodes.get_mut(index)) {
713 node.status = BuilderNodeStatus::parse(value);
714 }
715 }
716 "BuildKit" => {
717 if let Some(node) = current_node.and_then(|index| builder.nodes.get_mut(index)) {
718 node.buildkit_version = super::parse_vendor_version(value);
719 }
720 }
721 "Platforms" => {
722 if let Some(node) = current_node.and_then(|index| builder.nodes.get_mut(index)) {
723 node.platforms.extend(parse_platforms(value));
724 }
725 }
726 _ => {}
727 }
728 }
729 true
730}
731
732fn builder_endpoint_fingerprint(raw: &str) -> Fingerprint {
733 let mut identity = b"osdk-buildx-node-endpoint-v1\0".to_vec();
734 identity.extend_from_slice(raw.as_bytes());
735 Fingerprint::for_bytes(&identity)
736}
737
738fn empty_node(name: &str) -> BuilderNode {
739 BuilderNode {
740 name: name.to_owned(),
741 endpoint: None,
742 endpoint_fingerprint: None,
743 status: BuilderNodeStatus::Unknown,
744 buildkit_version: None,
745 platforms: BTreeSet::new(),
746 }
747}
748
749#[cfg(test)]
750mod tests {
751 use std::collections::VecDeque;
752 use std::io;
753 use std::process::ExitStatus;
754 use std::sync::Mutex;
755 use std::time::Duration;
756
757 use super::*;
758 use crate::container::EndpointTransport;
759 use crate::process::CapturedOutput;
760
761 struct FakeRunner {
762 outcomes: Mutex<VecDeque<CommandOutcome>>,
763 calls: Mutex<Vec<Vec<String>>>,
764 }
765
766 impl FakeRunner {
767 fn new(outcomes: impl IntoIterator<Item = CommandOutcome>) -> Self {
768 Self {
769 outcomes: Mutex::new(outcomes.into_iter().collect()),
770 calls: Mutex::new(Vec::new()),
771 }
772 }
773 }
774
775 impl CommandRunner for FakeRunner {
776 fn run_captured(&self, command: &CommandSpec, _limits: CaptureLimits) -> CommandOutcome {
777 self.calls.lock().unwrap().push(
778 command
779 .arguments()
780 .iter()
781 .map(|argument| argument.to_string_lossy().into_owned())
782 .collect(),
783 );
784 self.outcomes.lock().unwrap().pop_front().unwrap()
785 }
786
787 fn run_foreground(&self, _command: &CommandSpec) -> io::Result<ExitStatus> {
788 panic!("BuildkitAdapter discovery must not run foreground commands")
789 }
790 }
791
792 fn exited(success: bool, stdout: &str, stderr: &str) -> CommandOutcome {
793 let status = shell_status(success);
794 CommandOutcome::Exited {
795 status,
796 output: CapturedOutput {
797 stdout: stdout.as_bytes().to_vec(),
798 stderr: stderr.as_bytes().to_vec(),
799 elapsed: Duration::ZERO,
800 ..CapturedOutput::default()
801 },
802 }
803 }
804
805 #[cfg(unix)]
806 fn shell_status(success: bool) -> ExitStatus {
807 use std::os::unix::process::ExitStatusExt;
808 ExitStatus::from_raw(if success { 0 } else { 1 << 8 })
809 }
810
811 #[cfg(windows)]
812 fn shell_status(success: bool) -> ExitStatus {
813 use std::os::windows::process::ExitStatusExt;
814 ExitStatus::from_raw(if success { 0 } else { 1 })
815 }
816
817 #[test]
818 fn parses_selected_builder_json_lines_and_inspect_without_bootstrap() {
819 let list = concat!(
820 r#"{"Current":false,"Driver":"docker","Name":"other","Nodes":[]}"#,
821 "\n",
822 r#"{"Current":true,"Driver":"docker-container","Err":"","Name":"selected","Nodes":[{"Endpoint":"ssh://alice:secret@example.test/private?token=x","Name":"selected0","Status":"running","Buildkit":"v0.25.0","Platforms":"linux/amd64, linux/arm64/v8*"}],"FutureField":true}"#,
823 "\n"
824 );
825 let inspect = concat!(
826 "Name: selected\n",
827 "Driver: docker-container\n",
828 "Nodes:\n",
829 "Name: selected0\n",
830 "Endpoint: unix:///var/run/docker.sock\n",
831 "Status: running\n",
832 "BuildKit: v0.25.0\n",
833 "Platforms: linux/amd64, linux/arm64/v8*\n",
834 "Labels:\n",
835 " org.mobyproject.buildkit.worker.hostname: fixture:with:colons\n"
836 );
837 let runner = FakeRunner::new([
838 exited(
839 true,
840 "github.com/docker/buildx v0.36.1 deadbeef\n",
841 "warning\n",
842 ),
843 exited(true, list, "warning that must not invalidate stdout\n"),
844 exited(true, inspect, ""),
845 ]);
846
847 let discovery = BuildkitAdapter::default().inspect(&runner, CaptureLimits::default());
848 assert_eq!(discovery.report.status, DiagnosticStatus::Healthy);
849 assert_eq!(discovery.buildx_version, Some(Version::new(0, 36, 1)));
850 let builder = discovery.selected_builder.unwrap();
851 assert_eq!(builder.name, "selected");
852 assert_eq!(builder.driver, BuilderDriver::DockerContainer);
853 assert_eq!(builder.nodes[0].status, BuilderNodeStatus::Running);
854 assert_eq!(builder.nodes[0].platforms.len(), 2);
855 let endpoint = builder.nodes[0].endpoint.as_ref().unwrap();
856 assert_eq!(endpoint.transport, EndpointTransport::LocalSocket);
857 assert!(!endpoint.address.as_str().contains("docker.sock"));
858 let calls = runner.calls.lock().unwrap();
859 assert_eq!(calls[2], ["buildx", "inspect", "selected"]);
860 assert!(!calls
861 .iter()
862 .flatten()
863 .any(|argument| argument == "--bootstrap"));
864 }
865
866 #[test]
867 fn topology_fingerprint_binds_driver_and_unredacted_node_endpoints() {
868 fn builder(driver: &str, endpoint: &str) -> SelectedBuilder {
869 parse_builder_json(serde_json::json!({
870 "Current": true,
871 "Driver": driver,
872 "Name": "selected",
873 "Nodes": [{
874 "Name": "selected0",
875 "Endpoint": endpoint,
876 "Status": "running"
877 }]
878 }))
879 .unwrap()
880 }
881
882 let first = builder(
883 "docker-container",
884 "ssh://alice:secret@host.example/private-a?token=one",
885 );
886 let retargeted = builder(
887 "docker-container",
888 "ssh://alice:secret@host.example/private-b?token=two",
889 );
890 let changed_driver = builder(
891 "remote",
892 "ssh://alice:secret@host.example/private-a?token=one",
893 );
894
895 assert_eq!(first.name, retargeted.name);
896 assert_eq!(first.nodes[0].endpoint, retargeted.nodes[0].endpoint);
897 let first_id = first.topology_fingerprint().unwrap().unwrap();
898 assert_ne!(
899 first_id,
900 retargeted.topology_fingerprint().unwrap().unwrap()
901 );
902 assert_ne!(
903 first_id,
904 changed_driver.topology_fingerprint().unwrap().unwrap()
905 );
906 let serialized = serde_json::to_string(&first_id).unwrap();
907 for secret in ["alice", "secret", "private-a", "token", "one"] {
908 assert!(
909 !serialized.contains(secret),
910 "leaked {secret}: {serialized}"
911 );
912 }
913 }
914
915 #[test]
916 fn explicit_builder_selects_non_current_and_is_one_safe_argument() {
917 let list = concat!(
918 r#"{"Current":true,"Driver":"docker","Name":"current","Nodes":[]}"#,
919 "\n",
920 r#"{"Current":false,"Driver":"docker-container","Name":"team.builder-2","Nodes":[{"Name":"team.builder-20","Status":"running"}]}"#,
921 "\n"
922 );
923 let runner = FakeRunner::new([
924 exited(true, "github.com/docker/buildx v0.36.1 deadbeef\n", ""),
925 exited(true, list, ""),
926 exited(
927 true,
928 "Name: team.builder-2\nDriver: docker-container\nName: team.builder-20\nStatus: running\n",
929 "",
930 ),
931 ]);
932 let adapter = BuildkitAdapter::new(BuildxBuilderSelector::named("team.builder-2").unwrap());
933
934 let discovery = adapter.inspect(&runner, CaptureLimits::default());
935
936 assert_eq!(discovery.report.status, DiagnosticStatus::Healthy);
937 assert_eq!(discovery.selected_builder.unwrap().name, "team.builder-2");
938 let calls = runner.calls.lock().unwrap();
939 assert_eq!(calls[2], ["buildx", "inspect", "team.builder-2"]);
940 assert!(!calls.iter().flatten().any(|value| value == "--bootstrap"));
941 }
942
943 #[test]
944 fn missing_named_or_ambiguous_current_builder_fails_without_inspection() {
945 for (selector, list) in [
946 (
947 BuildxBuilderSelector::named("missing").unwrap(),
948 r#"{"Current":true,"Name":"current","Nodes":[]}"#,
949 ),
950 (
951 BuildxBuilderSelector::Auto,
952 concat!(
953 r#"{"Current":true,"Name":"one","Nodes":[]}"#,
954 "\n",
955 r#"{"Current":true,"Name":"two","Nodes":[]}"#
956 ),
957 ),
958 ] {
959 let runner = FakeRunner::new([
960 exited(true, "github.com/docker/buildx v0.36.1 deadbeef\n", ""),
961 exited(true, list, ""),
962 ]);
963 let discovery =
964 BuildkitAdapter::new(selector).inspect(&runner, CaptureLimits::default());
965 assert_eq!(discovery.report.status, DiagnosticStatus::ClientOnly);
966 assert!(discovery.selected_builder.is_none());
967 assert_eq!(runner.calls.lock().unwrap().len(), 2);
968 }
969 }
970
971 #[test]
972 fn rejects_unsafe_builder_selectors_without_echoing_them() {
973 let invalid = [
974 "".to_owned(),
975 "--help".to_owned(),
976 "-builder".to_owned(),
977 ".builder".to_owned(),
978 "name with spaces".to_owned(),
979 "b\u{fc}ilder".to_owned(),
980 "a".repeat(129),
981 ];
982 for value in invalid {
983 let error = value.parse::<BuildxBuilderSelector>().unwrap_err();
984 if !value.is_empty() {
985 assert!(!error.to_string().contains(&value));
986 assert!(!format!("{error:?}").contains(&value));
987 }
988 }
989 for value in ["default", "remote-builder_1", "team.builder-2"] {
990 assert_eq!(
991 value.parse::<BuildxBuilderSelector>().unwrap().as_name(),
992 Some(value)
993 );
994 }
995 }
996
997 #[test]
998 fn mismatched_inspect_name_is_not_merged() {
999 let list = r#"{"Current":true,"Driver":"docker","Name":"selected","Nodes":[]}"#;
1000 let runner = FakeRunner::new([
1001 exited(true, "github.com/docker/buildx v0.36.1 deadbeef\n", ""),
1002 exited(true, list, ""),
1003 exited(
1004 true,
1005 "Name: different\nDriver: docker-container\nName: node0\nStatus: running\n",
1006 "",
1007 ),
1008 ]);
1009
1010 let discovery = BuildkitAdapter::default().inspect(&runner, CaptureLimits::default());
1011
1012 assert_eq!(discovery.report.status, DiagnosticStatus::Degraded);
1013 let builder = discovery.selected_builder.unwrap();
1014 assert_eq!(builder.driver, BuilderDriver::Docker);
1015 assert!(builder.nodes.is_empty());
1016 assert!(builder.has_error);
1017 }
1018
1019 #[test]
1020 fn buildkit_json_and_text_versions_share_vendor_conversion() {
1021 let list = r#"{"Current":true,"Driver":"docker-container","Name":"selected","Nodes":[{"Name":"selected0","Status":"running","Buildkit":"V0.25"}]}"#;
1022 let runner = FakeRunner::new([
1023 exited(true, "github.com/docker/buildx V0.36\n", ""),
1024 exited(true, list, ""),
1025 exited(
1026 true,
1027 "Name: selected\nDriver: docker-container\nName: selected0\nStatus: running\nBuildKit: V0.25\n",
1028 "",
1029 ),
1030 ]);
1031
1032 let discovery = BuildkitAdapter::default().inspect(&runner, CaptureLimits::default());
1033
1034 assert_eq!(discovery.buildx_version, Some(Version::new(0, 36, 0)));
1035 assert_eq!(
1036 discovery.selected_builder.unwrap().nodes[0].buildkit_version,
1037 Some(Version::new(0, 25, 0))
1038 );
1039 }
1040
1041 #[test]
1042 fn loopback_buildkit_endpoints_are_local() {
1043 for raw in [
1044 "tcp://127.0.0.1:1234",
1045 "http://localhost:1234",
1046 "https://[::1]:1234",
1047 ] {
1048 assert_eq!(
1049 parse_endpoint(raw).unwrap().scope,
1050 crate::container::EndpointScope::Local
1051 );
1052 }
1053 assert_eq!(
1054 parse_endpoint("tcp://192.0.2.10:1234").unwrap().scope,
1055 crate::container::EndpointScope::Remote
1056 );
1057 }
1058
1059 #[test]
1060 fn malicious_platform_output_is_not_serialized() {
1061 let list = r#"{"Current":true,"Driver":"docker-container","Name":"selected","Nodes":[{"Name":"selected0","Status":"running","Platforms":"linux/amd64, linux/TOKEN_SECRET, linux/arm64/../../secret, linux/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]}"#;
1062 let runner = FakeRunner::new([
1063 exited(true, "github.com/docker/buildx v0.36.1 deadbeef\n", ""),
1064 exited(true, list, ""),
1065 exited(
1066 true,
1067 "Name: selected\nDriver: docker-container\nName: selected0\nStatus: running\nPlatforms: linux/amd64, linux/TOKEN_SECRET, linux/arm64/../../secret\n",
1068 "",
1069 ),
1070 ]);
1071
1072 let report = BuildkitAdapter::default().diagnose(&runner, CaptureLimits::default());
1073 let json = serde_json::to_string(&report).unwrap();
1074
1075 assert!(json.contains("\"platforms\":[\"linux/amd64\"]"), "{json}");
1076 for rejected in ["TOKEN_SECRET", "../../secret", &"a".repeat(65)] {
1077 assert!(!json.contains(rejected), "leaked {rejected}: {json}");
1078 }
1079 }
1080
1081 #[test]
1082 fn classifies_missing_old_and_client_only_buildx() {
1083 let missing = FakeRunner::new([CommandOutcome::NotInstalled]);
1084 assert_eq!(
1085 BuildkitAdapter::default()
1086 .inspect(&missing, CaptureLimits::default())
1087 .report
1088 .status,
1089 DiagnosticStatus::NotInstalled
1090 );
1091
1092 let old = FakeRunner::new([exited(true, "github.com/docker/buildx v0.9.1 abc\n", "")]);
1093 assert_eq!(
1094 BuildkitAdapter::default()
1095 .inspect(&old, CaptureLimits::default())
1096 .report
1097 .status,
1098 DiagnosticStatus::UnsupportedVersion
1099 );
1100
1101 let client_only = FakeRunner::new([
1102 exited(true, "github.com/docker/buildx v0.36.1 abc\n", ""),
1103 exited(true, "", ""),
1104 ]);
1105 assert_eq!(
1106 BuildkitAdapter::default()
1107 .inspect(&client_only, CaptureLimits::default())
1108 .report
1109 .status,
1110 DiagnosticStatus::ClientOnly
1111 );
1112 }
1113
1114 #[test]
1115 fn classifies_builder_errors_even_when_buildx_exits_successfully() {
1116 for (message, expected) in [
1117 (
1118 "permission denied while trying to connect to the docker API",
1119 DiagnosticStatus::PermissionDenied,
1120 ),
1121 (
1122 "Cannot connect to the Docker daemon at tcp://host:2375",
1123 DiagnosticStatus::Unreachable,
1124 ),
1125 ] {
1126 let list = format!(
1127 r#"{{"Current":true,"Driver":"","Err":"{message}","Name":"default","Nodes":[{{"Endpoint":"","Name":""}}]}}"#
1128 );
1129 let inspect = format!("Name: default\nDriver:\nError: {message}\n");
1130 let runner = FakeRunner::new([
1131 exited(true, "github.com/docker/buildx v0.36.1 abc\n", ""),
1132 exited(true, &list, ""),
1133 exited(true, &inspect, ""),
1134 ]);
1135 assert_eq!(
1136 BuildkitAdapter::default()
1137 .inspect(&runner, CaptureLimits::default())
1138 .report
1139 .status,
1140 expected
1141 );
1142 }
1143 }
1144
1145 #[test]
1146 fn malformed_or_truncated_discovery_is_not_treated_as_healthy() {
1147 let malformed = FakeRunner::new([
1148 exited(true, "github.com/docker/buildx v0.36.1 abc\n", ""),
1149 exited(true, "not-json\n{}\n", ""),
1150 ]);
1151 assert_eq!(
1152 BuildkitAdapter::default()
1153 .inspect(&malformed, CaptureLimits::default())
1154 .report
1155 .status,
1156 DiagnosticStatus::ClientOnly
1157 );
1158
1159 let mut truncated = exited(true, "github.com/docker/buildx v0.36.1", "");
1160 if let CommandOutcome::Exited { output, .. } = &mut truncated {
1161 output.stdout_truncated = true;
1162 }
1163 let runner = FakeRunner::new([truncated]);
1164 assert_ne!(
1165 BuildkitAdapter::default()
1166 .inspect(&runner, CaptureLimits::default())
1167 .report
1168 .status,
1169 DiagnosticStatus::Healthy
1170 );
1171 }
1172
1173 #[test]
1174 fn classifies_inspect_timeout_and_spawn_permission_denial() {
1175 let list =
1176 r#"{"Current":true,"Driver":"docker-container","Err":"","Name":"selected","Nodes":[]}"#;
1177 for (outcome, expected) in [
1178 (
1179 CommandOutcome::TimedOut {
1180 output: CapturedOutput::default(),
1181 termination: crate::process::TerminationStatus::Requested,
1182 },
1183 DiagnosticStatus::Unreachable,
1184 ),
1185 (
1186 CommandOutcome::PermissionDenied,
1187 DiagnosticStatus::PermissionDenied,
1188 ),
1189 ] {
1190 let runner = FakeRunner::new([
1191 exited(true, "github.com/docker/buildx v0.36.1 abc\n", ""),
1192 exited(true, list, ""),
1193 outcome,
1194 ]);
1195 assert_eq!(
1196 BuildkitAdapter::default()
1197 .inspect(&runner, CaptureLimits::default())
1198 .report
1199 .status,
1200 expected
1201 );
1202 }
1203 }
1204}