Skip to content

Commit a3225b2

Browse files
feat: guest-kernel (vmlinux) Provider (#20)
1 parent 4b10e5d commit a3225b2

14 files changed

Lines changed: 515 additions & 33 deletions

File tree

lib/hyper/config.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ defmodule Hyper.Config do
5858
@spec firecracker_install_dir :: Path.t()
5959
def firecracker_install_dir, do: Path.join(redist_dir(), "firecracker")
6060

61+
@doc "Directory where `Hyper.Node.FireVMM.VmLinux.Provider` installs guest kernels."
62+
@spec vmlinux_install_dir :: Path.t()
63+
def vmlinux_install_dir, do: Path.join(redist_dir(), "vmlinux")
64+
6165
@doc """
6266
Path to the directory where all VM chroot's are created (`<work_dir>/jails`).
6367

lib/hyper/node.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ defmodule Hyper.Node do
145145
def test_system do
146146
with {:ok, _} <- Hyper.Node.Config.Budget.load(),
147147
:ok <- Hyper.Node.FireVMM.Provider.ensure_installed(),
148+
:ok <- Hyper.Node.FireVMM.VmLinux.Provider.ensure_installed(),
148149
:ok <- Hyper.Node.Vmlinux.test_system(),
149150
:ok <- Hyper.Node.Users.test_system(),
150151
:ok <- Hyper.Node.Layer.Repo.test_system(),
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
defmodule Hyper.Node.FireVMM.VmLinux.Manifest do
2+
@moduledoc """
3+
The statically-embedded vmlinux release manifest.
4+
5+
`priv/vmlinux/manifest.json` is vendored verbatim from a pinned
6+
`github.com/harmont-dev/hyper-vmlinux` release and read at compile time, so the
7+
per-build SHA-256 sums (used by `Hyper.Node.FireVMM.VmLinux.Provider` to verify
8+
downloads) are baked into the compiled module and cannot drift from what was
9+
published. `@external_resource` forces a recompile if the file changes.
10+
11+
All functions here are pure; they never touch the network or the filesystem.
12+
"""
13+
14+
alias Hyper.Node.FireVMM.VmLinux.Manifest.Build
15+
16+
@repo "harmont-dev/hyper-vmlinux"
17+
@manifest_path "priv/vmlinux/manifest.json"
18+
@external_resource @manifest_path
19+
20+
@decoded @manifest_path |> File.read!() |> Jason.decode!()
21+
@sha @decoded["sha"]
22+
@builds (for b <- @decoded["builds"] do
23+
arch =
24+
case b["arch"] do
25+
"x86_64" -> :x86_64
26+
"aarch64" -> :aarch64
27+
end
28+
29+
%Build{
30+
name: b["name"],
31+
arch: arch,
32+
version: b["version"],
33+
asset: b["asset"],
34+
sha256: b["sha256"]
35+
}
36+
end)
37+
38+
@doc "All builds in the manifest."
39+
@spec builds() :: [Build.t()]
40+
def builds, do: @builds
41+
42+
@doc "All builds matching `arch`."
43+
@spec builds_for(Sys.Arch.t()) :: [Build.t()]
44+
def builds_for(arch), do: Enum.filter(@builds, &(&1.arch == arch))
45+
46+
@doc "Look up a build by its `name` (e.g. \"x86_64-6.1\")."
47+
@spec fetch(String.t()) :: {:ok, Build.t()} | :error
48+
def fetch(name) do
49+
case Enum.find(@builds, &(&1.name == name)) do
50+
nil -> :error
51+
build -> {:ok, build}
52+
end
53+
end
54+
55+
@doc """
56+
The default build for `arch`: the highest `version`, breaking ties toward the
57+
shorter `name` (so a plain build wins over a variant like `-no-acpi`). Returns
58+
`nil` if the manifest has no build for `arch`.
59+
"""
60+
@spec default_for(Sys.Arch.t()) :: Build.t() | nil
61+
def default_for(arch) do
62+
arch
63+
|> builds_for()
64+
|> Enum.sort(&preferred?/2)
65+
|> List.first()
66+
end
67+
68+
@doc "The download URL for `build`'s asset on the pinned release."
69+
@spec asset_url(Build.t()) :: String.t()
70+
def asset_url(%Build{asset: asset}) do
71+
"https://github.com/#{@repo}/releases/download/release-#{@sha}/#{asset}"
72+
end
73+
74+
# True if `a` is the more-preferred default than `b`: higher version first,
75+
# then shorter name (plain build over variant).
76+
defp preferred?(%Build{} = a, %Build{} = b) do
77+
case Version.compare(a.version, b.version) do
78+
:gt -> true
79+
:lt -> false
80+
:eq -> String.length(a.name) <= String.length(b.name)
81+
end
82+
end
83+
end
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
defmodule Hyper.Node.FireVMM.VmLinux.Manifest.Build do
2+
@moduledoc "One kernel build from the manifest."
3+
@enforce_keys [:name, :arch, :version, :asset, :sha256]
4+
defstruct [:name, :arch, :version, :asset, :sha256]
5+
6+
@type t :: %__MODULE__{
7+
name: String.t(),
8+
arch: Sys.Arch.t(),
9+
version: String.t(),
10+
asset: String.t(),
11+
sha256: String.t()
12+
}
13+
end
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
defmodule Hyper.Node.FireVMM.VmLinux.Provider do
2+
@moduledoc """
3+
Installs the guest-kernel (vmlinux) images for the current architecture into
4+
`Hyper.Config.vmlinux_install_dir/0` (`<work_dir>/redist/vmlinux`).
5+
6+
The available kernels and their SHA-256 sums come from the statically-embedded
7+
`Hyper.Node.FireVMM.VmLinux.Manifest`. `ensure_installed/0` installs *every*
8+
build for this node's architecture and is idempotent: if all the expected
9+
images are already present it returns `:ok` without touching the network.
10+
Otherwise it fetches each missing image via `Hyper.Redist.File` (download,
11+
SHA-256 verify, install).
12+
13+
This is the download-side counterpart to operator-provided kernels; see
14+
`Hyper.Node.Vmlinux`, which prefers an operator-configured path and falls back
15+
to `default_path/1` here.
16+
"""
17+
18+
alias Hyper.Node.FireVMM.VmLinux.Manifest
19+
alias Hyper.Redist
20+
21+
@doc "Ensure every kernel for this node's architecture is installed."
22+
@spec ensure_installed() :: :ok | {:error, term()}
23+
def ensure_installed do
24+
with {:ok, arch} <- Sys.Arch.current() do
25+
builds = Manifest.builds_for(arch)
26+
27+
case install_state(install_dir(), builds) do
28+
:ok -> :ok
29+
{:error, :not_installed} -> install_all(builds)
30+
{:error, :bad_install} -> reinstall(builds)
31+
end
32+
end
33+
end
34+
35+
@doc "Absolute path to the installed kernel for build `name` (e.g. \"x86_64-6.1\"). The returned path is only guaranteed to exist for the node's current architecture (the one `ensure_installed/0` installs)."
36+
@spec path(String.t()) :: {:ok, Path.t()} | {:error, {:unknown_build, String.t()}}
37+
def path(name) do
38+
case Manifest.fetch(name) do
39+
{:ok, build} -> {:ok, build_path(install_dir(), build)}
40+
:error -> {:error, {:unknown_build, name}}
41+
end
42+
end
43+
44+
@doc "Absolute path to the default (highest-version) kernel for `arch`. The returned path is only guaranteed to exist for the node's current architecture (the one `ensure_installed/0` installs)."
45+
@spec default_path(Sys.Arch.t()) :: {:ok, Path.t()} | {:error, {:no_kernel, Sys.Arch.t()}}
46+
def default_path(arch) do
47+
case Manifest.default_for(arch) do
48+
nil -> {:error, {:no_kernel, arch}}
49+
build -> {:ok, build_path(install_dir(), build)}
50+
end
51+
end
52+
53+
@doc """
54+
Install state of `builds` under `dir`: `:ok` if every asset file is present;
55+
`{:error, :not_installed}` if none are; `{:error, :bad_install}` if only some
56+
are (a partial/corrupt install - `Hyper.Redist.File` keeps existing files, so
57+
the remedy is to wipe and reinstall).
58+
"""
59+
@spec install_state(Path.t(), [Manifest.Build.t()]) ::
60+
:ok | {:error, :not_installed | :bad_install}
61+
def install_state(dir, builds) do
62+
present = Enum.count(builds, &File.regular?(build_path(dir, &1)))
63+
64+
cond do
65+
present == length(builds) -> :ok
66+
present == 0 -> {:error, :not_installed}
67+
true -> {:error, :bad_install}
68+
end
69+
end
70+
71+
defp install_all(builds) do
72+
Enum.reduce_while(builds, :ok, fn build, :ok ->
73+
case Redist.File.install(
74+
Manifest.asset_url(build),
75+
build.sha256,
76+
build_path(install_dir(), build)
77+
) do
78+
:ok -> {:cont, :ok}
79+
{:error, _} = err -> {:halt, err}
80+
end
81+
end)
82+
end
83+
84+
# Safe to wipe wholesale: install_dir/0 is Hyper's own redist cache
85+
# (<work_dir>/redist/vmlinux), never operator data.
86+
defp reinstall(builds) do
87+
_ = File.rm_rf!(install_dir())
88+
install_all(builds)
89+
end
90+
91+
defp build_path(dir, build), do: Path.join(dir, build.asset)
92+
93+
defp install_dir, do: Hyper.Config.vmlinux_install_dir()
94+
end

lib/hyper/node/vmlinux.ex

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,63 @@
11
defmodule Hyper.Node.Vmlinux do
22
@moduledoc """
3-
Resolves the guest kernel (vmlinux) image for this node. Unlike
4-
`Hyper.Node.FireVMM.Provider` (which downloads firecracker), kernels are
5-
pre-provisioned by the operator; their per-architecture paths are configured
6-
via `config :hyper, vmlinux: %{<arch> => <path>}` (see `Hyper.Config.vmlinux/0`).
7-
8-
`test_system/0` verifies the image for this node's architecture is actually
9-
present on disk, so a misconfigured node aborts at boot instead of failing the
10-
first VM launch.
3+
Resolves the guest kernel (vmlinux) image for this node.
4+
5+
Two sources, in priority order:
6+
7+
1. An operator-configured path for the node's architecture, via
8+
`config :hyper, vmlinux: %{<arch> => <path>}` (see `Hyper.Config.vmlinux/0`).
9+
If set, it wins - the operator can pin a custom kernel.
10+
2. Otherwise, the default kernel downloaded by
11+
`Hyper.Node.FireVMM.VmLinux.Provider` (highest version for the arch).
12+
13+
`test_system/0` verifies that whichever source applies actually yields a file
14+
on disk for this node's architecture, so a misconfigured node aborts at boot
15+
instead of failing the first VM launch.
1116
"""
1217

13-
@doc "Absolute path to the configured vmlinux image for `arch`."
18+
alias Hyper.Node.FireVMM.VmLinux.Provider
19+
20+
@doc """
21+
Absolute path to the kernel image for `arch`: the operator-configured path if
22+
set, otherwise the Provider's default kernel. Raises if neither resolves
23+
(boot's `test_system/0` is expected to have caught that first).
24+
"""
1425
@spec path(Sys.Arch.t()) :: Path.t()
15-
def path(arch), do: Map.fetch!(Hyper.Config.vmlinux(), arch)
26+
def path(arch) do
27+
case Map.fetch(Hyper.Config.vmlinux(), arch) do
28+
{:ok, path} ->
29+
path
30+
31+
:error ->
32+
{:ok, path} = Provider.default_path(arch)
33+
path
34+
end
35+
end
1636

1737
@doc """
18-
Ensure this node's vmlinux image is configured and present. Returns
19-
`{:error, {:vmlinux_unconfigured, arch}}` if no path is set for the node's
20-
architecture, or `{:error, {:vmlinux_missing, path}}` if the configured file
21-
is absent.
38+
Ensure this node's vmlinux image is present. With an operator-configured path,
39+
returns `{:error, {:vmlinux_missing, path}}` if that file is absent. Otherwise
40+
falls back to the Provider's default kernel and returns
41+
`{:error, {:vmlinux_missing, path}}` if it is absent, or `{:error, {:no_kernel, arch}}`
42+
if the manifest has no kernel for this architecture.
2243
"""
2344
@spec test_system() :: :ok | {:error, term()}
2445
def test_system do
2546
with {:ok, arch} <- Sys.Arch.current() do
2647
case Map.fetch(Hyper.Config.vmlinux(), arch) do
2748
{:ok, path} ->
28-
if File.regular?(path), do: :ok, else: {:error, {:vmlinux_missing, path}}
49+
present(path)
2950

3051
:error ->
31-
{:error, {:vmlinux_unconfigured, arch}}
52+
case Provider.default_path(arch) do
53+
{:ok, path} -> present(path)
54+
{:error, _} = err -> err
55+
end
3256
end
3357
end
3458
end
59+
60+
defp present(path) do
61+
if File.regular?(path), do: :ok, else: {:error, {:vmlinux_missing, path}}
62+
end
3563
end

lib/hyper/redist/file.ex

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
defmodule Hyper.Redist.File do
2+
@moduledoc """
3+
Fetches a single raw file from a URL, verifies its SHA-256, and installs it at
4+
a destination path. The raw-file analogue of `Hyper.Redist.Targz` (used for
5+
assets that ship as plain files rather than tarballs, e.g. vmlinux images).
6+
"""
7+
8+
alias Hyper.Redist.Sha256
9+
10+
@doc """
11+
Download `url`, verify its SHA-256 equals `sha256` (lowercase hex), and install
12+
the file at `dest_path` (creating parent directories). The download lands in a
13+
temp dir first, so a failed verify never leaves a partial file at `dest_path`.
14+
"""
15+
@spec install(String.t(), String.t(), Path.t()) ::
16+
:ok
17+
| {:error,
18+
{:download_failed, non_neg_integer()}
19+
| {:download_error, term()}
20+
| {:checksum_mismatch, String.t(), String.t()}
21+
| {:install_failed, term()}}
22+
def install(url, sha256, dest_path) do
23+
Sys.Tmp.with_tempdir("hyper-redist", fn tmp ->
24+
tmp_file = Path.join(tmp, "download")
25+
26+
with :ok <- fetch(url, tmp_file),
27+
:ok <- verify_checksum(tmp_file, sha256) do
28+
place(tmp_file, dest_path)
29+
end
30+
end)
31+
end
32+
33+
defp verify_checksum(path, expected) do
34+
actual = Sha256.file(path)
35+
if actual == expected, do: :ok, else: {:error, {:checksum_mismatch, expected, actual}}
36+
end
37+
38+
defp place(src, dest) do
39+
File.mkdir_p!(Path.dirname(dest))
40+
41+
case File.cp(src, dest) do
42+
:ok -> :ok
43+
{:error, reason} -> {:error, {:install_failed, reason}}
44+
end
45+
end
46+
47+
defp fetch(url, dest_path) do
48+
case Req.get(url, into: File.stream!(dest_path), redirect: true, max_redirects: 5) do
49+
{:ok, %Req.Response{status: 200}} -> :ok
50+
{:ok, %Req.Response{status: status}} -> {:error, {:download_failed, status}}
51+
{:error, reason} -> {:error, {:download_error, reason}}
52+
end
53+
end
54+
end

lib/hyper/redist/sha256.ex

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
defmodule Hyper.Redist.Sha256 do
2+
@moduledoc "Streaming SHA-256 of a file, returned as lowercase hex."
3+
4+
@doc "Streaming SHA-256 of the file at `path`, as lowercase hex."
5+
@spec file(Path.t()) :: String.t()
6+
def file(path) do
7+
path
8+
|> File.open!([:read, :binary, :raw], fn io -> hash_io(io, :crypto.hash_init(:sha256)) end)
9+
|> :crypto.hash_final()
10+
|> Base.encode16(case: :lower)
11+
end
12+
13+
# Fold the file through the hash context in 2 MiB chunks.
14+
defp hash_io(io, ctx) do
15+
case :file.read(io, 2 * 1024 * 1024) do
16+
{:ok, data} -> hash_io(io, :crypto.hash_update(ctx, data))
17+
:eof -> ctx
18+
end
19+
end
20+
end

0 commit comments

Comments
 (0)