@rackbops/ac-agent 2.0.0-alpha.40 → 2.0.0-alpha.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,6 +19,17 @@
19
19
  git repo (artifacts-console/artifact-console/data) -- and, since #320, inside the
20
20
  installed package.
21
21
 
22
+ On a Linux side (WSL or an -SshTargets host) that has docker on PATH and can run
23
+ `docker ps`, also adds a Containers section (issue #721): every RUNNING container's
24
+ name, image reference, image digest, best-effort package list (dpkg/apk/rpm, or
25
+ "unknown" when none apply) and catalogued tools found on its own PATH (the same
26
+ catalog pass, run inside the container). Windows hosts never get this section --
27
+ no Docker on the Windows boxes today. Every docker call the probe makes is
28
+ READ-ONLY (`ps`, `inspect`, `exec` running only the package query and the catalog
29
+ pass) -- nothing here pulls, starts, stops, restarts or removes a container, and a
30
+ container that vanishes mid-probe is skipped or reported with unknown
31
+ packages/tools rather than failing the run.
32
+
22
33
  .PARAMETER IncludeEditors
23
34
  Also probe editors / IDEs (VS Code, Neovim, Vim, Emacs, Sublime, Helix).
24
35
  Off by default. To turn it on permanently, either pass this switch or flip
@@ -568,18 +579,34 @@ function New-ToolchainProbeScript {
568
579
  $tsvLines = $(foreach ($t in $Catalog) { "{0}`t{1}`t{2}" -f $t.Name, $t.Exe, ($t.Ver -join ' ') })
569
580
  $tsv = ($tsvLines -join "`n")
570
581
 
582
+ # The catalog-pass body (issue #721 decision 2): reused VERBATIM in two places below --
583
+ # once at top level for this host/side, and again (unchanged text) fed into each running
584
+ # container via `docker exec -i <id> bash -s` in the Containers block -- reuse, not a
585
+ # second catalog. A function, not inline, precisely so the SAME text can be dropped in
586
+ # both places via one `.Replace()` below. `known` is populated as a side effect (every
587
+ # catalog Exe name, found or not) for the uncatalogued-on-PATH scan further down the HOST
588
+ # script to reuse; it must already be `declare -A`'d by the CALLER -- a `declare` INSIDE
589
+ # this function would make it local and the later scan would see an empty array.
590
+ $catalogPassFn = @'
591
+ catalog_pass() {
592
+ while IFS=$'\t' read -r name exe verargs; do
593
+ [ -z "$exe" ] && continue
594
+ known["$(printf '%s' "$exe" | tr 'A-Z' 'a-z')"]=1
595
+ loc=$(command -v "$exe" 2>/dev/null) || continue
596
+ case "$loc" in "${TOOLCHAIN_PROBE_INTEROP_PREFIX:-/mnt/}"*) continue;; esac # skip Windows tools seen via interop
597
+ ver=$($exe $verargs 2>&1 | grep -m1 . | tr -d '\r')
598
+ printf '%s\t%s\t%s\n' "$name" "$ver" "$loc"
599
+ done
600
+ }
601
+ '@
602
+ $catalogPassFn = ($catalogPassFn -replace "`r`n", "`n").Trim("`n")
603
+
571
604
  $probeSh = @'
572
605
  #!/usr/bin/env bash
573
606
  interop="${TOOLCHAIN_PROBE_INTEROP_PREFIX:-/mnt/}"
607
+ __CATALOG_PASS_FN__
574
608
  declare -A known
575
- while IFS=$'\t' read -r name exe verargs; do
576
- [ -z "$exe" ] && continue
577
- known["$(printf '%s' "$exe" | tr 'A-Z' 'a-z')"]=1
578
- loc=$(command -v "$exe" 2>/dev/null) || continue
579
- case "$loc" in "$interop"*) continue;; esac # skip Windows tools seen via interop
580
- ver=$($exe $verargs 2>&1 | grep -m1 . | tr -d '\r')
581
- printf '%s\t%s\t%s\n' "$name" "$ver" "$loc"
582
- done <<'__CATALOG__'
609
+ catalog_pass <<'__CATALOG__'
583
610
  __TSV__
584
611
  __CATALOG__
585
612
  echo "===GLOBALS==="
@@ -603,8 +630,126 @@ for d in "$HOME/.cargo/bin" "$gb" "$HOME/.local/bin" "$HOME/.dotnet/tools" /usr/
603
630
  printf '%s\t%s\n' "$b" "$f"
604
631
  done
605
632
  done | sort -u
633
+
634
+ # Containers (issue #721): a running container's package list + catalogued tools, on a
635
+ # Linux side only -- Windows never has docker on PATH here, so this whole block is inert
636
+ # there. Emitted ONLY when docker is on PATH and `docker ps` itself succeeds, so an
637
+ # absent/broken docker leaves NO ===CONTAINERS=== marker at all (decision 1: the sidecar
638
+ # then carries no `containers` key) -- distinct from docker present with zero running
639
+ # containers, which DOES print the marker with zero ===CONTAINER=== blocks (sidecar:
640
+ # `containers: []`). Every docker verb below is read-only (ps, inspect, exec) -- decision
641
+ # 4 -- and each container's failure is isolated from every other's (decision 3): `docker
642
+ # inspect` failing (the container vanished between `ps` and here) skips that container
643
+ # entirely, since decision 6 pins `imageId` as ALWAYS present and sha256-shaped, unlike
644
+ # packages/tools, which have an explicit 'unknown' escape hatch.
645
+ if command -v docker >/dev/null 2>&1 && docker ps >/dev/null 2>&1; then
646
+ echo "===CONTAINERS==="
647
+ # Process substitution (`< <(...)`), never `docker ps | while read`: this loop's body
648
+ # feeds heredocs to nested `docker exec -i ... bash -s` calls below, and a `| while`
649
+ # would redirect the LOOP's own stdin to the `docker ps` pipe -- ambiguous with heredoc
650
+ # parsing. Process substitution never touches this script's own stdin, so the nested
651
+ # heredocs (parsed once, at script-parse time, from the ORIGINAL stdin this whole probe
652
+ # arrived on) stay unambiguous no matter how many containers the loop iterates over.
653
+ mapfile -t __rb_containers < <(docker ps --no-trunc --format '{{.ID}}'$'\t''{{.Names}}'$'\t''{{.Image}}' 2>/dev/null)
654
+ for __rb_line in "${__rb_containers[@]}"; do
655
+ IFS=$'\t' read -r cid cname cimage <<< "$__rb_line"
656
+ [ -z "$cid" ] && continue
657
+ image_id=$(docker inspect -f '{{.Image}}' "$cid" 2>/dev/null)
658
+ [ -z "$image_id" ] && continue # vanished between ps and inspect: skip, not an error
659
+ echo "===CONTAINER==="
660
+ printf '%s\t%s\t%s\n' "$cname" "$cimage" "$image_id"
661
+
662
+ # Packages: ONE exec, a three-way query with an exit-code sentinel for "none of the
663
+ # three" (decision 2). The chosen manager echoes a __PM_*__ marker as its own FIRST
664
+ # line before its listing, so the caller (below, on the HOST -- never inside the
665
+ # container) knows which format the rows are in without a second exec. dpkg-query's
666
+ # and rpm's own format strings already emit name<TAB>version; apk's `-v` output
667
+ # (`name-ver-rN`) is parsed into the same two-column shape OUTSIDE the container, on
668
+ # the host, by the sed pass below -- no sed dependency inside the target image. `\$`
669
+ # (not `$`) before dpkg's `${Package}`/`${Version}` placeholders: this whole query is
670
+ # single-quoted at the bash level (so THIS script's own shell never touches it), but
671
+ # `sh -c` still double-quote-parses its one argument, where a bare `$` would try (and
672
+ # fail) to expand an unset shell variable -- `\$` survives that parse as a literal `$`
673
+ # for dpkg-query itself to interpret. `\t`/`\n` are not recognised escapes inside
674
+ # POSIX double quotes, so they reach dpkg-query/rpm untouched, exactly as their own
675
+ # format-string escapes expect.
676
+ #
677
+ # `timeout 20` (round-1 review): a hung package manager inside ONE container (a wedged
678
+ # dpkg lock, a stuck apk network call despite -no-network commands being used here)
679
+ # would otherwise hang this exec indefinitely -- and with it the WHOLE remote probe,
680
+ # every OTHER container and every OTHER SSH target queued after it, since nothing
681
+ # upstream of this call bounds it. `timeout`'s own 124 exit on expiry is just another
682
+ # non-zero $pkg_rc to the existing check below -- no new branch, same 'unknown' outcome
683
+ # a real exec failure already gets.
684
+ pkg_out=$(timeout 20 docker exec "$cid" sh -c '
685
+ if command -v dpkg-query >/dev/null 2>&1; then
686
+ echo "__PM_DPKG__"
687
+ dpkg-query -W -f="\${Package}\t\${Version}\n" 2>/dev/null
688
+ elif command -v apk >/dev/null 2>&1; then
689
+ echo "__PM_APK__"
690
+ apk info -v 2>/dev/null
691
+ elif command -v rpm >/dev/null 2>&1; then
692
+ echo "__PM_RPM__"
693
+ rpm -qa --qf "%{NAME}\t%{VERSION}\n" 2>/dev/null
694
+ else
695
+ exit 7
696
+ fi
697
+ ' 2>/dev/null)
698
+ pkg_rc=$?
699
+ echo "===PACKAGES==="
700
+ if [ $pkg_rc -ne 0 ] || [ -z "$pkg_out" ]; then
701
+ echo "__UNKNOWN__"
702
+ else
703
+ pm_marker=$(printf '%s\n' "$pkg_out" | head -1)
704
+ case "$pm_marker" in
705
+ __PM_APK__)
706
+ # name-ver-rN -> name<TAB>version (decision 2): greedy backtracking on the first
707
+ # group finds the RIGHTMOST hyphen whose remainder looks like a version (starts
708
+ # with a digit, optionally followed by -rN), so a hyphenated package name (e.g.
709
+ # ca-certificates-20230506-r0 -> "ca-certificates" / "20230506-r0") splits
710
+ # correctly without a package database to consult.
711
+ echo "$pm_marker"
712
+ printf '%s\n' "$pkg_out" | tail -n +2 | sed -n 's/^\(.*\)-\([0-9][^-]*\(-r[0-9][0-9]*\)\{0,1\}\)$/\1\t\2/p'
713
+ ;;
714
+ __PM_DPKG__|__PM_RPM__)
715
+ printf '%s\n' "$pkg_out"
716
+ ;;
717
+ *)
718
+ echo "__UNKNOWN__"
719
+ ;;
720
+ esac
721
+ fi
722
+
723
+ # Tools: the SAME catalog pass, fed the SAME catalog TSV, run inside the container via
724
+ # `docker exec -i <id> bash -s` (decision 2). No separate "has bash" probe first (that
725
+ # would be a THIRD exec'd command, past decision 4's "packages query + catalog pass"
726
+ # allowance) -- a container with no bash just makes this `docker exec` itself fail,
727
+ # which the exit-code check below treats identically to "vanished" (decision 3:
728
+ # unknown, run continues). A zero-tools-found container (bash present, nothing on its
729
+ # PATH is catalogued) is NOT unknown -- exit 0 with empty stdout, kept distinct.
730
+ # `timeout 20` -- same round-1-review reasoning as the package query above: this pass
731
+ # runs the full catalog (~70 `command -v` + version-flag checks), so a single wedged
732
+ # tool inside one container must not be able to hang the whole remote probe.
733
+ tools_out=$(timeout 20 docker exec -i "$cid" bash -s 2>/dev/null <<'__RB_CONTAINER_PROBE__'
734
+ __CATALOG_PASS_FN__
735
+ declare -A known
736
+ catalog_pass <<'__CATALOG__'
737
+ __TSV__
738
+ __CATALOG__
739
+ __RB_CONTAINER_PROBE__
740
+ )
741
+ tools_rc=$?
742
+ echo "===TOOLS==="
743
+ if [ $tools_rc -ne 0 ]; then
744
+ echo "__UNKNOWN__"
745
+ else
746
+ printf '%s\n' "$tools_out"
747
+ fi
748
+ echo "===CONTAINER_END==="
749
+ done
750
+ fi
606
751
  '@
607
- $probeSh = ($probeSh -replace "`r`n", "`n").Replace('__TSV__', $tsv)
752
+ $probeSh = ($probeSh -replace "`r`n", "`n").Replace('__CATALOG_PASS_FN__', $catalogPassFn).Replace('__TSV__', $tsv)
608
753
  return $probeSh
609
754
  }
610
755
 
@@ -623,10 +768,16 @@ function Invoke-ToolchainProbe {
623
768
 
624
769
  function ConvertFrom-ToolchainProbeOutput {
625
770
  <#
626
- Parses one probe run's raw stdout into catalogued rows, the globals text and the
627
- uncatalogued-tool list -- the `===GLOBALS===` / `===UNKNOWN===` sentinel split that
628
- used to live inline in the WSL block. Same shapes as before: Rows carry
629
- Cat/Name/Version/Path/Source (Source via Get-WslSource); Unknown carries Name/Path.
771
+ Parses one probe run's raw stdout into catalogued rows, the globals text, the
772
+ uncatalogued-tool list and (issue #721) the containers list -- the `===GLOBALS===` /
773
+ `===UNKNOWN===` / `===CONTAINERS===` sentinel split that used to live inline in the WSL
774
+ block. Same shapes as before: Rows carry Cat/Name/Version/Path/Source (Source via
775
+ Get-WslSource); Unknown carries Name/Path. `Containers` carries
776
+ Name/Image/ImageId/PackageManager/Packages/Tools (see ConvertFrom-ToolchainContainersOutput);
777
+ `ContainersReported` is $true only when a `===CONTAINERS===` marker appeared at all --
778
+ distinct from an empty `Containers` array, which can ALSO mean "no docker on this side"
779
+ (decision 1: the caller must tell those two apart to decide whether the sidecar carries
780
+ a `containers` key at all).
630
781
  #>
631
782
  param(
632
783
  [Parameter(Mandatory)][AllowEmptyString()][string]$Raw,
@@ -641,7 +792,12 @@ function ConvertFrom-ToolchainProbeOutput {
641
792
  $rest = if ($g.Count -gt 1) { $g[1] } else { '' }
642
793
  $u = $rest -split '===UNKNOWN==='
643
794
  $globalsText = $u[0].Trim()
644
- $unknownOut = if ($u.Count -gt 1) { $u[1] } else { '' }
795
+ $afterUnknown = if ($u.Count -gt 1) { $u[1] } else { '' }
796
+
797
+ $c = $afterUnknown -split '===CONTAINERS==='
798
+ $unknownOut = $c[0]
799
+ $containersReported = $c.Count -gt 1
800
+ $containersOut = if ($containersReported) { $c[1] } else { '' }
645
801
 
646
802
  foreach ($line in ($toolOut -split "`n")) {
647
803
  if (-not $line.Trim()) { continue }
@@ -662,7 +818,117 @@ function ConvertFrom-ToolchainProbeOutput {
662
818
  $unknown += @{ Name = $p[0].Trim(); Path = $p[1].Trim() }
663
819
  }
664
820
 
665
- return @{ Rows = @($rows); GlobalsText = $globalsText; Unknown = @($unknown) }
821
+ $containers = if ($containersReported) { ConvertFrom-ToolchainContainersOutput -Raw $containersOut -Catalog $Catalog } else { @() }
822
+
823
+ return @{
824
+ Rows = @($rows); GlobalsText = $globalsText; Unknown = @($unknown)
825
+ Containers = @($containers); ContainersReported = $containersReported
826
+ }
827
+ }
828
+
829
+ function ConvertFrom-ToolchainContainerPackages([string]$Raw) {
830
+ <#
831
+ The `===PACKAGES===` block for ONE container -> @{ PackageManager; Packages }.
832
+ PackageManager is 'dpkg' | 'apk' | 'rpm' | 'unknown' (decision 6); Packages is the
833
+ string 'unknown' (mirroring PackageManager) or an array of @{Name;Version}. The probe
834
+ script (New-ToolchainProbeScript) already normalises apk's `name-ver-rN` into
835
+ name<TAB>version on the host side, so every known manager's rows share one shape here.
836
+ #>
837
+ $lines = @($Raw -split "`n" | Where-Object { $_.Trim() -ne '' })
838
+ if (-not $lines.Count -or $lines[0].Trim() -eq '__UNKNOWN__') {
839
+ return @{ PackageManager = 'unknown'; Packages = 'unknown' }
840
+ }
841
+ $pm = switch ($lines[0].Trim()) {
842
+ '__PM_DPKG__' { 'dpkg' }
843
+ '__PM_APK__' { 'apk' }
844
+ '__PM_RPM__' { 'rpm' }
845
+ default { $null }
846
+ }
847
+ if (-not $pm) { return @{ PackageManager = 'unknown'; Packages = 'unknown' } }
848
+ $out = @()
849
+ foreach ($line in ($lines | Select-Object -Skip 1)) {
850
+ $p = $line -split "`t", 2
851
+ if ($p.Count -lt 2) { continue }
852
+ $out += @{ Name = $p[0].Trim(); Version = $p[1].Trim() }
853
+ }
854
+ # No leading comma here, unlike this file's other ConvertTo-*/ConvertFrom-* functions'
855
+ # OWN bare `return , @(...)`: THIS array is a plain hashtable-literal VALUE, not itself
856
+ # the function's top-level return -- PowerShell's auto-unwrap-a-single-element-stream
857
+ # quirk (what the comma trick compensates for) only fires at a function-call boundary,
858
+ # and constructing @($out) directly inside a `return @{ ... }` hashtable literal is not
859
+ # one -- confirmed by this file's OWN `return @{ Rows = @($rows); ... }` a few functions
860
+ # up, which uses no comma at this exact same position. A leading comma here double-wraps
861
+ # into an array-of-one-array instead (caught in review: Packages came back as
862
+ # `[[{...}]]`).
863
+ return @{ PackageManager = $pm; Packages = @($out) }
864
+ }
865
+
866
+ function ConvertFrom-ToolchainContainerTools {
867
+ <#
868
+ The `===TOOLS===` block for ONE container -> the string 'unknown' (no bash in the
869
+ container, decision 3) or an array shaped exactly like ConvertFrom-ToolchainProbeOutput's
870
+ own Rows (Cat/Name/Version/Path/Source, matched against -Catalog) -- the SAME catalog
871
+ pass, so a container's catalogued tools carry the same category/refLinks-ready Name the
872
+ rest of the report does. `Source` is always '' here (Get-WslSource's heuristics are
873
+ host-filesystem-shaped, e.g. WSL mount prefixes -- meaningless for a container's own
874
+ rootfs), matching ConvertTo-ToolchainSidecarTools's own '(present)'/'' fallbacks.
875
+ #>
876
+ param([string]$Raw, [array]$Catalog)
877
+ $lines = @($Raw -split "`n" | Where-Object { $_.Trim() -ne '' })
878
+ if (-not $lines.Count -or $lines[0].Trim() -eq '__UNKNOWN__') { return 'unknown' }
879
+ $out = @()
880
+ foreach ($line in $lines) {
881
+ $parts = $line -split "`t"
882
+ if ($parts.Count -lt 3) { continue }
883
+ $tname = $parts[0].Trim(); $ver = Get-CleanVersion $parts[1]; $loc = $parts[2].Trim()
884
+ $catEntry = $Catalog | Where-Object { $_.Name -eq $tname } | Select-Object -First 1
885
+ if (-not $catEntry) { continue }
886
+ $out += @{ Cat = $catEntry.Cat; Name = $tname; Version = $ver; Path = $loc; Source = '' }
887
+ }
888
+ return , @($out)
889
+ }
890
+
891
+ function ConvertFrom-ToolchainContainersOutput {
892
+ <#
893
+ Parses the `===CONTAINERS===` block (issue #721) into an array of container hashtables:
894
+ @{ Name; Image; ImageId; PackageManager; Packages; Tools }. One `===CONTAINER===` block
895
+ per running container; a container whose `docker inspect` failed between `ps` and the
896
+ probe reaching it is never emitted at all (skipped upstream, in bash) rather than
897
+ reported with a missing/invalid ImageId -- decision 6 pins the digest as ALWAYS present
898
+ and sha256-shaped, unlike Packages/Tools, which have an explicit 'unknown' escape hatch.
899
+ #>
900
+ param(
901
+ [Parameter(Mandatory)][AllowEmptyString()][string]$Raw,
902
+ [Parameter(Mandatory)][array]$Catalog
903
+ )
904
+
905
+ $out = @()
906
+ $blocks = $Raw -split '===CONTAINER==='
907
+ foreach ($block in $blocks) {
908
+ if (-not $block.Trim()) { continue }
909
+
910
+ $afterHeader = $block -split '===PACKAGES==='
911
+ if ($afterHeader.Count -lt 2) { continue }
912
+ $headerLine = ($afterHeader[0] -split "`n" | Where-Object { $_.Trim() -ne '' } | Select-Object -First 1)
913
+ if (-not $headerLine) { continue }
914
+ $hp = $headerLine -split "`t"
915
+ if ($hp.Count -lt 3) { continue }
916
+ $name = $hp[0].Trim(); $image = $hp[1].Trim(); $imageId = $hp[2].Trim()
917
+
918
+ $afterPackages = $afterHeader[1] -split '===TOOLS==='
919
+ $packagesRaw = $afterPackages[0]
920
+ $toolsSection = if ($afterPackages.Count -gt 1) { $afterPackages[1] } else { '' }
921
+ $toolsRaw = ($toolsSection -split '===CONTAINER_END===')[0]
922
+
923
+ $pkgResult = ConvertFrom-ToolchainContainerPackages $packagesRaw
924
+ $tools = ConvertFrom-ToolchainContainerTools -Raw $toolsRaw -Catalog $Catalog
925
+
926
+ $out += @{
927
+ Name = $name; Image = $image; ImageId = $imageId
928
+ PackageManager = $pkgResult.PackageManager; Packages = $pkgResult.Packages; Tools = $tools
929
+ }
930
+ }
931
+ return , @($out)
666
932
  }
667
933
 
668
934
  # Change detection: ignore only the volatile _Generated: line, so a no-change run does not
@@ -780,6 +1046,109 @@ function ConvertTo-ToolchainSidecarUnknown($items) {
780
1046
  return , @($out)
781
1047
  }
782
1048
 
1049
+ function ConvertTo-ToolchainSidecarContainers($containers) {
1050
+ <#
1051
+ Container list (Name/Image/ImageId/PackageManager/Packages/Tools) -> the sidecar's
1052
+ top-level `containers` array (issue #721 decision 6): {name, image, imageId,
1053
+ packageManager, packages, tools}. `packages`/`tools` are each either the literal
1054
+ string 'unknown' or a list -- `packages` of {name, version}, `tools` of the SAME shape
1055
+ ConvertTo-ToolchainSidecarTools's own `tools` entries use (name/category/version/path/
1056
+ source), so a container's catalogued tools round-trip through the identical field set
1057
+ the per-side tools already carry. `containers` is its OWN top-level key, not nested
1058
+ under any side, since a container isn't scoped to "windows"/"wsl:<distro>"/"linux" the
1059
+ way a side is -- it's a fact about the host as a whole.
1060
+ #>
1061
+ $out = @()
1062
+ foreach ($c in $containers) {
1063
+ $packagesOut = if ($c.Packages -eq 'unknown') { 'unknown' } else {
1064
+ , @($c.Packages | ForEach-Object { [ordered]@{ name = $_.Name; version = $_.Version } })
1065
+ }
1066
+ $toolsOut = if ($c.Tools -eq 'unknown') { 'unknown' } else {
1067
+ , @($c.Tools | ForEach-Object {
1068
+ $ver = if ($_.Version) { $_.Version } else { '(present)' }
1069
+ [ordered]@{ name = $_.Name; category = $_.Cat; version = $ver; path = $_.Path; source = $(if ($_.Source) { $_.Source } else { '' }) }
1070
+ })
1071
+ }
1072
+ $out += [ordered]@{
1073
+ name = $c.Name
1074
+ image = $c.Image
1075
+ imageId = $c.ImageId
1076
+ packageManager = $c.PackageManager
1077
+ packages = $packagesOut
1078
+ tools = $toolsOut
1079
+ }
1080
+ }
1081
+ return , @($out)
1082
+ }
1083
+
1084
+ function Format-ContainersSection {
1085
+ <#
1086
+ Renders the #721 `## Containers` section: one summary table (name, image, image ID --
1087
+ the first 12 hex chars after `sha256:` -- package manager, package count and tools
1088
+ found), then one `<details><summary>container name</summary>` block per container
1089
+ holding its full package table (name, version) and catalogued-tools table. Full lists
1090
+ stay in the Markdown, not sidecar-only: Publish-ToolchainReport's change detection
1091
+ compares the .md text (ignoring only `_Generated:`), and the sidecar is rewritten only
1092
+ when the .md changed or was missing -- a sidecar-only package list would freeze the
1093
+ moment the .md stopped changing for an unrelated reason (the #721 plan's own caught
1094
+ trap). Called only when the probe actually reported a Containers section (docker was on
1095
+ PATH and `docker ps` succeeded) -- an empty array still renders the "_none running_"
1096
+ line, distinct from the caller skipping this function entirely (no docker at all).
1097
+ #>
1098
+ param([array]$Containers)
1099
+
1100
+ $sb = New-Object System.Text.StringBuilder
1101
+ [void]$sb.AppendLine('## Containers')
1102
+ [void]$sb.AppendLine('')
1103
+ if (-not $Containers -or $Containers.Count -eq 0) {
1104
+ [void]$sb.AppendLine('_none running_')
1105
+ [void]$sb.AppendLine('')
1106
+ return $sb.ToString()
1107
+ }
1108
+
1109
+ [void]$sb.AppendLine('| Name | Image | Image ID | Package manager | Packages | Tools |')
1110
+ [void]$sb.AppendLine('|------|-------|----------|------------------|----------|-------|')
1111
+ foreach ($c in ($Containers | Sort-Object Name)) {
1112
+ $shortId = if ($c.ImageId -match '^sha256:(.{12})') { $Matches[1] } else { $c.ImageId }
1113
+ $pkgCount = if ($c.Packages -eq 'unknown') { 'unknown' } else { "$(@($c.Packages).Count)" }
1114
+ $toolCount = if ($c.Tools -eq 'unknown') { 'unknown' } else { "$(@($c.Tools).Count)" }
1115
+ [void]$sb.AppendLine("| $($c.Name) | ``$($c.Image)`` | ``$shortId`` | $($c.PackageManager) | $pkgCount | $toolCount |")
1116
+ }
1117
+ [void]$sb.AppendLine('')
1118
+
1119
+ foreach ($c in ($Containers | Sort-Object Name)) {
1120
+ [void]$sb.AppendLine("<details><summary>$($c.Name)</summary>")
1121
+ [void]$sb.AppendLine('')
1122
+ if ($c.Packages -eq 'unknown') {
1123
+ [void]$sb.AppendLine('_packages: unknown (no dpkg/apk/rpm found)_')
1124
+ } elseif (@($c.Packages).Count -eq 0) {
1125
+ [void]$sb.AppendLine('_no packages reported_')
1126
+ } else {
1127
+ [void]$sb.AppendLine('| Package | Version |')
1128
+ [void]$sb.AppendLine('|---------|---------|')
1129
+ foreach ($p in ($c.Packages | Sort-Object Name)) {
1130
+ [void]$sb.AppendLine("| $($p.Name) | $($p.Version) |")
1131
+ }
1132
+ }
1133
+ [void]$sb.AppendLine('')
1134
+ if ($c.Tools -eq 'unknown') {
1135
+ [void]$sb.AppendLine('_tools: unknown (no bash in the container)_')
1136
+ } elseif (@($c.Tools).Count -eq 0) {
1137
+ [void]$sb.AppendLine('_no catalogued tools found_')
1138
+ } else {
1139
+ [void]$sb.AppendLine('| Tool | Version | Path |')
1140
+ [void]$sb.AppendLine('|------|---------|------|')
1141
+ foreach ($t in ($c.Tools | Sort-Object Name)) {
1142
+ [void]$sb.AppendLine("| $($t.Name) | $($t.Version) | ``$($t.Path)`` |")
1143
+ }
1144
+ }
1145
+ [void]$sb.AppendLine('')
1146
+ [void]$sb.AppendLine('</details>')
1147
+ [void]$sb.AppendLine('')
1148
+ }
1149
+ return $sb.ToString()
1150
+ }
1151
+
783
1152
  function New-ToolchainReport {
784
1153
  <#
785
1154
  Assembles ONE report's full Markdown text AND its JSON sidecar (#716, TC-4) from the
@@ -804,6 +1173,16 @@ function New-ToolchainReport {
804
1173
  (`hostname`); -Os is 'windows' or 'linux'; -GeneratedIso is the run's UTC timestamp,
805
1174
  second precision, e.g. '2026-09-15T14:02:11Z' (the sidecar's `generated` -- distinct
806
1175
  from -Now, the local-time display string the Markdown's `_Generated:` line uses).
1176
+
1177
+ -Containers / -ContainersReported (issue #721) are REPORT-level, not per-Side: a
1178
+ container isn't scoped to one side's identity the way tools/uncatalogued are, so a
1179
+ `## Containers` section (Format-ContainersSection) renders once, after every -Sides
1180
+ entry, and the sidecar's `containers` key is added once, top-level -- only when
1181
+ -ContainersReported is $true (docker was on PATH and `docker ps` succeeded on
1182
+ whichever side actually ran the bash probe). -ContainersReported $false must NOT add
1183
+ the key at all, even with -Containers empty -- that is how a host/side with no docker
1184
+ stays indistinguishable from before this feature (no `containers` key), while docker
1185
+ present with zero running containers still gets `containers: []`.
807
1186
  #>
808
1187
  param(
809
1188
  [Parameter(Mandatory)][string]$HostLabel,
@@ -815,7 +1194,9 @@ function New-ToolchainReport {
815
1194
  [Parameter(Mandatory)][string]$HostId,
816
1195
  [Parameter(Mandatory)][string]$Hostname,
817
1196
  [Parameter(Mandatory)][ValidateSet('windows', 'linux')][string]$Os,
818
- [Parameter(Mandatory)][string]$GeneratedIso
1197
+ [Parameter(Mandatory)][string]$GeneratedIso,
1198
+ [array]$Containers = @(),
1199
+ [bool]$ContainersReported = $false
819
1200
  )
820
1201
 
821
1202
  $md = New-Object System.Text.StringBuilder
@@ -847,6 +1228,9 @@ function New-ToolchainReport {
847
1228
  uncatalogued = (ConvertTo-ToolchainSidecarUnknown $side.Unknown)
848
1229
  }
849
1230
  }
1231
+ if ($ContainersReported) {
1232
+ [void]$md.Append((Format-ContainersSection $Containers))
1233
+ }
850
1234
  $sidecar = [ordered]@{
851
1235
  schemaVersion = 1
852
1236
  host = $HostId
@@ -855,6 +1239,9 @@ function New-ToolchainReport {
855
1239
  generated = $GeneratedIso
856
1240
  sides = @($sidecarSides)
857
1241
  }
1242
+ if ($ContainersReported) {
1243
+ $sidecar['containers'] = (ConvertTo-ToolchainSidecarContainers $Containers)
1244
+ }
858
1245
  return @{
859
1246
  Markdown = ($md.ToString() -replace "`r`n", "`n")
860
1247
  Sidecar = $sidecar
@@ -995,9 +1382,25 @@ function Resolve-ToolchainSshExe {
995
1382
  optional feature shipped in-box) over whatever `ssh` a `Get-Command` search turns up,
996
1383
  for the same reason Register-InventoryTask.ps1 resolves pwsh explicitly rather than
997
1384
  trusting PATH.
1385
+
1386
+ `$env:SystemRoot` is skipped entirely when it's null/empty (issue #636 review round
1387
+ 2: `Join-Path $null ...` throws "Cannot bind argument to parameter 'Path'", which
1388
+ this function did unguarded before this fix, real on Linux where SystemRoot is never
1389
+ set. This script is Windows/WSL-shaped throughout and every env var it reads
1390
+ (SystemRoot here, LOCALAPPDATA/USERPROFILE/APPDATA elsewhere) is real in production,
1391
+ so this guard changes nothing on an actual Windows box -- it was added because this
1392
+ function was reached by a cross-platform test (the tools-site push, #636), the first
1393
+ thing in this file to call this function outside a fake -Transport/-SshExe. That test
1394
+ was retired with the push itself (Rackbops/Tooling#667 C3); the guard stays since
1395
+ it's still correct and free. The other env reads in this script are NOT similarly
1396
+ guarded (LOCALAPPDATA in $stateDir, USERPROFILE/APPDATA in Get-UncataloguedWin) --
1397
+ fixing those is out of scope here since nothing currently reaches them from a
1398
+ non-Windows test either.
998
1399
  #>
999
- $sshExe = Join-Path $env:SystemRoot 'System32\OpenSSH\ssh.exe'
1000
- if (Test-Path -LiteralPath $sshExe) { return $sshExe }
1400
+ if ($env:SystemRoot) {
1401
+ $sshExe = Join-Path $env:SystemRoot 'System32\OpenSSH\ssh.exe'
1402
+ if (Test-Path -LiteralPath $sshExe) { return $sshExe }
1403
+ }
1001
1404
  $cmd = Get-Command ssh -ErrorAction SilentlyContinue
1002
1405
  if ($cmd) { return $cmd.Source }
1003
1406
  return $null
@@ -1128,6 +1531,13 @@ function Invoke-RemoteToolchainInventory {
1128
1531
  single-`linux`-side report, and published under `-Label '<alias>: '`. A failure at any
1129
1532
  step for one target is caught, printed as `! <alias>: unreachable (<reason>)`, and does
1130
1533
  NOT stop the loop -- the local report and every other target still publish.
1534
+
1535
+ Returns `{Written, Unchanged, Failed, RenderFailed, ExitCode}`. `RenderFailed` is the
1536
+ subset of `Failed` whose underlying `Publish-ToolchainReport` result was specifically
1537
+ `'render-failed'`, not a genuinely unreachable host -- the report DID reach the store
1538
+ even though build.py's render/land step failed. Kept as its own bucket for diagnostic
1539
+ value even though the one consumer that needed the distinction (the tools-site
1540
+ page-data push's change detection) was retired in Rackbops/Tooling#667, E2/C3.
1131
1541
  #>
1132
1542
  param(
1133
1543
  [Parameter(Mandatory)][array]$Targets,
@@ -1141,6 +1551,7 @@ function Invoke-RemoteToolchainInventory {
1141
1551
  $written = @()
1142
1552
  $unchanged = @()
1143
1553
  $failed = @()
1554
+ $renderFailed = @()
1144
1555
 
1145
1556
  foreach ($pair in $Targets) {
1146
1557
  $alias, $target = $pair
@@ -1161,7 +1572,8 @@ function Invoke-RemoteToolchainInventory {
1161
1572
  }
1162
1573
  $report = New-ToolchainReport -HostLabel "$remoteHostName ($alias)" `
1163
1574
  -GeneratedOn "$remoteHostName via ssh from $LocalHost" -Now $Now -Sides @($side) `
1164
- -HostId $alias -Hostname $remoteHostName -Os 'linux' -GeneratedIso $NowIso
1575
+ -HostId $alias -Hostname $remoteHostName -Os 'linux' -GeneratedIso $NowIso `
1576
+ -Containers $parsed.Containers -ContainersReported $parsed.ContainersReported
1165
1577
 
1166
1578
  $remoteFileName = "toolchain-inventory-$alias.md"
1167
1579
  $remoteOutPath = if ($storeAvailable) { Join-Path $storeDir $remoteFileName } else { Join-Path $stateDir $remoteFileName }
@@ -1171,10 +1583,17 @@ function Invoke-RemoteToolchainInventory {
1171
1583
  -Sidecar $report.Sidecar -Rel $remoteRel -Label "${alias}: " -NoRender:$NoRender
1172
1584
  # 'render-failed' (build.py exited non-zero) is isolated exactly like an
1173
1585
  # unreachable host: the report DID reach the store, but is not counted as a
1174
- # clean success, and this alias must not stop the loop for the others.
1586
+ # clean success, and this alias must not stop the loop for the others. It
1587
+ # still joins $failed (every existing caller's "not a clean success" meaning
1588
+ # is unchanged) but ALSO its own $renderFailed bucket, because unlike a
1589
+ # genuinely unreachable host it DID just write a fresh sidecar to the store --
1590
+ # kept as diagnostic detail (see this function's own docstring).
1175
1591
  if ($result -eq 'written') { $written += $alias }
1176
1592
  elseif ($result -eq 'unchanged') { $unchanged += $alias }
1177
- else { $failed += $alias }
1593
+ else {
1594
+ $failed += $alias
1595
+ if ($result -eq 'render-failed') { $renderFailed += $alias }
1596
+ }
1178
1597
  } catch {
1179
1598
  Write-Host "! ${alias}: unreachable ($($_.Exception.Message))" -ForegroundColor Yellow
1180
1599
  $failed += $alias
@@ -1182,10 +1601,11 @@ function Invoke-RemoteToolchainInventory {
1182
1601
  }
1183
1602
 
1184
1603
  return @{
1185
- Written = $written
1186
- Unchanged = $unchanged
1187
- Failed = $failed
1188
- ExitCode = $(if ($failed.Count -gt 0) { 2 } else { 0 })
1604
+ Written = $written
1605
+ Unchanged = $unchanged
1606
+ Failed = $failed
1607
+ RenderFailed = $renderFailed
1608
+ ExitCode = $(if ($failed.Count -gt 0) { 2 } else { 0 })
1189
1609
  }
1190
1610
  }
1191
1611
 
@@ -1258,6 +1678,8 @@ if ($MyInvocation.InvocationName -ne '.') {
1258
1678
  $wslRows = @()
1259
1679
  $wslGlobalsText = ''
1260
1680
  $wslUnknown = @()
1681
+ $wslContainers = @()
1682
+ $wslContainersReported = $false
1261
1683
  $wslAvailable = $false
1262
1684
  if ($WslDistro) {
1263
1685
  $wslExe = Get-Command wsl.exe -ErrorAction SilentlyContinue
@@ -1291,6 +1713,8 @@ if ($MyInvocation.InvocationName -ne '.') {
1291
1713
  $wslRows = $parsed.Rows
1292
1714
  $wslGlobalsText = $parsed.GlobalsText
1293
1715
  $wslUnknown = $parsed.Unknown
1716
+ $wslContainers = $parsed.Containers
1717
+ $wslContainersReported = $parsed.ContainersReported
1294
1718
  foreach ($r in $wslRows) { Write-Host (" + {0,-16} {1}" -f $r.Name, $r.Version) }
1295
1719
  if ($wslUnknown.Count) { Write-Host (" ~ {0} uncatalogued on PATH" -f $wslUnknown.Count) -ForegroundColor DarkYellow }
1296
1720
  } else {
@@ -1321,7 +1745,8 @@ if ($MyInvocation.InvocationName -ne '.') {
1321
1745
  -GeneratedOn "$hostName (editors: $(if($includeEd){'included'}else{'excluded'}))" -Now $now `
1322
1746
  -SummaryLine "**Windows:** $($winRows.Count) tools &middot; **$wslCountLabel**" `
1323
1747
  -HistoryRel "$ArtifactCategory/$fileName" -Sides $sides `
1324
- -HostId $machine -Hostname $hostName -Os 'windows' -GeneratedIso $nowIso
1748
+ -HostId $machine -Hostname $hostName -Os 'windows' -GeneratedIso $nowIso `
1749
+ -Containers $wslContainers -ContainersReported $wslContainersReported
1325
1750
  $mdText = $localReport.Markdown
1326
1751
 
1327
1752
  # Always record the last run time (out-of-repo state, untracked).
@@ -1388,7 +1813,11 @@ if ($MyInvocation.InvocationName -ne '.') {
1388
1813
  # A remote failure (2) outranks a local-only render failure (1, the pre-#715 behaviour
1389
1814
  # of exiting non-zero when build.py fails) -- both are folded in HERE, after SSH
1390
1815
  # probing has already run to completion, never as an early `exit` from inside
1391
- # Publish-ToolchainReport (which would abort probing before it started).
1816
+ # Publish-ToolchainReport (which would abort probing before it started). The ranking
1817
+ # only decides what this run's Last Result shows, not whether work happened. (Exit
1818
+ # code 3 was the tools-site page-data push's failure signal, retired along with the
1819
+ # push itself -- Rackbops/Tooling#667, E2/C3 -- now that the page reads the store
1820
+ # directly; 1 and 2 keep their pre-existing meanings unchanged.)
1392
1821
  $exitCode = 0
1393
1822
  if ($localResult -eq 'render-failed') { $exitCode = 1 }
1394
1823
  if ($sshTargetsAllMalformed) { $exitCode = 2 }