m/p/localregistry: init
Adds the localregistry package, which serves Bazel-built container
images via the Docker/OCI V2 registry API.
This will be used to serve test images instead of preseeding them.
Change-Id: I0c2ceb9a83f807c9c87ab03bc1141ca67cc64268
Reviewed-on: https://review.monogon.dev/c/monogon/+/1926
Tested-by: Jenkins CI
Reviewed-by: Leopold Schabel <leo@monogon.tech>
diff --git a/metropolis/pkg/localregistry/localregistry.go b/metropolis/pkg/localregistry/localregistry.go
new file mode 100644
index 0000000..e40fb1b
--- /dev/null
+++ b/metropolis/pkg/localregistry/localregistry.go
@@ -0,0 +1,131 @@
+// Package localregistry implements a read-only OCI Distribution / Docker
+// V2 container image registry backed by local layers.
+package localregistry
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "regexp"
+ "strconv"
+
+ "github.com/docker/distribution"
+ "github.com/docker/distribution/manifest/schema2"
+ "github.com/docker/distribution/reference"
+ "github.com/opencontainers/go-digest"
+ "google.golang.org/protobuf/encoding/prototext"
+
+ "source.monogon.dev/metropolis/pkg/localregistry/spec"
+)
+
+type Server struct {
+ manifests map[string][]byte
+ blobs map[digest.Digest]blobMeta
+}
+
+type blobMeta struct {
+ filePath string
+ mediaType string
+ contentLength int64
+}
+
+func blobFromBazel(s *Server, bd *spec.BlobDescriptor, mediaType string) (distribution.Descriptor, error) {
+ digestRaw, err := os.ReadFile(bd.DigestPath)
+ if err != nil {
+ return distribution.Descriptor{}, fmt.Errorf("while opening digest file: %w", err)
+ }
+ stat, err := os.Stat(bd.FilePath)
+ if err != nil {
+ return distribution.Descriptor{}, fmt.Errorf("while stat'ing blob file: %w", err)
+ }
+ digest := digest.Digest("sha256:" + string(digestRaw))
+ s.blobs[digest] = blobMeta{filePath: bd.FilePath, mediaType: mediaType, contentLength: stat.Size()}
+ return distribution.Descriptor{
+ MediaType: mediaType,
+ Size: stat.Size(),
+ Digest: digest,
+ }, nil
+}
+
+func FromBazelManifest(m []byte) (*Server, error) {
+ var manifest spec.Manifest
+ if err := prototext.Unmarshal(m, &manifest); err != nil {
+ log.Fatalf("failed to parse manifest: %v", err)
+ }
+ s := Server{
+ manifests: make(map[string][]byte),
+ blobs: make(map[digest.Digest]blobMeta),
+ }
+ for _, i := range manifest.Images {
+ imageManifest := schema2.Manifest{
+ Versioned: schema2.SchemaVersion,
+ }
+ var err error
+ imageManifest.Config, err = blobFromBazel(&s, i.Config, schema2.MediaTypeImageConfig)
+ if err != nil {
+ return nil, fmt.Errorf("while creating blob spec for %q: %w", i.Name, err)
+ }
+ for _, l := range i.Layers {
+ ml, err := blobFromBazel(&s, l, schema2.MediaTypeLayer)
+ if err != nil {
+ return nil, fmt.Errorf("while creating blob spec for %q: %w", i.Name, err)
+ }
+ imageManifest.Layers = append(imageManifest.Layers, ml)
+ }
+ s.manifests[i.Name], err = json.Marshal(imageManifest)
+ if err != nil {
+ return nil, fmt.Errorf("while marshaling image %q manifest: %w", i.Name, err)
+ }
+ // For Digest lookups
+ s.manifests[string(digest.Canonical.FromBytes(s.manifests[i.Name]))] = s.manifests[i.Name]
+ }
+ return &s, nil
+}
+
+var (
+ versionCheckEp = regexp.MustCompile(`^/v2/$`)
+ manifestEp = regexp.MustCompile("^/v2/(" + reference.NameRegexp.String() + ")/manifests/(" + reference.TagRegexp.String() + "|" + digest.DigestRegexp.String() + ")$")
+ blobEp = regexp.MustCompile("^/v2/(" + reference.NameRegexp.String() + ")/blobs/(" + digest.DigestRegexp.String() + ")$")
+)
+
+func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+ if req.Method != http.MethodGet && req.Method != http.MethodHead {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ fmt.Fprintf(w, "Registry is read-only, only GET and HEAD are allowed\n")
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ if versionCheckEp.MatchString(req.URL.Path) {
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, "{}")
+ return
+ } else if matches := manifestEp.FindStringSubmatch(req.URL.Path); len(matches) > 0 {
+ name := matches[1]
+ manifest, ok := s.manifests[name]
+ if !ok {
+ w.WriteHeader(http.StatusNotFound)
+ fmt.Fprintf(w, "Image not found")
+ return
+ }
+ w.Header().Set("Content-Type", schema2.MediaTypeManifest)
+ w.Header().Set("Content-Length", strconv.FormatInt(int64(len(manifest)), 10))
+ w.WriteHeader(http.StatusOK)
+ io.Copy(w, bytes.NewReader(manifest))
+ } else if matches := blobEp.FindStringSubmatch(req.URL.Path); len(matches) > 0 {
+ bm, ok := s.blobs[digest.Digest(matches[2])]
+ if !ok {
+ w.WriteHeader(http.StatusNotFound)
+ fmt.Fprintf(w, "Blob not found")
+ return
+ }
+ w.Header().Set("Content-Type", bm.mediaType)
+ w.Header().Set("Content-Length", strconv.FormatInt(bm.contentLength, 10))
+ http.ServeFile(w, req, bm.filePath)
+ } else {
+ w.WriteHeader(http.StatusNotFound)
+ }
+}