9294686ea6
ci/woodpecker/tag/docker Pipeline was successful
Virtual repos cover rpm, deb, npm and others but not alpine, so a local alpine repo and github_alpine remotes cannot be served as one apk repository. - merge members' `<dir>/APKINDEX.tar.gz` per directory; member order wins duplicate (P, V, A); a member 404 is skipped - 302 `<dir>/<P>-<V>.apk` downloads to the member whose index listed the package - serve the merged index unsigned, like local and github_alpine indexes - cache merges per directory on the shared virtual cache; member errors fail with 502 - add docs/alpine/virtual.md, unit tests and an apk e2e with abuild-built packages Requires unkin/artifactapi#163. Reviewed-on: #164 Co-authored-by: unkin-agent <unkin-agent@unkin.net> Co-committed-by: unkin-agent <unkin-agent@unkin.net>
91 lines
2.5 KiB
Go
91 lines
2.5 KiB
Go
package virtual
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// AlpineMember is one member's decompressed APKINDEX for a directory; Index is
|
|
// nil when the member does not serve that directory.
|
|
type AlpineMember struct {
|
|
RemoteName string
|
|
Index []byte
|
|
}
|
|
|
|
// AlpineRepo is a merged directory: its APKINDEX.tar.gz plus, per
|
|
// "<name>-<version>.apk" apk will request, the member that owns it.
|
|
type AlpineRepo struct {
|
|
Index []byte
|
|
Owners map[string][]byte
|
|
}
|
|
|
|
func (r *AlpineRepo) index() []byte { return r.Index }
|
|
func (r *AlpineRepo) files() map[string][]byte { return r.Owners }
|
|
|
|
// MergeAlpine merges members' APKINDEX records. Members are in priority order:
|
|
// the first to carry a (P, V, A) wins. The index is unsigned, like the local
|
|
// and github_alpine indexes it merges.
|
|
func MergeAlpine(members []AlpineMember) *AlpineRepo {
|
|
repo := &AlpineRepo{Owners: map[string][]byte{}}
|
|
seen := map[string]bool{}
|
|
var b bytes.Buffer
|
|
for _, m := range members {
|
|
for _, rec := range splitStanzas(m.Index) {
|
|
p, v, a := stanzaField(rec, "P"), stanzaField(rec, "V"), stanzaField(rec, "A")
|
|
key := p + "\x00" + v + "\x00" + a
|
|
if p == "" || v == "" || seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
if _, ok := repo.Owners[p+"-"+v+".apk"]; !ok {
|
|
repo.Owners[p+"-"+v+".apk"] = []byte(m.RemoteName)
|
|
}
|
|
b.WriteString(rec)
|
|
b.WriteString("\n\n")
|
|
}
|
|
}
|
|
var t bytes.Buffer
|
|
tw := tar.NewWriter(&t)
|
|
_ = tw.WriteHeader(&tar.Header{Name: "APKINDEX", Mode: 0o644, Size: int64(b.Len()), Typeflag: tar.TypeReg, ModTime: time.Unix(0, 0)})
|
|
_, _ = tw.Write(b.Bytes())
|
|
_ = tw.Close()
|
|
repo.Index = gzipDeterministic(t.Bytes())
|
|
return repo
|
|
}
|
|
|
|
// apkIndexText extracts the APKINDEX entry from an APKINDEX.tar.gz, skipping a
|
|
// leading signature stream if present.
|
|
func apkIndexText(body []byte) ([]byte, error) {
|
|
zr, err := gzip.NewReader(bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tr := tar.NewReader(zr)
|
|
for {
|
|
hdr, err := tr.Next()
|
|
if errors.Is(err, io.EOF) {
|
|
return nil, fmt.Errorf("no APKINDEX entry")
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if hdr.Name == "APKINDEX" {
|
|
return io.ReadAll(tr)
|
|
}
|
|
}
|
|
}
|
|
|
|
// alpinePath splits a request into its directory and file, or ok is false for
|
|
// anything but an APKINDEX.tar.gz or .apk.
|
|
func alpinePath(p string) (dir, file string, ok bool) {
|
|
i := strings.LastIndex(p, "/")
|
|
dir, file = p[:i+1], p[i+1:]
|
|
return dir, file, dir != "" && (file == "APKINDEX.tar.gz" || strings.HasSuffix(file, ".apk"))
|
|
}
|