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/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 cfac8eee..afd82646 100644 --- a/docs/grpc.md +++ b/docs/grpc.md @@ -5,17 +5,13 @@ 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. - > #### No authentication {: .warning} > > The gRPC API has **no authentication**. Any client that can reach the port can > 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; the `v0` contract is UNSTABLE. +> encrypts the channel but does not authenticate the client. ## Configuration @@ -61,8 +57,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. @@ -104,13 +99,17 @@ print(created.vm_id, created.node) ### 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,13 +142,13 @@ 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 -[`.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 65f7dd8c..f3218cee 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,9 +8,7 @@ defmodule Hyper.Grpc.Codec do or a `GRPC.RPCError` for the server to raise. """ - alias Google.Protobuf.Empty - - alias Hyper.Grpc.V0.{ + alias Hyper.Grpc.V1.{ CreateVmRequest, CreateVmResponse, ForkVmResponse, @@ -19,6 +17,7 @@ defmodule Hyper.Grpc.Codec do ListVmsResponse, LoadImageRequest, LoadImageResponse, + StopVmResponse, Vm } @@ -88,16 +87,16 @@ 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.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} @spec to_grpc({:loaded, Hyper.Img.id()}) :: LoadImageResponse.t() 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) @@ -105,20 +104,35 @@ 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} @spec rpc_error(term()) :: GRPC.RPCError.t() 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_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..ce7af08b --- /dev/null +++ b/lib/hyper/grpc/page.ex @@ -0,0 +1,71 @@ +defmodule Hyper.Grpc.Page do + @moduledoc """ + 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-`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). + + Invariants are exercised in `test/hyper/grpc/page_properties_test.exs`. + """ + + @type resource :: term() + + @doc """ + One page of `entries` after the cursor in `page_token`, plus the next 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([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(&pageable.cursor/1) + |> drop_through(after_id, pageable) + + {page, rest} = Enum.split(remaining, clamp(page_size, pageable)) + {:ok, {page, next_token(page, rest, pageable)}} + 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(), 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([resource()], binary() | nil, module()) :: [resource()] + defp drop_through(sorted, nil, _pageable), do: sorted + + defp drop_through(sorted, after_id, pageable), + do: Enum.drop_while(sorted, fn e -> pageable.cursor(e) <= after_id end) + + @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, pageable), + do: encode(pageable.cursor(List.last(page))) +end 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 diff --git a/lib/hyper/grpc/pageable/vm.ex b/lib/hyper/grpc/pageable/vm.ex new file mode 100644 index 00000000..e98b2d88 --- /dev/null +++ b/lib/hyper/grpc/pageable/vm.ex @@ -0,0 +1,16 @@ +defmodule Hyper.Grpc.Pageable.Vm do + @moduledoc false + @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/lib/hyper/grpc/server.ex b/lib/hyper/grpc/server.ex index 00ad7c87..5d7933eb 100644 --- a/lib/hyper/grpc/server.ex +++ b/lib/hyper/grpc/server.ex @@ -1,18 +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 Google.Protobuf.Empty alias Hyper.Grpc.Codec - alias Hyper.Grpc.V0.{ + alias Hyper.Grpc.V1.{ CreateVmRequest, CreateVmResponse, ForkVmRequest, @@ -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,17 @@ 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 - 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, + Hyper.Grpc.Pageable.Vm + ) do + {:ok, {page, next}} -> Codec.to_grpc({:vms, page, next}) + {:error, reason} -> raise Codec.to_grpc({:error, reason}) + end end end 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 78% rename from proto/hyper/grpc/v0/hyper.proto rename to proto/hyper/grpc/v1/hyper.proto index a9ca98f6..d5f360c9 100644 --- a/proto/hyper/grpc/v0/hyper.proto +++ b/proto/hyper/grpc/v1/hyper.proto @@ -7,14 +7,9 @@ // 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 `v0` and UNSTABLE. The contract may change without notice -// during early development. Pin to a specific commit if you depend on it. syntax = "proto3"; -package hyper.grpc.v0; - -import "google/protobuf/empty.proto"; +package hyper.grpc.v1; // The VM lifecycle service. // @@ -54,7 +49,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 +67,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. // @@ -93,31 +88,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; @@ -163,6 +159,9 @@ message StopVmRequest { string vm_id = 1; } +// Result of StopVm. Empty today. +message StopVmResponse {} + // Request to locate a VM. message GetVmRequest { // The id of the VM to look up. @@ -193,10 +192,25 @@ message GetVmUsageResponse { uint64 cpu_usec = 2; } +// 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. + 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. `""` when this is the last page. + string next_page_token = 2; } // A VM and where it runs. Returned by ListVms. 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 c2873010..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; @@ -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_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 f15f09bb..69884f04 100644 --- a/test/hyper/grpc/codec_test.exs +++ b/test/hyper/grpc/codec_test.exs @@ -2,8 +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.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} = @@ -37,4 +39,39 @@ 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 + + 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 + + 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..d553b400 --- /dev/null +++ b/test/hyper/grpc/page_properties_test.exs @@ -0,0 +1,101 @@ +defmodule Hyper.Grpc.PagePropertiesTest do + @moduledoc """ + 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 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. + * 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 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(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, TestResource) + + 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 {key, _payload} -> key 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, "", TestResource) + 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", :p}], 10, "not*valid*base64", TestResource) + end + + test "a known set pages exactly, with the cursor threading correctly" do + 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, "", 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, 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, 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, TestResource) + assert page4 == [{"j", :p}] + assert next4 == "" + end +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