Skip to main content

osdk_core/container/
buildkit.rs

1//! Read-only BuildKit discovery through the Docker Buildx CLI.
2//!
3//! Discovery deliberately does not use `buildx inspect --bootstrap`: probing a
4//! builder must not start it or otherwise mutate native state. Raw native
5//! output is parsed into the typed values below and is never retained in a
6//! serializable diagnostic report.
7
8use 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/// Selection policy for Buildx discovery. `Auto` follows the one current
26/// builder reported by Buildx; `Named` binds discovery to an explicit builder.
27#[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/// The Buildx driver which owns a selected builder.
108#[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/// The observed state of a Buildx node.
144#[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/// An OCI platform reported by Buildx.
167#[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/// One secret-safe BuildKit node projection. Ordinals are stable within the
175/// native output and avoid disclosing native node names.
176#[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/// Secret-safe Buildx/BuildKit facts exposed by diagnostic schema v2.
186#[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/// One node belonging to the selected Buildx builder.
224#[derive(Clone, Debug, PartialEq, Eq)]
225pub struct BuilderNode {
226    pub name: String,
227    pub endpoint: Option<Endpoint>,
228    /// SHA-256 identity of the complete native endpoint before diagnostic
229    /// redaction. This binds mutation previews without exposing the endpoint.
230    pub endpoint_fingerprint: Option<Fingerprint>,
231    pub status: BuilderNodeStatus,
232    pub buildkit_version: Option<Version>,
233    pub platforms: BTreeSet<BuildPlatform>,
234}
235
236/// The selected builder and its read-only inspection facts.
237#[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    /// Fingerprint the discovered driver and complete node endpoint topology.
247    ///
248    /// Raw endpoints are hashed before entering this projection. Missing node
249    /// endpoints make the target unbindable, so mutating callers must fail
250    /// closed rather than authorizing a name-only target.
251    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/// Typed Buildx discovery, alongside its stable secret-safe report.
296#[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/// Read-only BuildKit adapter backed by `docker buildx`.
304#[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    /// Inspect the Buildx client, selected builder list entry, and selected
319    /// builder details. The inspect command intentionally omits `--bootstrap`.
320    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        // Bind inspection to the exact validated list result so an ambient
383        // current-builder change cannot mix facts. Never add `--bootstrap`.
384        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}