Files
unkin-agent 7f77666709 Add Alpine/apk local repository support (#114)
## Why

The alpine provider only supported remote (proxy) repositories, so there was no way to publish first-party `.apk` packages the way `rpm-local` and `deb-local` already allow. This extends the existing alpine provider into a real apk repository: uploaded `.apk` files are parsed in pure Go and a per-arch `APKINDEX.tar.gz` is generated on demand, at parity with rpm repodata and deb Packages generation. (The metadata-only `github_alpine` type is a separate follow-up and is not part of this PR.)

## How

- Implements `LocalUploader` / `LocalIndexer` / `PostUploadHook` / `PostDeleteHook` on the existing `alpine` provider, leaving the remote proxy methods (`UpstreamURL`/`ContentType`/`AuthHeaders`/`RewriteResponse`/`Classify`) intact.
- Parses the `.apk` (up to three concatenated, independently gzipped tar streams) in pure Go: locates the control stream by its `.PKGINFO` member, reads the `key = value` fields, and computes the apk pull checksum `C:` = `Q1` + base64(sha1(**control gzip stream bytes**)) — the sha1 of the second gzip member, not of the whole file.
- Derives arch from `.PKGINFO` and records download size (`S:` blob size) and installed size (`I:` from `.PKGINFO size`).
- Generates an **unsigned** per-arch `APKINDEX.tar.gz` = gzip(tar(`APKINDEX`)) filtered by requested arch (clients use `--allow-untrusted`, matching rpm `gpgcheck=0` / deb `[trusted=yes]`), applying the same dot-segment normalization as deb so `./<arch>/APKINDEX.tar.gz` resolves. Non-index / `.apk` paths return `false` so the generic file streamer serves the stored blob.
- Adds `AlpineMetadata` plus **separate** `AlpineMetadataStore` / `AlpineMetadataReader` / `AlpineMetadataDeleter` interfaces (type-asserted from the generic hooks) so the shared rpm/deb metadata interfaces and their test doubles are untouched.
- Adds the `alpine_metadata` table (keyed by `repo_name` + `file_path`, per-arch index) and its `Insert`/`Delete`/`List` DB methods.
- Adds `testsupport.MinimalApk`, unit tests (`.PKGINFO` parse, Q1 checksum over the control stream, per-arch filtering, empty-field omission, `./` dot-segment handling, ValidateUpload accept/reject), and a `dockere2e` `TestLocalAlpineIndex`.

## Consumption

`/etc/apk/repositories` line = `<url>/api/v1/local/<name>` (apk appends `/<arch>/APKINDEX.tar.gz`); `apk update --allow-untrusted && apk add --allow-untrusted <pkg>`. Packages live at `/api/v1/local/<name>/<arch>/<file>.apk`.

## Verification

`go build ./...`, `go vet ./...` (incl. `-tags dockere2e`), `go mod tidy` (no change), `make test` (`-race`), and `pre-commit run --all-files` all pass.

Reviewed-on: #114
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-12 20:39:27 +10:00

325 lines
11 KiB
Go

package alpine
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha1"
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/internal/testsupport"
)
type fakeBlobReader struct{ data []byte }
func (f fakeBlobReader) Download(_ context.Context, _ string) (io.ReadCloser, int64, error) {
return io.NopCloser(bytes.NewReader(f.data)), int64(len(f.data)), nil
}
type errBlobReader struct{}
func (errBlobReader) Download(_ context.Context, _ string) (io.ReadCloser, int64, error) {
return nil, 0, io.ErrUnexpectedEOF
}
// fakeAlpineStore satisfies provider.MetadataStore (shared) and
// provider.AlpineMetadataStore, recording the row AfterUpload writes.
type fakeAlpineStore struct{ inserted *provider.AlpineMetadata }
func (f *fakeAlpineStore) InsertRPMMetadata(context.Context, *provider.RPMMetadata) error { return nil }
func (f *fakeAlpineStore) InsertDebMetadata(context.Context, *provider.DebMetadata) error { return nil }
func (f *fakeAlpineStore) InsertAlpineMetadata(_ context.Context, m *provider.AlpineMetadata) error {
f.inserted = m
return nil
}
// fakeAlpineDeleter satisfies provider.MetadataDeleter and AlpineMetadataDeleter.
type fakeAlpineDeleter struct{ deleted bool }
func (f *fakeAlpineDeleter) DeleteRPMMetadata(context.Context, string, string) error { return nil }
func (f *fakeAlpineDeleter) DeleteDebMetadata(context.Context, string, string) error { return nil }
func (f *fakeAlpineDeleter) DeleteAlpineMetadata(context.Context, string, string) error {
f.deleted = true
return nil
}
// fakeAlpineReader is a FileStore that also serves alpine metadata rows.
type fakeAlpineReader struct{ metas []provider.AlpineMetadata }
func (f fakeAlpineReader) ListAlpineMetadataEntries(context.Context, string) ([]provider.AlpineMetadata, error) {
return f.metas, nil
}
func (f fakeAlpineReader) ListFilesByPrefix(context.Context, string, string) ([]provider.FileEntry, error) {
return nil, nil
}
func (f fakeAlpineReader) ListPackages(context.Context, string) ([]string, error) { return nil, nil }
type errAlpineReader struct{}
func (errAlpineReader) ListAlpineMetadataEntries(context.Context, string) ([]provider.AlpineMetadata, error) {
return nil, io.ErrUnexpectedEOF
}
func (errAlpineReader) ListFilesByPrefix(context.Context, string, string) ([]provider.FileEntry, error) {
return nil, nil
}
func (errAlpineReader) ListPackages(context.Context, string) ([]string, error) { return nil, nil }
func TestAlpineValidateUpload(t *testing.T) {
p := &Provider{}
sp, ct, err := p.ValidateUpload("x86_64/foo-1.0-r0.apk")
if err != nil || sp != "x86_64/foo-1.0-r0.apk" || ct != "application/vnd.android.package-archive" {
t.Errorf("sp=%q ct=%q err=%v", sp, ct, err)
}
// Dot-segment prefix is normalized away.
if sp, _, err := p.ValidateUpload("./aarch64/bar-2.0-r1.apk"); err != nil || sp != "aarch64/bar-2.0-r1.apk" {
t.Errorf("dot-seg: sp=%q err=%v", sp, err)
}
if _, _, err := p.ValidateUpload("foo.rpm"); err == nil {
t.Error("expected error for non-apk")
}
resp := p.UploadResponse("x86_64/foo-1.0-r0.apk", "sha256:abc", 42)
if resp["filename"] != "foo-1.0-r0.apk" || resp["content_hash"] != "sha256:abc" || resp["size_bytes"] != int64(42) {
t.Errorf("upload response %v", resp)
}
}
func TestAlpineAfterUpload(t *testing.T) {
data := testsupport.MinimalApk("hello", "1.0-r0", "x86_64")
store := &fakeAlpineStore{}
(&Provider{}).AfterUpload(context.Background(), "myrepo", "x86_64/hello-1.0-r0.apk",
"sha256:deadbeef", fakeBlobReader{data: data}, store)
m := store.inserted
if m == nil {
t.Fatal("no metadata inserted")
}
if m.Name != "hello" || m.Version != "1.0-r0" || m.Arch != "x86_64" {
t.Errorf("unexpected metadata: %+v", m)
}
if m.DownloadSize != int64(len(data)) {
t.Errorf("DownloadSize = %d, want %d", m.DownloadSize, len(data))
}
if m.InstalledSize != 4 {
t.Errorf("InstalledSize = %d, want 4", m.InstalledSize)
}
if m.License != "MIT" || m.Origin != "hello" || !strings.HasPrefix(m.Maintainer, "e2e") {
t.Errorf("scalar fields not parsed: %+v", m)
}
if len(m.Depends) != 1 || m.Depends[0] != "so:libc.musl-x86_64.so.1" {
t.Errorf("Depends = %v", m.Depends)
}
if len(m.Provides) != 1 || m.Provides[0] != "cmd:hello=1.0-r0" {
t.Errorf("Provides = %v", m.Provides)
}
// The Q1 checksum is the sha1 of the CONTROL gzip stream (the member whose
// tar carries .PKGINFO), not of the whole file.
controlRaw := controlStreamBytes(t, data)
sum := sha1.Sum(controlRaw)
want := "Q1" + base64.StdEncoding.EncodeToString(sum[:])
if m.Checksum != want {
t.Errorf("Checksum = %q, want %q (sha1 of control stream)", m.Checksum, want)
}
// And explicitly NOT the sha1 of the whole apk.
whole := sha1.Sum(data)
if m.Checksum == "Q1"+base64.StdEncoding.EncodeToString(whole[:]) {
t.Error("Checksum was computed over the whole file, not the control stream")
}
}
func TestAlpineAfterUploadErrors(t *testing.T) {
store := &fakeAlpineStore{}
(&Provider{}).AfterUpload(context.Background(), "r", "x86_64/p.apk", "sha256:x", errBlobReader{}, store)
if store.inserted != nil {
t.Error("no metadata should be inserted on download error")
}
store2 := &fakeAlpineStore{}
(&Provider{}).AfterUpload(context.Background(), "r", "x86_64/p.apk", "sha256:x", fakeBlobReader{data: []byte("not an apk")}, store2)
if store2.inserted != nil {
t.Error("no metadata should be inserted on parse error")
}
}
func TestAlpineAfterDelete(t *testing.T) {
d := &fakeAlpineDeleter{}
if err := (&Provider{}).AfterDelete(context.Background(), "r", "x86_64/p.apk", d); err != nil {
t.Fatalf("AfterDelete: %v", err)
}
if !d.deleted {
t.Error("DeleteAlpineMetadata not called")
}
}
func TestAlpineServeLocalIndex(t *testing.T) {
p := &Provider{}
reader := fakeAlpineReader{metas: []provider.AlpineMetadata{
{Name: "aaa", Version: "1.0-r0", Arch: "x86_64", Checksum: "Q1aaa", DownloadSize: 100, InstalledSize: 10,
Description: "pkg aaa", URL: "https://a", License: "MIT", Depends: []string{"so:libc"}, Provides: []string{"cmd:aaa"}},
{Name: "bbb", Version: "2.0-r0", Arch: "aarch64", Checksum: "Q1bbb", DownloadSize: 200, InstalledSize: 20},
}}
// x86_64 index contains only aaa, with its fields, and not bbb.
w := serveIndex(t, p, reader, "x86_64/APKINDEX.tar.gz")
if w.Code != 200 {
t.Fatalf("code %d", w.Code)
}
idx := untarIndex(t, w.Body.Bytes())
for _, want := range []string{"C:Q1aaa", "P:aaa", "V:1.0-r0", "A:x86_64", "S:100", "I:10", "T:pkg aaa", "U:https://a", "L:MIT", "D:so:libc", "p:cmd:aaa"} {
if !strings.Contains(idx, want) {
t.Errorf("x86_64 APKINDEX missing %q:\n%s", want, idx)
}
}
if strings.Contains(idx, "P:bbb") {
t.Errorf("x86_64 APKINDEX leaked aarch64 package:\n%s", idx)
}
// aarch64 index contains only bbb.
w = serveIndex(t, p, reader, "aarch64/APKINDEX.tar.gz")
idx = untarIndex(t, w.Body.Bytes())
if !strings.Contains(idx, "P:bbb") || strings.Contains(idx, "P:aaa") {
t.Errorf("aarch64 filtering wrong:\n%s", idx)
}
// Non-index and .apk paths are not owned by the indexer.
for _, path := range []string{"x86_64/foo-1.0-r0.apk", "x86_64/", "README"} {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/"+path, nil)
if p.ServeLocalIndex(w, r, reader, "repo", path) {
t.Errorf("ServeLocalIndex should return false for %q", path)
}
}
}
// Empty fields are omitted from the record (bbb has no description/url).
func TestAlpineIndexOmitsEmptyFields(t *testing.T) {
p := &Provider{}
reader := fakeAlpineReader{metas: []provider.AlpineMetadata{
{Name: "bbb", Version: "2.0-r0", Arch: "x86_64", Checksum: "Q1bbb", DownloadSize: 200, InstalledSize: 20},
}}
idx := untarIndex(t, serveIndex(t, p, reader, "x86_64/APKINDEX.tar.gz").Body.Bytes())
for _, absent := range []string{"T:", "U:", "L:", "D:", "p:", "i:", "o:", "m:", "c:", "k:"} {
if strings.Contains(idx, absent) {
t.Errorf("empty field %q should be omitted:\n%s", absent, idx)
}
}
}
// apk requests "./<arch>/APKINDEX.tar.gz" for a bare repo base URL; the
// dot-segment must be collapsed and yield the same bytes as the plain path.
func TestAlpineServeLocalIndexDotSegment(t *testing.T) {
p := &Provider{}
reader := fakeAlpineReader{metas: []provider.AlpineMetadata{
{Name: "aaa", Version: "1.0-r0", Arch: "x86_64", Checksum: "Q1aaa", DownloadSize: 100, InstalledSize: 10},
}}
plain := untarIndex(t, serveIndex(t, p, reader, "x86_64/APKINDEX.tar.gz").Body.Bytes())
dotted := untarIndex(t, serveIndex(t, p, reader, "./x86_64/APKINDEX.tar.gz").Body.Bytes())
if plain != dotted {
t.Errorf("dot-segment path differs:\nplain=%q\ndotted=%q", plain, dotted)
}
}
func TestAlpineServeLocalIndexArchRequired(t *testing.T) {
p := &Provider{}
reader := fakeAlpineReader{}
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/APKINDEX.tar.gz", nil)
if !p.ServeLocalIndex(w, r, reader, "repo", "APKINDEX.tar.gz") {
t.Fatal("bare APKINDEX should be owned (and rejected) by the indexer")
}
if w.Code != http.StatusNotFound {
t.Errorf("bare APKINDEX code = %d, want 404", w.Code)
}
}
func TestAlpineServeMetadataError(t *testing.T) {
p := &Provider{}
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/x86_64/APKINDEX.tar.gz", nil)
p.ServeLocalIndex(w, r, errAlpineReader{}, "repo", "x86_64/APKINDEX.tar.gz")
if w.Code != 500 {
t.Errorf("failing reader code = %d, want 500", w.Code)
}
}
func TestAlpineGenerateLocalIndexUnsupported(t *testing.T) {
if _, err := (&Provider{}).GenerateLocalIndex(context.Background(), fakeAlpineReader{}, "r", "x86_64/APKINDEX.tar.gz"); err == nil {
t.Error("expected unsupported error")
}
}
func serveIndex(t *testing.T, p *Provider, files provider.FileStore, path string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/"+path, nil)
if !p.ServeLocalIndex(w, r, files, "repo", path) {
t.Fatalf("ServeLocalIndex returned false for %q", path)
}
return w
}
// untarIndex un-gzips and un-tars an APKINDEX.tar.gz and returns the APKINDEX text.
func untarIndex(t *testing.T, gzTar []byte) string {
t.Helper()
zr, err := gzip.NewReader(bytes.NewReader(gzTar))
if err != nil {
t.Fatalf("APKINDEX not gzip: %v", err)
}
tarBytes, _ := io.ReadAll(zr)
tr := tar.NewReader(bytes.NewReader(tarBytes))
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("APKINDEX not tar: %v", err)
}
if hdr.Name == "APKINDEX" {
b, _ := io.ReadAll(tr)
return string(b)
}
}
t.Fatal("no APKINDEX member in tarball")
return ""
}
// controlStreamBytes returns the raw bytes of the gzip stream whose tar carries
// .PKGINFO, so the test can independently compute the expected Q1 checksum.
func controlStreamBytes(t *testing.T, apk []byte) []byte {
t.Helper()
br := bytes.NewReader(apk)
zr, err := gzip.NewReader(br)
if err != nil {
t.Fatalf("gzip: %v", err)
}
prev := 0
for {
zr.Multistream(false)
out, _ := io.ReadAll(zr)
end := len(apk) - br.Len()
tr := tar.NewReader(bytes.NewReader(out))
for {
h, err := tr.Next()
if err != nil {
break
}
if strings.TrimPrefix(h.Name, "./") == ".PKGINFO" {
return apk[prev:end]
}
}
prev = end
if err := zr.Reset(br); err != nil {
break
}
}
t.Fatal("no control stream found")
return nil
}