Files
artifactapi/internal/provider/puppet/local_test.go
T
unkin-agent 9b91aa39a6 Add local puppet forge (#145)
Puppet modules can only be proxied from a remote Forge; there is nowhere to publish internal modules for puppet module install, r10k and g10k.

- add puppet local uploads at <owner>-<module>-<version>.tar.gz, rejecting existing versions
- record each release's metadata.json, md5 and sha256 in a puppet_metadata table, streaming the tarball under size caps
- serve Forge v3 /v3/modules, paginated /v3/releases, /v3/releases/<slug> and /v3/files
- add an e2e-docker case driving real r10k and puppet module install
- document local puppet in README and UI usage snippets

Reviewed-on: #145
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-10-17 13:17:51 +11:00

302 lines
11 KiB
Go

package puppet
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.unkin.net/unkin/artifactapi/internal/provider"
)
func moduleTarball(t *testing.T, dir, metadata string) []byte {
t.Helper()
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
tw := tar.NewWriter(zw)
for name, body := range map[string]string{dir + "/metadata.json": metadata, dir + "/manifests/init.pp": "class hello {}\n"} {
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body))}); err != nil {
t.Fatal(err)
}
_, _ = tw.Write([]byte(body))
}
_ = tw.Close()
_ = zw.Close()
return buf.Bytes()
}
const helloMetadata = `{"name":"unkin-hello","version":"0.1.0","dependencies":[{"name":"puppetlabs/stdlib","version_requirement":">= 4.0.0"}]}`
type fakeBlobs struct{ data []byte }
func (f fakeBlobs) Download(context.Context, string) (io.ReadCloser, int64, error) {
return io.NopCloser(bytes.NewReader(f.data)), int64(len(f.data)), nil
}
type fakeStore struct{ inserted *provider.PuppetMetadata }
func (f *fakeStore) InsertRPMMetadata(context.Context, *provider.RPMMetadata) error { return nil }
func (f *fakeStore) InsertDebMetadata(context.Context, *provider.DebMetadata) error { return nil }
func (f *fakeStore) InsertPuppetMetadata(_ context.Context, m *provider.PuppetMetadata) error {
f.inserted = m
return nil
}
type fakeReader struct{ metas []provider.PuppetMetadata }
func (f fakeReader) ListPuppetMetadataEntries(_ context.Context, _, slug string) ([]provider.PuppetMetadata, error) {
var out []provider.PuppetMetadata
for _, m := range f.metas {
if slug == "" || m.Slug == slug {
out = append(out, m)
}
}
return out, nil
}
func (fakeReader) ListFilesByPrefix(context.Context, string, string) ([]provider.FileEntry, error) {
return nil, nil
}
func (fakeReader) ListPackages(context.Context, string) ([]string, error) { return nil, nil }
func TestValidateUpload(t *testing.T) {
p := &Provider{}
sp, ct, err := p.ValidateUpload("some/dir/Unkin-hello-0.1.0.tar.gz")
if err != nil || sp != "v3/files/unkin-hello-0.1.0.tar.gz" || ct != "application/gzip" {
t.Errorf("sp=%q ct=%q err=%v", sp, ct, err)
}
for _, bad := range []string{"hello-0.1.0.tar.gz", "unkin-hello-0.1.tar.gz", "unkin-hello-0.1.0.zip", "unkin-Hello_x-0.1.0.tgz"} {
if _, _, err := p.ValidateUpload(bad); err == nil {
t.Errorf("%q accepted", bad)
}
}
}
func TestAfterUploadRecordsMetadata(t *testing.T) {
raw := moduleTarball(t, "unkin-hello-0.1.0", helloMetadata)
store := &fakeStore{}
(&Provider{}).AfterUpload(context.Background(), "forge", "v3/files/unkin-hello-0.1.0.tar.gz", "sha256:abc", fakeBlobs{raw}, store)
m := store.inserted
if m == nil {
t.Fatal("nothing inserted")
}
sum := md5.Sum(raw)
if m.Slug != "unkin-hello" || m.Version != "0.1.0" || m.MD5 != hex.EncodeToString(sum[:]) || m.Size != int64(len(raw)) || m.MetadataJSON != helloMetadata {
t.Errorf("got %+v", m)
}
}
func TestAfterUploadRejectsMismatchedFilename(t *testing.T) {
raw := moduleTarball(t, "unkin-hello-0.2.0", `{"name":"unkin-hello","version":"0.2.0"}`)
store := &fakeStore{}
(&Provider{}).AfterUpload(context.Background(), "forge", "v3/files/unkin-hello-0.1.0.tar.gz", "sha256:abc", fakeBlobs{raw}, store)
if store.inserted != nil {
t.Error("mismatched version indexed")
}
}
func TestParseModuleRequiresTopLevelMetadata(t *testing.T) {
if _, err := parseModule(bytes.NewReader(moduleTarball(t, "a/b", helloMetadata))); err == nil {
t.Error("nested metadata.json accepted")
}
if _, err := parseModule(bytes.NewReader(moduleTarball(t, "x", `{"name":"unkin-hello"}`))); err == nil {
t.Error("missing version accepted")
}
}
func tarballOf(t *testing.T, entries ...*tar.Header) []byte {
t.Helper()
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
tw := tar.NewWriter(zw)
for _, h := range entries {
if err := tw.WriteHeader(h); err != nil {
t.Fatal(err)
}
if h.Typeflag == tar.TypeReg {
body := helloMetadata
if !strings.HasSuffix(h.Name, "metadata.json") {
body = strings.Repeat("x", int(h.Size))
}
_, _ = tw.Write([]byte(body))
}
}
_ = tw.Close()
_ = zw.Close()
return buf.Bytes()
}
func TestParseModuleSkipsNonRegularMetadata(t *testing.T) {
raw := tarballOf(t,
&tar.Header{Name: "unkin-hello-0.1.0/metadata.json", Typeflag: tar.TypeSymlink, Linkname: "/etc/passwd"},
&tar.Header{Name: "unkin-hello-0.1.0/metadata.json/", Typeflag: tar.TypeDir, Mode: 0o755},
&tar.Header{Name: "unkin-hello-0.1.0/metadata.json", Typeflag: tar.TypeReg, Mode: 0o644, Size: int64(len(helloMetadata))},
)
meta, err := parseModule(bytes.NewReader(raw))
if err != nil || meta.Slug != "unkin-hello" || meta.Version != "0.1.0" {
t.Fatalf("got %v, %v", meta, err)
}
}
func TestParseModuleCapsDecompressedScan(t *testing.T) {
defer func(n int64) { maxModuleScan = n }(maxModuleScan)
maxModuleScan = 64 << 10
raw := tarballOf(t,
&tar.Header{Name: "unkin-hello-0.1.0/big", Typeflag: tar.TypeReg, Mode: 0o644, Size: 1 << 20},
&tar.Header{Name: "unkin-hello-0.1.0/metadata.json", Typeflag: tar.TypeReg, Mode: 0o644, Size: int64(len(helloMetadata))},
)
if _, err := parseModule(bytes.NewReader(raw)); err == nil || !strings.Contains(err.Error(), "scan limit") {
t.Fatalf("expected scan limit error, got %v", err)
}
maxModuleScan = 2 << 20
if _, err := parseModule(bytes.NewReader(raw)); err != nil {
t.Fatalf("under cap: %v", err)
}
}
func TestAfterUploadCapsTarballSize(t *testing.T) {
defer func(n int64) { maxModuleTarball = n }(maxModuleTarball)
raw := moduleTarball(t, "unkin-hello-0.1.0", helloMetadata)
maxModuleTarball = int64(len(raw)) - 1
store := &fakeStore{}
(&Provider{}).AfterUpload(context.Background(), "forge", "v3/files/unkin-hello-0.1.0.tar.gz", "sha256:abc", fakeBlobs{raw}, store)
if store.inserted != nil {
t.Error("oversized tarball indexed")
}
maxModuleTarball = int64(len(raw))
(&Provider{}).AfterUpload(context.Background(), "forge", "v3/files/unkin-hello-0.1.0.tar.gz", "sha256:abc", fakeBlobs{raw}, store)
if store.inserted == nil {
t.Error("tarball at cap not indexed")
}
}
func TestReleaseListClampsPagination(t *testing.T) {
files := releases("0.1.0", "0.2.0", "0.3.0")
for _, q := range []string{"offset=9223372036854775807", "offset=9223372036854775807&limit=9223372036854775807", "offset=-5&limit=-5", "limit=9223372036854775807"} {
code, body := get(t, files, "v3/releases?module=unkin-hello&"+q)
if code != http.StatusOK {
t.Fatalf("%s: code %d", q, code)
}
pg := body["pagination"].(map[string]any)
if l := pg["limit"].(float64); l < 1 || l > 100 {
t.Errorf("%s: limit %v", q, l)
}
if o := pg["offset"].(float64); o < 0 || o > 3 {
t.Errorf("%s: offset %v", q, o)
}
}
_, body := get(t, files, "v3/releases?module=unkin-hello&offset=9223372036854775807")
if len(body["results"].([]any)) != 0 || body["pagination"].(map[string]any)["next"] != nil {
t.Errorf("past-end page: %v", body)
}
}
func get(t *testing.T, files provider.FileStore, target string) (int, map[string]any) {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/x/"+target, nil)
path, _, _ := strings.Cut(target, "?")
if !(&Provider{}).ServeLocalIndex(rec, req, files, "forge", path) {
return 0, nil
}
var body map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &body)
return rec.Code, body
}
func releases(versions ...string) fakeReader {
var f fakeReader
for i, v := range versions {
f.metas = append(f.metas, provider.PuppetMetadata{
FilePath: "v3/files/unkin-hello-" + v + ".tar.gz", ContentHash: "sha256:feed", Slug: "unkin-hello",
Version: v, MetadataJSON: `{"name":"unkin-hello","version":"` + v + `"}`, MD5: "m" + v,
CreatedAt: time.Unix(int64(i), 0),
})
}
f.metas = append(f.metas, provider.PuppetMetadata{Slug: "other-mod", Version: "9.9.9", MetadataJSON: `{}`})
return f
}
func TestReleaseListPaginatesNewestFirst(t *testing.T) {
files := releases("0.1.0", "0.10.0", "0.2.0", "1.0.0-rc.1", "1.0.0")
code, body := get(t, files, "v3/releases?module=unkin/hello&limit=2")
if code != http.StatusOK {
t.Fatalf("code %d", code)
}
results := body["results"].([]any)
pg := body["pagination"].(map[string]any)
if len(results) != 2 || results[0].(map[string]any)["version"] != "1.0.0" || results[1].(map[string]any)["version"] != "1.0.0-rc.1" {
t.Fatalf("page 1: %v", results)
}
if pg["total"] != float64(5) || pg["next"] != "/v3/releases?limit=2&module=unkin-hello&offset=2" || pg["previous"] != nil {
t.Errorf("pagination: %v", pg)
}
_, body = get(t, files, "v3/releases?module=unkin-hello&limit=2&offset=4")
results = body["results"].([]any)
pg = body["pagination"].(map[string]any)
if len(results) != 1 || results[0].(map[string]any)["version"] != "0.1.0" || pg["next"] != nil || pg["previous"] != "/v3/releases?limit=2&module=unkin-hello&offset=2" {
t.Errorf("last page: %v %v", results, pg)
}
_, body = get(t, files, "v3/releases?module=nobody-none")
if len(body["results"].([]any)) != 0 {
t.Error("unknown module returned results")
}
}
func TestReleaseObject(t *testing.T) {
code, body := get(t, releases("0.1.0"), "v3/releases/unkin-hello-0.1.0")
if code != http.StatusOK {
t.Fatalf("code %d", code)
}
if body["file_uri"] != "/v3/files/unkin-hello-0.1.0.tar.gz" || body["file_md5"] != "m0.1.0" || body["file_sha256"] != "feed" {
t.Errorf("release: %v", body)
}
if body["metadata"].(map[string]any)["name"] != "unkin-hello" || body["module"].(map[string]any)["owner"].(map[string]any)["username"] != "unkin" {
t.Errorf("release refs: %v", body)
}
if code, _ := get(t, releases("0.1.0"), "v3/releases/unkin-hello-0.2.0"); code != http.StatusNotFound {
t.Errorf("missing version: %d", code)
}
}
func TestModuleObject(t *testing.T) {
code, body := get(t, releases("0.9.0", "0.10.0"), "v3/modules/unkin-hello")
if code != http.StatusOK {
t.Fatalf("code %d", code)
}
if body["slug"] != "unkin-hello" || body["name"] != "hello" || body["current_release"].(map[string]any)["version"] != "0.10.0" || len(body["releases"].([]any)) != 2 {
t.Errorf("module: %v", body)
}
if code, _ := get(t, releases(), "v3/modules/unkin-missing"); code != http.StatusNotFound {
t.Errorf("missing module: %d", code)
}
}
func TestFilesFallThrough(t *testing.T) {
if code, _ := get(t, releases(), "v3/files/unkin-hello-0.1.0.tar.gz"); code != 0 {
t.Error("file downloads must fall through to the stored file")
}
}
func TestCompareSemver(t *testing.T) {
ordered := []string{"0.1.0", "0.2.0", "0.10.0", "1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-beta", "1.0.0-beta.2", "1.0.0-beta.11", "1.0.0", "1.0.1+build"}
for i := 1; i < len(ordered); i++ {
if compareSemver(ordered[i-1], ordered[i]) >= 0 || compareSemver(ordered[i], ordered[i-1]) <= 0 {
t.Errorf("%s should sort before %s", ordered[i-1], ordered[i])
}
}
if compareSemver("1.0.0+a", "1.0.0+b") != 0 {
t.Error("build metadata must not affect precedence")
}
}