Skip to content

Commit df1f5fc

Browse files
feat: public gRPC interface (Hyper.Grpc) (#18)
1 parent f2ce56a commit df1f5fc

14 files changed

Lines changed: 680 additions & 18 deletions

File tree

.credo.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
"apps/*/test/",
3232
"apps/*/web/"
3333
],
34-
excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/", "lib/hyper/firecracker/api/operations/", "lib/hyper/firecracker/api/schemas/"]
34+
excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/", "lib/hyper/firecracker/api/operations/", "lib/hyper/firecracker/api/schemas/", "lib/hyper/grpc/v0/"]
3535
},
3636
#
3737
# Load and configure plugins here:

.github/workflows/ci.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@ jobs:
6565
- name: Install dependencies
6666
run: mix deps.get
6767

68+
# protoc + the protoc-gen-elixir escript drive the `:grpc_gen` Mix compiler,
69+
# which generates the (gitignored) gRPC bindings before the Elixir compiler.
70+
- name: Install protoc
71+
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
72+
73+
- name: Install protoc-gen-elixir
74+
run: mix escript.install hex protobuf 0.17.0 --force
75+
6876
- name: Compile (warnings as errors)
6977
# --force matches the `mix check` alias: re-surfaces warnings even on a
7078
# cached _build, where an unchanged module would otherwise be skipped.

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,7 @@ docs/superpowers/
4040
# (priv/firecracker/*.openapi.json) before compile -- see mix.exs gen_firecracker/1.
4141
/lib/hyper/firecracker/api/operations/
4242
/lib/hyper/firecracker/api/schemas/
43+
44+
# gRPC bindings are generated from proto/hyper/grpc/v0/hyper.proto before compile
45+
# -- see the :grpc_gen Mix compiler in mix.exs.
46+
/lib/hyper/grpc/v0/

docs/grpc.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# gRPC interface
2+
3+
Hyper's native API is BEAM-native (Elixir/Erlang processes calling `Hyper.*`).
4+
The gRPC interface puts that same machine lifecycle behind a language-agnostic
5+
contract, so consumers in **any** language -- and off-BEAM services -- can
6+
create, stop, locate, and list microVMs.
7+
8+
> **v0 -- unstable.** The contract may change without notice during early
9+
> development. Pin to a commit if you depend on it.
10+
11+
## Configuration
12+
13+
By default, the gRPC interface is **disabled**. You can enable it by editing
14+
`config/runtime.exs` and setting:
15+
16+
```elixir
17+
config :hyper, Hyper.Grpc.Config,
18+
enabled: true,
19+
port: 50051,
20+
cred: GRPC.Credential.new(
21+
ssl: [certfile: "/path/to/cert.pem", keyfile: "/path/to/key.pem"]
22+
)
23+
```
24+
25+
Note that you can also disable secure mode and use plaintext gRPC:
26+
27+
```elixir
28+
config :hyper, Hyper.Grpc.Config,
29+
enabled: true,
30+
port: 50051
31+
```
32+
33+
> #### TLS Security {: .error}
34+
>
35+
> It is **strongly** advised you run gRPC with SSL enabled. Running
36+
> `Hyper.Grpc` without SSL enabled in production could present a security risk
37+
> and we strongly suggest against it.
38+
39+
## Client Usage
40+
41+
With `Hyper` running, you can create a new `gRPC` client in your favorite
42+
language. We will be using _Python_ here:
43+
44+
```python
45+
import grpc
46+
from google.protobuf import empty_pb2
47+
from hyper.grpc.v0 import hyper_pb2, hyper_pb2_grpc
48+
49+
# Plaintext. For TLS, pass grpc.ssl_channel_credentials(ca_pem) to
50+
# grpc.aio.secure_channel(...) instead.
51+
client = hyper_pb2_grpc.HyperStub(grpc.aio.insecure_channel("localhost:50051"))
52+
```
53+
54+
### Creating VMs
55+
56+
You can create new VMs with the `CreateVm` RPC.
57+
58+
```python
59+
created = await client.CreateVm(
60+
hyper_pb2.CreateVmRequest(
61+
img_id="img-abc",
62+
instance_type=hyper_pb2.INSTANCE_TYPE_DECI,
63+
arch=hyper_pb2.ARCHITECTURE_X86_64,
64+
# boot_args is optional; omit it for the default kernel cmdline.
65+
)
66+
)
67+
print(created.vm_id, created.node)
68+
```
69+
70+
### Listing VMs
71+
72+
You can list running virtual machines with `ListVms`, which takes a
73+
`google.protobuf.Empty`:
74+
75+
```python
76+
listed = await client.ListVms(empty_pb2.Empty())
77+
for vm in listed.vms:
78+
print(vm.vm_id, vm.node)
79+
```
80+
81+
### Getting VM Info
82+
83+
You can query a VM with `GetVm`, which returns the node it runs on:
84+
85+
```python
86+
info = await client.GetVm(hyper_pb2.GetVmRequest(vm_id=created.vm_id))
87+
print(info.vm_id, info.node)
88+
```
89+
90+
### Stopping a VM
91+
92+
You can stop a running VM with `StopVm`, which returns a
93+
`google.protobuf.Empty`:
94+
95+
```python
96+
await client.StopVm(hyper_pb2.StopVmRequest(vm_id=created.vm_id))
97+
```
98+
99+
For full documentation, please read the documentation in the
100+
[`.proto`](https://github.com/harmont-dev/hyper/blob/main/proto/hyper/grpc/v0/hyper.proto)
101+
file.

lib/hyper.ex

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,21 @@ defmodule Hyper do
5151
@doc "Cluster-wide: which node currently runs `vm_id`? `nil` if unknown."
5252
@spec whereis(Hyper.Vm.id()) :: node() | nil
5353
def whereis(vm_id), do: Hyper.Cluster.Routing.whereis(vm_id)
54+
55+
@doc """
56+
The vm id for a VM handle -- the pid returned by `create_vm/1`. Resolves on the
57+
pid's owning node, so a VM just placed on a remote node is found immediately
58+
rather than waiting for the routing CRDT to propagate. `nil` if unknown.
59+
60+
Returns `nil` (rather than crashing the caller) if the owning node is
61+
unreachable -- e.g. it died with a VM just placed on it. In that case the VM
62+
died with its host, so "unknown" is the truthful answer. Only `:erpc`'s own
63+
transport failures are swallowed; a genuine fault in the lookup still raises.
64+
"""
65+
@spec id(Hyper.Vm.t()) :: Hyper.Vm.id() | nil
66+
def id(pid) when is_pid(pid) do
67+
:erpc.call(node(pid), Hyper.Cluster.Routing, :id_for, [pid])
68+
catch
69+
:error, {:erpc, _} -> nil
70+
end
5471
end

lib/hyper/application.ex

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,20 @@ defmodule Hyper.Application do
1414

1515
topologies = Application.get_env(:libcluster, :topologies, [])
1616

17-
children = [
18-
# The image-lineage database. Started first so the rest of the node can
19-
# query images/leases on boot.
20-
Hyper.Img.Db.Repo,
21-
# Form the BEAM cluster (Distributed Erlang) so Horde's `members: :auto`
22-
# can discover peer nodes. Gossip strategy in dev - see config/config.exs.
23-
{Cluster.Supervisor, [topologies, [name: Hyper.ClusterSupervisor]]},
24-
# Cluster-wide CRDTs (VM routing + budget telemetry). Must precede
25-
# Hyper.Node so VM registrations and budget advertisements have their
26-
# registries on boot.
27-
Hyper.Cluster,
28-
Hyper.Node
29-
]
17+
children =
18+
[
19+
# The image-lineage database. Started first so the rest of the node can
20+
# query images/leases on boot.
21+
Hyper.Img.Db.Repo,
22+
# Form the BEAM cluster (Distributed Erlang) so Horde's `members: :auto`
23+
# can discover peer nodes. Gossip strategy in dev - see config/config.exs.
24+
{Cluster.Supervisor, [topologies, [name: Hyper.ClusterSupervisor]]},
25+
# Cluster-wide CRDTs (VM routing + budget telemetry). Must precede
26+
# Hyper.Node so VM registrations and budget advertisements have their
27+
# registries on boot.
28+
Hyper.Cluster,
29+
Hyper.Node
30+
] ++ Hyper.Grpc.server_children()
3031

3132
Supervisor.start_link(children, strategy: :one_for_one, name: Hyper.Supervisor)
3233
end

lib/hyper/cluster/routing.ex

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,29 @@ defmodule Hyper.Cluster.Routing do
3434
[] -> nil
3535
end
3636
end
37+
38+
@doc """
39+
The vm_id whose `:supervisor` process is `pid`, or `nil`. Consults the local
40+
replica via a registry match spec; intended to be called on the node that owns
41+
`pid` (see `Hyper.id/1`).
42+
"""
43+
@spec id_for(pid()) :: Hyper.Vm.id() | nil
44+
def id_for(pid) when is_pid(pid) do
45+
case Horde.Registry.select(@name, [
46+
{{{:"$1", :supervisor}, :"$2", :_}, [{:==, :"$2", pid}], [:"$1"]}
47+
]) do
48+
[vm_id | _] -> vm_id
49+
[] -> nil
50+
end
51+
end
52+
53+
@doc "Every VM the cluster currently knows about, paired with the node it runs on."
54+
@spec all() :: [{Hyper.Vm.id(), node()}]
55+
def all do
56+
@name
57+
|> Horde.Registry.select([
58+
{{{:"$1", :supervisor}, :"$2", :_}, [], [{{:"$1", :"$2"}}]}
59+
])
60+
|> Enum.map(fn {vm_id, pid} -> {vm_id, node(pid)} end)
61+
end
3762
end

lib/hyper/grpc.ex

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
defmodule Hyper.Grpc do
2+
@moduledoc """
3+
Public gRPC interface to a Hyper cluster.
4+
5+
The service contract is `hyper.grpc.v0.Hyper` (see
6+
`proto/hyper/grpc/v0/hyper.proto`). Any gRPC client, in any language, can
7+
create, stop, locate, and list microVMs. Off-BEAM clients generate their own
8+
stubs from the `.proto`; BEAM clients can use the generated
9+
`Hyper.Grpc.V0.Hyper.Stub` together with `connect/2`.
10+
"""
11+
12+
defmodule Config do
13+
@moduledoc """
14+
gRPC server configuration, read from application env into a struct:
15+
16+
config :hyper, Hyper.Grpc.Config,
17+
enabled: true,
18+
port: 50_051,
19+
cred: GRPC.Credential.new(ssl: [certfile: "/path/cert.pem", keyfile: "/path/key.pem"])
20+
21+
Fields:
22+
23+
* `:enabled` -- whether the server starts. Defaults to `false`.
24+
* `:port` -- the listen port. Defaults to `50051`.
25+
* `:cred` -- a `GRPC.Credential` for TLS, or `nil` (the default) for
26+
plaintext.
27+
* `:adapter_opts` -- options forwarded to the server adapter, e.g.
28+
`[ip: {0, 0, 0, 0}]`.
29+
30+
Build the credential where you load your keys (e.g. `config/runtime.exs`);
31+
Hyper never reads the filesystem on your behalf.
32+
33+
> #### Co-located nodes {: .info}
34+
>
35+
> Every node binds `:port`. Running multiple nodes on one host (e.g. a local
36+
> cluster) requires giving each a distinct port via its own config.
37+
"""
38+
39+
@default_port 50_051
40+
41+
defstruct enabled: false, port: @default_port, cred: nil, adapter_opts: []
42+
43+
@type t :: %__MODULE__{
44+
enabled: boolean(),
45+
port: :inet.port_number(),
46+
cred: GRPC.Credential.t() | nil,
47+
adapter_opts: keyword()
48+
}
49+
50+
@doc "Load the gRPC server configuration from application env."
51+
@spec load() :: t()
52+
def load, do: struct!(__MODULE__, Application.get_env(:hyper, __MODULE__, []))
53+
54+
@doc """
55+
The `GRPC.Server.Supervisor` options for this config: the endpoint and port,
56+
plus the TLS credential and adapter options when set.
57+
"""
58+
@spec server_options(t()) :: keyword()
59+
def server_options(%__MODULE__{} = config) do
60+
[endpoint: Hyper.Grpc.Endpoint, port: config.port, start_server: true]
61+
|> put_unless(:cred, config.cred, nil)
62+
|> put_unless(:adapter_opts, config.adapter_opts, [])
63+
end
64+
65+
@spec put_unless(keyword(), atom(), term(), term()) :: keyword()
66+
defp put_unless(opts, _key, skip, skip), do: opts
67+
defp put_unless(opts, key, value, _skip), do: Keyword.put(opts, key, value)
68+
end
69+
70+
@doc """
71+
The gRPC server's supervisor child, or `[]` when the server is disabled (the
72+
default). Spliced into the app supervision tree by `Hyper.Application`.
73+
"""
74+
@spec server_children() :: [{module(), keyword()}]
75+
def server_children do
76+
config = Config.load()
77+
78+
if config.enabled do
79+
[{GRPC.Server.Supervisor, Config.server_options(config)}]
80+
else
81+
[]
82+
end
83+
end
84+
end

0 commit comments

Comments
 (0)