276 lines
10 KiB
Go
276 lines
10 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
|
|
)
|
|
|
|
// artifactChoices fans the per-version sha lookups out concurrently (hub v0.100.0). The
|
|
// Configuration page took 26 SECONDS with two dropdowns because it made 42 sequential metadata
|
|
// round-trips. These tests pin the three things that must survive the change: the ORDER, the
|
|
// per-version failure isolation, and the concurrency itself.
|
|
|
|
// fakeGitea serves the two endpoints the client uses, with a controllable per-request delay so
|
|
// "concurrent" is measurable rather than asserted.
|
|
type fakeGitea struct {
|
|
versions []string
|
|
delay time.Duration
|
|
failVer string // this version's /files call 500s
|
|
bundleOnly string // R-1011: this version's package holds only the config bundle (an agent step bundle)
|
|
|
|
inFlight int32
|
|
maxSeen int32
|
|
callCount int32
|
|
}
|
|
|
|
func (f *fakeGitea) start(t *testing.T) *gitea.Client {
|
|
t.Helper()
|
|
mux := http.NewServeMux()
|
|
// BOTH patterns: the version list is GET /api/v1/packages/admin?... with NO trailing slash,
|
|
// the per-version files call is /api/v1/packages/admin/generic/<pkg>/<ver>/files.
|
|
handler := func(w http.ResponseWriter, r *http.Request) {
|
|
n := atomic.AddInt32(&f.inFlight, 1)
|
|
for {
|
|
old := atomic.LoadInt32(&f.maxSeen)
|
|
if n <= old || atomic.CompareAndSwapInt32(&f.maxSeen, old, n) {
|
|
break
|
|
}
|
|
}
|
|
defer atomic.AddInt32(&f.inFlight, -1)
|
|
atomic.AddInt32(&f.callCount, 1)
|
|
time.Sleep(f.delay)
|
|
|
|
// .../generic/<pkg>/<version>/files → the sha metadata
|
|
if len(r.URL.Path) > 6 && r.URL.Path[len(r.URL.Path)-6:] == "/files" {
|
|
for _, v := range f.versions {
|
|
if f.failVer != "" && v == f.failVer && strings.Contains(r.URL.Path, "/"+v+"/") {
|
|
http.Error(w, "boom", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
ver := versionFromFilesPath(r.URL.Path)
|
|
if f.bundleOnly != "" && ver == f.bundleOnly {
|
|
fmt.Fprintf(w, `[{"name":"felhom-config-bundle.json","sha256":"bundle-%s"}]`, ver)
|
|
return
|
|
}
|
|
fmt.Fprintf(w, `[{"name":"felhom-agent","sha256":"sha-%s"}]`, ver)
|
|
return
|
|
}
|
|
// the version list
|
|
out := "["
|
|
for i, v := range f.versions {
|
|
if i > 0 {
|
|
out += ","
|
|
}
|
|
out += fmt.Sprintf(`{"name":"felhom-agent","type":"generic","version":%q}`, v)
|
|
}
|
|
fmt.Fprint(w, out+"]")
|
|
}
|
|
mux.HandleFunc("/api/v1/packages/admin", handler)
|
|
mux.HandleFunc("/api/v1/packages/admin/", handler)
|
|
srv := httptest.NewServer(mux)
|
|
t.Cleanup(srv.Close)
|
|
return gitea.New(srv.URL, "admin", "u", "t")
|
|
}
|
|
|
|
func versionFromFilesPath(p string) string {
|
|
// .../generic/<pkg>/<version>/files
|
|
end := len(p) - len("/files")
|
|
start := end - 1
|
|
for start > 0 && p[start-1] != '/' {
|
|
start--
|
|
}
|
|
return p[start:end]
|
|
}
|
|
|
|
func newTestWebServer(t *testing.T, c *gitea.Client) *Server {
|
|
t.Helper()
|
|
s := &Server{logger: log.New(io.Discard, "", 0)}
|
|
s.SetGiteaClient(c)
|
|
return s
|
|
}
|
|
|
|
// The dropdown is newest-first and the concurrent version must not scramble it.
|
|
func TestArtifactChoices_PreservesOrder(t *testing.T) {
|
|
f := &fakeGitea{versions: []string{"0.9.0", "0.8.0", "0.7.0", "0.6.0", "0.5.0"}}
|
|
s := newTestWebServer(t, f.start(t))
|
|
|
|
got := s.artifactChoices(context.Background(), "felhom-agent", "felhom-agent")
|
|
|
|
if len(got) != 5 {
|
|
t.Fatalf("got %d choices, want 5: %+v", len(got), got)
|
|
}
|
|
// ListVersions sorts newest-first; the fan-out must not disturb that, and each sha must belong
|
|
// to ITS OWN version — a slot mix-up would show the operator a hash for a different artifact.
|
|
for i, want := range []string{"0.9.0", "0.8.0", "0.7.0", "0.6.0", "0.5.0"} {
|
|
if got[i].Version != want {
|
|
t.Errorf("choice %d = %q, want %q — the concurrent fan-out scrambled the order", i, got[i].Version, want)
|
|
}
|
|
if got[i].SHA256 != "sha-"+want {
|
|
t.Errorf("version %s carries sha %q — a sha was written into the wrong slot, which would show "+
|
|
"the operator a hash belonging to a different artifact", got[i].Version, got[i].SHA256)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A single failing version drops ITSELF and nothing else — unchanged from the serial version.
|
|
func TestArtifactChoices_OneFailureDropsOnlyThatVersion(t *testing.T) {
|
|
f := &fakeGitea{versions: []string{"0.9.0", "0.8.0", "0.7.0"}, failVer: "0.8.0"}
|
|
s := newTestWebServer(t, f.start(t))
|
|
|
|
got := s.artifactChoices(context.Background(), "felhom-agent", "felhom-agent")
|
|
|
|
if len(got) != 2 {
|
|
t.Fatalf("got %d choices, want 2 (the failing one dropped): %+v", len(got), got)
|
|
}
|
|
for _, c := range got {
|
|
if c.Version == "0.8.0" {
|
|
t.Error("the failing version was included")
|
|
}
|
|
}
|
|
if got[0].Version != "0.9.0" || got[1].Version != "0.7.0" {
|
|
t.Errorf("order broken around the dropped version: %+v", got)
|
|
}
|
|
}
|
|
|
|
// THE POINT OF THE CHANGE. With a per-request delay, a serial implementation takes
|
|
// len(versions) * delay; the concurrent one takes about ceil(n/8) * delay. Asserting the wall-clock
|
|
// is what makes this a test of the fix rather than of the plumbing — and the in-flight counter
|
|
// proves requests genuinely overlapped rather than the timing being luck.
|
|
func TestArtifactChoices_IsConcurrent(t *testing.T) {
|
|
const n = 16
|
|
vers := make([]string, n)
|
|
for i := range vers {
|
|
vers[i] = fmt.Sprintf("0.%d.0", 100-i) // already newest-first
|
|
}
|
|
f := &fakeGitea{versions: vers, delay: 50 * time.Millisecond}
|
|
s := newTestWebServer(t, f.start(t))
|
|
|
|
start := time.Now()
|
|
got := s.artifactChoices(context.Background(), "felhom-agent", "felhom-agent")
|
|
elapsed := time.Since(start)
|
|
|
|
if len(got) != n {
|
|
t.Fatalf("got %d choices, want %d", len(got), n)
|
|
}
|
|
serial := time.Duration(n) * 50 * time.Millisecond // 800ms
|
|
if elapsed > serial/2 {
|
|
t.Errorf("took %v; a serial implementation would take ~%v and the concurrent one should be far "+
|
|
"under half of that. This is the 26-second Configuration page.", elapsed, serial)
|
|
}
|
|
if max := atomic.LoadInt32(&f.maxSeen); max < 2 {
|
|
t.Errorf("max concurrent in-flight requests was %d — the lookups did not actually overlap, so a "+
|
|
"fast wall-clock here would be luck rather than concurrency", max)
|
|
}
|
|
// and it must respect the bound rather than opening one connection per version
|
|
if max := atomic.LoadInt32(&f.maxSeen); max > 8 {
|
|
t.Errorf("max concurrent in-flight was %d, above the bound of 8 — a large package list would "+
|
|
"stampede Gitea", max)
|
|
}
|
|
}
|
|
|
|
// The cap is unchanged: at most 20 versions are offered however many exist.
|
|
func TestArtifactChoices_StillCapsAtTwenty(t *testing.T) {
|
|
vers := make([]string, 30)
|
|
for i := range vers {
|
|
vers[i] = fmt.Sprintf("0.%d.0", 200-i)
|
|
}
|
|
f := &fakeGitea{versions: vers}
|
|
s := newTestWebServer(t, f.start(t))
|
|
|
|
got := s.artifactChoices(context.Background(), "felhom-agent", "felhom-agent")
|
|
if len(got) != 20 {
|
|
t.Errorf("got %d choices, want the 20 cap", len(got))
|
|
}
|
|
if atomic.LoadInt32(&f.callCount) > 21 { // 1 version list + 20 sha lookups
|
|
t.Errorf("made %d requests; the cap must limit the FAN-OUT too, not just the rendered list",
|
|
atomic.LoadInt32(&f.callCount))
|
|
}
|
|
}
|
|
|
|
// R-267: the dropdown is memoised for artifactChoicesTTL. A second load inside the window must make
|
|
// NO further Gitea calls; a failed resolve must NOT be cached, or a blip would pin an empty list in
|
|
// front of the operator for a minute.
|
|
func TestArtifactChoices_CachedWithinTTL(t *testing.T) {
|
|
f := &fakeGitea{versions: []string{"0.9.0", "0.8.0"}}
|
|
s := newTestWebServer(t, f.start(t))
|
|
|
|
first := s.artifactChoices(context.Background(), "felhom-agent", "felhom-agent")
|
|
after := atomic.LoadInt32(&f.callCount)
|
|
if after == 0 {
|
|
t.Fatal("the first resolve made no calls")
|
|
}
|
|
second := s.artifactChoices(context.Background(), "felhom-agent", "felhom-agent")
|
|
|
|
if atomic.LoadInt32(&f.callCount) != after {
|
|
t.Errorf("a second load inside the TTL hit Gitea again (%d -> %d calls) — the cache is not "+
|
|
"in the path", after, atomic.LoadInt32(&f.callCount))
|
|
}
|
|
if len(second) != len(first) || second[0].Version != first[0].Version || second[0].SHA256 != first[0].SHA256 {
|
|
t.Errorf("cached result differs from the fresh one: %+v vs %+v", second, first)
|
|
}
|
|
}
|
|
|
|
func TestArtifactChoices_FailureIsNotCached(t *testing.T) {
|
|
// every version fails → an empty list, which must NOT be remembered
|
|
f := &fakeGitea{versions: []string{"0.9.0"}, failVer: "0.9.0"}
|
|
s := newTestWebServer(t, f.start(t))
|
|
|
|
if got := s.artifactChoices(context.Background(), "felhom-agent", "felhom-agent"); len(got) != 0 {
|
|
t.Fatalf("precondition: expected an empty list, got %+v", got)
|
|
}
|
|
before := atomic.LoadInt32(&f.callCount)
|
|
_ = s.artifactChoices(context.Background(), "felhom-agent", "felhom-agent")
|
|
if atomic.LoadInt32(&f.callCount) == before {
|
|
t.Error("an empty/failed resolve was cached — a Gitea blip would then show the operator an " +
|
|
"empty dropdown for a full minute with no way to retry")
|
|
}
|
|
}
|
|
|
|
// Two packages must not share a cache slot.
|
|
func TestArtifactChoices_CacheIsPerPackage(t *testing.T) {
|
|
f := &fakeGitea{versions: []string{"0.9.0"}}
|
|
s := newTestWebServer(t, f.start(t))
|
|
a := s.artifactChoices(context.Background(), "felhom-agent", "felhom-agent")
|
|
before := atomic.LoadInt32(&f.callCount)
|
|
_ = s.artifactChoices(context.Background(), "felhom-golden", "golden.tar.zst")
|
|
if atomic.LoadInt32(&f.callCount) == before {
|
|
t.Error("a different package was served from the first package's cache entry")
|
|
}
|
|
if len(a) == 0 {
|
|
t.Error("precondition")
|
|
}
|
|
}
|
|
|
|
// R-1011: the Day-0 agent list shows only versions whose package holds the felhom-agent file. A step bundle
|
|
// (`0.156.1-step1`, only felhom-config-bundle.json) is left out — never listed with the bundle's sha.
|
|
//
|
|
// COMPANION RED-PROOF (observed 2026-10-19): with FileSHA256's pre-fix fallback to the first file, it fails with
|
|
//
|
|
// artifact_choices_test.go: the list offers 0.156.1-step1 (sha "bundle-0.156.1-step1"), a package with no agent binary
|
|
//
|
|
// Restored.
|
|
func TestArtifactChoices_R1011_StepBundleIsNotAnAgentVersion(t *testing.T) {
|
|
f := &fakeGitea{versions: []string{"0.157.0", "0.156.1-step1", "0.156.1"}, bundleOnly: "0.156.1-step1"}
|
|
s := newTestWebServer(t, f.start(t))
|
|
got := s.artifactChoices(context.Background(), "felhom-agent", "felhom-agent")
|
|
for _, c := range got {
|
|
if c.Version == "0.156.1-step1" {
|
|
t.Fatalf("the list offers %s (sha %q), a package with no agent binary", c.Version, c.SHA256)
|
|
}
|
|
}
|
|
if len(got) != 2 || got[0].SHA256 != "sha-0.157.0" || got[1].Version != "0.156.1" || got[1].SHA256 != "sha-0.156.1" {
|
|
t.Fatalf("the release versions must stay, each with its own binary's sha: %+v", got)
|
|
}
|
|
}
|