@rackbops/ac-agent 2.0.0-alpha.39 → 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.
- package/README.md +143 -17
- package/data/Get-ToolchainInventory.ps1 +456 -27
- package/data/README.md +20 -16
- package/dist/ac-agent.mjs +7634 -47
- package/package.json +3 -2
|
@@ -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
|
-
|
|
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
|
|
627
|
-
uncatalogued-tool list -- the `===GLOBALS===` /
|
|
628
|
-
used to live inline in the WSL
|
|
629
|
-
Cat/Name/Version/Path/Source (Source via
|
|
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
|
-
$
|
|
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
|
-
|
|
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
|
-
|
|
1000
|
-
|
|
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 {
|
|
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
|
|
1186
|
-
Unchanged
|
|
1187
|
-
Failed
|
|
1188
|
-
|
|
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 · **$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 }
|