e4d75234
smoke: a host nobody could converge cannot fail a release
a73x 2026-09-05 19:06
Commit message
RETRO.md
| Old | New | ||
|---|---|---|---|
| @@ -99,3 +99,10 @@ One line per push to `main`: what slowed the work down. Enforced by | |||
| 99 | ImagePullBackOff nobody was watching, which is the exact failure the change | 99 | ImagePullBackOff nobody was watching, which is the exact failure the change |
| 100 | set out to end. A workload that changes registries changes its auth, and only | 100 | set out to end. A workload that changes registries changes its auth, and only |
| 101 | running the job proves it. | 101 | running the job proves it. |
| 102 | - Scanner blindness: the flood of "Version matching error" lines during the | ||
| 103 | v0.0.8 ship read as cosmetic noise and was the finding. trivy 0.58 could not | ||
| 104 | parse the toolchain's go1.27.0-X:nodwarf5 stamp, so it matched no Go module | ||
| 105 | against any advisory and said so once per module rather than once per image — | ||
| 106 | loud enough to be ignored, quiet enough to look like a warning. A scanner that | ||
| 107 | cannot read a version passes; only reading the summary and noticing the | ||
| 108 | gobinary targets were missing would have caught it. | ||
internal/smoke/scenario.go
| Old | New | ||
|---|---|---|---|
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | "errors" | 5 | "errors" |
| 6 | "fmt" | 6 | "fmt" |
| 7 | "regexp" | 7 | "regexp" |
| 8 | "strings" | ||
| 8 | "time" | 9 | "time" |
| 9 | 10 | ||
| 10 | "github.com/a73x/eitri/internal/server/api/client" | 11 | "github.com/a73x/eitri/internal/server/api/client" |
| @@ -106,6 +107,36 @@ func proveHostRelease(h client.Host, want string) error { | |||
| 106 | h.Name, reported, want, want) | 107 | h.Name, reported, want, want) |
| 107 | } | 108 | } |
| 108 | 109 | ||
| 110 | // placeableHost picks the host this run will place its VM on: the first one | ||
| 111 | // that is connected. | ||
| 112 | // | ||
| 113 | // A dark host is skipped rather than judged. The ship has already dealt with it | ||
| 114 | // one stage earlier — it cannot take an upgrade offer while it is disconnected, | ||
| 115 | // so stage 8 names it, warns, and carries on — and a host that cannot take an | ||
| 116 | // offer cannot take a VM either. Letting it reach proveHostRelease meant a | ||
| 117 | // machine that was merely switched off could fail a release two other hosts had | ||
| 118 | // converged and were ready to prove, with no retry that could ever pass while it | ||
| 119 | // stayed off. | ||
| 120 | // | ||
| 121 | // This is deliberately not "search for a host on the release". Every connected | ||
| 122 | // host is still judged by proveHostRelease, so a fleet that is up and behind | ||
| 123 | // fails exactly as it did — which is the #33 defect this whole check exists for. | ||
| 124 | // The only hosts skipped are the ones nobody could have converged. | ||
| 125 | func placeableHost(hosts []client.Host) (client.Host, error) { | ||
| 126 | for _, h := range hosts { | ||
| 127 | if h.Online { | ||
| 128 | return h, nil | ||
| 129 | } | ||
| 130 | } | ||
| 131 | names := make([]string, 0, len(hosts)) | ||
| 132 | for _, h := range hosts { | ||
| 133 | names = append(names, h.Name) | ||
| 134 | } | ||
| 135 | return client.Host{}, fmt.Errorf("FAIL: no connected host to place a VM on — every host is dark (%s). "+ | ||
| 136 | "The plane is up, so this is the fleet, not the release: bring a host back and resume "+ | ||
| 137 | "with scripts/ship.sh --from 8", strings.Join(names, ", ")) | ||
| 138 | } | ||
| 139 | |||
| 109 | // gateHooks bundles the optional SSH-CA gate steps. nil means "skip the gate". | 140 | // gateHooks bundles the optional SSH-CA gate steps. nil means "skip the gate". |
| 110 | type gateHooks struct { | 141 | type gateHooks struct { |
| 111 | register func(ctx context.Context) error // upload the smoke user CA to the tenant (before create) | 142 | register func(ctx context.Context) error // upload the smoke user CA to the tenant (before create) |
| @@ -175,12 +206,15 @@ func runScenario(ctx context.Context, vmName string, c vmAPI, dialConsole consol | |||
| 175 | if len(hostList) == 0 { | 206 | if len(hostList) == 0 { |
| 176 | return "", errors.New("no hosts available") | 207 | return "", errors.New("no hosts available") |
| 177 | } | 208 | } |
| 178 | host := hostList[0] | 209 | host, err := placeableHost(hostList) |
| 210 | if err != nil { | ||
| 211 | return "", err | ||
| 212 | } | ||
| 179 | // Before anything is created: there is no reason to boot-prove a VM on an | 213 | // Before anything is created: there is no reason to boot-prove a VM on an |
| 180 | // agent this run was never meant to prove. The list is not searched for a | 214 | // agent this run was never meant to prove. The list is still not searched |
| 181 | // host that does match, either — a fleet where only some hosts converged is | 215 | // for a host that does match — a CONNECTED fleet where only some hosts |
| 182 | // itself the defect, and shopping for a working host would hide exactly the | 216 | // converged is itself the defect, and shopping among those would hide |
| 183 | // bug this check exists to expose. | 217 | // exactly the bug this check exists to expose. |
| 184 | if err := proveHostRelease(host, expectAgentVersion); err != nil { | 218 | if err := proveHostRelease(host, expectAgentVersion); err != nil { |
| 185 | return "", err | 219 | return "", err |
| 186 | } | 220 | } |
internal/smoke/scenario_test.go
| Old | New | ||
|---|---|---|---|
| @@ -166,7 +166,7 @@ func TestRunScenarioSuccess(t *testing.T) { | |||
| 166 | deleted := false | 166 | deleted := false |
| 167 | api := &testAPI{ | 167 | api := &testAPI{ |
| 168 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | 168 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { |
| 169 | return []client.Host{{ID: "host-1", UplinkAddr: "192.168.0.190"}}, nil | 169 | return []client.Host{{ID: "host-1", Online: true, UplinkAddr: "192.168.0.190"}}, nil |
| 170 | }, | 170 | }, |
| 171 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | 171 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { |
| 172 | if req.HostID != "host-1" { | 172 | if req.HostID != "host-1" { |
| @@ -241,7 +241,7 @@ func TestRunScenarioRebootDeathFails(t *testing.T) { | |||
| 241 | power := "running" | 241 | power := "running" |
| 242 | api := &testAPI{ | 242 | api := &testAPI{ |
| 243 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | 243 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { |
| 244 | return []client.Host{{ID: "host-1", UplinkAddr: "192.168.0.190"}}, nil | 244 | return []client.Host{{ID: "host-1", Online: true, UplinkAddr: "192.168.0.190"}}, nil |
| 245 | }, | 245 | }, |
| 246 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | 246 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { |
| 247 | return client.CreateVMResponse{ID: "vm-1"}, nil | 247 | return client.CreateVMResponse{ID: "vm-1"}, nil |
| @@ -305,7 +305,7 @@ func TestRunScenarioExposureFailureFails(t *testing.T) { | |||
| 305 | func TestRunScenarioSerialPanicFails(t *testing.T) { | 305 | func TestRunScenarioSerialPanicFails(t *testing.T) { |
| 306 | api := &testAPI{ | 306 | api := &testAPI{ |
| 307 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | 307 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { |
| 308 | return []client.Host{{ID: "host-1", UplinkAddr: "192.168.0.190"}}, nil | 308 | return []client.Host{{ID: "host-1", Online: true, UplinkAddr: "192.168.0.190"}}, nil |
| 309 | }, | 309 | }, |
| 310 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | 310 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { |
| 311 | return client.CreateVMResponse{ID: "vm-1"}, nil | 311 | return client.CreateVMResponse{ID: "vm-1"}, nil |
| @@ -335,7 +335,7 @@ func TestRunScenarioSerialPanicFails(t *testing.T) { | |||
| 335 | func TestRunScenarioNeverReadyTimesOut(t *testing.T) { | 335 | func TestRunScenarioNeverReadyTimesOut(t *testing.T) { |
| 336 | api := &testAPI{ | 336 | api := &testAPI{ |
| 337 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | 337 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { |
| 338 | return []client.Host{{ID: "host-1", UplinkAddr: "192.168.0.190"}}, nil | 338 | return []client.Host{{ID: "host-1", Online: true, UplinkAddr: "192.168.0.190"}}, nil |
| 339 | }, | 339 | }, |
| 340 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | 340 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { |
| 341 | return client.CreateVMResponse{ID: "vm-1"}, nil | 341 | return client.CreateVMResponse{ID: "vm-1"}, nil |
| @@ -376,7 +376,7 @@ func TestRunScenarioNeverReapedTimesOut(t *testing.T) { | |||
| 376 | power := "running" | 376 | power := "running" |
| 377 | api := &testAPI{ | 377 | api := &testAPI{ |
| 378 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | 378 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { |
| 379 | return []client.Host{{ID: "host-1", UplinkAddr: "192.168.0.190"}}, nil | 379 | return []client.Host{{ID: "host-1", Online: true, UplinkAddr: "192.168.0.190"}}, nil |
| 380 | }, | 380 | }, |
| 381 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | 381 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { |
| 382 | return client.CreateVMResponse{ID: "vm-1"}, nil | 382 | return client.CreateVMResponse{ID: "vm-1"}, nil |
| @@ -445,7 +445,7 @@ func happyPathAPI(t *testing.T, calls *[]string) *testAPI { | |||
| 445 | created := 0 | 445 | created := 0 |
| 446 | return &testAPI{ | 446 | return &testAPI{ |
| 447 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | 447 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { |
| 448 | return []client.Host{{ID: "host-1", UplinkAddr: "192.168.0.190"}}, nil | 448 | return []client.Host{{ID: "host-1", Online: true, UplinkAddr: "192.168.0.190"}}, nil |
| 449 | }, | 449 | }, |
| 450 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | 450 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { |
| 451 | *calls = append(*calls, "createVM") | 451 | *calls = append(*calls, "createVM") |
| @@ -731,7 +731,7 @@ func TestRunScenarioFailsWhenTheMCPLegFails(t *testing.T) { | |||
| 731 | func TestRunScenarioRefusesAHostBehindTheRelease(t *testing.T) { | 731 | func TestRunScenarioRefusesAHostBehindTheRelease(t *testing.T) { |
| 732 | api := &testAPI{ | 732 | api := &testAPI{ |
| 733 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | 733 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { |
| 734 | return []client.Host{{ID: "host-1", Name: "onyx", AgentVersion: "v0.0.6"}}, nil | 734 | return []client.Host{{ID: "host-1", Name: "onyx", Online: true, AgentVersion: "v0.0.6"}}, nil |
| 735 | }, | 735 | }, |
| 736 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | 736 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { |
| 737 | t.Fatal("CreateVM must not be called on a host that is not running the release") | 737 | t.Fatal("CreateVM must not be called on a host that is not running the release") |
| @@ -769,7 +769,7 @@ func TestRunScenarioAcceptsTheHostRunningTheRelease(t *testing.T) { | |||
| 769 | listHostsCalls := 0 | 769 | listHostsCalls := 0 |
| 770 | api.listHostsFunc = func(ctx context.Context) ([]client.Host, error) { | 770 | api.listHostsFunc = func(ctx context.Context) ([]client.Host, error) { |
| 771 | listHostsCalls++ | 771 | listHostsCalls++ |
| 772 | return []client.Host{{ID: "host-1", Name: "onyx", UplinkAddr: "192.168.0.190", AgentVersion: "v0.0.7"}}, nil | 772 | return []client.Host{{ID: "host-1", Name: "onyx", Online: true, UplinkAddr: "192.168.0.190", AgentVersion: "v0.0.7"}}, nil |
| 773 | } | 773 | } |
| 774 | 774 | ||
| 775 | clock := &fakeClock{t: time.Unix(0, 0)} | 775 | clock := &fakeClock{t: time.Unix(0, 0)} |
| @@ -794,7 +794,7 @@ func TestRunScenarioWithoutAnExpectationJudgesNoVersion(t *testing.T) { | |||
| 794 | listHostsCalls := 0 | 794 | listHostsCalls := 0 |
| 795 | api.listHostsFunc = func(ctx context.Context) ([]client.Host, error) { | 795 | api.listHostsFunc = func(ctx context.Context) ([]client.Host, error) { |
| 796 | listHostsCalls++ | 796 | listHostsCalls++ |
| 797 | return []client.Host{{ID: "host-1", Name: "onyx", UplinkAddr: "192.168.0.190", AgentVersion: "dev"}}, nil | 797 | return []client.Host{{ID: "host-1", Name: "onyx", Online: true, UplinkAddr: "192.168.0.190", AgentVersion: "dev"}}, nil |
| 798 | } | 798 | } |
| 799 | 799 | ||
| 800 | clock := &fakeClock{t: time.Unix(0, 0)} | 800 | clock := &fakeClock{t: time.Unix(0, 0)} |
| @@ -822,3 +822,99 @@ func TestProveHostReleaseNamesAnAgentTooOldToNameItself(t *testing.T) { | |||
| 822 | t.Errorf("error = %q, want it to say the host reports no version at all", err.Error()) | 822 | t.Errorf("error = %q, want it to say the host reports no version at all", err.Error()) |
| 823 | } | 823 | } |
| 824 | } | 824 | } |
| 825 | |||
| 826 | // TestRunScenarioSkipsADarkHostAndProvesOnAConnectedOne is the other half of | ||
| 827 | // the #33 story, learned on the first prod ship of v0.0.8: a host that is | ||
| 828 | // simply switched off must not fail a release that two connected hosts have | ||
| 829 | // converged and are ready to prove. The ship already said its piece about that | ||
| 830 | // host one stage earlier — it cannot take an upgrade offer while it is dark — | ||
| 831 | // and no retry could ever pass while it stayed off. | ||
| 832 | func TestRunScenarioSkipsADarkHostAndProvesOnAConnectedOne(t *testing.T) { | ||
| 833 | var calls []string | ||
| 834 | api := happyPathAPI(t, &calls) | ||
| 835 | var placedOn string | ||
| 836 | api.listHostsFunc = func(ctx context.Context) ([]client.Host, error) { | ||
| 837 | return []client.Host{ | ||
| 838 | // Dark, never reported a version: exactly onyx on 2026-09-05. | ||
| 839 | {ID: "host-dark", Name: "onyx", Online: false, AgentVersion: ""}, | ||
| 840 | {ID: "host-up", Name: "charizard", Online: true, AgentVersion: "v0.0.8"}, | ||
| 841 | }, nil | ||
| 842 | } | ||
| 843 | create := api.createVMFunc | ||
| 844 | api.createVMFunc = func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | ||
| 845 | placedOn = req.HostID | ||
| 846 | return create(ctx, req) | ||
| 847 | } | ||
| 848 | |||
| 849 | clock := &fakeClock{t: time.Unix(0, 0)} | ||
| 850 | msg, err := runScenario(context.Background(), "smoke-test", api, powerCycleConsole(t, api, bootedGuest, "ubuntu-vm login: ").dial, nil, nil, "v0.0.8", clock.now, clock.sleep, noopReadPubKey, okBanner) | ||
| 851 | if err != nil { | ||
| 852 | t.Fatalf("runScenario: want the dark host skipped, got %v", err) | ||
| 853 | } | ||
| 854 | if placedOn != "host-up" { | ||
| 855 | t.Errorf("placed on %q, want the connected host host-up", placedOn) | ||
| 856 | } | ||
| 857 | if !strings.Contains(msg, "SMOKE COMPLETE") { | ||
| 858 | t.Errorf("message = %q, want a complete run", msg) | ||
| 859 | } | ||
| 860 | } | ||
| 861 | |||
| 862 | // TestRunScenarioStillRefusesAConnectedHostBehindTheRelease guards the line the | ||
| 863 | // fix above must not cross. Skipping dark hosts is not "shop for a host on the | ||
| 864 | // release": a host that is UP and behind is the #33 defect itself, and it still | ||
| 865 | // fails even when a converged host sits beside it in the same listing. | ||
| 866 | func TestRunScenarioStillRefusesAConnectedHostBehindTheRelease(t *testing.T) { | ||
| 867 | api := &testAPI{ | ||
| 868 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | ||
| 869 | return []client.Host{ | ||
| 870 | {ID: "host-old", Name: "onyx", Online: true, AgentVersion: "v0.0.7"}, | ||
| 871 | {ID: "host-up", Name: "charizard", Online: true, AgentVersion: "v0.0.8"}, | ||
| 872 | }, nil | ||
| 873 | }, | ||
| 874 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | ||
| 875 | t.Fatal("CreateVM must not be called while a connected host is behind the release") | ||
| 876 | return client.CreateVMResponse{}, nil | ||
| 877 | }, | ||
| 878 | } | ||
| 879 | |||
| 880 | clock := &fakeClock{t: time.Unix(0, 0)} | ||
| 881 | _, err := runScenario(context.Background(), "smoke-test", api, nil, nil, nil, "v0.0.8", clock.now, clock.sleep, noopReadPubKey, okBanner) | ||
| 882 | if err == nil { | ||
| 883 | t.Fatal("runScenario: want a failure on a connected host a release behind, got nil") | ||
| 884 | } | ||
| 885 | for _, want := range []string{"onyx", "v0.0.7", "v0.0.8", "did not converge"} { | ||
| 886 | if !strings.Contains(err.Error(), want) { | ||
| 887 | t.Errorf("error = %q, want it to mention %q", err.Error(), want) | ||
| 888 | } | ||
| 889 | } | ||
| 890 | } | ||
| 891 | |||
| 892 | // TestRunScenarioFailsWhenEveryHostIsDark: skipping dark hosts cannot become | ||
| 893 | // skipping the proof. With nobody left to place on, the run fails — and says | ||
| 894 | // this is the fleet rather than the release, because a reader who has just been | ||
| 895 | // told the plane is serving the tag deserves to know which half is at fault. | ||
| 896 | func TestRunScenarioFailsWhenEveryHostIsDark(t *testing.T) { | ||
| 897 | api := &testAPI{ | ||
| 898 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | ||
| 899 | return []client.Host{ | ||
| 900 | {ID: "host-a", Name: "onyx", Online: false, AgentVersion: "v0.0.8"}, | ||
| 901 | {ID: "host-b", Name: "Squirtle.local", Online: false, AgentVersion: "v0.0.8"}, | ||
| 902 | }, nil | ||
| 903 | }, | ||
| 904 | createVMFunc: func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | ||
| 905 | t.Fatal("CreateVM must not be called when no host is connected") | ||
| 906 | return client.CreateVMResponse{}, nil | ||
| 907 | }, | ||
| 908 | } | ||
| 909 | |||
| 910 | clock := &fakeClock{t: time.Unix(0, 0)} | ||
| 911 | _, err := runScenario(context.Background(), "smoke-test", api, nil, nil, nil, "v0.0.8", clock.now, clock.sleep, noopReadPubKey, okBanner) | ||
| 912 | if err == nil { | ||
| 913 | t.Fatal("runScenario: want a failure when every host is dark, got nil") | ||
| 914 | } | ||
| 915 | for _, want := range []string{"onyx", "Squirtle.local", "dark", "--from 8"} { | ||
| 916 | if !strings.Contains(err.Error(), want) { | ||
| 917 | t.Errorf("error = %q, want it to mention %q", err.Error(), want) | ||
| 918 | } | ||
| 919 | } | ||
| 920 | } | ||
scripts/scan-image.sh
| Old | New | ||
|---|---|---|---|
| @@ -21,7 +21,14 @@ set -euo pipefail | |||
| 21 | 21 | ||
| 22 | IMAGE="${1:?scan-image: usage: scan-image.sh <image:tag>}" | 22 | IMAGE="${1:?scan-image: usage: scan-image.sh <image:tag>}" |
| 23 | SEVERITY="${SCAN_SEVERITY:-HIGH,CRITICAL}" | 23 | SEVERITY="${SCAN_SEVERITY:-HIGH,CRITICAL}" |
| 24 | TRIVY_IMAGE="${TRIVY_IMAGE:-docker.io/aquasec/trivy:0.58.1}" | 24 | # 0.74, not 0.58: the older scanner could not parse the version string this |
| 25 | # toolchain stamps into every Go binary — go1.27.0-X:nodwarf5, the experiment | ||
| 26 | # tag baked into the packaged toolchain — and answered by printing one | ||
| 27 | # "Version matching error" per module and matching NONE of them. The Go half of | ||
| 28 | # every image scan was quietly doing nothing while the OS half carried the gate. | ||
| 29 | # A scanner that cannot read a version does not fail; it passes. Bump this when | ||
| 30 | # the toolchain moves again, and check the summary names each gobinary target. | ||
| 31 | TRIVY_IMAGE="${TRIVY_IMAGE:-docker.io/aquasec/trivy:0.74.0}" | ||
| 25 | DOCKER="$(command -v docker || command -v podman)" | 32 | DOCKER="$(command -v docker || command -v podman)" |
| 26 | 33 | ||
| 27 | if [ "${SKIP_IMAGE_SCAN:-}" = "1" ]; then | 34 | if [ "${SKIP_IMAGE_SCAN:-}" = "1" ]; then |