Skip to content

Commit b8471fb

Browse files
committed
feat(grpc): LoadImage RPC -- load OCI images via gRPC
1 parent 109fe9f commit b8471fb

4 files changed

Lines changed: 120 additions & 0 deletions

File tree

lib/hyper/grpc/codec.ex

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ defmodule Hyper.Grpc.Codec do
1515
CreateVmResponse,
1616
GetVmResponse,
1717
ListVmsResponse,
18+
LoadImageRequest,
19+
LoadImageResponse,
1820
Vm
1921
}
2022

@@ -72,6 +74,16 @@ defmodule Hyper.Grpc.Codec do
7274
end
7375
end
7476

77+
@spec from_grpc(LoadImageRequest.t()) ::
78+
{:ok, {String.t(), keyword()}} | {:error, :missing_image_ref}
79+
def from_grpc(%LoadImageRequest{image_ref: ref}) when ref in [nil, ""],
80+
do: {:error, :missing_image_ref}
81+
82+
def from_grpc(%LoadImageRequest{image_ref: ref, label: label}) do
83+
opts = if label in [nil, ""], do: [], else: [label: label]
84+
{:ok, {ref, opts}}
85+
end
86+
7587
@doc "Convert a domain result to an outbound response message, or an error to `GRPC.RPCError`."
7688
@spec to_grpc({:created, Hyper.Vm.id(), node()}) :: CreateVmResponse.t()
7789
def to_grpc({:created, vm_id, node}) when is_binary(vm_id),
@@ -85,6 +97,10 @@ defmodule Hyper.Grpc.Codec do
8597
def to_grpc({:vms, vms}),
8698
do: %ListVmsResponse{vms: Enum.map(vms, &vm/1)}
8799

100+
@spec to_grpc({:loaded, Hyper.Img.id()}) :: LoadImageResponse.t()
101+
def to_grpc({:loaded, img_id}) when is_binary(img_id),
102+
do: %LoadImageResponse{img_id: img_id}
103+
88104
@spec to_grpc(:stopped) :: Empty.t()
89105
def to_grpc(:stopped), do: %Empty{}
90106

@@ -124,6 +140,19 @@ defmodule Hyper.Grpc.Codec do
124140
defp rpc_error(reason) when reason in [:no_capacity, :exhausted],
125141
do: GRPC.RPCError.exception(:resource_exhausted, "no capacity")
126142

143+
defp rpc_error(:missing_image_ref),
144+
do: GRPC.RPCError.exception(:invalid_argument, "image_ref is required")
145+
146+
defp rpc_error(:invalid_ref),
147+
do: GRPC.RPCError.exception(:invalid_argument, "image_ref is malformed")
148+
149+
defp rpc_error({:missing_tools, tools}),
150+
do:
151+
GRPC.RPCError.exception(
152+
:failed_precondition,
153+
"node is missing required image tools: #{Enum.join(tools, ", ")}"
154+
)
155+
127156
defp rpc_error(reason),
128157
do: GRPC.RPCError.exception(:internal, "internal error: #{inspect(reason)}")
129158
end

lib/hyper/grpc/server.ex

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,21 @@ defmodule Hyper.Grpc.Server do
1717
GetVmRequest,
1818
GetVmResponse,
1919
ListVmsResponse,
20+
LoadImageRequest,
21+
LoadImageResponse,
2022
StopVmRequest
2123
}
2224

25+
@spec load_image(LoadImageRequest.t(), GRPC.Server.Stream.t()) :: LoadImageResponse.t()
26+
def load_image(%LoadImageRequest{} = req, _stream) do
27+
with {:ok, {ref, opts}} <- Codec.from_grpc(req),
28+
{:ok, img_id} <- Hyper.Img.OciLoader.load(ref, opts) do
29+
Codec.to_grpc({:loaded, img_id})
30+
else
31+
{:error, reason} -> raise Codec.to_grpc({:error, reason})
32+
end
33+
end
34+
2335
@spec create_vm(CreateVmRequest.t(), GRPC.Server.Stream.t()) :: CreateVmResponse.t()
2436
def create_vm(%CreateVmRequest{} = req, _stream) do
2537
with {:ok, spec} <- Codec.from_grpc(req),

proto/hyper/grpc/v0/hyper.proto

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,20 @@ service Hyper {
5252

5353
// List every microVM currently known to the cluster, across all nodes.
5454
rpc ListVms(google.protobuf.Empty) returns (ListVmsResponse);
55+
56+
// Load an OCI image into the cluster's shared media store and image database.
57+
//
58+
// Pulls the referenced image for this node's architecture, flattens it, builds
59+
// an ext4 rootfs, content-addresses it, and records a base image. Runs on the
60+
// node that receives the call; the result is visible cluster-wide. Blocks until
61+
// the load completes -- this can take minutes -- so set a generous deadline.
62+
// Returns the content-addressed `img_id` to pass to CreateVm.
63+
//
64+
// Errors:
65+
// INVALID_ARGUMENT -- `image_ref` is empty or malformed.
66+
// FAILED_PRECONDITION -- the node lacks the required tools (skopeo/umoci/mke2fs).
67+
// INTERNAL -- the pull, unpack, build, publish, or DB record failed.
68+
rpc LoadImage(LoadImageRequest) returns (LoadImageResponse);
5569
}
5670

5771
// A fixed (vCPU, memory, disk) size, like a cloud instance class. Each step up
@@ -142,3 +156,18 @@ message Vm {
142156
// The cluster node (Erlang node name) the VM runs on.
143157
string node = 2;
144158
}
159+
160+
// Request to load an OCI image.
161+
message LoadImageRequest {
162+
// Required. An OCI image reference, e.g. "docker.io/library/alpine:3.19".
163+
string image_ref = 1;
164+
165+
// Optional. A human-readable label stored with the image (defaults to image_ref).
166+
optional string label = 2;
167+
}
168+
169+
// Result of a successful LoadImage.
170+
message LoadImageResponse {
171+
// The content-addressed image id; pass it to CreateVm.
172+
string img_id = 1;
173+
}

test/hyper/grpc/codec_test.exs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
defmodule Hyper.Grpc.CodecTest do
2+
use ExUnit.Case, async: true
3+
4+
alias Hyper.Grpc.Codec
5+
alias Hyper.Grpc.V0.{LoadImageRequest, LoadImageResponse}
6+
7+
describe "from_grpc/1 (LoadImageRequest)" do
8+
test "rejects a blank image_ref" do
9+
assert Codec.from_grpc(%LoadImageRequest{image_ref: ""}) == {:error, :missing_image_ref}
10+
assert Codec.from_grpc(%LoadImageRequest{image_ref: nil}) == {:error, :missing_image_ref}
11+
end
12+
13+
test "passes the ref through with no opts when label is unset" do
14+
assert Codec.from_grpc(%LoadImageRequest{image_ref: "alpine:3.19"}) ==
15+
{:ok, {"alpine:3.19", []}}
16+
17+
assert Codec.from_grpc(%LoadImageRequest{image_ref: "alpine:3.19", label: ""}) ==
18+
{:ok, {"alpine:3.19", []}}
19+
end
20+
21+
test "carries label as an opt when set" do
22+
assert Codec.from_grpc(%LoadImageRequest{image_ref: "alpine:3.19", label: "base"}) ==
23+
{:ok, {"alpine:3.19", [label: "base"]}}
24+
end
25+
end
26+
27+
describe "to_grpc/1 (loaded)" do
28+
test "wraps the img_id in a LoadImageResponse" do
29+
assert Codec.to_grpc({:loaded, "oci-abc"}) == %LoadImageResponse{img_id: "oci-abc"}
30+
end
31+
end
32+
33+
describe "to_grpc/1 (LoadImage error mapping)" do
34+
test "missing/invalid ref -> INVALID_ARGUMENT" do
35+
assert %GRPC.RPCError{status: status} = Codec.to_grpc({:error, :missing_image_ref})
36+
assert status == GRPC.Status.invalid_argument()
37+
assert Codec.to_grpc({:error, :invalid_ref}).status == GRPC.Status.invalid_argument()
38+
end
39+
40+
test "missing tools -> FAILED_PRECONDITION" do
41+
err = Codec.to_grpc({:error, {:missing_tools, ["skopeo", "umoci"]}})
42+
assert err.status == GRPC.Status.failed_precondition()
43+
end
44+
45+
test "tool/build/db failures -> INTERNAL" do
46+
assert Codec.to_grpc({:error, {:skopeo_failed, 1, "boom"}}).status == GRPC.Status.internal()
47+
assert Codec.to_grpc({:error, {:record_failed, :blob, :db}}).status == GRPC.Status.internal()
48+
end
49+
end
50+
end

0 commit comments

Comments
 (0)