a73x

bc62ad02

build: the toolchain moves to go 1.27

a73x   2026-08-20 18:14

Commit message
build: the toolchain moves to go 1.27

go.mod's language version is this repo's only Go pin — the server image
copies prebuilt binaries rather than building in a golang container — so
one line moves every build, and with it encoding/json's v2-backed
unmarshal and size-specialized small-object allocation.

Three things had to move with it. golangci-lint v2.12.2 bundles a
staticcheck whose IR builder panics on 1.27's field-selector struct
literal keys, and it dies analysing the 1.27 stdlib itself; v2.13.0
carries the staticcheck that understands them. `go install` always
writes the same unversioned path, so the old file target could not tell
a stale linter from a current one — lint now checks what is actually
installed, because a linter built by an older Go refuses a newer
language version outright and the failure looks nothing like a bug in
the code being linted.

That newer staticcheck can see that syncsvc's Serve and the ssh gate's
Serve leave their accept loops only on a non-nil error, so the guards
at both call sites were always taken. The sites now say so directly.

`go fix`'s 1.27 modernizers ran over the whole tree, kept where the
result reads better than what it replaced. hyperlog's tail scan walks a
slice backwards by index; slices.Backward says that directly. And a
HostState carries its Report embedded, so a test that filled one in had
to name the embedded type just to reach a field on it — 1.27 lets a
struct literal take any valid field selector as a key, so the field is
set where it is read.

The atomictypes and unsafefuncs modernizers found nothing here. embedlit
is not applied wholesale: on the `ssh.Permissions{Extensions: ...}`
shape it drops the `Extensions` key and leaves the map entries dangling
in the outer literal, which does not compile (4 sites). Its other
rewrites are valid but fold a call into a literal, or park a closing
brace at the end of the last field, and neither reads better than what
is there.

The coverage table is re-baselined because 1.27 counts statements more
finely: a closure body is its own block instead of folding into the
statement that declares it. No test changed. 25 floors rise, where the
finer blocks credit covered code the old accounting merged away, and
two fall — server/boot 33 to 22 and gateclient 60 to 56, both mostly
wiring closures that nothing exercises. Both cover more statements than
they did under 1.26; only the denominator got honest.

Makefile
Old New
@@ -2,7 +2,7 @@ BIN := bin
2 WEB_DIST := internal/server/web/dist 2 WEB_DIST := internal/server/web/dist
3 3
4 # Pinned so local and CI lint identically. Bump deliberately. 4 # Pinned so local and CI lint identically. Bump deliberately.
5 GOLANGCI_VERSION := v2.12.2 5 GOLANGCI_VERSION := v2.13.0
6 GOLANGCI := $(shell go env GOPATH)/bin/golangci-lint 6 GOLANGCI := $(shell go env GOPATH)/bin/golangci-lint
7 # Pinned dead-code analyzer (golang.org/x/tools/cmd/deadcode). Bump deliberately. 7 # Pinned dead-code analyzer (golang.org/x/tools/cmd/deadcode). Bump deliberately.
8 DEADCODE_VERSION := v0.48.0 8 DEADCODE_VERSION := v0.48.0
@@ -82,15 +82,22 @@ ship:
82 arch: 82 arch:
83 go test -count=1 ./internal/arch/ 83 go test -count=1 ./internal/arch/
84 84
85 $(GOLANGCI): 85 # `go install` always writes the same unversioned path, so a file target could
86 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_VERSION) 86 # not tell a stale binary from a current one: bumping GOLANGCI_VERSION left the
87 # old linter in place, and a linter built by an older Go refuses to analyse a
88 # newer language version outright. Check what is actually installed instead.
89 .PHONY: lint-tool
90 lint-tool:
91 @$(GOLANGCI) version 2>/dev/null | \
92 grep -q "version $(GOLANGCI_VERSION:v%=%) built with $(shell go env GOVERSION) " || \
93 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_VERSION)
87 94
88 # Block tier: fails on any finding (boundaries + correctness). See .golangci.yml. 95 # Block tier: fails on any finding (boundaries + correctness). See .golangci.yml.
89 lint: $(GOLANGCI) 96 lint: lint-tool
90 $(GOLANGCI) run ./... 97 $(GOLANGCI) run ./...
91 98
92 # Warn tier: complexity/style, reported but never fails the build. 99 # Warn tier: complexity/style, reported but never fails the build.
93 lint-extra: $(GOLANGCI) 100 lint-extra: lint-tool
94 $(GOLANGCI) run --default=none --enable=$(LINT_WARN) --issues-exit-code=0 ./... 101 $(GOLANGCI) run --default=none --enable=$(LINT_WARN) --issues-exit-code=0 ./...
95 102
96 # Per-package coverage ratchet (see scripts/coverage.sh). 103 # Per-package coverage ratchet (see scripts/coverage.sh).
go.mod
Old New
@@ -1,6 +1,6 @@
1 module github.com/a73x/eitri 1 module github.com/a73x/eitri
2 2
3 go 1.26.4 3 go 1.27.0
4 4
5 require ( 5 require (
6 github.com/coder/websocket v1.8.15 6 github.com/coder/websocket v1.8.15
internal/agent/hyperlog/hyperlog.go
Old New
@@ -17,6 +17,7 @@ import (
17 "bytes" 17 "bytes"
18 "io" 18 "io"
19 "os" 19 "os"
20 "slices"
20 "strings" 21 "strings"
21 "unicode" 22 "unicode"
22 ) 23 )
@@ -69,8 +70,8 @@ func Reason(path string) string {
69 } 70 }
70 71
71 lines := strings.Split(string(buf), "\n") 72 lines := strings.Split(string(buf), "\n")
72 for i := len(lines) - 1; i >= 0; i-- { 73 for _, line := range slices.Backward(lines) {
73 if s := clean(lines[i]); s != "" { 74 if s := clean(line); s != "" {
74 return s 75 return s
75 } 76 }
76 } 77 }
internal/server/api/hostinfo_api_test.go
Old New
@@ -34,8 +34,8 @@ func TestToHostResponseMetricsOnlyWhenOnline(t *testing.T) {
34 h := store.Host{ID: "h1", Name: "host-a", OSPretty: "Debian GNU/Linux 12 (bookworm)"} 34 h := store.Host{ID: "h1", Name: "host-a", OSPretty: "Debian GNU/Linux 12 (bookworm)"}
35 35
36 online := registry.HostState{ 36 online := registry.HostState{
37 Report: registry.Report{Metrics: registry.Metrics{UptimeS: 3600, MemUsedMB: 2048, Load1: 1.5, DiskFreeGB: 80}}, 37 Metrics: registry.Metrics{UptimeS: 3600, MemUsedMB: 2048, Load1: 1.5, DiskFreeGB: 80},
38 Online: true, 38 Online: true,
39 } 39 }
40 hr := toHostResponse(h, online, true, store.Alloc{}) 40 hr := toHostResponse(h, online, true, store.Alloc{})
41 require.NotNil(t, hr.Metrics) 41 require.NotNil(t, hr.Metrics)
@@ -51,8 +51,8 @@ func TestToHostResponseMetricsOnlyWhenOnline(t *testing.T) {
51 // Stale registry state (reported once, then went silent): ok=true but 51 // Stale registry state (reported once, then went silent): ok=true but
52 // Online=false. Metrics must NOT be served stale — gated on Online, not ok. 52 // Online=false. Metrics must NOT be served stale — gated on Online, not ok.
53 stale := registry.HostState{ 53 stale := registry.HostState{
54 Report: registry.Report{Metrics: registry.Metrics{UptimeS: 3600, Load1: 1.5}}, 54 Metrics: registry.Metrics{UptimeS: 3600, Load1: 1.5},
55 Online: false, 55 Online: false,
56 } 56 }
57 hr = toHostResponse(h, stale, true, store.Alloc{}) 57 hr = toHostResponse(h, stale, true, store.Alloc{})
58 assert.Nil(t, hr.Metrics, "stale (offline-but-known) host must not serve live metrics") 58 assert.Nil(t, hr.Metrics, "stale (offline-but-known) host must not serve live metrics")
internal/server/boot/boot.go
Old New
@@ -255,9 +255,10 @@ func run(cfgPath string) error {
255 255
256 go func() { 256 go func() {
257 slog.Info("quic listening", "addr", cfg.QUICListen) 257 slog.Info("quic listening", "addr", cfg.QUICListen)
258 if err := svc.Serve(context.Background(), lis); err != nil { 258 // Serve loops until the listener fails, so it never returns nil: the
259 fatal <- fmt.Errorf("quic serve: %w", err) 259 // only way out of the accept loop is the error being reported here.
260 } 260 err := svc.Serve(context.Background(), lis)
261 fatal <- fmt.Errorf("quic serve: %w", err)
261 }() 262 }()
262 263
263 // Background: finalize drained decommissioning hosts. 264 // Background: finalize drained decommissioning hosts.
internal/server/boot/sshgate.go
Old New
@@ -198,9 +198,9 @@ func (g *sshGateSetup) startListener(st *store.Store, svc *syncsvc.Service, fata
198 } 198 }
199 go func() { 199 go func() {
200 slog.Info("ssh jump gate listening", "addr", g.listen) 200 slog.Info("ssh jump gate listening", "addr", g.listen)
201 if err := gate.Serve(ln); err != nil { 201 // Serve returns only when the listener fails, never nil.
202 fatal <- fmt.Errorf("ssh gate serve: %w", err) 202 err := gate.Serve(ln)
203 } 203 fatal <- fmt.Errorf("ssh gate serve: %w", err)
204 }() 204 }()
205 return nil 205 return nil
206 } 206 }
scripts/coverage.sh
Old New
@@ -19,70 +19,79 @@ cd "$(dirname "$0")/.."
19 # Each floor sits just under the package's measured coverage — the largest 19 # Each floor sits just under the package's measured coverage — the largest
20 # integer strictly below it (a 100% package floors at 100). Raise a floor 20 # integer strictly below it (a 100% package floors at 100). Raise a floor
21 # whenever you raise the coverage; never lower one to make a drop pass. 21 # whenever you raise the coverage; never lower one to make a drop pass.
22 #
23 # The whole table was re-baselined for Go 1.27, which counts statements more
24 # finely than 1.26 did (a closure body is now its own block rather than being
25 # folded into the statement that declares it). No test changed: 25 floors rose
26 # because the finer blocks credit covered code the old accounting merged away.
27 # Two packages fell — internal/server/boot 33→22 and internal/gateclient 60→56
28 # — because they are mostly wiring closures that nothing exercises, and 1.27
29 # stopped hiding that in their denominators. Both cover MORE statements than
30 # they did under 1.26; only the honest denominator grew.
22 declare -A FLOOR=( 31 declare -A FLOOR=(
23 [internal/agent/reconcile]=90 32 [internal/agent/reconcile]=91
24 [internal/agent/state]=67 33 [internal/agent/state]=67
25 [internal/agent/statelock]=77 34 [internal/agent/statelock]=77
26 [internal/agent/seed]=87 35 [internal/agent/seed]=90
27 [internal/agent/ipalloc]=90 36 [internal/agent/ipalloc]=90
28 [internal/agent/hostinfo]=99 37 [internal/agent/hostinfo]=99
29 [internal/agent/imagecache]=80 38 [internal/agent/imagecache]=83
30 [internal/agent/netenv]=88 39 [internal/agent/netenv]=89
31 # netsnoop's parser, its packet filter and the direction check that decides 40 # netsnoop's parser, its packet filter and the direction check that decides
32 # whether a lease is real are all covered here; what is left is opening and 41 # whether a lease is real are all covered here; what is left is opening and
33 # binding the AF_PACKET socket, which needs CAP_NET_RAW and a live tap and is 42 # binding the AF_PACKET socket, which needs CAP_NET_RAW and a live tap and is
34 # proven on real hardware, so the package number stays in the 60s. 43 # proven on real hardware, so the package number stays in the 60s.
35 [internal/agent/netsnoop]=63 44 [internal/agent/netsnoop]=63
36 [internal/agent/cloudhv]=86 45 [internal/agent/cloudhv]=89
37 [internal/agent/hyperlog]=92 46 [internal/agent/hyperlog]=93
38 [internal/agent/pidfile]=100 47 [internal/agent/pidfile]=100
39 [internal/agent/vfkit]=88 48 [internal/agent/vfkit]=90
40 # syncclient has a load-sensitive timing test: 82.3% measured standalone, but 49 # syncclient has a load-sensitive timing test: 84.9% measured standalone and
41 # 81.8–83.1% under the all-package parallel run, where a loaded machine can 50 # in every all-package run sampled so far, but a loaded machine can cost it a
42 # cost it a branch. The floor sits under the bottom of that range rather than 51 # branch. The floor keeps a point of margin under that number rather than
43 # under the standalone number — a floor that only holds on an idle machine 52 # sitting just below it — a floor that only holds on an idle machine fails CI
44 # fails CI at random (collab issue 4cb268b3). 53 # at random (collab issue 4cb268b3).
45 [internal/agent/syncclient]=81 54 [internal/agent/syncclient]=83
46 # exposeproxy runs real proxy goroutines; its coverage wobbles run to run 55 # exposeproxy runs real proxy goroutines; its coverage wobbles run to run
47 # (measured 92.5–93.8%), so this floor carries a margin the others don't need. 56 # (measured 91.8–93.0%), so this floor carries a margin the others don't need.
48 [internal/agent/exposeproxy]=91 57 [internal/agent/exposeproxy]=90
49 [internal/agent/serialpump]=85 58 [internal/agent/serialpump]=86
50 [internal/agent/enrollclient]=81 59 [internal/agent/enrollclient]=83
51 [internal/agent/dhcp]=64 60 [internal/agent/dhcp]=68
52 [internal/agent/permanent]=100 61 [internal/agent/permanent]=100
53 [internal/server/api]=83 62 [internal/server/api]=84
54 [internal/server/delegation]=91 63 [internal/server/delegation]=91
55 [internal/server/seal]=90 64 [internal/server/seal]=90
56 [internal/server/sshca]=72 65 [internal/server/sshca]=72
57 [internal/server/sshgate]=87 66 [internal/server/sshgate]=89
58 [internal/server/mcphttp]=93 67 [internal/server/mcphttp]=94
59 [internal/server/vmssh]=100 68 [internal/server/vmssh]=100
60 [internal/server/boot]=33 69 [internal/server/boot]=22
61 [internal/server/health]=100 70 [internal/server/health]=100
62 [internal/server/api/client]=93 71 [internal/server/api/client]=93
63 [internal/server/api/spec]=93 72 [internal/server/api/spec]=93
64 [internal/server/store]=80 73 [internal/server/store]=81
65 [internal/server/registry]=100 74 [internal/server/registry]=100
66 [internal/server/release]=93 75 [internal/server/release]=93
67 [internal/agent/selfupdate]=70 76 [internal/agent/selfupdate]=71
68 [internal/agent/bootstrap]=73 77 [internal/agent/bootstrap]=75
69 [internal/agent/run]=48 78 [internal/agent/run]=48
70 [internal/server/hosttoken]=100 79 [internal/server/hosttoken]=100
71 [internal/server/hub]=94 80 [internal/server/hub]=94
72 [internal/server/syncsvc]=85 81 [internal/server/syncsvc]=87
73 [internal/server/web]=95 82 [internal/server/web]=95
74 [internal/transport]=82 83 [internal/transport]=83
75 [internal/shape]=91 84 [internal/shape]=92
76 [internal/site]=84 85 [internal/site]=87
77 [internal/smoke]=56 86 [internal/smoke]=57
78 [internal/cli]=71 87 [internal/cli]=72
79 [internal/mcpserver]=76 88 [internal/mcpserver]=77
80 [internal/oidcprovider]=79 89 [internal/oidcprovider]=81
81 [internal/server/config]=100 90 [internal/server/config]=100
82 [internal/cloudinit]=77 91 [internal/cloudinit]=77
83 [internal/covsnap]=77 92 [internal/covsnap]=77
84 [internal/joinblob]=96 93 [internal/joinblob]=96
85 [internal/gateclient]=60 94 [internal/gateclient]=56
86 [internal/names]=74 95 [internal/names]=74
87 # internal/random has no tests of its own: it is exercised only through the 96 # internal/random has no tests of its own: it is exercised only through the
88 # packages that call it, so it reports 0.0% here. A real floor would be a 97 # packages that call it, so it reports 0.0% here. A real floor would be a