Skip to content

Commit fba9e03

Browse files
authored
Merge pull request #656 from simonchrz/feat/mirror-apk
Mirror Alpine packages
2 parents 1912deb + c3f0c23 commit fba9e03

8 files changed

Lines changed: 352 additions & 11 deletions

File tree

‎docs/configuration.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1726,6 +1726,8 @@ A Swift release is mirrored from `upstream.swift` with its source archive, check
17261726

17271727
A CRAN version is mirrored as its source package from `upstream.cran`, for example `pkg:cran/glue@1.8.1`, and cached under the file name, so it is served for both `/cran/src/contrib/{file}` and `/cran/src/contrib/Archive/{name}/{file}`, where CRAN moves it once a newer version is published. The current version is fetched from `src/contrib` and checked against the MD5 sum in `src/contrib/PACKAGES.gz`; older versions are fetched unchecked from `src/contrib/Archive/{name}/`, as the CRAN handler fetches them. Binary packages aren't mirrored, and neither are the `PACKAGES` indexes, so `install.packages` needs the upstream or an index the proxy already cached; installing the tarball by URL works without either.
17281728

1729+
An Alpine package is mirrored from the repository in `upstream.apk` named as the namespace, `alpine` by default, as syft writes it in image SBOMs: `pkg:apk/alpine/busybox@1.36.1-r29?arch=x86_64&distro=alpine-3.20.3`. The `distro` qualifier gives the release (`v3.20`) and `arch` the architecture; packages without either are refused. Edge isn't mirrored: a `distro` with a pre-release suffix, such as `alpine-3.21.0_alpha20240807`, is refused, but an edge system that reports a plain release number can't be told apart and is mirrored from that release. The package is looked up in the `main` and then the `community` `APKINDEX.tar.gz` of that release and cached under the path `/apk/{repository}/{release}/{repo}/{arch}/{file}` serves it from. The file isn't checked by the proxy, as when serving, since the index checksum covers only the package's control section; apk checks it on install. The indexes aren't mirrored, so `apk add` needs the upstream or an index the proxy already cached. Give a version: Alpine repositories keep only the current one.
1730+
17291731
| Flag | Default | Description |
17301732
|------|---------|-------------|
17311733
| `--sbom` | | Path to CycloneDX or SPDX SBOM file |

‎internal/handler/apk.go‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,18 +60,31 @@ type APKHandler struct {
6060
// When repositories is empty, a single repository named "alpine" pointing at
6161
// the official Alpine mirror is used.
6262
func NewAPKHandler(proxy *Proxy, proxyURL string, repositories map[string]string) *APKHandler {
63-
h := &APKHandler{
63+
return &APKHandler{
6464
proxy: proxy,
6565
proxyURL: strings.TrimSuffix(proxyURL, "/"),
66-
repositories: make(map[string]string, len(repositories)),
66+
repositories: APKRepositories(repositories),
6767
}
68-
for name, repositoryURL := range repositories {
69-
h.repositories[name] = strings.TrimSuffix(repositoryURL, "/")
68+
}
69+
70+
// APKRepositories returns the upstream.apk repositories by name without
71+
// trailing slashes, or the official Alpine mirror as "alpine" when none are
72+
// configured.
73+
func APKRepositories(configured map[string]string) map[string]string {
74+
repositories := make(map[string]string, len(configured))
75+
for name, repositoryURL := range configured {
76+
repositories[name] = strings.TrimSuffix(repositoryURL, "/")
7077
}
71-
if len(h.repositories) == 0 {
72-
h.repositories[defaultAPKRepositoryName] = defaultAPKUpstream
78+
if len(repositories) == 0 {
79+
repositories[defaultAPKRepositoryName] = defaultAPKUpstream
7380
}
74-
return h
81+
return repositories
82+
}
83+
84+
// APKCacheFilename returns the filename a package at path
85+
// ({release}/{repo}/{arch}/{file}) of repository is cached under.
86+
func APKCacheFilename(repository, path string) string {
87+
return repository + "/" + path
7588
}
7689

7790
// Routes returns the HTTP handler for APK requests.
@@ -145,7 +158,7 @@ func (h *APKHandler) handlePackageDownload(w http.ResponseWriter, r *http.Reques
145158
}
146159

147160
downloadURL := upstreamURL + "/" + path
148-
cacheFilename := repository + "/" + path
161+
cacheFilename := APKCacheFilename(repository, path)
149162

150163
h.proxy.Logger.Info("apk package download",
151164
"repository", repository, "name", name, "version", version, "arch", arch)

‎internal/mirror/apk.go‎

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package mirror
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"regexp"
8+
"strings"
9+
"sync"
10+
"time"
11+
12+
"github.com/git-pkgs/proxy/internal/handler"
13+
"github.com/git-pkgs/registries"
14+
)
15+
16+
// apkIndexTTL is how long a repository's APKINDEX is reused, so mirroring
17+
// the packages of an image reads each index once.
18+
const apkIndexTTL = 5 * time.Minute
19+
20+
var (
21+
// apkDistroPattern takes the release from syft's distro qualifier, such
22+
// as alpine-3.20.3 for v3.20. Edge, as alpine-3.21.0_alpha20240807,
23+
// isn't a release and doesn't match.
24+
apkDistroPattern = regexp.MustCompile(`^alpine-(\d+)\.(\d+)(?:\.\d+)?$`)
25+
apkArchPattern = regexp.MustCompile(`^[a-z0-9_]+$`)
26+
// apkComponents are the repositories of a release a package is looked
27+
// up in, in order.
28+
apkComponents = []string{"main", "community"}
29+
)
30+
31+
// apkIndexes keeps one registries client per repository directory, which
32+
// holds the parsed APKINDEX.
33+
type apkIndexes struct {
34+
mu sync.Mutex
35+
regs map[string]apkIndex
36+
}
37+
38+
type apkIndex struct {
39+
reg registries.Registry
40+
fetched time.Time
41+
}
42+
43+
// mirrorAPK mirrors an Alpine package under the path the handler caches it
44+
// at, {repository}/v{release}/{component}/{arch}/{file}. The PURL namespace
45+
// names the upstream.apk repository; the distro and arch qualifiers give
46+
// the release and architecture, and the package is looked up in the main
47+
// and community indexes of that release.
48+
func (m *Mirror) mirrorAPK(ctx context.Context, pv PackageVersion, tracker *progressTracker) {
49+
fail := func(err error) { m.recordFailure(tracker, pv, err) }
50+
repository, name, ok := strings.Cut(pv.Name, "/")
51+
if !ok {
52+
repository, name = "alpine", pv.Name
53+
}
54+
repositoryURL, ok := handler.APKRepositories(m.APKRepositories)[repository]
55+
if !ok {
56+
fail(fmt.Errorf("unknown APK repository %q: it isn't in upstream.apk", repository))
57+
return
58+
}
59+
match := apkDistroPattern.FindStringSubmatch(pv.Qualifiers["distro"])
60+
if match == nil {
61+
fail(fmt.Errorf("apk packages need a distro qualifier with the Alpine release, such as distro=alpine-3.20.3, not %q", pv.Qualifiers["distro"]))
62+
return
63+
}
64+
release := "v" + match[1] + "." + match[2]
65+
arch := pv.Qualifiers["arch"]
66+
if !apkArchPattern.MatchString(arch) {
67+
fail(fmt.Errorf("apk packages need an arch qualifier, such as arch=x86_64, not %q", arch))
68+
return
69+
}
70+
71+
for _, component := range apkComponents {
72+
dir := release + "/" + component + "/" + arch
73+
artifact, found, err := m.apkArtifact(ctx, repositoryURL+"/"+dir, name, pv.Version)
74+
if err != nil {
75+
fail(fmt.Errorf("reading %s/APKINDEX.tar.gz: %w", dir, err))
76+
return
77+
}
78+
if !found {
79+
continue
80+
}
81+
cacheFilename := handler.APKCacheFilename(repository, dir+"/"+artifact.Filename)
82+
result, err := m.proxy.GetOrFetchArtifactFromURL(ctx, pv.Ecosystem, name, pv.Version, cacheFilename, artifact.URL)
83+
if err != nil {
84+
fail(err)
85+
return
86+
}
87+
m.recordResult(tracker, pv, result)
88+
return
89+
}
90+
fail(fmt.Errorf("%s %s isn't in %s of %s for %s", name, pv.Version, strings.Join(apkComponents, " or "), release, arch))
91+
}
92+
93+
// apkArtifact returns the file of version of name that the index in dirURL
94+
// lists, and whether it lists one.
95+
func (m *Mirror) apkArtifact(ctx context.Context, dirURL, name, version string) (registries.Artifact, bool, error) {
96+
reg, err := m.apkRegistry(dirURL)
97+
if err != nil {
98+
return registries.Artifact{}, false, err
99+
}
100+
versions, err := reg.FetchVersions(ctx, name)
101+
if isNotFound(err) {
102+
return registries.Artifact{}, false, nil
103+
}
104+
if err != nil {
105+
m.apk.forget(dirURL)
106+
return registries.Artifact{}, false, err
107+
}
108+
for _, v := range versions {
109+
if v.Number == version && len(v.Artifacts) > 0 {
110+
return v.Artifacts[0], true, nil
111+
}
112+
}
113+
return registries.Artifact{}, false, nil
114+
}
115+
116+
// apkRegistry returns the client for the repository directory at dirURL,
117+
// reusing it, and the index it read, for apkIndexTTL.
118+
func (m *Mirror) apkRegistry(dirURL string) (registries.Registry, error) { //nolint:ireturn // registries.New returns the interface
119+
m.apk.mu.Lock()
120+
defer m.apk.mu.Unlock()
121+
if idx, ok := m.apk.regs[dirURL]; ok && time.Since(idx.fetched) < apkIndexTTL {
122+
return idx.reg, nil
123+
}
124+
r, _ := m.Versions.(RegistryVersions)
125+
// Naming the v2 index reads it also where Packages.adb is served beside
126+
// it, which the client would try first and refuse.
127+
reg, err := registries.New("apk", dirURL+"/APKINDEX.tar.gz", r.client())
128+
if err != nil {
129+
return nil, err
130+
}
131+
if m.apk.regs == nil {
132+
m.apk.regs = map[string]apkIndex{}
133+
}
134+
m.apk.regs[dirURL] = apkIndex{reg: reg, fetched: time.Now()}
135+
return reg, nil
136+
}
137+
138+
// forget drops the client for dirURL, so an index that failed to load is
139+
// read again next time.
140+
func (a *apkIndexes) forget(dirURL string) {
141+
a.mu.Lock()
142+
defer a.mu.Unlock()
143+
delete(a.regs, dirURL)
144+
}
145+
146+
// isNotFound reports whether err means a package or index doesn't exist,
147+
// such as a release without a community repository for an architecture.
148+
func isNotFound(err error) bool {
149+
if errors.Is(err, registries.ErrNotFound) {
150+
return true
151+
}
152+
var httpErr *registries.HTTPError
153+
return errors.As(err, &httpErr) && httpErr.IsNotFound()
154+
}

‎internal/mirror/mirror.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,16 @@ type Mirror struct {
4545
// uses https://pkg.julialang.org.
4646
JuliaUpstream string
4747

48+
// APKRepositories are the upstream.apk repositories by name. Nil uses
49+
// the official Alpine mirror as "alpine".
50+
APKRepositories map[string]string
51+
4852
// RefusePackages, when set, fails every item Images does not take with
4953
// this reason instead of fetching it, so nothing is cached.
5054
RefusePackages string
5155

5256
cran cranIndex
57+
apk apkIndexes
5358
}
5459

5560
// New creates a new Mirror with the given dependencies.
@@ -270,6 +275,10 @@ func (m *Mirror) mirrorOne(ctx context.Context, pv PackageVersion, tracker *prog
270275
m.mirrorCRAN(ctx, pv, tracker)
271276
return
272277
}
278+
if pv.Ecosystem == "alpine" {
279+
m.mirrorAPK(ctx, pv, tracker)
280+
return
281+
}
273282
result, err := m.proxy.GetOrFetchArtifact(ctx, pv.Ecosystem, pv.Name, pv.Version, "")
274283
if err != nil {
275284
m.recordFailure(tracker, pv, err)

‎internal/mirror/source.go‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,11 @@ func mirrorTarget(p *purl.PURL) (ecosystem, name string, qualifiers map[string]s
148148
name = p.Namespace + "/" + p.Name
149149
}
150150
qualifiers = fileQualifiers(ecosystem, p.Qualifiers.Map())
151-
if ecosystem == "julia" {
151+
switch ecosystem {
152+
case "julia":
152153
qualifiers = map[string]string{"uuid": p.Qualifier("uuid")}
154+
case "alpine":
155+
qualifiers = map[string]string{"arch": p.Qualifier("arch"), "distro": p.Qualifier("distro")}
153156
}
154157
return ecosystem, name, qualifiers
155158
}

0 commit comments

Comments
 (0)