Files

166 lines
5.6 KiB
Go

package registrygate
import (
"encoding/json"
"errors"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
// IndexPathPrefix serves the manifest index of one repository:
//
// GET /felis/manifests/<repo> {"revisions":[{"digest":…,"pushed":…,"children":[…]}],"tags":{"<tag>":"<digest>"}}
//
// The registry API can list a repository's tags but not its manifests, so a
// manifest a tag moved off (every rebuild of :demo or :latest leaves one) is
// invisible to it, while it still holds every layer it names. felis-api's pruner
// needs the full set to decide which to delete; the gate reads it off the data
// volume it mounts read-only, from the filesystem driver's layout that registry
// 2.x and 3.x share. "pushed" is the modification time of the revision link, which
// the registry rewrites on every push of that manifest.
//
// Anonymous like every read: a digest list says no more than tags/list and
// _catalog already do.
const IndexPathPrefix = "/felis/manifests/"
// Index is the manifest index of one repository.
type Index struct {
Revisions []Revision `json:"revisions"`
Tags map[string]string `json:"tags"`
}
// Revision is one manifest stored in a repository.
type Revision struct {
Digest string `json:"digest"`
Pushed time.Time `json:"pushed"`
// Children lists the manifests an image index (or Docker manifest list) names:
// one per platform, plus BuildKit's attestation manifests. Each is a revision
// of its own, untagged and never spelled in an image ref, yet a pull of the
// index fetches them, so whoever keeps the index must keep them too. Empty for
// a single-platform manifest.
Children []string `json:"children,omitempty"`
}
// maxManifestBytes caps how much of a revision's blob is read to find its
// children. It is the registry's own limit on a pushed manifest.
const maxManifestBytes = 4 << 20
// manifestChildren reads a manifest blob off the filesystem driver's layout and
// returns the digests it names when it is an index. A blob that is missing,
// oversized or unparsable names nothing: a pull of it fails already, and the
// pruner then treats it like any other revision.
func manifestChildren(root, hex string) []string {
f, err := os.Open(filepath.Join(root, "docker", "registry", "v2", "blobs", "sha256", hex[:2], hex, "data"))
if err != nil {
return nil
}
defer f.Close()
// The manifests array is what makes an index, whatever mediaType says (an OCI
// index may omit it); a single-platform manifest has layers and no manifests.
var m struct {
Manifests []struct {
Digest string `json:"digest"`
} `json:"manifests"`
}
if err := json.NewDecoder(io.LimitReader(f, maxManifestBytes)).Decode(&m); err != nil {
return nil
}
var out []string
for _, c := range m.Manifests {
if digestRE.MatchString(c.Digest) {
out = append(out, c.Digest)
}
}
return out
}
// repoNameRE is the distribution reference grammar for a repository path. Every
// component starts and ends alphanumeric, so no match can hold "." or "..".
var repoNameRE = regexp.MustCompile(`^[a-z0-9]+(?:(?:[._]|__|[-]*)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]*)[a-z0-9]+)*)*$`)
var hexDigestRE = regexp.MustCompile(`^[0-9a-f]{64}$`)
func (g *Gate) serveIndex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "UNSUPPORTED", "method not allowed")
return
}
if g.DataDir == "" {
writeError(w, http.StatusNotFound, "UNSUPPORTED", "this gate does not mount the registry data")
return
}
repo := strings.TrimPrefix(r.URL.Path, IndexPathPrefix)
if r.URL.RawPath != "" || !repoNameRE.MatchString(repo) {
writeError(w, http.StatusBadRequest, "NAME_INVALID", "invalid repository name")
return
}
idx, err := ReadIndex(g.DataDir, repo)
if err != nil {
writeError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(idx)
}
// ReadIndex reads repo's manifests and tags from a registry filesystem root. A
// repository that does not exist has an empty index.
func ReadIndex(root, repo string) (*Index, error) {
if !repoNameRE.MatchString(repo) {
return nil, errors.New("invalid repository name")
}
base := filepath.Join(root, "docker", "registry", "v2", "repositories", filepath.FromSlash(repo), "_manifests")
idx := &Index{Revisions: []Revision{}, Tags: map[string]string{}}
revDir := filepath.Join(base, "revisions", "sha256")
entries, err := os.ReadDir(revDir)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, err
}
for _, e := range entries {
if !e.IsDir() || !hexDigestRE.MatchString(e.Name()) {
continue
}
// A deleted manifest keeps its directory and loses its link.
st, err := os.Stat(filepath.Join(revDir, e.Name(), "link"))
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return nil, err
}
idx.Revisions = append(idx.Revisions, Revision{Digest: "sha256:" + e.Name(), Pushed: st.ModTime().UTC(),
Children: manifestChildren(root, e.Name())})
}
sort.Slice(idx.Revisions, func(i, j int) bool { return idx.Revisions[i].Digest < idx.Revisions[j].Digest })
tagDir := filepath.Join(base, "tags")
tags, err := os.ReadDir(tagDir)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, err
}
for _, e := range tags {
if !e.IsDir() {
continue
}
b, err := os.ReadFile(filepath.Join(tagDir, e.Name(), "current", "link"))
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return nil, err
}
if d := strings.TrimSpace(string(b)); digestRE.MatchString(d) {
idx.Tags[e.Name()] = d
}
}
return idx, nil
}