From ab723147c9ff0e62c3a9f0927548437a8909f77f Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sun, 18 Oct 2026 12:08:07 +1100 Subject: [PATCH 1/2] Forward allowlisted query params for remote providers (#146) Remote proxying drops the query string, so paginated JSON APIs (e.g. Galaxy `versions/?limit=&offset=`) cannot be served. Forwarding the raw query would let arbitrary params mint unbounded cache entries and bypass path-anchored blocklists. - add `provider.QueryForwarder`, mapping each forwarded param for a path to a validator that canonicalises or rejects its value - forward only validated params upstream and into the cache key, single-valued and in sorted order - classify on the bare path so blocklist/allowlist/mutability patterns ignore the query - evict a path together with its `path?...` query variants - leave providers without `QueryForwarder` unchanged Reviewed-on: https://git.unkin.net/unkin/artifactapi/pulls/146 Co-authored-by: unkin-agent Co-committed-by: unkin-agent --- internal/api/v1/forwardquery_test.go | 58 ++++++++++++++++++++++++ internal/api/v1/proxy.go | 22 ++++++++- internal/provider/cap_query_forwarder.go | 12 +++++ internal/proxy/classifier.go | 3 ++ internal/proxy/classifier_extra_test.go | 18 ++++++++ internal/proxy/engine.go | 17 ++++++- internal/proxy/evict_test.go | 25 ++++++++++ 7 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 internal/api/v1/forwardquery_test.go create mode 100644 internal/provider/cap_query_forwarder.go diff --git a/internal/api/v1/forwardquery_test.go b/internal/api/v1/forwardquery_test.go new file mode 100644 index 0000000..6121838 --- /dev/null +++ b/internal/api/v1/forwardquery_test.go @@ -0,0 +1,58 @@ +package v1 + +import ( + "net/url" + "strings" + "testing" + + "git.unkin.net/unkin/artifactapi/internal/provider" +) + +type plainProvider struct{ provider.Provider } + +type forwardingProvider struct{ plainProvider } + +func digits(v string) (string, bool) { + if v == "" || strings.Trim(v, "0123456789") != "" { + return "", false + } + return strings.TrimLeft(v, "0"), true +} + +func (forwardingProvider) ForwardedQuery(path string) map[string]provider.QueryValidator { + if strings.HasSuffix(path, ".tar.gz") { + return nil + } + return map[string]provider.QueryValidator{"limit": digits, "offset": digits} +} + +func TestForwardQuery(t *testing.T) { + tests := []struct { + name string + prov provider.Provider + path string + query string + want string + }{ + {"non-forwarder keeps path", plainProvider{}, "a/versions/", "limit=10&offset=5", "a/versions/"}, + {"no query", forwardingProvider{}, "a/versions/", "", "a/versions/"}, + {"allowlisted params sorted", forwardingProvider{}, "a/versions/", "offset=5&limit=10", "a/versions/?limit=10&offset=5"}, + {"unknown params dropped", forwardingProvider{}, "a/versions/", "x=1&limit=10&cb=zz", "a/versions/?limit=10"}, + {"only unknown params", forwardingProvider{}, "a/versions/", "x=1", "a/versions/"}, + {"first value wins", forwardingProvider{}, "a/versions/", "limit=10&limit=20", "a/versions/?limit=10"}, + {"invalid value dropped", forwardingProvider{}, "a/versions/", "limit=1%202&offset=5", "a/versions/?offset=5"}, + {"value canonicalised", forwardingProvider{}, "a/versions/", "limit=0010", "a/versions/?limit=10"}, + {"path without allowlist", forwardingProvider{}, "a/b-1.0.0.tar.gz", "limit=10", "a/b-1.0.0.tar.gz"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, err := url.ParseQuery(tt.query) + if err != nil { + t.Fatal(err) + } + if got := forwardQuery(tt.prov, tt.path, q); got != tt.want { + t.Errorf("forwardQuery = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/api/v1/proxy.go b/internal/api/v1/proxy.go index dbe083e..486394b 100644 --- a/internal/api/v1/proxy.go +++ b/internal/api/v1/proxy.go @@ -8,6 +8,7 @@ import ( "log/slog" "net" "net/http" + "net/url" "strconv" "strings" @@ -115,7 +116,7 @@ func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) { } func (h *ProxyHandler) serveFetch(w http.ResponseWriter, r *http.Request, remote *models.Remote, prov provider.Provider, path string) { - result, err := h.engine.Fetch(r.Context(), *remote, path, prov, r.Header) + result, err := h.engine.Fetch(r.Context(), *remote, forwardQuery(prov, path, r.URL.Query()), prov, r.Header) if err != nil { var proxyErr *proxy.ProxyError if errors.As(err, &proxyErr) { @@ -325,6 +326,25 @@ func Scheme(r *http.Request) string { return "http" } +// forwardQuery appends the provider's validated query params to path in a +// canonical (sorted, single-valued) form, since the result is the cache key. +func forwardQuery(prov provider.Provider, path string, query url.Values) string { + f, ok := prov.(provider.QueryForwarder) + if !ok { + return path + } + kept := url.Values{} + for name, valid := range f.ForwardedQuery(path) { + if v, ok := valid(query.Get(name)); ok { + kept.Set(name, v) + } + } + if len(kept) == 0 { + return path + } + return path + "?" + kept.Encode() +} + // rewriteServed rewrites a cached body for the requesting host and marks the // response uncacheable by shared caches, since it varies per Host. func rewriteServed(w http.ResponseWriter, r *http.Request, sr provider.ServeRewriter, remote models.Remote, src io.Reader) ([]byte, bool) { diff --git a/internal/provider/cap_query_forwarder.go b/internal/provider/cap_query_forwarder.go new file mode 100644 index 0000000..096336c --- /dev/null +++ b/internal/provider/cap_query_forwarder.go @@ -0,0 +1,12 @@ +package provider + +// QueryForwarder is a remote provider whose upstream answers depend on the +// query string (e.g. paginated JSON APIs). Only the named params for path are +// forwarded upstream and kept in the cache key, each as the canonical value +// its validator returns; a value the validator rejects is dropped. +type QueryForwarder interface { + ForwardedQuery(path string) map[string]QueryValidator +} + +// QueryValidator canonicalises a raw query value, reporting false to drop it. +type QueryValidator func(v string) (string, bool) diff --git a/internal/proxy/classifier.go b/internal/proxy/classifier.go index 82b889c..28aee30 100644 --- a/internal/proxy/classifier.go +++ b/internal/proxy/classifier.go @@ -2,6 +2,7 @@ package proxy import ( "regexp" + "strings" "sync" "git.unkin.net/unkin/artifactapi/internal/provider" @@ -37,7 +38,9 @@ func NewClassifier(p provider.Provider) *Classifier { return &Classifier{provider: p} } +// Classify judges the bare path; a forwarded query must not change the verdict. func (c *Classifier) Classify(remote models.Remote, path string) Classification { + path, _, _ = strings.Cut(path, "?") if matchesAny(path, compilePatterns(remote.Blocklist)) { return ClassDenied } diff --git a/internal/proxy/classifier_extra_test.go b/internal/proxy/classifier_extra_test.go index dd5403a..7eaf152 100644 --- a/internal/proxy/classifier_extra_test.go +++ b/internal/proxy/classifier_extra_test.go @@ -38,6 +38,24 @@ func TestClassifierBranches(t *testing.T) { } } +func TestClassifierIgnoresForwardedQuery(t *testing.T) { + gp, err := provider.Get(models.PackageGeneric) + if err != nil { + t.Fatal(err) + } + c := NewClassifier(gp) + if got := c.Classify(models.Remote{Blocklist: []string{`\.exe$`}}, "x.exe?limit=1"); got != ClassDenied { + t.Errorf("blocklist bypassed by query: %v", got) + } + allow := models.Remote{Patterns: []string{`/versions/$`}} + if got := c.Classify(allow, "a/versions/?limit=1&offset=100"); got != ClassImmutable { + t.Errorf("allowlisted paginated path = %v, want immutable", got) + } + if got := c.Classify(models.Remote{MutablePatterns: []string{`/versions/$`}}, "a/versions/?offset=100"); got != ClassMutable { + t.Errorf("mutable pattern on paginated path = %v, want mutable", got) + } +} + func TestClassificationString(t *testing.T) { for c, want := range map[Classification]string{ ClassImmutable: "immutable", diff --git a/internal/proxy/engine.go b/internal/proxy/engine.go index 99d24b7..779bec9 100644 --- a/internal/proxy/engine.go +++ b/internal/proxy/engine.go @@ -217,7 +217,8 @@ func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, p // Evict drops path from every cache layer (artifact row, index object, Redis // freshness and ETag keys) so the next request refetches from upstream. A -// trailing "/*" evicts every path under that directory. +// trailing "/*" evicts every path under that directory; a plain path also +// drops its forwarded-query variants ("path?..."). func (e *Engine) Evict(ctx context.Context, remoteName, path string) error { prefix, wildcard := strings.CutSuffix(path, "*") if wildcard && !strings.HasSuffix(prefix, "/") { @@ -240,11 +241,23 @@ func (e *Engine) Evict(ctx context.Context, remoteName, path string) error { if err := e.store.Delete(ctx, storage.IndexKey(remoteName, path)); err != nil { return fmt.Errorf("delete index: %w", err) } - return e.cache.ForgetPath(ctx, remoteName, path) + if err := e.cache.ForgetPath(ctx, remoteName, path); err != nil { + return err + } + if strings.Contains(path, "?") { + return nil + } + return e.evictPrefix(ctx, remoteName, path+"?") } // ponytail: no lock for wildcards; a Fetch already in flight under the // prefix can re-cache its path after the evict. Per-path locks over a // directory would close it if that ever matters. + return e.evictPrefix(ctx, remoteName, prefix) +} + +// evictPrefix drops every cached path starting with prefix, including the +// forwarded-query variants ("path?...") of a single path. +func (e *Engine) evictPrefix(ctx context.Context, remoteName, prefix string) error { if err := e.db.DeleteArtifactsByPrefix(ctx, remoteName, prefix); err != nil { return fmt.Errorf("delete artifacts: %w", err) } diff --git a/internal/proxy/evict_test.go b/internal/proxy/evict_test.go index 55635f4..aa05abc 100644 --- a/internal/proxy/evict_test.go +++ b/internal/proxy/evict_test.go @@ -271,3 +271,28 @@ func TestEvictWildcardClearsPrefixOnly(t *testing.T) { t.Errorf("path outside prefix = %q, want cached rev1", got) } } + +func TestEvictPathDropsQueryVariants(t *testing.T) { + requireStack(t) + srv, _, _ := changingUpstream(t) + r := rpmRemote(t, "evict-query", srv.URL) + const ( + path = "8/Everything/x86_64/repodata/repomd.xml" + variant = path + "?limit=10" + sibling = "8/Everything/x86_64/repodata/repomd.xml.asc" + ) + for _, p := range []string{path, variant, sibling} { + fetchBody(t, r, p) + } + if err := testEngine.Evict(context.Background(), r.Name, path); err != nil { + t.Fatalf("evict: %v", err) + } + for _, p := range []string{path, variant} { + if c := layers(t, r.Name, p); c != (cached{}) { + t.Errorf("%s after evict = %+v, want every layer gone", p, c) + } + } + if c := layers(t, r.Name, sibling); c == (cached{}) { + t.Errorf("%s sharing the name prefix was evicted", sibling) + } +} From ca08c580b892b94a3fa3c07820da09caf99c4e02 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sun, 18 Oct 2026 12:08:55 +1100 Subject: [PATCH 2/2] Add ansible Galaxy remote type (#137) ansible-galaxy installs pull collections straight from galaxy.ansible.com because artifactapi has no Galaxy remote type. - add `ansible` package type proxying the Galaxy v3 API (discovery, collection versions, artifact downloads) - forward digits-only `limit` (capped at 100) and `offset` on metadata paths, canonicalised; tarball paths forward no query - rewrite only `href`, `download_url` and `links.*` to root-relative remote paths so tarballs flow through artifactapi - treat `artifacts/*.tar.gz` as immutable and JSON metadata as mutable - add `docs/ansible/remote.md`, a UI type def and an ansible e2e caching case requires unkin/artifactapi#146 Reviewed-on: https://git.unkin.net/unkin/artifactapi/pulls/137 Co-authored-by: unkin-agent Co-committed-by: unkin-agent --- README.md | 1 + docs/ansible/remote.md | 11 ++ docs/common/caching.md | 2 +- e2e-docker/caching_ansible_test.go | 7 + .../galaxy/artifacts/ns-col-1.0.0.tar.gz | Bin 0 -> 51 bytes internal/provider/all/ansible.go | 3 + internal/provider/ansible/ansible.go | 139 ++++++++++++++++ internal/provider/ansible/ansible_test.go | 150 ++++++++++++++++++ internal/provider/provider.go | 3 + pkg/models/package_type_ansible.go | 5 + ui/src/packagetypes/defs/ansible.ts | 16 ++ 11 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 docs/ansible/remote.md create mode 100644 e2e-docker/caching_ansible_test.go create mode 100644 e2e-docker/fixtures/galaxy/artifacts/ns-col-1.0.0.tar.gz create mode 100644 internal/provider/all/ansible.go create mode 100644 internal/provider/ansible/ansible.go create mode 100644 internal/provider/ansible/ansible_test.go create mode 100644 pkg/models/package_type_ansible.go create mode 100644 ui/src/packagetypes/defs/ansible.ts diff --git a/README.md b/README.md index bfe1eb9..21e173f 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Classification precedence, TTLs and eviction for every remote: [docs/common/cach Each type's paths, Terraform examples and local/virtual behaviour are in `docs//`: - `alpine`: [remote](docs/alpine/remote.md), [remote-github](docs/alpine/remote-github.md) +- `ansible`: [remote](docs/ansible/remote.md) - `cargo`: [remote](docs/cargo/remote.md) - `deb`: [remote](docs/deb/remote.md), [remote-github](docs/deb/remote-github.md) - `docker`: [remote](docs/docker/remote.md), [local](docs/docker/local.md) diff --git a/docs/ansible/remote.md b/docs/ansible/remote.md new file mode 100644 index 0000000..c6ea0b5 --- /dev/null +++ b/docs/ansible/remote.md @@ -0,0 +1,11 @@ +# `ansible` remote + +Shared classification, TTL and eviction rules: [caching](../common/caching.md). + +| Type | Mutable (auto-detected) | Immutable (auto-detected) | +|---|---|---| +| `ansible` | Galaxy `api/` JSON (links rewritten, `limit`/`offset` forwarded) | `artifacts/*.tar.gz` | + +```bash +ansible-galaxy collection install community.general -s https://artifactapi.example/api/v1/remote/galaxy/api/ +``` diff --git a/docs/common/caching.md b/docs/common/caching.md index 20fd733..223cc75 100644 --- a/docs/common/caching.md +++ b/docs/common/caching.md @@ -93,7 +93,7 @@ Force a moving path mutable, e.g. branch tarballs on `generic`: ## Built-in classification per type Each type's page lists its auto-detected mutable and immutable paths: -[alpine](../alpine/remote.md), [alpine github](../alpine/remote-github.md), +[alpine](../alpine/remote.md), [alpine github](../alpine/remote-github.md), [ansible](../ansible/remote.md), [cargo](../cargo/remote.md), [deb](../deb/remote.md), [deb github](../deb/remote-github.md), [docker](../docker/remote.md), [generic](../generic/remote.md), [gems](../gems/remote.md), [goproxy](../goproxy/remote.md), [helm](../helm/remote.md), [npm](../npm/remote.md), [nuget](../nuget/remote.md), diff --git a/e2e-docker/caching_ansible_test.go b/e2e-docker/caching_ansible_test.go new file mode 100644 index 0000000..62c9d19 --- /dev/null +++ b/e2e-docker/caching_ansible_test.go @@ -0,0 +1,7 @@ +//go:build dockere2e + +package e2edocker + +func init() { + cachingCases = append(cachingCases, cachingCase{"ansible", "galaxy/artifacts/ns-col-1.0.0.tar.gz", "galaxy/artifacts/ns-col-1.0.0.tar.gz"}) +} diff --git a/e2e-docker/fixtures/galaxy/artifacts/ns-col-1.0.0.tar.gz b/e2e-docker/fixtures/galaxy/artifacts/ns-col-1.0.0.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..8245b996dfad42ac6ee368f73285008472d63c92 GIT binary patch literal 51 zcmb2|=3oE=W^YrkP>(a(=X_3ihI*Vkf6~)e@8p^D!5W@AUS4NTK4fC>HQjC?4^#^P Do175v literal 0 HcmV?d00001 diff --git a/internal/provider/all/ansible.go b/internal/provider/all/ansible.go new file mode 100644 index 0000000..22c78b1 --- /dev/null +++ b/internal/provider/all/ansible.go @@ -0,0 +1,3 @@ +package all + +import _ "git.unkin.net/unkin/artifactapi/internal/provider/ansible" diff --git a/internal/provider/ansible/ansible.go b/internal/provider/ansible/ansible.go new file mode 100644 index 0000000..464591f --- /dev/null +++ b/internal/provider/ansible/ansible.go @@ -0,0 +1,139 @@ +// Package ansible proxies an Ansible Galaxy server (v3 collections API): +// mutable JSON metadata whose links are rewritten back to the remote, plus +// immutable collection tarballs. +package ansible + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/url" + "strconv" + "strings" + + "git.unkin.net/unkin/artifactapi/internal/auth" + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func init() { + provider.Register(&Provider{}) +} + +type Provider struct{} + +func (p *Provider) Type() models.PackageType { return models.PackageAnsible } + +func (p *Provider) Classify(path string) provider.Mutability { + if isArtifact(path) { + return provider.Immutable + } + return provider.Mutable +} + +func (p *Provider) ContentType(path string) string { + if isArtifact(path) { + return "application/gzip" + } + return "application/json" +} + +func (p *Provider) UpstreamURL(remote models.Remote, path string) string { + return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/") +} + +const maxPageLimit = 100 + +// ForwardedQuery keeps limit/offset pagination on metadata paths only. +func (p *Provider) ForwardedQuery(path string) map[string]provider.QueryValidator { + if isArtifact(path) { + return nil + } + return map[string]provider.QueryValidator{"limit": pageLimit, "offset": pageOffset} +} + +func pageOffset(v string) (string, bool) { + n, err := strconv.ParseUint(v, 10, 32) + return strconv.FormatUint(n, 10), err == nil +} + +func pageLimit(v string) (string, bool) { + n, err := strconv.ParseUint(v, 10, 32) + if err != nil || n == 0 { + return "", false + } + return strconv.FormatUint(min(n, maxPageLimit), 10), true +} + +// RewriteResponse points href, download_url and links.* at the remote. Links +// stay root-relative, which ansible-galaxy joins onto the server URL, so the +// cached body is independent of the request host. +func (p *Provider) RewriteResponse(body []byte, remote models.Remote, _ string) ([]byte, error) { + dec := json.NewDecoder(bytes.NewReader(body)) + dec.UseNumber() + var doc any + if err := dec.Decode(&doc); err != nil { + return nil, nil + } + r := rewriter{ + prefix: provider.RemoteMountPath + url.PathEscape(remote.Name) + "/", + base: strings.TrimRight(remote.BaseURL, "/") + "/", + } + r.walk(doc) + var out bytes.Buffer + enc := json.NewEncoder(&out) + enc.SetEscapeHTML(false) + if err := enc.Encode(doc); err != nil { + return nil, err + } + return out.Bytes(), nil +} + +func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) { + return auth.BasicHeaders(remote), nil +} + +type rewriter struct{ prefix, base string } + +func (r rewriter) walk(v any) { + switch v := v.(type) { + case []any: + for _, e := range v { + r.walk(e) + } + case map[string]any: + for k, e := range v { + switch k { + case "href", "download_url": + v[k] = r.link(e) + case "links": + if links, ok := e.(map[string]any); ok { + for lk, l := range links { + links[lk] = r.link(l) + } + continue + } + } + r.walk(v[k]) + } + } +} + +func (r rewriter) link(v any) any { + s, ok := v.(string) + switch { + case !ok: + return v + case strings.HasPrefix(s, "/api/"): + return r.prefix + s[1:] + case strings.HasPrefix(s, r.base): + return r.prefix + strings.TrimPrefix(s, r.base) + } + return s +} + +func isArtifact(path string) bool { + path, _, _ = strings.Cut(path, "?") + return strings.Contains(path, "/artifacts/") && strings.HasSuffix(path, ".tar.gz") +} diff --git a/internal/provider/ansible/ansible_test.go b/internal/provider/ansible/ansible_test.go new file mode 100644 index 0000000..978eacc --- /dev/null +++ b/internal/provider/ansible/ansible_test.go @@ -0,0 +1,150 @@ +package ansible + +import ( + "encoding/json" + "testing" + + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +const tarball = "api/v3/plugin/ansible/content/published/collections/artifacts/ansible-posix-2.0.0.tar.gz" + +func TestClassify(t *testing.T) { + p := &Provider{} + tests := []struct { + path string + want provider.Mutability + }{ + {"api/", provider.Mutable}, + {"api/v3/collections/community/general/versions/?limit=100", provider.Mutable}, + {"api/v3/collections/community/general/versions/?x=.tar.gz", provider.Mutable}, + {"api/v3/collections/ansible/posix/versions/2.0.0/", provider.Mutable}, + {"api/v3/collections/ansible/posix/readme.tar.gz", provider.Mutable}, + {tarball, provider.Immutable}, + {tarball + "?limit=1", provider.Immutable}, + } + for _, tt := range tests { + if got := p.Classify(tt.path); got != tt.want { + t.Errorf("Classify(%q) = %v, want %v", tt.path, got, tt.want) + } + } +} + +func TestContentTypeIgnoresQuery(t *testing.T) { + if got := (&Provider{}).ContentType("api/v3/collections/a/b/versions/?x=.tar.gz"); got != "application/json" { + t.Errorf("ContentType = %q, want application/json", got) + } +} + +func TestForwardedQuery(t *testing.T) { + p := &Provider{} + if got := p.ForwardedQuery(tarball); got != nil { + t.Errorf("tarball ForwardedQuery = %v, want nil", got) + } + q := p.ForwardedQuery("api/v3/collections/a/b/versions/") + if len(q) != 2 || q["limit"] == nil || q["offset"] == nil { + t.Fatalf("metadata ForwardedQuery keys = %v, want limit and offset", q) + } + tests := []struct { + param, in, want string + ok bool + }{ + {"limit", "10", "10", true}, + {"limit", "007", "7", true}, + {"limit", "100", "100", true}, + {"limit", "5000", "100", true}, + {"limit", "0", "", false}, + {"limit", "", "", false}, + {"limit", "-1", "", false}, + {"limit", "+5", "", false}, + {"limit", "1e3", "", false}, + {"limit", "10 ", "", false}, + {"offset", "0", "0", true}, + {"offset", "000", "0", true}, + {"offset", "0100", "100", true}, + {"offset", "abc", "", false}, + {"offset", "99999999999999999999", "", false}, + } + for _, tt := range tests { + got, ok := q[tt.param](tt.in) + if ok != tt.ok || (ok && got != tt.want) { + t.Errorf("%s(%q) = %q, %v, want %q, %v", tt.param, tt.in, got, ok, tt.want, tt.ok) + } + } +} + +func rewrite(t *testing.T, remote models.Remote, body string) map[string]any { + t.Helper() + out, err := (&Provider{}).RewriteResponse([]byte(body), remote, "") + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("rewritten body is not JSON: %v: %s", err, out) + } + return got +} + +func TestRewriteResponse(t *testing.T) { + remote := models.Remote{Name: "galaxy", BaseURL: "https://galaxy.ansible.com/"} + got := rewrite(t, remote, `{ + "links": {"next": "/api/v3/collections/a/b/versions/?limit=10&offset=10", "previous": null}, + "data": [{ + "href": "/api/v3/collections/a/b/versions/1.0.0/", + "download_url": "https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/artifacts/a-b-1.0.0.tar.gz", + "description": "/api/ is where it lives", + "docs": "/api/v3/not-a-link/", + "metadata": {"repository": "https://github.com/a/b", "homepage": "https://galaxy.ansible.com/ui/"} + }] + }`) + pre := "/api/v1/remote/galaxy/" + links := got["links"].(map[string]any) + if want := pre + "api/v3/collections/a/b/versions/?limit=10&offset=10"; links["next"] != want { + t.Errorf("links.next = %v, want %q", links["next"], want) + } + if links["previous"] != nil { + t.Errorf("links.previous = %v, want null", links["previous"]) + } + item := got["data"].([]any)[0].(map[string]any) + if want := pre + "api/v3/collections/a/b/versions/1.0.0/"; item["href"] != want { + t.Errorf("href = %v, want %q", item["href"], want) + } + if want := pre + "api/v3/plugin/ansible/content/published/collections/artifacts/a-b-1.0.0.tar.gz"; item["download_url"] != want { + t.Errorf("download_url = %v, want %q", item["download_url"], want) + } + for k, want := range map[string]string{"description": "/api/ is where it lives", "docs": "/api/v3/not-a-link/"} { + if item[k] != want { + t.Errorf("%s rewritten to %v", k, item[k]) + } + } + meta := item["metadata"].(map[string]any) + if meta["repository"] != "https://github.com/a/b" || meta["homepage"] != "https://galaxy.ansible.com/ui/" { + t.Errorf("unrelated URLs rewritten: %v", meta) + } +} + +func TestRewriteResponseEscapesRemoteName(t *testing.T) { + got := rewrite(t, models.Remote{Name: `a"b/c`, BaseURL: "https://g/"}, `{"href":"/api/x/"}`) + if want := "/api/v1/remote/a%22b%2Fc/api/x/"; got["href"] != want { + t.Errorf("href = %v, want %q", got["href"], want) + } +} + +func TestRewriteResponseKeepsLargeNumbers(t *testing.T) { + out, err := (&Provider{}).RewriteResponse([]byte(`{"n":12345678901234567890}`), models.Remote{Name: "g"}, "") + if err != nil { + t.Fatal(err) + } + if want := "{\"n\":12345678901234567890}\n"; string(out) != want { + t.Errorf("got %q, want %q", out, want) + } +} + +func TestRewriteResponseSkipsNonJSON(t *testing.T) { + out, err := (&Provider{}).RewriteResponse([]byte(""), models.Remote{Name: "g"}, "") + if err != nil || out != nil { + t.Errorf("got %q, %v; want nil, nil", out, err) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 5b8eea5..951a897 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -8,6 +8,9 @@ import ( "git.unkin.net/unkin/artifactapi/pkg/models" ) +// RemoteMountPath is the URL path remote proxies are served under. +const RemoteMountPath = "/api/v1/remote/" + type Mutability int const ( diff --git a/pkg/models/package_type_ansible.go b/pkg/models/package_type_ansible.go new file mode 100644 index 0000000..976a1b1 --- /dev/null +++ b/pkg/models/package_type_ansible.go @@ -0,0 +1,5 @@ +package models + +const PackageAnsible PackageType = "ansible" + +func init() { registerPackageType(PackageAnsible) } diff --git a/ui/src/packagetypes/defs/ansible.ts b/ui/src/packagetypes/defs/ansible.ts new file mode 100644 index 0000000..653b9ed --- /dev/null +++ b/ui/src/packagetypes/defs/ansible.ts @@ -0,0 +1,16 @@ +import type { PackageTypeUI } from '../types'; + +const def: PackageTypeUI = { + color: 'red', + snippets: ({ proxy }) => { + return [ + { + title: 'Install collections through this Galaxy proxy', + language: 'bash', + code: `ansible-galaxy collection install community.general -s ${proxy}/api/`, + }, + ]; + }, +}; + +export default def;