From 4dac0f09e430444a4beb8ee459c3ac0e28a94b51 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 11 Jul 2026 23:16:26 +0000 Subject: [PATCH 01/14] feat(grpc): replace google.protobuf.Empty with dedicated messages StopVm now returns StopVmResponse; ListVms takes ListVmsRequest. Empty can never grow a field; purpose-built messages keep both RPCs extensible. --- lib/hyper/grpc/codec.ex | 7 +++---- lib/hyper/grpc/server.ex | 11 ++++++----- proto/hyper/grpc/v0/hyper.proto | 14 ++++++++++---- test/hyper/grpc/codec_test.exs | 5 +++++ 4 files changed, 24 insertions(+), 13 deletions(-) diff --git a/lib/hyper/grpc/codec.ex b/lib/hyper/grpc/codec.ex index 65f7dd8c..8a0447b9 100644 --- a/lib/hyper/grpc/codec.ex +++ b/lib/hyper/grpc/codec.ex @@ -8,8 +8,6 @@ defmodule Hyper.Grpc.Codec do or a `GRPC.RPCError` for the server to raise. """ - alias Google.Protobuf.Empty - alias Hyper.Grpc.V0.{ CreateVmRequest, CreateVmResponse, @@ -19,6 +17,7 @@ defmodule Hyper.Grpc.Codec do ListVmsResponse, LoadImageRequest, LoadImageResponse, + StopVmResponse, Vm } @@ -96,8 +95,8 @@ defmodule Hyper.Grpc.Codec do def to_grpc({:loaded, img_id}) when is_binary(img_id), do: %LoadImageResponse{img_id: img_id} - @spec to_grpc(:stopped) :: Empty.t() - def to_grpc(:stopped), do: %Empty{} + @spec to_grpc(:stopped) :: StopVmResponse.t() + def to_grpc(:stopped), do: %StopVmResponse{} @spec to_grpc({:error, term()}) :: GRPC.RPCError.t() def to_grpc({:error, reason}), do: rpc_error(reason) diff --git a/lib/hyper/grpc/server.ex b/lib/hyper/grpc/server.ex index 00ad7c87..76dbf525 100644 --- a/lib/hyper/grpc/server.ex +++ b/lib/hyper/grpc/server.ex @@ -9,7 +9,6 @@ defmodule Hyper.Grpc.Server do use GRPC.Server, service: Hyper.Grpc.V0.Hyper.Service use OpenTelemetryDecorator - alias Google.Protobuf.Empty alias Hyper.Grpc.Codec alias Hyper.Grpc.V0.{ @@ -21,10 +20,12 @@ defmodule Hyper.Grpc.Server do GetVmResponse, GetVmUsageRequest, GetVmUsageResponse, + ListVmsRequest, ListVmsResponse, LoadImageRequest, LoadImageResponse, - StopVmRequest + StopVmRequest, + StopVmResponse } @spec load_image(LoadImageRequest.t(), GRPC.Server.Stream.t()) :: LoadImageResponse.t() @@ -66,7 +67,7 @@ defmodule Hyper.Grpc.Server do end end - @spec stop_vm(StopVmRequest.t(), GRPC.Server.Stream.t()) :: Empty.t() + @spec stop_vm(StopVmRequest.t(), GRPC.Server.Stream.t()) :: StopVmResponse.t() @decorate with_span("Hyper.Grpc.Server.stop_vm", include: [:vm_id]) def stop_vm(%StopVmRequest{vm_id: vm_id}, _stream) do case Hyper.stop_vm(vm_id) do @@ -93,9 +94,9 @@ defmodule Hyper.Grpc.Server do end end - @spec list_vms(Empty.t(), GRPC.Server.Stream.t()) :: ListVmsResponse.t() + @spec list_vms(ListVmsRequest.t(), GRPC.Server.Stream.t()) :: ListVmsResponse.t() @decorate with_span("Hyper.Grpc.Server.list_vms") - def list_vms(%Empty{}, _stream) do + def list_vms(%ListVmsRequest{}, _stream) do Codec.to_grpc({:vms, Hyper.Cluster.Routing.all()}) end end diff --git a/proto/hyper/grpc/v0/hyper.proto b/proto/hyper/grpc/v0/hyper.proto index a9ca98f6..a9c0e6e9 100644 --- a/proto/hyper/grpc/v0/hyper.proto +++ b/proto/hyper/grpc/v0/hyper.proto @@ -14,8 +14,6 @@ syntax = "proto3"; package hyper.grpc.v0; -import "google/protobuf/empty.proto"; - // The VM lifecycle service. // // Errors are returned as standard gRPC statuses (see each RPC). Beyond the ones @@ -54,7 +52,7 @@ service Hyper { // Errors: // NOT_FOUND -- no VM with this `vm_id` is running in the cluster. // UNAVAILABLE -- the VM's host node is unreachable. - rpc StopVm(StopVmRequest) returns (google.protobuf.Empty); + rpc StopVm(StopVmRequest) returns (StopVmResponse); // Locate a microVM: report the cluster node it currently runs on. // @@ -72,7 +70,7 @@ service Hyper { rpc GetVmUsage(GetVmUsageRequest) returns (GetVmUsageResponse); // List every microVM currently known to the cluster, across all nodes. - rpc ListVms(google.protobuf.Empty) returns (ListVmsResponse); + rpc ListVms(ListVmsRequest) returns (ListVmsResponse); // Load an OCI image into the cluster's shared media store and image database. // @@ -163,6 +161,11 @@ message StopVmRequest { string vm_id = 1; } +// Result of StopVm. Empty today; a dedicated message (never google.protobuf.Empty) +// so a field -- e.g. the VM's final metered usage -- can be added later without a +// breaking change. +message StopVmResponse {} + // Request to locate a VM. message GetVmRequest { // The id of the VM to look up. @@ -193,6 +196,9 @@ message GetVmUsageResponse { uint64 cpu_usec = 2; } +// Request to list VMs. +message ListVmsRequest {} + // Result of ListVms. message ListVmsResponse { // Every VM known to the cluster, in no particular order. diff --git a/test/hyper/grpc/codec_test.exs b/test/hyper/grpc/codec_test.exs index f15f09bb..3e2fdd78 100644 --- a/test/hyper/grpc/codec_test.exs +++ b/test/hyper/grpc/codec_test.exs @@ -4,6 +4,7 @@ defmodule Hyper.Grpc.CodecTest do alias Hyper.Grpc.Codec alias Hyper.Grpc.V0.CreateVmRequest alias Hyper.Grpc.V0.GetVmUsageResponse + alias Hyper.Grpc.V0.StopVmResponse test "usage encodes as microseconds on the wire" do assert %GetVmUsageResponse{vm_id: "v1", cpu_usec: 1_500_000} = @@ -37,4 +38,8 @@ defmodule Hyper.Grpc.CodecTest do assert %GRPC.RPCError{status: 3} = Codec.to_grpc({:error, :bad_arch}) end + + test "a stopped result maps to a StopVmResponse, not google.protobuf.Empty" do + assert %StopVmResponse{} = Codec.to_grpc(:stopped) + end end From 93a56f862aeb97f28effbd390becd850bdfd08ea Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 11 Jul 2026 23:37:46 +0000 Subject: [PATCH 02/14] feat(grpc): UNSPECIFIED enum zero-values, reject unset InstanceType/Architecture gain a *_UNSPECIFIED = 0 sentinel; real values shift up one. An omitted instance_type/arch is now INVALID_ARGUMENT rather than a silent MICRO/x86_64 default (AIP-126). --- lib/hyper/grpc/codec.ex | 14 +++++++++++-- proto/hyper/grpc/v0/hyper.proto | 35 +++++++++++++++++---------------- test/hyper/grpc/codec_test.exs | 22 +++++++++++++++++++++ 3 files changed, 52 insertions(+), 19 deletions(-) diff --git a/lib/hyper/grpc/codec.ex b/lib/hyper/grpc/codec.ex index 8a0447b9..8c5d6f3a 100644 --- a/lib/hyper/grpc/codec.ex +++ b/lib/hyper/grpc/codec.ex @@ -104,13 +104,17 @@ defmodule Hyper.Grpc.Codec do @spec vm({Hyper.Vm.Id.t(), node()}) :: Vm.t() defp vm({vm_id, node}), do: %Vm{vm_id: vm_id, node: to_string(node)} - @spec instance_type(term()) :: {:ok, Hyper.Vm.Instance.t()} | {:error, :bad_instance_type} + @spec instance_type(term()) :: + {:ok, Hyper.Vm.Instance.t()} | {:error, :missing_instance_type | :bad_instance_type} + defp instance_type(:INSTANCE_TYPE_UNSPECIFIED), do: {:error, :missing_instance_type} + defp instance_type(enum) when is_map_key(@instance_types, enum), do: {:ok, @instance_types[enum]} defp instance_type(_unrecognised), do: {:error, :bad_instance_type} - @spec arch(term()) :: {:ok, Hyper.Vm.Instance.arch()} | {:error, :bad_arch} + @spec arch(term()) :: {:ok, Hyper.Vm.Instance.arch()} | {:error, :missing_arch | :bad_arch} + defp arch(:ARCHITECTURE_UNSPECIFIED), do: {:error, :missing_arch} defp arch(enum) when is_map_key(@arches, enum), do: {:ok, @arches[enum]} defp arch(_unrecognised), do: {:error, :bad_arch} @@ -118,6 +122,12 @@ defmodule Hyper.Grpc.Codec do defp rpc_error(:missing_img_id), do: GRPC.RPCError.exception(:invalid_argument, "img_id is required") + defp rpc_error(:missing_instance_type), + do: GRPC.RPCError.exception(:invalid_argument, "instance_type is required") + + defp rpc_error(:missing_arch), + do: GRPC.RPCError.exception(:invalid_argument, "arch is required") + defp rpc_error(:bad_instance_type), do: GRPC.RPCError.exception(:invalid_argument, "instance_type holds an unrecognised value") diff --git a/proto/hyper/grpc/v0/hyper.proto b/proto/hyper/grpc/v0/hyper.proto index a9c0e6e9..6cea10af 100644 --- a/proto/hyper/grpc/v0/hyper.proto +++ b/proto/hyper/grpc/v0/hyper.proto @@ -91,31 +91,32 @@ service Hyper { // is roughly a doubling. The vCPU/memory annotations below are the headline // figures; disk and bandwidth scale alongside them. enum InstanceType { - INSTANCE_TYPE_MICRO = 0; // 0.25 vCPU, 128 MiB -- also the zero/default value - INSTANCE_TYPE_MILLI = 1; // 0.5 vCPU, 256 MiB - INSTANCE_TYPE_CENTI = 2; // 1 vCPU, 512 MiB - INSTANCE_TYPE_DECI = 3; // 2 vCPU, 1 GiB - INSTANCE_TYPE_BASE = 4; // 4 vCPU, 2 GiB - INSTANCE_TYPE_DECA = 5; // 8 vCPU, 4 GiB - INSTANCE_TYPE_HECTO = 6; // 16 vCPU, 8 GiB - INSTANCE_TYPE_KILO = 7; // 32 vCPU, 16 GiB - INSTANCE_TYPE_MEGA = 8; // 64 vCPU, 32 GiB - INSTANCE_TYPE_GIGA = 9; // 128 vCPU, 64 GiB - INSTANCE_TYPE_TERA = 10; // 256 vCPU, 128 GiB + INSTANCE_TYPE_UNSPECIFIED = 0; // unset -- the server rejects it (INVALID_ARGUMENT) + INSTANCE_TYPE_MICRO = 1; // 0.25 vCPU, 128 MiB + INSTANCE_TYPE_MILLI = 2; // 0.5 vCPU, 256 MiB + INSTANCE_TYPE_CENTI = 3; // 1 vCPU, 512 MiB + INSTANCE_TYPE_DECI = 4; // 2 vCPU, 1 GiB + INSTANCE_TYPE_BASE = 5; // 4 vCPU, 2 GiB + INSTANCE_TYPE_DECA = 6; // 8 vCPU, 4 GiB + INSTANCE_TYPE_HECTO = 7; // 16 vCPU, 8 GiB + INSTANCE_TYPE_KILO = 8; // 32 vCPU, 16 GiB + INSTANCE_TYPE_MEGA = 9; // 64 vCPU, 32 GiB + INSTANCE_TYPE_GIGA = 10; // 128 vCPU, 64 GiB + INSTANCE_TYPE_TERA = 11; // 256 vCPU, 128 GiB } // Guest CPU architecture. Must match the architecture the image was built for. enum Architecture { - ARCHITECTURE_X86_64 = 0; // x86-64 / amd64 -- also the zero/default value - ARCHITECTURE_AARCH64 = 1; // arm64 + ARCHITECTURE_UNSPECIFIED = 0; // unset -- the server rejects it (INVALID_ARGUMENT) + ARCHITECTURE_X86_64 = 1; // x86-64 / amd64 + ARCHITECTURE_AARCH64 = 2; // arm64 } // Request to create and boot a VM. // -// `instance_type` and `arch` are required by the contract. Note that proto3 -// does not enforce field presence: an omitted field decodes to its enum's zero -// value (INSTANCE_TYPE_MICRO / ARCHITECTURE_X86_64), so clients should always -// set them explicitly. +// `instance_type` and `arch` are required. Their proto3 zero value is the +// `*_UNSPECIFIED` sentinel, which the server rejects with INVALID_ARGUMENT -- +// an omitted field is an error, never a silent default. Set them explicitly. message CreateVmRequest { // Required. The content-addressed id of the image to boot. string img_id = 1; diff --git a/test/hyper/grpc/codec_test.exs b/test/hyper/grpc/codec_test.exs index 3e2fdd78..19174c9a 100644 --- a/test/hyper/grpc/codec_test.exs +++ b/test/hyper/grpc/codec_test.exs @@ -42,4 +42,26 @@ defmodule Hyper.Grpc.CodecTest do test "a stopped result maps to a StopVmResponse, not google.protobuf.Empty" do assert %StopVmResponse{} = Codec.to_grpc(:stopped) end + + test "an unset instance_type (UNSPECIFIED) is rejected as required, not defaulted" do + assert {:error, :missing_instance_type} = + Codec.from_grpc(%CreateVmRequest{ + img_id: "img", + instance_type: :INSTANCE_TYPE_UNSPECIFIED, + arch: :ARCHITECTURE_X86_64 + }) + + assert %GRPC.RPCError{status: 3} = Codec.to_grpc({:error, :missing_instance_type}) + end + + test "an unset arch (UNSPECIFIED) is rejected as required, not defaulted" do + assert {:error, :missing_arch} = + Codec.from_grpc(%CreateVmRequest{ + img_id: "img", + instance_type: :INSTANCE_TYPE_MICRO, + arch: :ARCHITECTURE_UNSPECIFIED + }) + + assert %GRPC.RPCError{status: 3} = Codec.to_grpc({:error, :missing_arch}) + end end From 89d4d3f2e0f307b364873a670786e87149031851 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 11 Jul 2026 23:50:24 +0000 Subject: [PATCH 03/14] feat(grpc): keyset pagination for ListVms (AIP-158) page_size/page_token on the request, next_page_token on the response. A pure Hyper.Grpc.Page core cursors by vm_id over Cluster.Routing.all/0; the keyset cursor is robust to the registry changing mid-listing. --- lib/hyper/grpc/codec.ex | 9 ++- lib/hyper/grpc/page.ex | 74 ++++++++++++++++++++++++ lib/hyper/grpc/server.ex | 7 ++- proto/hyper/grpc/v0/hyper.proto | 20 ++++++- test/grpc/tests/lifecycle.test.ts | 4 +- test/hyper/grpc/codec_test.exs | 10 ++++ test/hyper/grpc/page_properties_test.exs | 60 +++++++++++++++++++ 7 files changed, 175 insertions(+), 9 deletions(-) create mode 100644 lib/hyper/grpc/page.ex create mode 100644 test/hyper/grpc/page_properties_test.exs diff --git a/lib/hyper/grpc/codec.ex b/lib/hyper/grpc/codec.ex index 8c5d6f3a..a28dc302 100644 --- a/lib/hyper/grpc/codec.ex +++ b/lib/hyper/grpc/codec.ex @@ -87,9 +87,9 @@ defmodule Hyper.Grpc.Codec do def to_grpc({:usage, vm_id, cpu_time}), do: %GetVmUsageResponse{vm_id: vm_id, cpu_usec: Unit.Time.as_us(cpu_time)} - @spec to_grpc({:vms, [{Hyper.Vm.Id.t(), node()}]}) :: ListVmsResponse.t() - def to_grpc({:vms, vms}), - do: %ListVmsResponse{vms: Enum.map(vms, &vm/1)} + @spec to_grpc({:vms, [Hyper.Grpc.Page.entry()], String.t()}) :: ListVmsResponse.t() + def to_grpc({:vms, vms, next_page_token}), + do: %ListVmsResponse{vms: Enum.map(vms, &vm/1), next_page_token: next_page_token} @spec to_grpc({:loaded, Hyper.Img.id()}) :: LoadImageResponse.t() def to_grpc({:loaded, img_id}) when is_binary(img_id), @@ -128,6 +128,9 @@ defmodule Hyper.Grpc.Codec do defp rpc_error(:missing_arch), do: GRPC.RPCError.exception(:invalid_argument, "arch is required") + defp rpc_error(:bad_page_token), + do: GRPC.RPCError.exception(:invalid_argument, "page_token is malformed") + defp rpc_error(:bad_instance_type), do: GRPC.RPCError.exception(:invalid_argument, "instance_type holds an unrecognised value") diff --git a/lib/hyper/grpc/page.ex b/lib/hyper/grpc/page.ex new file mode 100644 index 00000000..c39bbf92 --- /dev/null +++ b/lib/hyper/grpc/page.ex @@ -0,0 +1,74 @@ +defmodule Hyper.Grpc.Page do + @moduledoc """ + Keyset pagination over the cluster VM listing. Pure: given the full unordered + `[{vm_id, node}]` from `Hyper.Cluster.Routing.all/0`, a requested page size, + and an opaque cursor token, returns one deterministic page plus the token for + the next page. + + Keyset (cursor-by-`vm_id`), not offset: VMs starting and stopping between calls + cannot shift a page or skip/duplicate an entry, because the cursor is the last + `vm_id` seen, not a position. `vm_id`s are unique and totally ordered, so the + sort is stable across calls. An empty `next_page_token` -- and only that -- + signals end-of-collection (AIP-158). + + Laws are exercised in `test/hyper/grpc/page_properties_test.exs`. + """ + + @default_page_size 100 + @max_page_size 1000 + + @type entry :: {Hyper.Vm.Id.t(), node()} + + @doc """ + One page of `entries` after the cursor in `page_token`, plus the next token. + + `page_size <= 0` selects the default (#{@default_page_size}); larger than + #{@max_page_size} is capped. A `page_token` that is not a token this module + issued yields `{:error, :bad_page_token}`. + """ + @spec paginate([entry()], integer(), String.t()) :: + {:ok, {[entry()], String.t()}} | {:error, :bad_page_token} + def paginate(entries, page_size, page_token) do + with {:ok, after_id} <- decode(page_token) do + remaining = + entries + |> Enum.sort_by(fn {vm_id, _node} -> vm_id end) + |> drop_through(after_id) + + {page, rest} = Enum.split(remaining, clamp(page_size)) + {:ok, {page, next_token(page, rest)}} + end + end + + @spec decode(String.t()) :: {:ok, binary() | nil} | {:error, :bad_page_token} + defp decode(""), do: {:ok, nil} + + defp decode(token) do + case Base.url_decode64(token, padding: false) do + {:ok, id} -> {:ok, id} + :error -> {:error, :bad_page_token} + end + end + + @spec encode(binary()) :: String.t() + defp encode(id), do: Base.url_encode64(id, padding: false) + + @spec clamp(integer()) :: pos_integer() + defp clamp(size) when size <= 0, do: @default_page_size + defp clamp(size), do: min(size, @max_page_size) + + @spec drop_through([entry()], binary() | nil) :: [entry()] + defp drop_through(sorted, nil), do: sorted + + defp drop_through(sorted, after_id), + do: Enum.drop_while(sorted, fn {vm_id, _node} -> vm_id <= after_id end) + + @spec next_token([entry()], [entry()]) :: String.t() + defp next_token([], _rest), do: "" + defp next_token(_page, []), do: "" + + defp next_token(page, _rest) do + {last_id, _node} = List.last(page) + encode(last_id) + end +end diff --git a/lib/hyper/grpc/server.ex b/lib/hyper/grpc/server.ex index 76dbf525..f655614c 100644 --- a/lib/hyper/grpc/server.ex +++ b/lib/hyper/grpc/server.ex @@ -96,7 +96,10 @@ defmodule Hyper.Grpc.Server do @spec list_vms(ListVmsRequest.t(), GRPC.Server.Stream.t()) :: ListVmsResponse.t() @decorate with_span("Hyper.Grpc.Server.list_vms") - def list_vms(%ListVmsRequest{}, _stream) do - Codec.to_grpc({:vms, Hyper.Cluster.Routing.all()}) + def list_vms(%ListVmsRequest{page_size: page_size, page_token: page_token}, _stream) do + case Hyper.Grpc.Page.paginate(Hyper.Cluster.Routing.all(), page_size, page_token) do + {:ok, {page, next}} -> Codec.to_grpc({:vms, page, next}) + {:error, reason} -> raise Codec.to_grpc({:error, reason}) + end end end diff --git a/proto/hyper/grpc/v0/hyper.proto b/proto/hyper/grpc/v0/hyper.proto index 6cea10af..94b8d08f 100644 --- a/proto/hyper/grpc/v0/hyper.proto +++ b/proto/hyper/grpc/v0/hyper.proto @@ -197,13 +197,27 @@ message GetVmUsageResponse { uint64 cpu_usec = 2; } -// Request to list VMs. -message ListVmsRequest {} +// Request to list VMs. All fields are optional; an empty request lists from the +// beginning with a server-default page size. +message ListVmsRequest { + // Optional. Maximum VMs to return in one page. 0 or unset selects a server + // default (100); values above the server maximum (1000) are capped down. + int32 page_size = 1; + + // Optional. An opaque cursor taken verbatim from a previous response's + // `next_page_token`. Empty or unset starts from the beginning. Keep every + // other argument identical across a paged sequence. + string page_token = 2; +} // Result of ListVms. message ListVmsResponse { - // Every VM known to the cluster, in no particular order. + // A page of VMs, ordered by `vm_id`. repeated Vm vms = 1; + + // Opaque cursor for the next page. Empty exactly when this is the last page + // -- the only signal of end-of-collection. + string next_page_token = 2; } // A VM and where it runs. Returned by ListVms. diff --git a/test/grpc/tests/lifecycle.test.ts b/test/grpc/tests/lifecycle.test.ts index c2873010..ec84b199 100644 --- a/test/grpc/tests/lifecycle.test.ts +++ b/test/grpc/tests/lifecycle.test.ts @@ -57,8 +57,10 @@ test( expect(located.vmId).toBe(created.vmId); expect(located.node).toBe(created.node); - const listed = (await call(client, client.listVms, {})) as ListVmsResponse__Output; + const listed = (await call(client, client.listVms, { pageSize: 1000 })) as ListVmsResponse__Output; expect((listed.vms ?? []).map((vm) => vm.vmId)).toContain(created.vmId); + // next_page_token is a string field: absent-on-last-page decodes to "". + expect(typeof (listed.nextPageToken ?? "")).toBe("string"); const usage = (await call(client, client.getVmUsage, { vmId: created.vmId })) as GetVmUsageResponse__Output; expect(usage.vmId).toBe(created.vmId); diff --git a/test/hyper/grpc/codec_test.exs b/test/hyper/grpc/codec_test.exs index 19174c9a..0550ba6a 100644 --- a/test/hyper/grpc/codec_test.exs +++ b/test/hyper/grpc/codec_test.exs @@ -4,6 +4,7 @@ defmodule Hyper.Grpc.CodecTest do alias Hyper.Grpc.Codec alias Hyper.Grpc.V0.CreateVmRequest alias Hyper.Grpc.V0.GetVmUsageResponse + alias Hyper.Grpc.V0.ListVmsResponse alias Hyper.Grpc.V0.StopVmResponse test "usage encodes as microseconds on the wire" do @@ -64,4 +65,13 @@ defmodule Hyper.Grpc.CodecTest do assert %GRPC.RPCError{status: 3} = Codec.to_grpc({:error, :missing_arch}) end + + test "a vms page maps to ListVmsResponse carrying the next_page_token" do + assert %ListVmsResponse{vms: [%{vm_id: "a"}], next_page_token: "cursor"} = + Codec.to_grpc({:vms, [{"a", :node@host}], "cursor"}) + end + + test "a malformed page_token maps to INVALID_ARGUMENT" do + assert %GRPC.RPCError{status: 3} = Codec.to_grpc({:error, :bad_page_token}) + end end diff --git a/test/hyper/grpc/page_properties_test.exs b/test/hyper/grpc/page_properties_test.exs new file mode 100644 index 00000000..cefc668d --- /dev/null +++ b/test/hyper/grpc/page_properties_test.exs @@ -0,0 +1,60 @@ +defmodule Hyper.Grpc.PagePropertiesTest do + @moduledoc """ + Laws under test for keyset pagination over the VM listing: + + * Termination + reconstruction -- following `next_page_token` from the empty + cursor until it is empty visits every entry exactly once, in `vm_id` order, + reproducing the whole set with no duplicates or gaps. + * Order-independence -- the pages produced do not depend on the order of the + input list (the registry returns VMs unordered). + * Size bound -- no page exceeds `min(requested, max)`; a non-positive + `page_size` uses the default. + * Token contract -- a malformed `page_token` is rejected with + `:bad_page_token`, never silently treated as "start". + """ + use ExUnit.Case, async: true + use ExUnitProperties + + alias Hyper.Grpc.Page + + defp entries do + gen all(ids <- uniq_list_of(string(:alphanumeric, min_length: 1), max_length: 40)) do + Enum.map(ids, fn id -> {id, :"node@#{:erlang.phash2(id)}"} end) + end + end + + defp collect_all(entries, page_size, token \\ "", acc \\ []) do + {:ok, {page, next}} = Page.paginate(entries, page_size, token) + + case next do + "" -> acc ++ page + _ -> collect_all(entries, page_size, next, acc ++ page) + end + end + + property "paging to the end reproduces the whole set, sorted, exactly once" do + check all(entries <- entries(), page_size <- integer(1..10)) do + walked = collect_all(entries, page_size) + assert walked == Enum.sort_by(entries, fn {id, _node} -> id end) + end + end + + property "the pages are independent of input order" do + check all(entries <- entries(), page_size <- integer(1..10)) do + shuffled = Enum.reverse(entries) + assert collect_all(entries, page_size) == collect_all(shuffled, page_size) + end + end + + property "no page exceeds min(requested, max); non-positive size uses the default" do + check all(entries <- entries(), page_size <- integer(-5..2000)) do + {:ok, {page, _next}} = Page.paginate(entries, page_size, "") + effective = if page_size <= 0, do: 100, else: min(page_size, 1000) + assert length(page) <= effective + end + end + + test "a malformed page_token is rejected, never treated as start" do + assert {:error, :bad_page_token} = Page.paginate([{"a", :n@h}], 10, "not*valid*base64") + end +end From b5e2af9f94d094197299233e422f777ac1a9a0dc Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 12 Jul 2026 00:03:57 +0000 Subject: [PATCH 04/14] feat(grpc)!: promote public contract hyper.grpc.v0 -> v1 Freeze the cleaned-up contract as the first stable version. Service address is now hyper.grpc.v1.Hyper; generated bindings move to Hyper.Grpc.V1.*. BREAKING CHANGE: clients must target the hyper.grpc.v1 package. --- .gitignore | 2 +- AGENTS.md | 2 +- docs/grpc.md | 10 +++++----- lib/hyper/grpc.ex | 6 +++--- lib/hyper/grpc/codec.ex | 4 ++-- lib/hyper/grpc/server.ex | 6 +++--- mix.exs | 2 +- proto/hyper/grpc/{v0 => v1}/hyper.proto | 9 ++++++--- test/e2e/grpc_contract_test.exs | 10 +++++----- test/grpc/package.json | 4 ++-- test/grpc/src/client.ts | 6 +++--- test/grpc/tests/lifecycle.test.ts | 10 +++++----- test/hyper/grpc/codec_fork_test.exs | 2 +- test/hyper/grpc/codec_test.exs | 8 ++++---- 14 files changed, 42 insertions(+), 39 deletions(-) rename proto/hyper/grpc/{v0 => v1}/hyper.proto (95%) diff --git a/.gitignore b/.gitignore index 6f669141..b44c69f1 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,7 @@ docs/superpowers/ # gRPC bindings are generated from proto/**/*.proto before compile # -- see the :grpc_gen Mix compiler in mix.exs. -/lib/hyper/grpc/v0/ +/lib/hyper/grpc/v1/ /lib/hyper/agent/v1/ # The expected suidhelper build identity (version + checksum) is captured from diff --git a/AGENTS.md b/AGENTS.md index ffb1020a..97f2ca35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ run in CI's `integration` job, which provisions the GitHub-hosted runner via - `native/suidhelper/` — the privileged Rust helper. Source in `src/`, tests in `tests/` (see Rust rules below). - Generated, do not hand-edit: `lib/hyper/firecracker/api/{operations,schemas}` - (regen `mix firecracker.gen`) and `lib/hyper/grpc/v0/hyper.pb.ex` + (regen `mix firecracker.gen`) and `lib/hyper/grpc/v1/hyper.pb.ex` (regen `mix grpc.gen`). Both are gitignored and rebuilt by a Mix compiler. ## Testing philosophy — read this before writing any test diff --git a/docs/grpc.md b/docs/grpc.md index cfac8eee..9be9abaf 100644 --- a/docs/grpc.md +++ b/docs/grpc.md @@ -5,8 +5,8 @@ The gRPC interface puts that same machine lifecycle behind a language-agnostic contract, so consumers in **any** language -- and off-BEAM services -- can create, stop, locate, and list microVMs. -> **v0 -- unstable.** The contract may change without notice during early -> development. Pin to a commit if you depend on it. +> **v1 -- stable.** Backward-compatible changes may land in place; a breaking +> change ships as a side-by-side `hyper.grpc.v2` package. > #### No authentication {: .warning} > @@ -15,7 +15,7 @@ create, stop, locate, and list microVMs. > false`). When you enable it, bind it to loopback or a trusted network, or put > it behind a proxy that terminates TLS and authenticates callers. TLS (`cred`) > encrypts the channel but does not authenticate the client. Authentication is -> planned for a later release; the `v0` contract is UNSTABLE. +> planned for a later release. ## Configuration @@ -62,7 +62,7 @@ language. We will be using _Python_ here: ```python import grpc from google.protobuf import empty_pb2 -from hyper.grpc.v0 import hyper_pb2, hyper_pb2_grpc +from hyper.grpc.v1 import hyper_pb2, hyper_pb2_grpc # Plaintext. For TLS, pass grpc.ssl_channel_credentials(ca_pem) to # grpc.aio.secure_channel(...) instead. @@ -151,5 +151,5 @@ await client.StopVm(hyper_pb2.StopVmRequest(vm_id=created.vm_id)) ``` For full documentation, please read the documentation in the -[`.proto`](https://github.com/harmont-dev/hyper/blob/main/proto/hyper/grpc/v0/hyper.proto) +[`.proto`](https://github.com/harmont-dev/hyper/blob/main/proto/hyper/grpc/v1/hyper.proto) file. diff --git a/lib/hyper/grpc.ex b/lib/hyper/grpc.ex index 450bd609..95f194fd 100644 --- a/lib/hyper/grpc.ex +++ b/lib/hyper/grpc.ex @@ -2,11 +2,11 @@ defmodule Hyper.Grpc do @moduledoc """ Public gRPC interface to a Hyper cluster. - The service contract is `hyper.grpc.v0.Hyper` (see - `proto/hyper/grpc/v0/hyper.proto`). Any gRPC client, in any language, can + The service contract is `hyper.grpc.v1.Hyper` (see + `proto/hyper/grpc/v1/hyper.proto`). Any gRPC client, in any language, can create, stop, locate, and list microVMs. Off-BEAM clients generate their own stubs from the `.proto`; BEAM clients can use the generated - `Hyper.Grpc.V0.Hyper.Stub` together with `connect/2`. + `Hyper.Grpc.V1.Hyper.Stub` together with `connect/2`. """ alias Hyper.Cfg.Grpc, as: Config diff --git a/lib/hyper/grpc/codec.ex b/lib/hyper/grpc/codec.ex index a28dc302..f4220160 100644 --- a/lib/hyper/grpc/codec.ex +++ b/lib/hyper/grpc/codec.ex @@ -1,6 +1,6 @@ defmodule Hyper.Grpc.Codec do @moduledoc """ - Translation between the gRPC wire types (`Hyper.Grpc.V0.*`) and Hyper's domain + Translation between the gRPC wire types (`Hyper.Grpc.V1.*`) and Hyper's domain types. Two entry points, each dispatching by pattern match on the value's type: * `from_grpc/1` -- an inbound request message -> a domain value. @@ -8,7 +8,7 @@ defmodule Hyper.Grpc.Codec do or a `GRPC.RPCError` for the server to raise. """ - alias Hyper.Grpc.V0.{ + alias Hyper.Grpc.V1.{ CreateVmRequest, CreateVmResponse, ForkVmResponse, diff --git a/lib/hyper/grpc/server.ex b/lib/hyper/grpc/server.ex index f655614c..8f65ac18 100644 --- a/lib/hyper/grpc/server.ex +++ b/lib/hyper/grpc/server.ex @@ -1,17 +1,17 @@ defmodule Hyper.Grpc.Server do @moduledoc """ - gRPC handler for `hyper.grpc.v0.Hyper`. A thin translation layer: each RPC + gRPC handler for `hyper.grpc.v1.Hyper`. A thin translation layer: each RPC maps its request to a domain value via `Hyper.Grpc.Codec.from_grpc/1`, calls the existing `Hyper` BEAM API, and maps the result back with `Hyper.Grpc.Codec.to_grpc/1` (raising the `GRPC.RPCError` it returns on error). """ - use GRPC.Server, service: Hyper.Grpc.V0.Hyper.Service + use GRPC.Server, service: Hyper.Grpc.V1.Hyper.Service use OpenTelemetryDecorator alias Hyper.Grpc.Codec - alias Hyper.Grpc.V0.{ + alias Hyper.Grpc.V1.{ CreateVmRequest, CreateVmResponse, ForkVmRequest, diff --git a/mix.exs b/mix.exs index 6563fc23..4c4bdcb7 100644 --- a/mix.exs +++ b/mix.exs @@ -221,7 +221,7 @@ defmodule Hyper.MixProject do # package deterministic: consumers regenerate all of these at compile. exclude_patterns: [ ~r{^lib/hyper/firecracker/api/(operations|schemas)/}, - ~r{^lib/hyper/grpc/v0/}, + ~r{^lib/hyper/grpc/v1/}, ~r{^lib/hyper/agent/v1/}, ~r{^lib/hyper/suid_helper/expected\.ex$} ], diff --git a/proto/hyper/grpc/v0/hyper.proto b/proto/hyper/grpc/v1/hyper.proto similarity index 95% rename from proto/hyper/grpc/v0/hyper.proto rename to proto/hyper/grpc/v1/hyper.proto index 94b8d08f..786c8a14 100644 --- a/proto/hyper/grpc/v0/hyper.proto +++ b/proto/hyper/grpc/v1/hyper.proto @@ -8,11 +8,14 @@ // at creation. Placement across the cluster is automatic; the server is // stateless and identical on every node, so any node can serve any request. // -// Versioning: this is `v0` and UNSTABLE. The contract may change without notice -// during early development. Pin to a specific commit if you depend on it. +// Versioning: this is `v1`, the first stable contract. Backward-compatible +// changes (new RPCs, new messages, appended fields, new enum values) may land +// in place. A breaking change ships as a side-by-side `hyper.grpc.v2` package; +// this one is never mutated incompatibly. Removed field numbers/names are +// retired with `reserved`, never reused. syntax = "proto3"; -package hyper.grpc.v0; +package hyper.grpc.v1; // The VM lifecycle service. // diff --git a/test/e2e/grpc_contract_test.exs b/test/e2e/grpc_contract_test.exs index 23383bab..bfa6f9b0 100644 --- a/test/e2e/grpc_contract_test.exs +++ b/test/e2e/grpc_contract_test.exs @@ -1,15 +1,15 @@ defmodule Hyper.E2e.GrpcContractTest do @moduledoc """ - Live contract test of the public gRPC surface (`hyper.grpc.v0.Hyper`), + Live contract test of the public gRPC surface (`hyper.grpc.v1.Hyper`), exercised from outside the BEAM: starts the gRPC server against the running app tree, then drives it with the TypeScript suite in `test/grpc/`, which - loads `proto/hyper/grpc/v0/hyper.proto` directly. Status codes and the full + loads `proto/hyper/grpc/v1/hyper.proto` directly. Status codes and the full VM lifecycle are asserted over the real wire, catching proto/codec/server drift a BEAM-side client cannot produce (e.g. unrecognised enum integers). Also carries one BEAM-side test of `ForkVm`: it needs a real booted parent VM (Firecracker + device-mapper), which the TypeScript suite cannot boot, - so it drives `Hyper.Grpc.V0.Hyper.Stub` directly over the same server + so it drives `Hyper.Grpc.V1.Hyper.Stub` directly over the same server instead. Runs only under `--only integration` on a provisioned host (CI: the @@ -18,8 +18,8 @@ defmodule Hyper.E2e.GrpcContractTest do """ use ExUnit.Case, async: false - alias Hyper.Grpc.V0.{ForkVmRequest, ForkVmResponse, StopVmRequest} - alias Hyper.Grpc.V0.Hyper.Stub + alias Hyper.Grpc.V1.{ForkVmRequest, ForkVmResponse, StopVmRequest} + alias Hyper.Grpc.V1.Hyper.Stub @moduletag :integration @moduletag timeout: :timer.minutes(25) diff --git a/test/grpc/package.json b/test/grpc/package.json index b237a2c9..ac2eb149 100644 --- a/test/grpc/package.json +++ b/test/grpc/package.json @@ -2,9 +2,9 @@ "name": "hyper-grpc-contract-tests", "private": true, "type": "module", - "description": "Wire-level contract tests for hyper.grpc.v0.Hyper, driven from outside the BEAM", + "description": "Wire-level contract tests for hyper.grpc.v1.Hyper, driven from outside the BEAM", "scripts": { - "gen": "proto-loader-gen-types --longs=Number --enums=String --defaults --oneofs --grpcLib=@grpc/grpc-js --includeDirs=../../proto --outDir=generated hyper/grpc/v0/hyper.proto", + "gen": "proto-loader-gen-types --longs=Number --enums=String --defaults --oneofs --grpcLib=@grpc/grpc-js --includeDirs=../../proto --outDir=generated hyper/grpc/v1/hyper.proto", "pretest": "npm run gen", "test": "vitest run", "pretypecheck": "npm run gen", diff --git a/test/grpc/src/client.ts b/test/grpc/src/client.ts index 5789741a..094385fa 100644 --- a/test/grpc/src/client.ts +++ b/test/grpc/src/client.ts @@ -3,14 +3,14 @@ import { fileURLToPath } from "node:url"; import * as grpc from "@grpc/grpc-js"; import * as protoLoader from "@grpc/proto-loader"; import type { ProtoGrpcType } from "../generated/hyper"; -import type { HyperClient } from "../generated/hyper/grpc/v0/Hyper"; +import type { HyperClient } from "../generated/hyper/grpc/v1/Hyper"; const here = path.dirname(fileURLToPath(import.meta.url)); const PROTO_ROOT = path.resolve(here, "../../../proto"); // Options must mirror the `gen` script in package.json, or the generated // types describe a different runtime shape than proto-loader produces. -const packageDefinition = protoLoader.loadSync("hyper/grpc/v0/hyper.proto", { +const packageDefinition = protoLoader.loadSync("hyper/grpc/v1/hyper.proto", { longs: Number, enums: String, defaults: true, @@ -25,7 +25,7 @@ export type { HyperClient }; export const DEFAULT_ADDR = process.env.HYPER_GRPC_ADDR ?? "127.0.0.1:50061"; export function connect(addr: string = DEFAULT_ADDR): HyperClient { - return new proto.hyper.grpc.v0.Hyper(addr, grpc.credentials.createInsecure()); + return new proto.hyper.grpc.v1.Hyper(addr, grpc.credentials.createInsecure()); } type Unary = ( diff --git a/test/grpc/tests/lifecycle.test.ts b/test/grpc/tests/lifecycle.test.ts index ec84b199..386a7602 100644 --- a/test/grpc/tests/lifecycle.test.ts +++ b/test/grpc/tests/lifecycle.test.ts @@ -1,11 +1,11 @@ import { expect, test } from "vitest"; import { status } from "@grpc/grpc-js"; import { call, connect, eventually, expectStatus, statusOf } from "../src/client"; -import type { CreateVmResponse__Output } from "../generated/hyper/grpc/v0/CreateVmResponse"; -import type { GetVmResponse__Output } from "../generated/hyper/grpc/v0/GetVmResponse"; -import type { GetVmUsageResponse__Output } from "../generated/hyper/grpc/v0/GetVmUsageResponse"; -import type { ListVmsResponse__Output } from "../generated/hyper/grpc/v0/ListVmsResponse"; -import type { LoadImageResponse__Output } from "../generated/hyper/grpc/v0/LoadImageResponse"; +import type { CreateVmResponse__Output } from "../generated/hyper/grpc/v1/CreateVmResponse"; +import type { GetVmResponse__Output } from "../generated/hyper/grpc/v1/GetVmResponse"; +import type { GetVmUsageResponse__Output } from "../generated/hyper/grpc/v1/GetVmUsageResponse"; +import type { ListVmsResponse__Output } from "../generated/hyper/grpc/v1/ListVmsResponse"; +import type { LoadImageResponse__Output } from "../generated/hyper/grpc/v1/LoadImageResponse"; const MIN = 60_000; diff --git a/test/hyper/grpc/codec_fork_test.exs b/test/hyper/grpc/codec_fork_test.exs index 273d3eee..901dd7d3 100644 --- a/test/hyper/grpc/codec_fork_test.exs +++ b/test/hyper/grpc/codec_fork_test.exs @@ -2,7 +2,7 @@ defmodule Hyper.Grpc.CodecForkTest do use ExUnit.Case, async: true alias Hyper.Grpc.Codec - alias Hyper.Grpc.V0.ForkVmResponse + alias Hyper.Grpc.V1.ForkVmResponse test "a forked result maps to a ForkVmResponse with the child id and node string" do assert %ForkVmResponse{vm_id: "child-abc", node: "hyper@host"} = diff --git a/test/hyper/grpc/codec_test.exs b/test/hyper/grpc/codec_test.exs index 0550ba6a..69884f04 100644 --- a/test/hyper/grpc/codec_test.exs +++ b/test/hyper/grpc/codec_test.exs @@ -2,10 +2,10 @@ defmodule Hyper.Grpc.CodecTest do use ExUnit.Case, async: true alias Hyper.Grpc.Codec - alias Hyper.Grpc.V0.CreateVmRequest - alias Hyper.Grpc.V0.GetVmUsageResponse - alias Hyper.Grpc.V0.ListVmsResponse - alias Hyper.Grpc.V0.StopVmResponse + alias Hyper.Grpc.V1.CreateVmRequest + alias Hyper.Grpc.V1.GetVmUsageResponse + alias Hyper.Grpc.V1.ListVmsResponse + alias Hyper.Grpc.V1.StopVmResponse test "usage encodes as microseconds on the wire" do assert %GetVmUsageResponse{vm_id: "v1", cpu_usec: 1_500_000} = From 763fb71d7f1cecbf48fd6c2496bddf18cdccba4c Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 12 Jul 2026 00:11:10 +0000 Subject: [PATCH 05/14] docs: gRPC guide for v1 (stable contract, no Empty, pagination) Swap v0->v1 throughout, replace the google.protobuf.Empty examples with the dedicated ListVms/StopVm messages, document required enum fields and the paged ListVms flow, and the v2-side-by-side stability policy. --- docs/grpc.md | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/docs/grpc.md b/docs/grpc.md index 9be9abaf..7672864b 100644 --- a/docs/grpc.md +++ b/docs/grpc.md @@ -5,8 +5,10 @@ The gRPC interface puts that same machine lifecycle behind a language-agnostic contract, so consumers in **any** language -- and off-BEAM services -- can create, stop, locate, and list microVMs. -> **v1 -- stable.** Backward-compatible changes may land in place; a breaking -> change ships as a side-by-side `hyper.grpc.v2` package. +> **v1 -- stable.** This is the first stable contract. Backward-compatible +> additions (new RPCs, new messages, appended fields, new enum values) may +> land in place; a breaking change would ship as a side-by-side `hyper.grpc.v2` +> package. Removed fields are retired with `reserved` and never reused. > #### No authentication {: .warning} > @@ -61,7 +63,6 @@ language. We will be using _Python_ here: ```python import grpc -from google.protobuf import empty_pb2 from hyper.grpc.v1 import hyper_pb2, hyper_pb2_grpc # Plaintext. For TLS, pass grpc.ssl_channel_credentials(ca_pem) to @@ -102,15 +103,24 @@ created = await client.CreateVm( print(created.vm_id, created.node) ``` +`instance_type` and `arch` are **required**. Their proto3 zero value is the +`*_UNSPECIFIED` sentinel; sending it (or omitting the field) returns +`INVALID_ARGUMENT`. Always set them explicitly, e.g. +`InstanceType.INSTANCE_TYPE_CENTI`, `Architecture.ARCHITECTURE_X86_64`. + ### Listing VMs -You can list running virtual machines with `ListVms`, which takes a -`google.protobuf.Empty`: +You can list running virtual machines with `ListVms`, which is paginated via +`page_size`/`page_token` in and `next_page_token` out: ```python -listed = await client.ListVms(empty_pb2.Empty()) +from hyper.grpc.v1 import hyper_pb2 + +# List a page of VMs. Follow next_page_token until it is empty for the full set. +listed = await client.ListVms(hyper_pb2.ListVmsRequest(page_size=100)) for vm in listed.vms: print(vm.vm_id, vm.node) +next_token = listed.next_page_token # "" means this was the last page ``` ### Getting VM Info @@ -143,11 +153,11 @@ the `vm_usage` Postgres table (`vm_id`, `window_start`, `window_end`, ### Stopping a VM -You can stop a running VM with `StopVm`, which returns a -`google.protobuf.Empty`: +You can stop a running VM with `StopVm`, which takes a request and returns an +(empty) `StopVmResponse`: ```python -await client.StopVm(hyper_pb2.StopVmRequest(vm_id=created.vm_id)) +await client.StopVm(hyper_pb2.StopVmRequest(vm_id=created.vm_id)) # -> StopVmResponse ``` For full documentation, please read the documentation in the From 38309d45cdc599033f4ab22aa2d1c2c725f242c9 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 12 Jul 2026 00:24:29 +0000 Subject: [PATCH 06/14] chore(grpc): final-review polish (changelog, page property, style, docs) --- CHANGELOG.md | 8 ++++++++ docs/grpc.md | 2 +- lib/hyper/grpc/codec.ex | 2 ++ test/hyper/grpc/page_properties_test.exs | 25 ++++++++++++++++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b25ab8e2..7f2e6a4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed +- **Breaking:** the gRPC package was promoted `hyper.grpc.v0` -> `hyper.grpc.v1`. + `google.protobuf.Empty` is replaced by dedicated `StopVmResponse` and + `ListVmsRequest` messages; the `InstanceType` and `Architecture` enums gained + `*_UNSPECIFIED = 0` sentinels (existing values renumbered), and an unset + value is now rejected with `INVALID_ARGUMENT`; `ListVms` is now paginated + (`page_size` / `page_token` / `next_page_token`). + ## [0.1.0] First public release: a distributed orchestrator for Firecracker microVMs on the diff --git a/docs/grpc.md b/docs/grpc.md index 7672864b..fc62c1b6 100644 --- a/docs/grpc.md +++ b/docs/grpc.md @@ -106,7 +106,7 @@ print(created.vm_id, created.node) `instance_type` and `arch` are **required**. Their proto3 zero value is the `*_UNSPECIFIED` sentinel; sending it (or omitting the field) returns `INVALID_ARGUMENT`. Always set them explicitly, e.g. -`InstanceType.INSTANCE_TYPE_CENTI`, `Architecture.ARCHITECTURE_X86_64`. +`hyper_pb2.INSTANCE_TYPE_CENTI`, `hyper_pb2.ARCHITECTURE_X86_64`. ### Listing VMs diff --git a/lib/hyper/grpc/codec.ex b/lib/hyper/grpc/codec.ex index f4220160..231fd271 100644 --- a/lib/hyper/grpc/codec.ex +++ b/lib/hyper/grpc/codec.ex @@ -115,7 +115,9 @@ defmodule Hyper.Grpc.Codec do @spec arch(term()) :: {:ok, Hyper.Vm.Instance.arch()} | {:error, :missing_arch | :bad_arch} defp arch(:ARCHITECTURE_UNSPECIFIED), do: {:error, :missing_arch} + defp arch(enum) when is_map_key(@arches, enum), do: {:ok, @arches[enum]} + defp arch(_unrecognised), do: {:error, :bad_arch} @spec rpc_error(term()) :: GRPC.RPCError.t() diff --git a/test/hyper/grpc/page_properties_test.exs b/test/hyper/grpc/page_properties_test.exs index cefc668d..424c35ca 100644 --- a/test/hyper/grpc/page_properties_test.exs +++ b/test/hyper/grpc/page_properties_test.exs @@ -11,6 +11,10 @@ defmodule Hyper.Grpc.PagePropertiesTest do `page_size` uses the default. * Token contract -- a malformed `page_token` is rejected with `:bad_page_token`, never silently treated as "start". + * Cursor threading -- on a fixed, known set, each page's contents and the + `next_page_token` that leads to the next one are exactly what keyset + pagination by `vm_id` prescribes (pins the boundary the properties above + only probe statistically). """ use ExUnit.Case, async: true use ExUnitProperties @@ -51,10 +55,31 @@ defmodule Hyper.Grpc.PagePropertiesTest do {:ok, {page, _next}} = Page.paginate(entries, page_size, "") effective = if page_size <= 0, do: 100, else: min(page_size, 1000) assert length(page) <= effective + assert length(page) == min(effective, length(entries)) end end test "a malformed page_token is rejected, never treated as start" do assert {:error, :bad_page_token} = Page.paginate([{"a", :n@h}], 10, "not*valid*base64") end + + test "a known set pages exactly, with the cursor threading correctly" do + entries = for id <- ~w(e a j c h b f i d g), do: {id, :node@x} + + assert {:ok, {page1, next1}} = Page.paginate(entries, 3, "") + assert page1 == for(id <- ~w(a b c), do: {id, :node@x}) + assert next1 == Base.url_encode64("c", padding: false) + + assert {:ok, {page2, next2}} = Page.paginate(entries, 3, next1) + assert page2 == for(id <- ~w(d e f), do: {id, :node@x}) + assert next2 == Base.url_encode64("f", padding: false) + + assert {:ok, {page3, next3}} = Page.paginate(entries, 3, next2) + assert page3 == for(id <- ~w(g h i), do: {id, :node@x}) + assert next3 == Base.url_encode64("i", padding: false) + + assert {:ok, {page4, next4}} = Page.paginate(entries, 3, next3) + assert page4 == [{"j", :node@x}] + assert next4 == "" + end end From deee9597dc04db80332e685cdd58e11a79bfe657 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 12 Jul 2026 04:38:55 +0000 Subject: [PATCH 07/14] deslop --- docs/grpc.md | 10 ---------- proto/hyper/grpc/v1/hyper.proto | 16 +++------------- 2 files changed, 3 insertions(+), 23 deletions(-) diff --git a/docs/grpc.md b/docs/grpc.md index fc62c1b6..99e7f985 100644 --- a/docs/grpc.md +++ b/docs/grpc.md @@ -5,11 +5,6 @@ The gRPC interface puts that same machine lifecycle behind a language-agnostic contract, so consumers in **any** language -- and off-BEAM services -- can create, stop, locate, and list microVMs. -> **v1 -- stable.** This is the first stable contract. Backward-compatible -> additions (new RPCs, new messages, appended fields, new enum values) may -> land in place; a breaking change would ship as a side-by-side `hyper.grpc.v2` -> package. Removed fields are retired with `reserved` and never reused. - > #### No authentication {: .warning} > > The gRPC API has **no authentication**. Any client that can reach the port can @@ -103,11 +98,6 @@ created = await client.CreateVm( print(created.vm_id, created.node) ``` -`instance_type` and `arch` are **required**. Their proto3 zero value is the -`*_UNSPECIFIED` sentinel; sending it (or omitting the field) returns -`INVALID_ARGUMENT`. Always set them explicitly, e.g. -`hyper_pb2.INSTANCE_TYPE_CENTI`, `hyper_pb2.ARCHITECTURE_X86_64`. - ### Listing VMs You can list running virtual machines with `ListVms`, which is paginated via diff --git a/proto/hyper/grpc/v1/hyper.proto b/proto/hyper/grpc/v1/hyper.proto index 786c8a14..d5f360c9 100644 --- a/proto/hyper/grpc/v1/hyper.proto +++ b/proto/hyper/grpc/v1/hyper.proto @@ -7,12 +7,6 @@ // A VM is addressed by its `vm_id` -- a URL-safe base64 string the server mints // at creation. Placement across the cluster is automatic; the server is // stateless and identical on every node, so any node can serve any request. -// -// Versioning: this is `v1`, the first stable contract. Backward-compatible -// changes (new RPCs, new messages, appended fields, new enum values) may land -// in place. A breaking change ships as a side-by-side `hyper.grpc.v2` package; -// this one is never mutated incompatibly. Removed field numbers/names are -// retired with `reserved`, never reused. syntax = "proto3"; package hyper.grpc.v1; @@ -165,9 +159,7 @@ message StopVmRequest { string vm_id = 1; } -// Result of StopVm. Empty today; a dedicated message (never google.protobuf.Empty) -// so a field -- e.g. the VM's final metered usage -- can be added later without a -// breaking change. +// Result of StopVm. Empty today. message StopVmResponse {} // Request to locate a VM. @@ -208,8 +200,7 @@ message ListVmsRequest { int32 page_size = 1; // Optional. An opaque cursor taken verbatim from a previous response's - // `next_page_token`. Empty or unset starts from the beginning. Keep every - // other argument identical across a paged sequence. + // `next_page_token`. Empty or unset starts from the beginning. string page_token = 2; } @@ -218,8 +209,7 @@ message ListVmsResponse { // A page of VMs, ordered by `vm_id`. repeated Vm vms = 1; - // Opaque cursor for the next page. Empty exactly when this is the last page - // -- the only signal of end-of-collection. + // Opaque cursor for the next page. `""` when this is the last page. string next_page_token = 2; } From c597782fb86e50d1bead1d19a710bdba3ca23e49 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 12 Jul 2026 04:39:44 +0000 Subject: [PATCH 08/14] deslop docs --- docs/grpc.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/grpc.md b/docs/grpc.md index 99e7f985..afd82646 100644 --- a/docs/grpc.md +++ b/docs/grpc.md @@ -11,8 +11,7 @@ create, stop, locate, and list microVMs. > create and stop VMs and load images. It is **off by default** (`grpc.enabled = > false`). When you enable it, bind it to loopback or a trusted network, or put > it behind a proxy that terminates TLS and authenticates callers. TLS (`cred`) -> encrypts the channel but does not authenticate the client. Authentication is -> planned for a later release. +> encrypts the channel but does not authenticate the client. ## Configuration From 1729661132b66b93e1409883e22667da6336caec Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 12 Jul 2026 04:48:39 +0000 Subject: [PATCH 09/14] feat(grpc): add Pageable behaviour for generic pagination --- lib/hyper/grpc/pageable.ex | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 lib/hyper/grpc/pageable.ex diff --git a/lib/hyper/grpc/pageable.ex b/lib/hyper/grpc/pageable.ex new file mode 100644 index 00000000..8ad3d276 --- /dev/null +++ b/lib/hyper/grpc/pageable.ex @@ -0,0 +1,36 @@ +defmodule Hyper.Grpc.Pageable do + @moduledoc """ + Contract a resource must satisfy to be paginated by `Hyper.Grpc.Page`. + + A conforming module declares how one element of a collection is ordered and + what page sizes are allowed. `Hyper.Grpc.Page` is generic over any such module, + so a new resource-listing RPC gets AIP-158 keyset pagination by implementing + this behaviour rather than copying the paginator. + + ## Invariants a conforming module must uphold + + * `cursor/1` returns a **unique** binary for every element in the collection. + Uniqueness is what makes keyset pagination stable: the page cursor is the + last key seen, not a position, so elements appearing or disappearing + between calls cannot shift a page or skip/duplicate an element. + * The cursor's **lexicographic (byte) order matches the resource's intended + total order**. `Hyper.Grpc.Page` sorts and compares cursors as binaries. + A key that is not already an order-preserving binary (e.g. an integer id) + must be encoded to one here -- zero-padded or fixed-width big-endian -- so + byte order matches the logical order. Returning, say, `Integer.to_string/1` + would sort `"10"` before `"2"` and page incorrectly. + + The cursor is a binary (not an arbitrary term) so the page token stays a plain + `Base.url_encode64` of the cursor and decoding a token never runs the unsafe + `:erlang.binary_to_term`. + """ + + @doc "A unique, order-preserving binary key for one collection element." + @callback cursor(resource :: term()) :: binary() + + @doc "Page size used when the request asks for a non-positive size." + @callback default_page_size() :: pos_integer() + + @doc "Upper bound a requested page size is capped to." + @callback max_page_size() :: pos_integer() +end From 1a751552f36714f0a43e48a146f0bff5488f44e9 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 12 Jul 2026 04:50:10 +0000 Subject: [PATCH 10/14] feat(grpc): Pageable.Vm for the cluster VM listing --- lib/hyper/grpc/pageable/vm.ex | 20 ++++++++++++++++++++ test/hyper/grpc/pageable/vm_test.exs | 21 +++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 lib/hyper/grpc/pageable/vm.ex create mode 100644 test/hyper/grpc/pageable/vm_test.exs diff --git a/lib/hyper/grpc/pageable/vm.ex b/lib/hyper/grpc/pageable/vm.ex new file mode 100644 index 00000000..a1c54e63 --- /dev/null +++ b/lib/hyper/grpc/pageable/vm.ex @@ -0,0 +1,20 @@ +defmodule Hyper.Grpc.Pageable.Vm do + @moduledoc """ + `Hyper.Grpc.Pageable` for the cluster VM listing: `{vm_id, node}` pairs from + `Hyper.Cluster.Routing.all/0`. The cursor is the `vm_id` -- already a unique, + totally-ordered binary -- so no extra encoding is needed. + """ + @behaviour Hyper.Grpc.Pageable + + @impl true + @spec cursor({Hyper.Vm.Id.t(), node()}) :: binary() + def cursor({vm_id, _node}), do: vm_id + + @impl true + @spec default_page_size() :: pos_integer() + def default_page_size, do: 100 + + @impl true + @spec max_page_size() :: pos_integer() + def max_page_size, do: 1000 +end diff --git a/test/hyper/grpc/pageable/vm_test.exs b/test/hyper/grpc/pageable/vm_test.exs new file mode 100644 index 00000000..aedc1c02 --- /dev/null +++ b/test/hyper/grpc/pageable/vm_test.exs @@ -0,0 +1,21 @@ +defmodule Hyper.Grpc.Pageable.VmTest do + @moduledoc """ + Pins the VM-specific pagination contract: the cursor is the `vm_id`, and the + clamp bounds are 100 (default) / 1000 (max). The generic invariants live in + `Hyper.Grpc.PagePropertiesTest`; this pins the numbers that suite no longer + knows. + """ + use ExUnit.Case, async: true + + alias Hyper.Grpc.Pageable.Vm + + test "cursor is the vm_id, independent of the node" do + assert Vm.cursor({"vm-abc", :node@a}) == "vm-abc" + assert Vm.cursor({"vm-abc", :node@b}) == "vm-abc" + end + + test "clamp bounds are 100 default / 1000 max" do + assert Vm.default_page_size() == 100 + assert Vm.max_page_size() == 1000 + end +end From 953999c1346e29afd40f645a71fa1fb734957087 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 12 Jul 2026 04:52:44 +0000 Subject: [PATCH 11/14] refactor(grpc)!: Page.paginate takes a Pageable module --- lib/hyper/grpc/page.ex | 73 ++++++++++++------------ test/hyper/grpc/page_properties_test.exs | 60 ++++++++++++------- 2 files changed, 73 insertions(+), 60 deletions(-) diff --git a/lib/hyper/grpc/page.ex b/lib/hyper/grpc/page.ex index c39bbf92..ce7af08b 100644 --- a/lib/hyper/grpc/page.ex +++ b/lib/hyper/grpc/page.ex @@ -1,42 +1,41 @@ defmodule Hyper.Grpc.Page do @moduledoc """ - Keyset pagination over the cluster VM listing. Pure: given the full unordered - `[{vm_id, node}]` from `Hyper.Cluster.Routing.all/0`, a requested page size, - and an opaque cursor token, returns one deterministic page plus the token for - the next page. + Generic keyset pagination over an in-memory collection. Pure: given the full + unordered list of resources, a requested page size, an opaque cursor token, + and a `Hyper.Grpc.Pageable` module describing how a resource is ordered, + returns one deterministic page plus the token for the next page. - Keyset (cursor-by-`vm_id`), not offset: VMs starting and stopping between calls - cannot shift a page or skip/duplicate an entry, because the cursor is the last - `vm_id` seen, not a position. `vm_id`s are unique and totally ordered, so the - sort is stable across calls. An empty `next_page_token` -- and only that -- - signals end-of-collection (AIP-158). + Keyset (cursor-by-`Pageable.cursor/1`), not offset: elements appearing or + disappearing between calls cannot shift a page or skip/duplicate an entry, + because the cursor is the last key seen, not a position. Cursors are unique and + totally ordered as binaries, so the sort is stable across calls. An empty + `next_page_token` -- and only that -- signals end-of-collection (AIP-158). - Laws are exercised in `test/hyper/grpc/page_properties_test.exs`. + Invariants are exercised in `test/hyper/grpc/page_properties_test.exs`. """ - @default_page_size 100 - @max_page_size 1000 - - @type entry :: {Hyper.Vm.Id.t(), node()} + @type resource :: term() @doc """ One page of `entries` after the cursor in `page_token`, plus the next token. - `page_size <= 0` selects the default (#{@default_page_size}); larger than - #{@max_page_size} is capped. A `page_token` that is not a token this module - issued yields `{:error, :bad_page_token}`. + `pageable` is a module implementing `Hyper.Grpc.Pageable`; its `cursor/1` + orders the collection and its `default_page_size/0` / `max_page_size/0` bound + the page size. `page_size <= 0` selects the default; larger than the max is + capped. A `page_token` that is not a token this module issued yields + `{:error, :bad_page_token}`. """ - @spec paginate([entry()], integer(), String.t()) :: - {:ok, {[entry()], String.t()}} | {:error, :bad_page_token} - def paginate(entries, page_size, page_token) do + @spec paginate([resource()], integer(), String.t(), module()) :: + {:ok, {[resource()], String.t()}} | {:error, :bad_page_token} + def paginate(entries, page_size, page_token, pageable) do with {:ok, after_id} <- decode(page_token) do remaining = entries - |> Enum.sort_by(fn {vm_id, _node} -> vm_id end) - |> drop_through(after_id) + |> Enum.sort_by(&pageable.cursor/1) + |> drop_through(after_id, pageable) - {page, rest} = Enum.split(remaining, clamp(page_size)) - {:ok, {page, next_token(page, rest)}} + {page, rest} = Enum.split(remaining, clamp(page_size, pageable)) + {:ok, {page, next_token(page, rest, pageable)}} end end @@ -53,22 +52,20 @@ defmodule Hyper.Grpc.Page do @spec encode(binary()) :: String.t() defp encode(id), do: Base.url_encode64(id, padding: false) - @spec clamp(integer()) :: pos_integer() - defp clamp(size) when size <= 0, do: @default_page_size - defp clamp(size), do: min(size, @max_page_size) + @spec clamp(integer(), module()) :: pos_integer() + defp clamp(size, pageable) when size <= 0, do: pageable.default_page_size() + defp clamp(size, pageable), do: min(size, pageable.max_page_size()) - @spec drop_through([entry()], binary() | nil) :: [entry()] - defp drop_through(sorted, nil), do: sorted + @spec drop_through([resource()], binary() | nil, module()) :: [resource()] + defp drop_through(sorted, nil, _pageable), do: sorted - defp drop_through(sorted, after_id), - do: Enum.drop_while(sorted, fn {vm_id, _node} -> vm_id <= after_id end) + defp drop_through(sorted, after_id, pageable), + do: Enum.drop_while(sorted, fn e -> pageable.cursor(e) <= after_id end) - @spec next_token([entry()], [entry()]) :: String.t() - defp next_token([], _rest), do: "" - defp next_token(_page, []), do: "" + @spec next_token([resource()], [resource()], module()) :: String.t() + defp next_token([], _rest, _pageable), do: "" + defp next_token(_page, [], _pageable), do: "" - defp next_token(page, _rest) do - {last_id, _node} = List.last(page) - encode(last_id) - end + defp next_token(page, _rest, pageable), + do: encode(pageable.cursor(List.last(page))) end diff --git a/test/hyper/grpc/page_properties_test.exs b/test/hyper/grpc/page_properties_test.exs index 424c35ca..d553b400 100644 --- a/test/hyper/grpc/page_properties_test.exs +++ b/test/hyper/grpc/page_properties_test.exs @@ -1,34 +1,49 @@ defmodule Hyper.Grpc.PagePropertiesTest do @moduledoc """ - Laws under test for keyset pagination over the VM listing: + Invariants under test for the generic keyset paginator, exercised against a + minimal `Hyper.Grpc.Pageable` implementation (`TestResource`) so they hold for + the behaviour contract itself, not for any one resource: * Termination + reconstruction -- following `next_page_token` from the empty - cursor until it is empty visits every entry exactly once, in `vm_id` order, - reproducing the whole set with no duplicates or gaps. + cursor until it is empty visits every element exactly once, in cursor + order, reproducing the whole set with no duplicates or gaps. * Order-independence -- the pages produced do not depend on the order of the - input list (the registry returns VMs unordered). + input list. * Size bound -- no page exceeds `min(requested, max)`; a non-positive `page_size` uses the default. * Token contract -- a malformed `page_token` is rejected with `:bad_page_token`, never silently treated as "start". * Cursor threading -- on a fixed, known set, each page's contents and the - `next_page_token` that leads to the next one are exactly what keyset - pagination by `vm_id` prescribes (pins the boundary the properties above - only probe statistically). + `next_page_token` that leads to the next are exactly what keyset + pagination by cursor prescribes. """ use ExUnit.Case, async: true use ExUnitProperties alias Hyper.Grpc.Page + defmodule TestResource do + @moduledoc "Minimal Pageable over `{binary_key, payload}`; default 100 / max 1000." + @behaviour Hyper.Grpc.Pageable + + @impl true + def cursor({key, _payload}), do: key + + @impl true + def default_page_size, do: 100 + + @impl true + def max_page_size, do: 1000 + end + defp entries do - gen all(ids <- uniq_list_of(string(:alphanumeric, min_length: 1), max_length: 40)) do - Enum.map(ids, fn id -> {id, :"node@#{:erlang.phash2(id)}"} end) + gen all(keys <- uniq_list_of(string(:alphanumeric, min_length: 1), max_length: 40)) do + Enum.map(keys, fn key -> {key, :"payload_#{:erlang.phash2(key)}"} end) end end defp collect_all(entries, page_size, token \\ "", acc \\ []) do - {:ok, {page, next}} = Page.paginate(entries, page_size, token) + {:ok, {page, next}} = Page.paginate(entries, page_size, token, TestResource) case next do "" -> acc ++ page @@ -39,7 +54,7 @@ defmodule Hyper.Grpc.PagePropertiesTest do property "paging to the end reproduces the whole set, sorted, exactly once" do check all(entries <- entries(), page_size <- integer(1..10)) do walked = collect_all(entries, page_size) - assert walked == Enum.sort_by(entries, fn {id, _node} -> id end) + assert walked == Enum.sort_by(entries, fn {key, _payload} -> key end) end end @@ -52,7 +67,7 @@ defmodule Hyper.Grpc.PagePropertiesTest do property "no page exceeds min(requested, max); non-positive size uses the default" do check all(entries <- entries(), page_size <- integer(-5..2000)) do - {:ok, {page, _next}} = Page.paginate(entries, page_size, "") + {:ok, {page, _next}} = Page.paginate(entries, page_size, "", TestResource) effective = if page_size <= 0, do: 100, else: min(page_size, 1000) assert length(page) <= effective assert length(page) == min(effective, length(entries)) @@ -60,26 +75,27 @@ defmodule Hyper.Grpc.PagePropertiesTest do end test "a malformed page_token is rejected, never treated as start" do - assert {:error, :bad_page_token} = Page.paginate([{"a", :n@h}], 10, "not*valid*base64") + assert {:error, :bad_page_token} = + Page.paginate([{"a", :p}], 10, "not*valid*base64", TestResource) end test "a known set pages exactly, with the cursor threading correctly" do - entries = for id <- ~w(e a j c h b f i d g), do: {id, :node@x} + entries = for key <- ~w(e a j c h b f i d g), do: {key, :p} - assert {:ok, {page1, next1}} = Page.paginate(entries, 3, "") - assert page1 == for(id <- ~w(a b c), do: {id, :node@x}) + assert {:ok, {page1, next1}} = Page.paginate(entries, 3, "", TestResource) + assert page1 == for(key <- ~w(a b c), do: {key, :p}) assert next1 == Base.url_encode64("c", padding: false) - assert {:ok, {page2, next2}} = Page.paginate(entries, 3, next1) - assert page2 == for(id <- ~w(d e f), do: {id, :node@x}) + assert {:ok, {page2, next2}} = Page.paginate(entries, 3, next1, TestResource) + assert page2 == for(key <- ~w(d e f), do: {key, :p}) assert next2 == Base.url_encode64("f", padding: false) - assert {:ok, {page3, next3}} = Page.paginate(entries, 3, next2) - assert page3 == for(id <- ~w(g h i), do: {id, :node@x}) + assert {:ok, {page3, next3}} = Page.paginate(entries, 3, next2, TestResource) + assert page3 == for(key <- ~w(g h i), do: {key, :p}) assert next3 == Base.url_encode64("i", padding: false) - assert {:ok, {page4, next4}} = Page.paginate(entries, 3, next3) - assert page4 == [{"j", :node@x}] + assert {:ok, {page4, next4}} = Page.paginate(entries, 3, next3, TestResource) + assert page4 == [{"j", :p}] assert next4 == "" end end From f318650bc92525344eff81cd50c271e330d18e2b Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 12 Jul 2026 04:53:05 +0000 Subject: [PATCH 12/14] feat(grpc): list_vms paginates via Pageable.Vm --- lib/hyper/grpc/server.ex | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/hyper/grpc/server.ex b/lib/hyper/grpc/server.ex index 8f65ac18..5d7933eb 100644 --- a/lib/hyper/grpc/server.ex +++ b/lib/hyper/grpc/server.ex @@ -97,7 +97,12 @@ defmodule Hyper.Grpc.Server do @spec list_vms(ListVmsRequest.t(), GRPC.Server.Stream.t()) :: ListVmsResponse.t() @decorate with_span("Hyper.Grpc.Server.list_vms") def list_vms(%ListVmsRequest{page_size: page_size, page_token: page_token}, _stream) do - case Hyper.Grpc.Page.paginate(Hyper.Cluster.Routing.all(), page_size, page_token) do + case Hyper.Grpc.Page.paginate( + Hyper.Cluster.Routing.all(), + page_size, + page_token, + Hyper.Grpc.Pageable.Vm + ) do {:ok, {page, next}} -> Codec.to_grpc({:vms, page, next}) {:error, reason} -> raise Codec.to_grpc({:error, reason}) end From 5cae0e9b9f41d06b4a56ab52dc9b2b646c7f2da8 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 12 Jul 2026 04:53:15 +0000 Subject: [PATCH 13/14] refactor(grpc): drop removed Page.entry type from codec spec --- lib/hyper/grpc/codec.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/hyper/grpc/codec.ex b/lib/hyper/grpc/codec.ex index 231fd271..f3218cee 100644 --- a/lib/hyper/grpc/codec.ex +++ b/lib/hyper/grpc/codec.ex @@ -87,7 +87,7 @@ defmodule Hyper.Grpc.Codec do def to_grpc({:usage, vm_id, cpu_time}), do: %GetVmUsageResponse{vm_id: vm_id, cpu_usec: Unit.Time.as_us(cpu_time)} - @spec to_grpc({:vms, [Hyper.Grpc.Page.entry()], String.t()}) :: ListVmsResponse.t() + @spec to_grpc({:vms, [{Hyper.Vm.Id.t(), node()}], String.t()}) :: ListVmsResponse.t() def to_grpc({:vms, vms, next_page_token}), do: %ListVmsResponse{vms: Enum.map(vms, &vm/1), next_page_token: next_page_token} From ae94c92bd6fa476b2ed02ec84c165fd3b3142502 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 12 Jul 2026 05:06:31 +0000 Subject: [PATCH 14/14] deslop --- lib/hyper/grpc/pageable/vm.ex | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/hyper/grpc/pageable/vm.ex b/lib/hyper/grpc/pageable/vm.ex index a1c54e63..e98b2d88 100644 --- a/lib/hyper/grpc/pageable/vm.ex +++ b/lib/hyper/grpc/pageable/vm.ex @@ -1,9 +1,5 @@ defmodule Hyper.Grpc.Pageable.Vm do - @moduledoc """ - `Hyper.Grpc.Pageable` for the cluster VM listing: `{vm_id, node}` pairs from - `Hyper.Cluster.Routing.all/0`. The cursor is the `vm_id` -- already a unique, - totally-ordered binary -- so no extra encoding is needed. - """ + @moduledoc false @behaviour Hyper.Grpc.Pageable @impl true