From 6f90c54125354205feebb025028eb875738d4ac7 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:36:50 -0300 Subject: [PATCH 1/3] test(xcat-dep): no test measures which x86-64 baseline goconserver is built for The published x86_64 goconserver is compiled GOAMD64=v3, and that binary stops a pre-AVX2 management node with SIGILL. Nothing caught it. A grep for an export line in the build script is no help: it passes when the export stops reaching the compiler, which is the failure itself. native/goconserver-openeuler.t now drives the cross path with an amd64 target and reads the setting out of the compiled binary. The stubbed compiler reports the setting the build asked for, and FIXTURE_GOAMD64 lets a case report a different one -- what a compiler that ignores the request looks like. Two more assertions hold the in-chroot gate down, because no unit test executes the generated spec. Twelve assertions fail on this tree. The fix that follows makes all 160 pass. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- native/goconserver-openeuler.t | 53 ++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/native/goconserver-openeuler.t b/native/goconserver-openeuler.t index 2383972..62c8e53 100644 --- a/native/goconserver-openeuler.t +++ b/native/goconserver-openeuler.t @@ -94,10 +94,20 @@ if ($command eq 'uname') { print "/private/mock/root\n"; } else { die "Unexpected mock arguments: @ARGV"; } } elsif ($command eq 'go') { - die 'Unexpected go invocation' unless $ARGV[0] eq 'build'; - my $output = option('-o'); - put($output, "#!/bin/sh\nexit 0\n"); - chmod 0755, $output; + if ($ARGV[0] eq 'version') { + # The real compiler stamps the setting it was given into the binary. FIXTURE_GOAMD64 lets a + # case report a different one, which is what a compiler ignoring the request looks like. + die 'Unexpected go version arguments' unless @ARGV == 3 && $ARGV[1] eq '-m'; + my $goarch = $ENV{GOARCH} || 'amd64'; + my $setting = $ENV{FIXTURE_GOAMD64} || $ENV{GOAMD64} || ''; + print "$ARGV[2]: go1.25.12\n\tpath\tcommand-line-arguments\n\tbuild\tGOARCH=$goarch\n"; + print "\tbuild\tGOAMD64=$setting\n" if $goarch eq 'amd64' && length $setting; + } else { + die 'Unexpected go invocation' unless $ARGV[0] eq 'build'; + my $output = option('-o'); + put($output, "#!/bin/sh\nexit 0\n"); + chmod 0755, $output; + } } elsif ($command eq 'rpmbuild') { my ($top) = map { /^_topdir (.+)$/ ? $1 : () } @ARGV; die 'Missing rpmbuild topdir' unless defined $top; @@ -149,6 +159,7 @@ sub run_case { local $ENV{FIXTURE_DOWNLOAD} = $options{corrupt} ? "corrupted payload\n" : $payload; local $ENV{FIXTURE_COMMIT} = exists($options{commit}) ? $options{commit} : $commit; local $ENV{FIXTURE_COMMIT_RC} = $options{commit_rc} || 0; + local $ENV{FIXTURE_GOAMD64} = $options{goamd64} || ''; my $pid = fork(); die $! unless defined $pid; if (!$pid) { @@ -165,6 +176,12 @@ sub run_case { commands => \@commands }; } +sub spec_check { + my ($case) = @_; + my ($section) = $case->{spec} =~ /^%check\n(.*?)^%files$/ms; + return defined($section) ? $section : ''; +} + sub calls { my ($case, $command, $argument) = @_; return [grep { $_->{command} eq $command && (!defined($argument) || grep { $_ eq $argument } @{$_->{args}}) } @{$case->{commands}}]; @@ -198,6 +215,10 @@ for my $cell (@cells) { like($case->{spec}, qr/^Source3:\s+https:\/\/go\.dev\/dl\/go1\.25\.12\.linux-\Q$goarch\E\.tar\.gz$/m, "$config stages the matching pinned compiler"); like($case->{spec}, qr/^BuildRequires:\s+coreutils tar gzip ca-certificates$/m, "$config uses the private compiler prerequisites"); + # Each spec section runs its own shell, so %check has to put the private compiler back on PATH + # or it cannot read the metadata it gates on. + like(spec_check($case), qr/^export PATH=\Q%{_builddir}\E\/go\/bin:/m, + "$config puts the private compiler on PATH for the GOAMD64 gate"); like($case->{spec}, qr/^echo '\Q$hash\E %\{SOURCE3\}' \| sha256sum -c -$/m, "$config verifies the compiler again in RPM prep"); is(scalar @{calls($case, 'mock', '--buildsrpm')}, 1, "$config reaches SRPM construction after verification"); is(scalar @{calls($case, 'mock', '--rebuild')}, 1, "$config reaches native RPM reconstruction"); @@ -275,12 +296,16 @@ for my $row ( like($case->{spec}, qr/^go build [^\n]*-ldflags "-X main.Version=%\{version\}" -o \Q$binary\E /m, "EL$release $binary preserves its existing linker flags"); } + # The in-chroot compile is gated inside the spec, where the compiler is. Nothing else runs it, + # so this holds the section down against a silent deletion. + like(spec_check($case), qr/go version -m \Q%{buildroot}\E\/usr\/bin\/\$bin/, + "EL$release gates the staged binaries on their own build metadata"); } { my $case = run_case(config => 'rocky-10-riscv64-xcat', target => 'riscv64'); is($case->{status}, 0, 'existing EL cross packaging completes with external build doubles') or diag($case->{output}); - my $go = calls($case, 'go'); + my $go = calls($case, 'go', 'build'); is(scalar @$go, 2, 'EL cross path invokes both host compiler outputs'); is_deeply([map { $_->{goarch} } @$go], ['riscv64', 'riscv64'], 'EL cross path selects the target GOARCH'); is(scalar @{calls($case, 'rpmbuild', 'riscv64')}, 1, 'EL cross path packages for the requested target'); @@ -288,4 +313,22 @@ for my $row ( is(read_text("$case->{directory}/results/goconserver-0.3.3-4.riscv64.rpm"), "fixture binary RPM\n", 'EL cross path collects its package output'); } +# The published x86_64 goconserver was compiled GOAMD64=v3 and stopped a pre-AVX2 headnode with +# SIGILL. These two cases hold the gate down: it reads the setting from the binary, so it fails +# when the compiler ignored the request, and an export line alone cannot satisfy it. +{ + my %cross = (config => 'rocky+epel-9-ppc64le', arch => 'ppc64le', target => 'x86_64'); + my $case = run_case(%cross); + is($case->{status}, 0, 'amd64 cross build completes when the compiler honours GOAMD64=v1') + or diag($case->{output}); + is(scalar @{calls($case, 'go', 'version')}, 2, 'the gate reads the metadata of both binaries'); + + my $bad = run_case(%cross, goamd64 => 'v3'); + isnt($bad->{status}, 0, 'a v3 amd64 binary fails the build'); + like($bad->{output}, qr/reports GOAMD64=v3, not GOAMD64=v1/, + 'the diagnostic names the setting the binary carries'); + ok(!-f "$bad->{directory}/results/goconserver-0.3.3-4.x86_64.rpm", + 'a v3 amd64 binary is never packaged'); +} + done_testing(); From 6cfa28603907947c52185a4f1794c547b14c560f Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:36:58 -0300 Subject: [PATCH 2/3] fix(xcat-dep): goconserver is built for x86-64-v3 and SIGILLs on an older node One x86_64 goconserver binary is published for rh8, rh9 and rh10. `go version -m` on it reports `build GOAMD64=v3`, and objdump counts 1731 v3-only instructions. An Ivy Bridge node has AVX but no AVX2, BMI2 or FMA, so goconserver stops with SIGILL and the xCAT console does not start. No site asked for v3. goconserver/mockbuild.pl exported GOFLAGS, GOTOOLCHAIN and CGO_ENABLED only, so the compile took the chroot default. EL10 raises its platform baseline to x86-64-v3 and bakes GOAMD64=v3 into its golang, and the el10 chroot is where that one binary is built. Every site that compiles goconserver now sets GOAMD64=v1: the in-chroot %build, the host cross-compile, the Debian build script and debian/rules. v1 and not v2, because the same rpm is published for rh8, whose own baseline is plain x86-64. /proc/cpuinfo on rome01, xcat-master and xcat-master-ub reports AMD EPYC 7H12 with avx2, bmi2 and fma on all three, so no lab host can reproduce a SIGILL and the setting cannot be left to the builder. The generated spec gains a %check, and debian/rules a gate, that read GOAMD64 out of the binaries that are about to be packaged. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- goconserver/debian/rules | 14 +++++++++++ goconserver/mockbuild.pl | 52 +++++++++++++++++++++++++++++++++++++++- goconserver/sbuild.pl | 4 ++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/goconserver/debian/rules b/goconserver/debian/rules index 87b5b94..4051a38 100755 --- a/goconserver/debian/rules +++ b/goconserver/debian/rules @@ -1,11 +1,25 @@ #!/usr/bin/make -f export CGO_ENABLED=0 +# Set explicitly, so the compiler never takes a distro default. A GOAMD64=v3 binary stops a +# pre-AVX2 node with SIGILL. +export GOAMD64=v1 %: dh $@ override_dh_auto_configure: +# The last recipe line reads GOAMD64 out of the binaries that were just compiled. An export line +# can stop reaching the compiler, and a grep for that line passes when it does. override_dh_auto_build: go build -trimpath -buildvcs=false -ldflags "-X main.Version=0.3.3" -o goconserver goconserver.go go build -trimpath -buildvcs=false -ldflags "-X main.Version=0.3.3" -o congo cmd/congo.go + if [ "$$(go env GOARCH)" = amd64 ]; then \ + for bin in goconserver congo; do \ + setting=$$(go version -m $$bin | awk -F'\t' '$$2 == "build" && $$3 ~ /^GOAMD64=/ { print $$3 }'); \ + if [ "$$setting" != "GOAMD64=v1" ]; then \ + echo "FATAL: $$bin reports '$$setting', not GOAMD64=v1" >&2; \ + exit 1; \ + fi; \ + done; \ + fi override_dh_auto_install: install -D -m 755 goconserver $(CURDIR)/debian/goconserver/usr/bin/goconserver install -D -m 755 congo $(CURDIR)/debian/goconserver/usr/bin/congo diff --git a/goconserver/mockbuild.pl b/goconserver/mockbuild.pl index 6acc658..1c8100d 100755 --- a/goconserver/mockbuild.pl +++ b/goconserver/mockbuild.pl @@ -279,7 +279,10 @@ $go_prep # but PINNED + integrity-checked by the committed go.sum, so the build is reproducible without a # vendored tree. GOTOOLCHAIN=local pins the chroot's Go (never auto-downloads a toolchain). $go_environment -export GOFLAGS=-mod=mod GOTOOLCHAIN=local CGO_ENABLED=0 +# GOAMD64 is set here, not inherited: EL10 raises its platform baseline to x86-64-v3 and its +# golang bakes GOAMD64=v3 in as the default. One el10 chroot compiles the x86_64 binary that +# is published for rh8, rh9 and rh10, and a v3 binary stops a pre-AVX2 node with SIGILL. +export GOFLAGS=-mod=mod GOTOOLCHAIN=local CGO_ENABLED=0 GOAMD64=v1 export GOCACHE=%{_builddir}/.gocache GOPATH=%{_builddir}/.gopath GOMODCACHE=%{_builddir}/.gomodcache go build -trimpath -buildvcs=false -ldflags "$go_ldflags" -o goconserver goconserver.go go build -trimpath -buildvcs=false -ldflags "$go_ldflags" -o congo cmd/congo.go @@ -291,6 +294,24 @@ install -Dm0644 %{SOURCE1} %{buildroot}/usr/lib/systemd/system/goconserver.servi install -Dm0644 %{SOURCE2} %{buildroot}/etc/goconserver/server.conf mkdir -p %{buildroot}/var/log/goconserver %{buildroot}/var/lib/goconserver +%check +$go_environment +# Read GOAMD64 out of the binaries that are staged for packaging. An export line can stop +# reaching the compiler, and a grep for that line passes when it does. The build metadata in the +# binary names what compiled it, so this gate fails the build instead of publishing a v3 binary. +# Do not write an rpm section name in this comment. rpm reads the name as the start of a new +# section, even behind a hash, and refuses the spec. +if [ "\$(go env GOARCH)" = amd64 ]; then + for bin in goconserver congo; do + setting=\$(go version -m %{buildroot}/usr/bin/\$bin | + awk -F'\\t' '\$2 == "build" && \$3 ~ /^GOAMD64=/ { print \$3 }') + if [ "\$setting" != "GOAMD64=v1" ]; then + echo "FATAL: \$bin reports '\$setting', not GOAMD64=v1" >&2 + exit 1 + fi + done +fi + %files /usr/bin/goconserver /usr/bin/congo @@ -384,6 +405,8 @@ sub cross_build_and_package { local $ENV{GOFLAGS} = '-mod=mod'; local $ENV{GOTOOLCHAIN} = 'local'; local $ENV{GOARCH} = $goarch{$target_arch}; + # Explicit, so no build inherits a distro default again (see the %build comment). + local $ENV{GOAMD64} = 'v1'; my $bin_dir = "$work_dir/bin"; make_path($bin_dir); @@ -395,6 +418,7 @@ sub cross_build_and_package { sh_quote($ldflags) . " -o " . sh_quote("$bin_dir/$out") . " " . sh_quote($main) . " >" . sh_quote("$log_dir/go-build-$out.log") . " 2>&1"); die "$out binary not built\n" if !-x "$bin_dir/$out"; + assert_goamd64_v1("$bin_dir/$out"); } print_step("Assemble SRPM sources"); @@ -501,6 +525,32 @@ SPEC return; } +#------------------------------------------------------------------------------- + +=head3 assert_goamd64_v1 + + Descriptions: Stop the build when an amd64 Go binary was not compiled with GOAMD64=v1. + The check reads the binary's own build metadata, because an export line can + stop reaching the compiler and still be present in the script. + Arguments: $binary -- path to a compiled Go binary + Returns: nothing; dies when the setting is absent or not v1 + +=cut + +#------------------------------------------------------------------------------- +sub assert_goamd64_v1 { + my ($binary) = @_; + my $quoted = sh_quote($binary); + my $meta = `go version -m $quoted 2>&1`; + die "FATAL: go version -m $binary failed:\n$meta" if $? != 0; + # GOAMD64 applies to amd64 only; another GOARCH records no such setting. + return if $meta !~ /^\s*build\s+GOARCH=amd64\s*$/m; + my ($setting) = $meta =~ /^\s*build\s+(GOAMD64=\S+)\s*$/m; + $setting = 'no GOAMD64 setting' if !defined $setting; + die "FATAL: $binary reports $setting, not GOAMD64=v1\n" if $setting ne 'GOAMD64=v1'; + return; +} + sub run_rc { my ($cmd) = @_; print "+ $cmd\n"; diff --git a/goconserver/sbuild.pl b/goconserver/sbuild.pl index 06e36e8..1a99361 100755 --- a/goconserver/sbuild.pl +++ b/goconserver/sbuild.pl @@ -74,6 +74,10 @@ curl -fsSL "https://go.dev/dl/go${GO_PIN}.linux-${go_host_arch}.tar.gz" | tar -C export PATH="$gotoolchain/bin:$PATH" export GOTOOLCHAIN=local # use exactly the pinned toolchain; never auto-download another export GOOS=linux GOARCH="$go_target_arch" +# GOAMD64 is set, never inherited from the chroot: a v3 binary stops a pre-AVX2 node with +# SIGILL, and one build serves every release of the family. debian/rules asserts the setting +# reached the compiler. +export GOAMD64=v1 go version go env GOHOSTARCH GOARCH From 7495cb6472c75ba0bccfe532816854fb942a8ec3 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:08:24 -0300 Subject: [PATCH 3/3] test(xcat-dep): nothing measured the x86-64 baseline of the goconserver deb The GOAMD64=v1 fix sets the variable at four sites. Three are in the RPM path and native/goconserver-openeuler.t covers them. The fourth is goconserver/debian/rules, which is where dpkg-buildpackage compiles, and no assertion read it. A wrong change there leaves the amd64 deb at v3, and the RPM evidence still looks complete. t/goconserver_cross_build.t already runs the real debian/rules build target with the commands around it shadowed. Its go stub now stamps the GOAMD64 it was given into the binary it emits, answers `go env GOARCH` and `go version -m`, and FIXTURE_GOAMD64 makes it report a different setting -- what a compiler that ignores the request looks like. Six assertions on an amd64 cell: the build completes, both `go build` calls carry GOAMD64=v1, the gate reads the metadata of both binaries, and a v3 binary fails the build with a diagnostic that names the setting it carries. Measured by mutation on xcat-master-ub. Deleting the gate from debian/rules fails two assertions. Deleting `export GOAMD64=v1` from both debian/rules and goconserver/sbuild.pl fails two more. Deleting it from debian/rules alone still passes, because the sbuild shell exports the same value: the two sites are redundant and no test can separate them. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- t/goconserver_cross_build.t | 58 +++++++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/t/goconserver_cross_build.t b/t/goconserver_cross_build.t index 5fea87e..5486d6d 100644 --- a/t/goconserver_cross_build.t +++ b/t/goconserver_cross_build.t @@ -54,7 +54,7 @@ sub write_stub { # run_build($target_arch): run the build shell with the chroot's architecture reported as # $target_arch, and return what it asked the outside world to do. sub run_build { - my ($target_arch) = @_; + my ($target_arch, %options) = @_; my $root = tempdir(CLEANUP => 1); my ($bin, $rec, $work) = ("$root/bin", "$root/rec", "$root/work"); make_path($bin, $rec, $work); @@ -98,9 +98,33 @@ sub run_build { # The real debian/rules has to see the environment, so run its build target for real. write_stub($bin, 'dpkg-buildpackage', 'make -f debian/rules override_dh_auto_build'); # The downloaded toolchain never lands, so `go` always resolves here. It records the environment - # of each invocation, which is the thing under test. + # of each invocation, which is the thing under test, and it stamps the GOAMD64 it was given into + # the binary it emits -- the real compiler does that, and debian/rules reads it back. + # FIXTURE_GOAMD64 makes it report a different setting, which is what a compiler that ignores the + # request looks like. write_stub($bin, 'go', qq{ - echo "GOARCH=\${GOARCH-} GOOS=\${GOOS-} ARGV=\$*" >> '$rec/go-calls' + echo "GOARCH=\${GOARCH-} GOOS=\${GOOS-} GOAMD64=\${GOAMD64-} ARGV=\$*" >> '$rec/go-calls' + case "\$1" in + build) + out=''; prev='' + for a in "\$\@"; do [ "\$prev" = -o ] && out="\$a"; prev="\$a"; done + [ -n "\$out" ] || exit 0 + printf '#!/bin/sh\\nexit 0\\n' > "\$out"; chmod 755 "\$out" + { printf '%s: go1.25.12\\n\\tpath\\tcommand-line-arguments\\n' "\$out" + printf '\\tbuild\\tGOARCH=%s\\n' "\${GOARCH-}" + [ "\${GOARCH-}" = amd64 ] && + printf '\\tbuild\\tGOAMD64=%s\\n' "\${FIXTURE_GOAMD64:-\${GOAMD64-}}" + } > "\$out.meta" + ;; + env) + for v in "\$\@"; do + case "\$v" in env) ;; GOARCH) echo "\${GOARCH-}";; *) echo '';; esac + done + ;; + version) + if [ "\$2" = -m ]; then cat "\$3.meta"; else echo 'go version go1.25.12 linux/\${GOARCH-}'; fi + ;; + esac exit 0 }); @@ -108,8 +132,10 @@ sub run_build { print {$fh} build_script(); close($fh); + my $fixture = $options{goamd64} || ''; my $rc = system('/bin/bash', '-c', "cd '$work' && PATH=\"$bin:\$PATH\" SOURCE_DATE_EPOCH=1789413339 " + . "FIXTURE_GOAMD64='$fixture' " . "bash '$root/build.sh' > '$root/build.log' 2>&1"); my $slurp = sub { @@ -179,4 +205,30 @@ compiles_for($n, $host_go, 'a native cell compiles for its own architecture'); my $p = run_build('ppc64el'); compiles_for($p, 'ppc64le', 'the dpkg name ppc64el reaches go as ppc64le'); +# ---- the x86-64 baseline the amd64 deb is compiled for ------------------------------------------- +# The published x86_64 goconserver was compiled GOAMD64=v3 and stopped a pre-AVX2 headnode with +# SIGILL. debian/rules is where dpkg-buildpackage compiles, so the setting has to reach that file's +# own build target and the binary has to be read back. An export line is not evidence: the failure +# is the export not reaching the compiler, and a grep for the line passes when it does not. +{ + my $a = run_build('amd64'); + is($a->{rc}, 0, 'an amd64 cell builds when the compiler honours GOAMD64=v1') + or diag($a->{log}); + my @builds = grep { /ARGV=.*\bbuild\b/ } @{ $a->{go_calls} }; + is(scalar @builds, 2, 'the amd64 cell reaches both `go build` calls') + or diag(join("\n", @{ $a->{go_calls} })); + is_deeply([ grep { !/\bGOAMD64=v1\b/ } @builds ], [], + 'every `go build` in the amd64 cell is told GOAMD64=v1') + or diag(join("\n", @builds)); + is(scalar(grep { /ARGV=version -m / } @{ $a->{go_calls} }), 2, + 'the gate reads the build metadata of both amd64 binaries') + or diag(join("\n", @{ $a->{go_calls} })); + + my $bad = run_build('amd64', goamd64 => 'v3'); + isnt($bad->{rc}, 0, 'a v3 amd64 binary fails the deb build'); + like($bad->{log}, qr/reports 'GOAMD64=v3', not GOAMD64=v1/, + 'the diagnostic names the setting the amd64 binary carries') + or diag($bad->{log}); +} + done_testing;