From 7f7ea21c3b75b1f36d759333b19a2620413c376f Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sun, 18 Oct 2026 03:57:46 +1100 Subject: [PATCH] Emit root-relative Galaxy pagination links on local ansible repos --- internal/api/v1/ansible_handler_test.go | 4 ++ internal/provider/ansible/local.go | 37 ++++++++++---- internal/provider/ansible/local_test.go | 64 ++++++++++++++++++++++++- 3 files changed, 94 insertions(+), 11 deletions(-) diff --git a/internal/api/v1/ansible_handler_test.go b/internal/api/v1/ansible_handler_test.go index b3b48e5..cf4624f 100644 --- a/internal/api/v1/ansible_handler_test.go +++ b/internal/api/v1/ansible_handler_test.go @@ -122,6 +122,10 @@ func TestAnsiblePublishAndServe(t *testing.T) { t.Fatalf("page 1 = %+v", page) } next := *page.Links.Next + if !strings.HasPrefix(next, "/api/v1/local/"+repo+"/") { + t.Fatalf("next = %q, want root-relative", next) + } + next = srv.URL + next page.Links.Next = nil getJSON(t, next, &page) if len(page.Data) != 1 || page.Data[0].Version != "1.0.0" || page.Links.Next != nil { diff --git a/internal/provider/ansible/local.go b/internal/provider/ansible/local.go index db50164..988f9df 100644 --- a/internal/provider/ansible/local.go +++ b/internal/provider/ansible/local.go @@ -238,12 +238,14 @@ func (e Entry) Ref() VersionRef { // CollectionDoc describes a collection from its versions; base is the repo // URL the hrefs are built on. func CollectionDoc(base, ns, name string, refs []VersionRef) map[string]any { - created, updated := "", "" + var created, updated time.Time for _, r := range refs { - if created == "" || r.CreatedAt < created { - created = r.CreatedAt + if t, err := time.Parse(time.RFC3339Nano, r.CreatedAt); err == nil && (created.IsZero() || t.Before(created)) { + created = t + } + if t, err := time.Parse(time.RFC3339Nano, r.UpdatedAt); err == nil && t.After(updated) { + updated = t } - updated = max(updated, r.UpdatedAt) } return map[string]any{ "href": base + CollectionPath(ns, name), @@ -251,11 +253,18 @@ func CollectionDoc(base, ns, name string, refs []VersionRef) map[string]any { "name": name, "deprecated": false, "versions_url": base + CollectionPath(ns, name) + "versions/", - "created_at": created, - "updated_at": updated, + "created_at": formatTime(created), + "updated_at": formatTime(updated), } } +func formatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format(time.RFC3339Nano) +} + // Page reads limit/offset as the remote forwards them, defaulting to 100/0. func Page(q url.Values) (limit, offset int) { limit, offset = maxPageLimit, 0 @@ -268,9 +277,11 @@ func Page(q url.Values) (limit, offset int) { return limit, offset } -// VersionsPage is one page of refs in Galaxy v3 list form. +// VersionsPage is one page of refs in Galaxy v3 list form. Links are +// root-relative like Galaxy's, since ansible-galaxy splices them onto the +// server URL. func VersionsPage(base, ns, name string, refs []VersionRef, limit, offset int) map[string]any { - list := base + CollectionPath(ns, name) + "versions/" + list := rootPath(base) + CollectionPath(ns, name) + "versions/" link := func(off int) any { return list + "?" + url.Values{"limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(off)}}.Encode() } @@ -294,11 +305,19 @@ func VersionsPage(base, ns, name string, refs []VersionRef, limit, offset int) m } return map[string]any{ "meta": map[string]any{"count": len(refs)}, - "links": map[string]any{"first": link(0), "previous": previous, "next": next, "last": link(max(len(refs)-limit, 0))}, + "links": map[string]any{"first": link(0), "previous": previous, "next": next, "last": link(max(len(refs)-1, 0) / limit * limit)}, "data": data, } } +// rootPath drops the scheme and host from base. +func rootPath(base string) string { + if u, err := url.Parse(base); err == nil { + return u.EscapedPath() + } + return base +} + // VersionDoc is a version's detail; the tarball is served from the repo at // artifacts/. func VersionDoc(base string, e Entry) map[string]any { diff --git a/internal/provider/ansible/local_test.go b/internal/provider/ansible/local_test.go index db25a7d..e23dee2 100644 --- a/internal/provider/ansible/local_test.go +++ b/internal/provider/ansible/local_test.go @@ -5,6 +5,7 @@ import ( "bytes" "compress/gzip" "encoding/json" + "fmt" "net/url" "strings" "testing" @@ -145,7 +146,7 @@ func TestParseAPIPath(t *testing.T) { func TestVersionsPage(t *testing.T) { refs := []VersionRef{{Version: "3.0.0"}, {Version: "2.0.0"}, {Version: "1.0.0"}} - list := "https://h/r/api/v3/collections/a/b/versions/" + list := "/r/api/v3/collections/a/b/versions/" links := func(p map[string]any) map[string]any { return p["links"].(map[string]any) } versions := func(p map[string]any) (out []string) { for _, d := range p["data"].([]map[string]any) { @@ -158,7 +159,7 @@ func TestVersionsPage(t *testing.T) { if got := versions(p); strings.Join(got, ",") != "3.0.0,2.0.0" { t.Fatalf("page 1 = %v", got) } - if l := links(p); l["previous"] != nil || l["next"] != list+"?limit=2&offset=2" || l["last"] != list+"?limit=2&offset=1" { + if l := links(p); l["previous"] != nil || l["next"] != list+"?limit=2&offset=2" || l["last"] != list+"?limit=2&offset=2" { t.Fatalf("page 1 links = %v", l) } if p["meta"].(map[string]any)["count"] != 3 { @@ -178,6 +179,65 @@ func TestVersionsPage(t *testing.T) { } } +// TestVersionsPageFollow pages through more than 100 versions the way +// ansible-galaxy does: old clients splice next onto the versions URL in place +// of its path, new ones urljoin it onto the server. +func TestVersionsPageFollow(t *testing.T) { + var refs []VersionRef + for i := 104; i >= 0; i-- { + refs = append(refs, VersionRef{Version: fmt.Sprintf("1.0.%d", i)}) + } + server := "https://h:8443/api/v1/local/r%2Bx/" + base := strings.TrimSuffix(server, "/") + versionsURL := server + "api/v3/collections/a/b/versions/" + for _, follow := range map[string]func(string) string{ + "replace": func(link string) string { + u, _ := url.Parse(versionsURL) + return strings.Replace(versionsURL, u.EscapedPath(), link, 1) + }, + "urljoin": func(link string) string { + s, _ := url.Parse(server) + l, _ := url.Parse(link) + return s.ResolveReference(l).String() + }, + } { + var got []string + next := versionsURL + "?limit=100" + for pages := 0; next != ""; pages++ { + if pages > 3 { + t.Fatal("pagination does not terminate") + } + u, err := url.Parse(next) + if err != nil || u.Host != "h:8443" || !strings.HasPrefix(u.EscapedPath(), "/api/v1/local/r%2Bx/api/v3/") { + t.Fatalf("followed link %q is not on the server", next) + } + limit, offset := Page(u.Query()) + p := VersionsPage(base, "a", "b", refs, limit, offset) + for _, d := range p["data"].([]map[string]any) { + got = append(got, d["version"].(string)) + } + next = "" + if l, ok := p["links"].(map[string]any)["next"].(string); ok { + next = follow(l) + } + } + if len(got) != 105 || got[0] != "1.0.104" || got[104] != "1.0.0" { + t.Fatalf("collected %d versions, first %v", len(got), got[:1]) + } + } +} + +func TestCollectionDocTimes(t *testing.T) { + d := CollectionDoc("", "a", "b", []VersionRef{ + {CreatedAt: "2026-01-01T00:00:05Z", UpdatedAt: "2026-01-01T00:00:05Z"}, + {CreatedAt: "2026-01-01T00:00:05.5Z", UpdatedAt: "2026-01-01T00:00:05.5Z"}, + {CreatedAt: "2026-01-01T00:00:04.9Z", UpdatedAt: "2026-01-01T00:00:04.9Z"}, + }) + if d["created_at"] != "2026-01-01T00:00:04.9Z" || d["updated_at"] != "2026-01-01T00:00:05.5Z" { + t.Fatalf("created/updated = %v/%v", d["created_at"], d["updated_at"]) + } +} + func TestPage(t *testing.T) { for _, tt := range []struct { q string