Add ansible Galaxy remote type
ci/woodpecker/pr/build Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

- add ansible package type proxying the Galaxy v3 API
- rewrite href/links/download_url back to the remote so tarballs flow through it
- forward the query string for providers that opt in, keeping Galaxy pagination working
- add a UI usage snippet and an ansible e2e caching case
This commit is contained in:
2026-10-16 23:06:34 +11:00
parent 9161558334
commit 13d693b58a
13 changed files with 179 additions and 2 deletions
+4
View File
@@ -92,6 +92,10 @@ func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) {
}
}
if _, ok := prov.(provider.QueryForwarder); ok && r.URL.RawQuery != "" {
path += "?" + r.URL.RawQuery
}
result, err := h.engine.Fetch(r.Context(), *remote, path, prov, r.Header)
if err != nil {
var proxyErr *proxy.ProxyError
+65
View File
@@ -0,0 +1,65 @@
// 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 (
"context"
"encoding/json"
"net/http"
"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, "/")
}
// ForwardQuery keeps limit/offset pagination in the proxied path.
func (p *Provider) ForwardQuery() {}
// RewriteResponse points root-relative hrefs/links and absolute download_url
// values 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) {
if !json.Valid(body) {
return nil, nil
}
prefix := `"/api/v1/remote/` + remote.Name + `/`
content := strings.ReplaceAll(string(body), `"/api/`, prefix+`api/`)
content = strings.ReplaceAll(content, `"`+strings.TrimRight(remote.BaseURL, "/")+`/`, prefix)
return []byte(content), nil
}
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
return auth.BasicHeaders(remote), nil
}
func isArtifact(path string) bool {
return strings.HasSuffix(path, ".tar.gz")
}
+85
View File
@@ -0,0 +1,85 @@
package ansible
import (
"encoding/json"
"testing"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
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/ansible/posix/versions/2.0.0/", provider.Mutable},
{"api/v3/plugin/ansible/content/published/collections/artifacts/ansible-posix-2.0.0.tar.gz", 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 TestUpstreamURLKeepsQuery(t *testing.T) {
p := &Provider{}
got := p.UpstreamURL(models.Remote{BaseURL: "https://galaxy.ansible.com/"}, "api/v3/collections/a/b/versions/?limit=100&offset=100")
want := "https://galaxy.ansible.com/api/v3/collections/a/b/versions/?limit=100&offset=100"
if got != want {
t.Errorf("UpstreamURL = %q, want %q", got, want)
}
}
func TestRewriteResponse(t *testing.T) {
p := &Provider{}
remote := models.Remote{Name: "galaxy", BaseURL: "https://galaxy.ansible.com/"}
body := `{"links":{"next":"/api/v3/plugin/ansible/content/published/collections/index/a/b/versions/?limit=10&offset=10","previous":null},` +
`"href":"/api/content/published/v3/plugin/ansible/content/published/collections/index/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",` +
`"metadata":{"repository":"https://github.com/a/b"}}`
out, err := p.RewriteResponse([]byte(body), remote, "")
if err != nil {
t.Fatal(err)
}
var got struct {
Links map[string]any `json:"links"`
Href string `json:"href"`
DownloadURL string `json:"download_url"`
Metadata map[string]any `json:"metadata"`
}
if err := json.Unmarshal(out, &got); err != nil {
t.Fatalf("rewritten body is not JSON: %v", err)
}
pre := "/api/v1/remote/galaxy/"
if want := pre + "api/v3/plugin/ansible/content/published/collections/index/a/b/versions/?limit=10&offset=10"; got.Links["next"] != want {
t.Errorf("next = %v, want %q", got.Links["next"], want)
}
if want := pre + "api/content/published/v3/plugin/ansible/content/published/collections/index/a/b/versions/1.0.0/"; got.Href != want {
t.Errorf("href = %q, want %q", got.Href, want)
}
if want := pre + "api/v3/plugin/ansible/content/published/collections/artifacts/a-b-1.0.0.tar.gz"; got.DownloadURL != want {
t.Errorf("download_url = %q, want %q", got.DownloadURL, want)
}
if got.Metadata["repository"] != "https://github.com/a/b" {
t.Errorf("unrelated URL rewritten: %v", got.Metadata["repository"])
}
}
func TestRewriteResponseSkipsNonJSON(t *testing.T) {
out, err := (&Provider{}).RewriteResponse([]byte("<html>"), models.Remote{Name: "g"}, "")
if err != nil || out != nil {
t.Errorf("got %q, %v; want nil, nil", out, err)
}
}
func TestForwardsQuery(t *testing.T) {
if _, ok := any(&Provider{}).(provider.QueryForwarder); !ok {
t.Error("ansible provider must forward the query string for pagination")
}
}
+7
View File
@@ -65,6 +65,13 @@ type MetadataStore interface {
InsertDebMetadata(ctx context.Context, meta *DebMetadata) error
}
// QueryForwarder marks a remote provider whose upstream answers depend on the
// query string (e.g. paginated JSON APIs); the query is kept as part of the
// proxied and cached path.
type QueryForwarder interface {
ForwardQuery()
}
// RemoteServer lets a remote provider fully answer a request itself instead of
// going through the byte-proxy engine. It is the remote-side analog of
// LocalIndexer: a metadata-only remote (e.g. github_rpm) uses it to synthesize
+1
View File
@@ -22,6 +22,7 @@ import (
"git.unkin.net/unkin/artifactapi/internal/gc"
"git.unkin.net/unkin/artifactapi/internal/githubauth"
"git.unkin.net/unkin/artifactapi/internal/provider/alpine"
_ "git.unkin.net/unkin/artifactapi/internal/provider/ansible"
_ "git.unkin.net/unkin/artifactapi/internal/provider/cargo"
"git.unkin.net/unkin/artifactapi/internal/provider/deb"
_ "git.unkin.net/unkin/artifactapi/internal/provider/docker"