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/BUILD.bazel b/metropolis/pkg/localregistry/BUILD.bazel
new file mode 100644
index 0000000..636bc57
--- /dev/null
+++ b/metropolis/pkg/localregistry/BUILD.bazel
@@ -0,0 +1,16 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "localregistry",
+    srcs = ["localregistry.go"],
+    importpath = "source.monogon.dev/metropolis/pkg/localregistry",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//metropolis/pkg/localregistry/spec",
+        "@com_github_docker_distribution//:distribution",
+        "@com_github_docker_distribution//manifest/schema2",
+        "@com_github_docker_distribution//reference",
+        "@com_github_opencontainers_go_digest//:go-digest",
+        "@org_golang_google_protobuf//encoding/prototext",
+    ],
+)
diff --git a/metropolis/pkg/localregistry/def.bzl b/metropolis/pkg/localregistry/def.bzl
new file mode 100644
index 0000000..061a63d
--- /dev/null
+++ b/metropolis/pkg/localregistry/def.bzl
@@ -0,0 +1,44 @@
+load("@io_bazel_rules_docker//container:providers.bzl", "ImageInfo")
+
+def _localregistry_manifest_impl(ctx):
+    manifest_out = ctx.actions.declare_file(ctx.label.name+".prototxt")
+    
+    images = []
+    referenced = [manifest_out]
+    for i in ctx.attr.images:
+        image_info = i[ImageInfo].container_parts
+        referenced.append(image_info['config'])
+        referenced.append(image_info['config_digest'])
+        image = struct(
+            name = i.label.package + "/" + i.label.name,
+            config = struct(
+                file_path = image_info['config'].short_path,
+                digest_path = image_info['config_digest'].short_path,
+            ),
+            layers = [],
+        )
+        for layer in zip(image_info['zipped_layer'], image_info['blobsum']):
+            referenced.append(layer[0])
+            referenced.append(layer[1])
+            image.layers.append(struct(file_path = layer[0].short_path, digest_path=layer[1].short_path))
+        images.append(image)
+    
+    ctx.actions.write(manifest_out, proto.encode_text(struct(images = images)))
+    return [DefaultInfo(runfiles = ctx.runfiles(files = referenced), files = depset([manifest_out]))]
+
+
+localregistry_manifest = rule(
+    implementation = _localregistry_manifest_impl,
+    doc = """
+        Builds a manifest for serving images directly from the build files.
+    """,
+    attrs = {
+        "images": attr.label_list(
+            mandatory = True,
+            doc = """
+                List of images (with ImageInfo provider) to be served from the local registry.
+            """,
+           providers = [ImageInfo],
+        ),
+    },
+)
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)
+	}
+}
diff --git a/metropolis/pkg/localregistry/spec/BUILD.bazel b/metropolis/pkg/localregistry/spec/BUILD.bazel
new file mode 100644
index 0000000..3d6b734
--- /dev/null
+++ b/metropolis/pkg/localregistry/spec/BUILD.bazel
@@ -0,0 +1,23 @@
+load("@rules_proto//proto:defs.bzl", "proto_library")
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
+
+proto_library(
+    name = "spec_proto",
+    srcs = ["manifest.proto"],
+    visibility = ["//visibility:public"],
+)
+
+go_proto_library(
+    name = "spec_go_proto",
+    importpath = "source.monogon.dev/metropolis/pkg/localregistry/spec",
+    proto = ":spec_proto",
+    visibility = ["//visibility:public"],
+)
+
+go_library(
+    name = "spec",
+    embed = [":spec_go_proto"],
+    importpath = "source.monogon.dev/metropolis/pkg/localregistry/spec",
+    visibility = ["//visibility:public"],
+)
diff --git a/metropolis/pkg/localregistry/spec/gomod-generated-placeholder.go b/metropolis/pkg/localregistry/spec/gomod-generated-placeholder.go
new file mode 100644
index 0000000..f09cd57
--- /dev/null
+++ b/metropolis/pkg/localregistry/spec/gomod-generated-placeholder.go
@@ -0,0 +1 @@
+package spec
diff --git a/metropolis/pkg/localregistry/spec/manifest.proto b/metropolis/pkg/localregistry/spec/manifest.proto
new file mode 100644
index 0000000..b4daeab
--- /dev/null
+++ b/metropolis/pkg/localregistry/spec/manifest.proto
@@ -0,0 +1,30 @@
+syntax = "proto3";
+
+package monogon.metropolis.pkg.localregistry;
+
+option go_package = "source.monogon.dev/metropolis/pkg/localregistry/spec";
+
+// BlobDescriptor is metadata for a single registry blob. Analogous to a
+// distribution.Descriptor in Go.
+message BlobDescriptor {
+    // Path to the file in the build directory.
+    string file_path = 1;
+    // Path to a file containing the SHA256 digest of the blob.
+    string digest_path = 2;
+}
+
+// Single image metadata
+message Image {
+    // Name of the image (no domain or tag, just slash-separated path)
+    string name = 1;
+    // Config blob
+    BlobDescriptor config = 2;
+    // Layer blobs in order
+    repeated BlobDescriptor layers = 3;
+}
+
+// Main message
+message Manifest {
+    // List of images for the local registry
+    repeated Image images = 1;
+}
\ No newline at end of file