Skip to content
4 changes: 4 additions & 0 deletions lib/hyper/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ defmodule Hyper.Config do
@spec firecracker_install_dir :: Path.t()
def firecracker_install_dir, do: Path.join(redist_dir(), "firecracker")

@doc "Directory where `Hyper.Node.FireVMM.VmLinux.Provider` installs guest kernels."
@spec vmlinux_install_dir :: Path.t()
def vmlinux_install_dir, do: Path.join(redist_dir(), "vmlinux")

@doc """
Path to the directory where all VM chroot's are created (`<work_dir>/jails`).

Expand Down
1 change: 1 addition & 0 deletions lib/hyper/node.ex
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ defmodule Hyper.Node do
def test_system do
with {:ok, _} <- Hyper.Node.Config.Budget.load(),
:ok <- Hyper.Node.FireVMM.Provider.ensure_installed(),
:ok <- Hyper.Node.FireVMM.VmLinux.Provider.ensure_installed(),
:ok <- Hyper.Node.Vmlinux.test_system(),
:ok <- Hyper.Node.Users.test_system(),
:ok <- Hyper.Node.Layer.Repo.test_system(),
Expand Down
83 changes: 83 additions & 0 deletions lib/hyper/node/fire_vmm/vm_linux/manifest.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
defmodule Hyper.Node.FireVMM.VmLinux.Manifest do
@moduledoc """
The statically-embedded vmlinux release manifest.

`priv/vmlinux/manifest.json` is vendored verbatim from a pinned
`github.com/harmont-dev/hyper-vmlinux` release and read at compile time, so the
per-build SHA-256 sums (used by `Hyper.Node.FireVMM.VmLinux.Provider` to verify
downloads) are baked into the compiled module and cannot drift from what was
published. `@external_resource` forces a recompile if the file changes.

All functions here are pure; they never touch the network or the filesystem.
"""

alias Hyper.Node.FireVMM.VmLinux.Manifest.Build

@repo "harmont-dev/hyper-vmlinux"
@manifest_path "priv/vmlinux/manifest.json"
@external_resource @manifest_path

@decoded @manifest_path |> File.read!() |> Jason.decode!()
@sha @decoded["sha"]
@builds (for b <- @decoded["builds"] do
arch =
case b["arch"] do
"x86_64" -> :x86_64
"aarch64" -> :aarch64
end

%Build{
name: b["name"],
arch: arch,
version: b["version"],
asset: b["asset"],
sha256: b["sha256"]
}
end)

@doc "All builds in the manifest."
@spec builds() :: [Build.t()]
def builds, do: @builds

@doc "All builds matching `arch`."
@spec builds_for(Sys.Arch.t()) :: [Build.t()]
def builds_for(arch), do: Enum.filter(@builds, &(&1.arch == arch))

@doc "Look up a build by its `name` (e.g. \"x86_64-6.1\")."
@spec fetch(String.t()) :: {:ok, Build.t()} | :error
def fetch(name) do
case Enum.find(@builds, &(&1.name == name)) do
nil -> :error
build -> {:ok, build}
end
end

@doc """
The default build for `arch`: the highest `version`, breaking ties toward the
shorter `name` (so a plain build wins over a variant like `-no-acpi`). Returns
`nil` if the manifest has no build for `arch`.
"""
@spec default_for(Sys.Arch.t()) :: Build.t() | nil
def default_for(arch) do
arch
|> builds_for()
|> Enum.sort(&preferred?/2)
|> List.first()
end

@doc "The download URL for `build`'s asset on the pinned release."
@spec asset_url(Build.t()) :: String.t()
def asset_url(%Build{asset: asset}) do
"https://github.com/#{@repo}/releases/download/release-#{@sha}/#{asset}"
end

# True if `a` is the more-preferred default than `b`: higher version first,
# then shorter name (plain build over variant).
defp preferred?(%Build{} = a, %Build{} = b) do
case Version.compare(a.version, b.version) do
:gt -> true
:lt -> false
:eq -> String.length(a.name) <= String.length(b.name)
end
end
end
13 changes: 13 additions & 0 deletions lib/hyper/node/fire_vmm/vm_linux/manifest/build.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
defmodule Hyper.Node.FireVMM.VmLinux.Manifest.Build do
@moduledoc "One kernel build from the manifest."
@enforce_keys [:name, :arch, :version, :asset, :sha256]
defstruct [:name, :arch, :version, :asset, :sha256]

@type t :: %__MODULE__{
name: String.t(),
arch: Sys.Arch.t(),
version: String.t(),
asset: String.t(),
sha256: String.t()
}
end
94 changes: 94 additions & 0 deletions lib/hyper/node/fire_vmm/vm_linux/provider.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
defmodule Hyper.Node.FireVMM.VmLinux.Provider do
@moduledoc """
Installs the guest-kernel (vmlinux) images for the current architecture into
`Hyper.Config.vmlinux_install_dir/0` (`<work_dir>/redist/vmlinux`).

The available kernels and their SHA-256 sums come from the statically-embedded
`Hyper.Node.FireVMM.VmLinux.Manifest`. `ensure_installed/0` installs *every*
build for this node's architecture and is idempotent: if all the expected
images are already present it returns `:ok` without touching the network.
Otherwise it fetches each missing image via `Hyper.Redist.File` (download,
SHA-256 verify, install).

This is the download-side counterpart to operator-provided kernels; see
`Hyper.Node.Vmlinux`, which prefers an operator-configured path and falls back
to `default_path/1` here.
"""

alias Hyper.Node.FireVMM.VmLinux.Manifest
alias Hyper.Redist

@doc "Ensure every kernel for this node's architecture is installed."
@spec ensure_installed() :: :ok | {:error, term()}
def ensure_installed do
with {:ok, arch} <- Sys.Arch.current() do
builds = Manifest.builds_for(arch)

case install_state(install_dir(), builds) do
:ok -> :ok
{:error, :not_installed} -> install_all(builds)
{:error, :bad_install} -> reinstall(builds)
end
end
end

@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)."
@spec path(String.t()) :: {:ok, Path.t()} | {:error, {:unknown_build, String.t()}}
def path(name) do
case Manifest.fetch(name) do
{:ok, build} -> {:ok, build_path(install_dir(), build)}
:error -> {:error, {:unknown_build, name}}
end
end

@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)."
@spec default_path(Sys.Arch.t()) :: {:ok, Path.t()} | {:error, {:no_kernel, Sys.Arch.t()}}
def default_path(arch) do
case Manifest.default_for(arch) do
nil -> {:error, {:no_kernel, arch}}
build -> {:ok, build_path(install_dir(), build)}
end
end

@doc """
Install state of `builds` under `dir`: `:ok` if every asset file is present;
`{:error, :not_installed}` if none are; `{:error, :bad_install}` if only some
are (a partial/corrupt install - `Hyper.Redist.File` keeps existing files, so
the remedy is to wipe and reinstall).
"""
@spec install_state(Path.t(), [Manifest.Build.t()]) ::
:ok | {:error, :not_installed | :bad_install}
def install_state(dir, builds) do
present = Enum.count(builds, &File.regular?(build_path(dir, &1)))

cond do
present == length(builds) -> :ok
present == 0 -> {:error, :not_installed}
true -> {:error, :bad_install}
end
end

defp install_all(builds) do
Enum.reduce_while(builds, :ok, fn build, :ok ->
case Redist.File.install(
Manifest.asset_url(build),
build.sha256,
build_path(install_dir(), build)
) do
:ok -> {:cont, :ok}
{:error, _} = err -> {:halt, err}
end
end)
end

# Safe to wipe wholesale: install_dir/0 is Hyper's own redist cache
# (<work_dir>/redist/vmlinux), never operator data.
defp reinstall(builds) do
_ = File.rm_rf!(install_dir())
install_all(builds)
end

defp build_path(dir, build), do: Path.join(dir, build.asset)

defp install_dir, do: Hyper.Config.vmlinux_install_dir()
end
60 changes: 44 additions & 16 deletions lib/hyper/node/vmlinux.ex
Original file line number Diff line number Diff line change
@@ -1,35 +1,63 @@
defmodule Hyper.Node.Vmlinux do
@moduledoc """
Resolves the guest kernel (vmlinux) image for this node. Unlike
`Hyper.Node.FireVMM.Provider` (which downloads firecracker), kernels are
pre-provisioned by the operator; their per-architecture paths are configured
via `config :hyper, vmlinux: %{<arch> => <path>}` (see `Hyper.Config.vmlinux/0`).

`test_system/0` verifies the image for this node's architecture is actually
present on disk, so a misconfigured node aborts at boot instead of failing the
first VM launch.
Resolves the guest kernel (vmlinux) image for this node.

Two sources, in priority order:

1. An operator-configured path for the node's architecture, via
`config :hyper, vmlinux: %{<arch> => <path>}` (see `Hyper.Config.vmlinux/0`).
If set, it wins - the operator can pin a custom kernel.
2. Otherwise, the default kernel downloaded by
`Hyper.Node.FireVMM.VmLinux.Provider` (highest version for the arch).

`test_system/0` verifies that whichever source applies actually yields a file
on disk for this node's architecture, so a misconfigured node aborts at boot
instead of failing the first VM launch.
"""

@doc "Absolute path to the configured vmlinux image for `arch`."
alias Hyper.Node.FireVMM.VmLinux.Provider

@doc """
Absolute path to the kernel image for `arch`: the operator-configured path if
set, otherwise the Provider's default kernel. Raises if neither resolves
(boot's `test_system/0` is expected to have caught that first).
"""
@spec path(Sys.Arch.t()) :: Path.t()
def path(arch), do: Map.fetch!(Hyper.Config.vmlinux(), arch)
def path(arch) do
case Map.fetch(Hyper.Config.vmlinux(), arch) do
{:ok, path} ->
path

:error ->
{:ok, path} = Provider.default_path(arch)
path
end
end

@doc """
Ensure this node's vmlinux image is configured and present. Returns
`{:error, {:vmlinux_unconfigured, arch}}` if no path is set for the node's
architecture, or `{:error, {:vmlinux_missing, path}}` if the configured file
is absent.
Ensure this node's vmlinux image is present. With an operator-configured path,
returns `{:error, {:vmlinux_missing, path}}` if that file is absent. Otherwise
falls back to the Provider's default kernel and returns
`{:error, {:vmlinux_missing, path}}` if it is absent, or `{:error, {:no_kernel, arch}}`
if the manifest has no kernel for this architecture.
"""
@spec test_system() :: :ok | {:error, term()}
def test_system do
with {:ok, arch} <- Sys.Arch.current() do
case Map.fetch(Hyper.Config.vmlinux(), arch) do
{:ok, path} ->
if File.regular?(path), do: :ok, else: {:error, {:vmlinux_missing, path}}
present(path)

:error ->
{:error, {:vmlinux_unconfigured, arch}}
case Provider.default_path(arch) do
{:ok, path} -> present(path)
{:error, _} = err -> err
end
end
end
end

defp present(path) do
if File.regular?(path), do: :ok, else: {:error, {:vmlinux_missing, path}}
end
end
54 changes: 54 additions & 0 deletions lib/hyper/redist/file.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
defmodule Hyper.Redist.File do
@moduledoc """
Fetches a single raw file from a URL, verifies its SHA-256, and installs it at
a destination path. The raw-file analogue of `Hyper.Redist.Targz` (used for
assets that ship as plain files rather than tarballs, e.g. vmlinux images).
"""

alias Hyper.Redist.Sha256

@doc """
Download `url`, verify its SHA-256 equals `sha256` (lowercase hex), and install
the file at `dest_path` (creating parent directories). The download lands in a
temp dir first, so a failed verify never leaves a partial file at `dest_path`.
"""
@spec install(String.t(), String.t(), Path.t()) ::
:ok
| {:error,
{:download_failed, non_neg_integer()}
| {:download_error, term()}
| {:checksum_mismatch, String.t(), String.t()}
| {:install_failed, term()}}
def install(url, sha256, dest_path) do
Sys.Tmp.with_tempdir("hyper-redist", fn tmp ->
tmp_file = Path.join(tmp, "download")

with :ok <- fetch(url, tmp_file),
:ok <- verify_checksum(tmp_file, sha256) do
place(tmp_file, dest_path)
end
end)
end

defp verify_checksum(path, expected) do
actual = Sha256.file(path)
if actual == expected, do: :ok, else: {:error, {:checksum_mismatch, expected, actual}}
end

defp place(src, dest) do
File.mkdir_p!(Path.dirname(dest))

case File.cp(src, dest) do
:ok -> :ok
{:error, reason} -> {:error, {:install_failed, reason}}
end
end

defp fetch(url, dest_path) do
case Req.get(url, into: File.stream!(dest_path), redirect: true, max_redirects: 5) do
{:ok, %Req.Response{status: 200}} -> :ok
{:ok, %Req.Response{status: status}} -> {:error, {:download_failed, status}}
{:error, reason} -> {:error, {:download_error, reason}}
end
end
end
20 changes: 20 additions & 0 deletions lib/hyper/redist/sha256.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
defmodule Hyper.Redist.Sha256 do
@moduledoc "Streaming SHA-256 of a file, returned as lowercase hex."

@doc "Streaming SHA-256 of the file at `path`, as lowercase hex."
@spec file(Path.t()) :: String.t()
def file(path) do
path
|> File.open!([:read, :binary, :raw], fn io -> hash_io(io, :crypto.hash_init(:sha256)) end)
|> :crypto.hash_final()
|> Base.encode16(case: :lower)
end

# Fold the file through the hash context in 2 MiB chunks.
defp hash_io(io, ctx) do
case :file.read(io, 2 * 1024 * 1024) do
{:ok, data} -> hash_io(io, :crypto.hash_update(ctx, data))
:eof -> ctx
end
end
end
Loading
Loading