Support injecting container images for development

This adds the LoadImage RPC and an accompanying subcommand
to the debug API which allows loading images into
an existing Metropolis node for
development or testing.

Change-Id: I51d802630ae4c95fb874e01bfb6510ab69c322e1
Reviewed-on: https://review.monogon.dev/c/monogon/+/219
Reviewed-by: Sergiusz Bazanski <serge@monogon.tech>
diff --git a/metropolis/cli/dbg/main.go b/metropolis/cli/dbg/main.go
index 557a9f2..84df60d 100644
--- a/metropolis/cli/dbg/main.go
+++ b/metropolis/cli/dbg/main.go
@@ -78,6 +78,14 @@
 	functionFilter := traceCmd.String("function_filter", "", "Only trace functions matched by this filter (comma-separated, supports wildcards via *)")
 	functionGraphFilter := traceCmd.String("function_graph_filter", "", "Only trace functions matched by this filter and their children (syntax same as function_filter)")
 
+	loadimageCmd := flag.NewFlagSet("loadimage", flag.ExitOnError)
+	loadimageCmd.Usage = func() {
+		fmt.Fprintf(os.Stderr, "Usage: %v %v [options] image\n", os.Args[0], os.Args[1])
+		flag.PrintDefaults()
+
+		fmt.Fprintf(os.Stderr, "Example: %v %v [options] helloworld_oci.tar.gz\n", os.Args[0], os.Args[1])
+	}
+
 	switch os.Args[1] {
 	case "logs":
 		logsCmd.Parse(os.Args[2:])
@@ -202,5 +210,39 @@
 			}
 			fmt.Println(traceEvent.RawLine)
 		}
+	case "loadimage":
+		loadimageCmd.Parse(os.Args[2:])
+		imagePath := loadimageCmd.Arg(0)
+		image, err := os.Open(imagePath)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "failed to open image file: %v\n", err)
+			os.Exit(1)
+		}
+		defer image.Close()
+
+		loadStream, err := debugClient.LoadImage(ctx)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "failed to start loading image: %v\n", err)
+			os.Exit(1)
+		}
+		buf := make([]byte, 64*1024)
+		for {
+			n, err := image.Read(buf)
+			if err != io.EOF && err != nil {
+				fmt.Fprintf(os.Stderr, "failed to read image: %v\n", err)
+				os.Exit(1)
+			}
+			if err == io.EOF && n == 0 {
+				break
+			}
+			if err := loadStream.Send(&apb.ImagePart{DataPart: buf[:n]}); err != nil {
+				fmt.Fprintf(os.Stderr, "failed to send image part: %v\n", err)
+				os.Exit(1)
+			}
+		}
+		if _, err := loadStream.CloseAndRecv(); err != nil {
+			fmt.Fprintf(os.Stderr, "image failed to load: %v\n", err)
+		}
+		fmt.Fprintf(os.Stderr, "Image loaded into Metropolis node\n")
 	}
 }
diff --git a/metropolis/node/core/BUILD.bazel b/metropolis/node/core/BUILD.bazel
index cb82fdd..e0d6d87 100644
--- a/metropolis/node/core/BUILD.bazel
+++ b/metropolis/node/core/BUILD.bazel
@@ -26,6 +26,8 @@
         "//metropolis/pkg/supervisor:go_default_library",
         "//metropolis/pkg/tpm:go_default_library",
         "//metropolis/proto/api:go_default_library",
+        "@com_github_containerd_containerd//:go_default_library",
+        "@com_github_containerd_containerd//namespaces:go_default_library",
         "@org_golang_google_grpc//:go_default_library",
         "@org_golang_google_grpc//codes:go_default_library",
         "@org_golang_google_grpc//status:go_default_library",
diff --git a/metropolis/node/core/debug_service.go b/metropolis/node/core/debug_service.go
index 30f7ac7..123d1ab 100644
--- a/metropolis/node/core/debug_service.go
+++ b/metropolis/node/core/debug_service.go
@@ -25,10 +25,13 @@
 	"regexp"
 	"strings"
 
+	ctr "github.com/containerd/containerd"
+	"github.com/containerd/containerd/namespaces"
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/status"
 
 	"source.monogon.dev/metropolis/node/core/roleserve"
+	"source.monogon.dev/metropolis/node/core/localstorage"
 	"source.monogon.dev/metropolis/pkg/logtree"
 	apb "source.monogon.dev/metropolis/proto/api"
 )
@@ -41,6 +44,8 @@
 type debugService struct {
 	roleserve *roleserve.Service
 	logtree   *logtree.LogTree
+	ephemeralVolume *localstorage.EphemeralContainerdDirectory
+
 	// traceLock provides exclusive access to the Linux tracing infrastructure
 	// (ftrace)
 	// This is a channel because Go's mutexes can't be cancelled or be acquired
@@ -272,3 +277,40 @@
 	}
 	return nil
 }
+
+// imageReader is an adapter converting a gRPC stream into an io.Reader
+type imageReader struct {
+	srv        apb.NodeDebugService_LoadImageServer
+	restOfPart []byte
+}
+
+func (i *imageReader) Read(p []byte) (n int, err error) {
+	n1 := copy(p, i.restOfPart)
+	if len(p) > len(i.restOfPart) {
+		part, err := i.srv.Recv()
+		if err != nil {
+			return n1, err
+		}
+		n2 := copy(p[n1:], part.DataPart)
+		i.restOfPart = part.DataPart[n2:]
+		return n1 + n2, nil
+	} else {
+		i.restOfPart = i.restOfPart[n1:]
+		return n1, nil
+	}
+}
+
+// LoadImage loads an OCI image into the image cache of this node
+func (s *debugService) LoadImage(srv apb.NodeDebugService_LoadImageServer) error {
+	client, err := ctr.New(s.ephemeralVolume.ClientSocket.FullPath())
+	if err != nil {
+		return status.Errorf(codes.Unavailable, "failed to connect to containerd: %v", err)
+	}
+	ctxWithNS := namespaces.WithNamespace(srv.Context(), "k8s.io")
+	reader := &imageReader{srv: srv}
+	_, err = client.Import(ctxWithNS, reader)
+	if err != nil {
+		return status.Errorf(codes.Unknown, "failed to import image: %v", err)
+	}
+	return srv.SendAndClose(&apb.LoadImageResponse{})
+}
diff --git a/metropolis/node/core/main.go b/metropolis/node/core/main.go
index 566e65d..e15cd6c 100644
--- a/metropolis/node/core/main.go
+++ b/metropolis/node/core/main.go
@@ -217,6 +217,7 @@
 			roleserve: rs,
 			logtree:   lt,
 			traceLock: make(chan struct{}, 1),
+			ephemeralVolume: &root.Ephemeral.Containerd,
 		}
 		dbgSrv := grpc.NewServer()
 		apb.RegisterNodeDebugServiceServer(dbgSrv, dbg)
diff --git a/metropolis/proto/api/debug.proto b/metropolis/proto/api/debug.proto
index 6cbe32b..eabc766 100644
--- a/metropolis/proto/api/debug.proto
+++ b/metropolis/proto/api/debug.proto
@@ -42,8 +42,19 @@
 
     // Trace enables tracing of Metropolis using the Linux ftrace infrastructure.
     rpc Trace(TraceRequest) returns (stream TraceEvent);
+
+    // LoadImage loads an uncompressed tarball containing a Docker v1.1, v1.2 or OCI v1 image into the local
+    // containerd image store. The client streams the tarball in arbitrary-sized chunks and closes the sending side
+    // once it has sent the entire image. The server then either returns an empty response if successful or a gRPC error.
+    rpc LoadImage(stream ImagePart) returns (LoadImageResponse);
 }
 
+message ImagePart {
+    bytes data_part = 1;
+}
+
+message LoadImageResponse {
+}
 
 message GetDebugKubeconfigRequest {
     string id = 1; // Kubernetes identity (user)