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 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(); 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;