Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f826aa331 | |||
| 13d693b58a |
@@ -1,6 +1,6 @@
|
|||||||
# ArtifactAPI
|
# ArtifactAPI
|
||||||
|
|
||||||
Caching proxy for package repositories. Single Go binary, 11 package types, content-addressable storage, managed by Terraform.
|
Caching proxy for package repositories. Single Go binary, 12 package types, content-addressable storage, managed by Terraform.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -33,6 +33,7 @@ API: `http://localhost:8000` | Frontend: `http://localhost:5173`
|
|||||||
| `terraform` | `*/versions` | `*/download/*/*` |
|
| `terraform` | `*/versions` | `*/download/*/*` |
|
||||||
| `goproxy` | `@v/list`, `@latest` | `.info`, `.mod`, `.zip` |
|
| `goproxy` | `@v/list`, `@latest` | `.info`, `.mod`, `.zip` |
|
||||||
| `cargo` | sparse index files (`config.json` synthesized) | `crates/*/*.crate` |
|
| `cargo` | sparse index files (`config.json` synthesized) | `crates/*/*.crate` |
|
||||||
|
| `ansible` | Galaxy `api/` JSON (links rewritten) | `*.tar.gz` |
|
||||||
| `github_rpm` | `repodata/*` (synthesized) | `.rpm` (redirected) |
|
| `github_rpm` | `repodata/*` (synthesized) | `.rpm` (redirected) |
|
||||||
|
|
||||||
Providers classify paths automatically. Users only configure what to proxy and TTLs.
|
Providers classify paths automatically. Users only configure what to proxy and TTLs.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ already-running stack.
|
|||||||
|
|
||||||
- **Repository lifecycle** — add / change / delete for remote, local and virtual repos.
|
- **Repository lifecycle** — add / change / delete for remote, local and virtual repos.
|
||||||
- **Caching** — one immutable artifact per remote package type (generic, docker,
|
- **Caching** — one immutable artifact per remote package type (generic, docker,
|
||||||
helm, pypi, npm, rpm, alpine, puppet, terraform, goproxy, cargo) proxied through the
|
helm, pypi, npm, rpm, alpine, puppet, terraform, goproxy, cargo, ansible) proxied through the
|
||||||
mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes
|
mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes
|
||||||
verified against the origin fixture.
|
verified against the origin fixture.
|
||||||
- **Local uploads** — generic (upload/download), pypi (wheel + generated `simple/`
|
- **Local uploads** — generic (upload/download), pypi (wheel + generated `simple/`
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ func TestCachingPerProvider(t *testing.T) {
|
|||||||
{"puppet", "puppet-releases/author-mod-1.0.0.tar.gz", "puppet-releases/author-mod-1.0.0.tar.gz"},
|
{"puppet", "puppet-releases/author-mod-1.0.0.tar.gz", "puppet-releases/author-mod-1.0.0.tar.gz"},
|
||||||
{"goproxy", "goproxy/example.com/mod/@v/v1.0.0.zip", "goproxy/example.com/mod/@v/v1.0.0.zip"},
|
{"goproxy", "goproxy/example.com/mod/@v/v1.0.0.zip", "goproxy/example.com/mod/@v/v1.0.0.zip"},
|
||||||
{"cargo", "crates/mycrate/mycrate-1.0.0.crate", "crates/mycrate/mycrate-1.0.0.crate"},
|
{"cargo", "crates/mycrate/mycrate-1.0.0.crate", "crates/mycrate/mycrate-1.0.0.crate"},
|
||||||
|
{"ansible", "galaxy/artifacts/ns-col-1.0.0.tar.gz", "galaxy/artifacts/ns-col-1.0.0.tar.gz"},
|
||||||
{"terraform", "hashicorp/aws/download/pkg.zip", "v1/providers/hashicorp/aws/download/pkg.zip"},
|
{"terraform", "hashicorp/aws/download/pkg.zip", "v1/providers/hashicorp/aws/download/pkg.zip"},
|
||||||
{"docker", "library/testimg/blobs/blobdata", "v2/library/testimg/blobs/blobdata"},
|
{"docker", "library/testimg/blobs/blobdata", "v2/library/testimg/blobs/blobdata"},
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -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)
|
result, err := h.engine.Fetch(r.Context(), *remote, path, prov, r.Header)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var proxyErr *proxy.ProxyError
|
var proxyErr *proxy.ProxyError
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,13 @@ type MetadataStore interface {
|
|||||||
InsertDebMetadata(ctx context.Context, meta *DebMetadata) error
|
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
|
// 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
|
// 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
|
// LocalIndexer: a metadata-only remote (e.g. github_rpm) uses it to synthesize
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"git.unkin.net/unkin/artifactapi/internal/gc"
|
"git.unkin.net/unkin/artifactapi/internal/gc"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/githubauth"
|
"git.unkin.net/unkin/artifactapi/internal/githubauth"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/provider/alpine"
|
"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/cargo"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/provider/deb"
|
"git.unkin.net/unkin/artifactapi/internal/provider/deb"
|
||||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/docker"
|
_ "git.unkin.net/unkin/artifactapi/internal/provider/docker"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const (
|
|||||||
PackageTerraform PackageType = "terraform"
|
PackageTerraform PackageType = "terraform"
|
||||||
PackageGoProxy PackageType = "goproxy"
|
PackageGoProxy PackageType = "goproxy"
|
||||||
PackageCargo PackageType = "cargo"
|
PackageCargo PackageType = "cargo"
|
||||||
|
PackageAnsible PackageType = "ansible"
|
||||||
PackageGitHubRPM PackageType = "github_rpm"
|
PackageGitHubRPM PackageType = "github_rpm"
|
||||||
PackageGitHubDeb PackageType = "github_deb"
|
PackageGitHubDeb PackageType = "github_deb"
|
||||||
PackageGitHubAlpine PackageType = "github_alpine"
|
PackageGitHubAlpine PackageType = "github_alpine"
|
||||||
@@ -35,6 +36,7 @@ var validPackageTypes = map[PackageType]bool{
|
|||||||
PackageTerraform: true,
|
PackageTerraform: true,
|
||||||
PackageGoProxy: true,
|
PackageGoProxy: true,
|
||||||
PackageCargo: true,
|
PackageCargo: true,
|
||||||
|
PackageAnsible: true,
|
||||||
PackageGitHubRPM: true,
|
PackageGitHubRPM: true,
|
||||||
PackageGitHubDeb: true,
|
PackageGitHubDeb: true,
|
||||||
PackageGitHubAlpine: true,
|
PackageGitHubAlpine: true,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ func TestPackageTypeValid(t *testing.T) {
|
|||||||
models.PackageTerraform,
|
models.PackageTerraform,
|
||||||
models.PackageGoProxy,
|
models.PackageGoProxy,
|
||||||
models.PackageCargo,
|
models.PackageCargo,
|
||||||
|
models.PackageAnsible,
|
||||||
models.PackageGitHubRPM,
|
models.PackageGitHubRPM,
|
||||||
models.PackageGitHubDeb,
|
models.PackageGitHubDeb,
|
||||||
models.PackageGitHubAlpine,
|
models.PackageGitHubAlpine,
|
||||||
|
|||||||
@@ -243,6 +243,15 @@ index = "sparse+${proxy}/"`,
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
case 'ansible':
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Install collections through this Galaxy proxy',
|
||||||
|
language: 'bash',
|
||||||
|
code: `ansible-galaxy collection install community.general -s ${proxy}/api/`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
case 'puppet':
|
case 'puppet':
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const typeColors: Record<string, 'blue' | 'green' | 'yellow' | 'red' | 'default'
|
|||||||
terraform: 'blue',
|
terraform: 'blue',
|
||||||
goproxy: 'green',
|
goproxy: 'green',
|
||||||
cargo: 'yellow',
|
cargo: 'yellow',
|
||||||
|
ansible: 'red',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Remotes() {
|
export function Remotes() {
|
||||||
|
|||||||
Reference in New Issue
Block a user