defmodule Hyper.MixProject do use Mix.Project def project do [ app: :hyper, version: "0.1.0", elixir: "~> 1.20", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, name: "Hyper", source_url: "https://github.com/harmont-dev/hyper", # Generate the (gitignored) Firecracker bindings before the Elixir compiler. # A Mix compiler (not a `compile` alias) is used because Mix honors a # dependency's `:compilers` but NOT its aliases or `config/` -- so this is # the only hook that also fires when hyper is compiled AS A DEPENDENCY. compilers: [ :suidhelper_stamp, :guest_agent_build, :firecracker_gen, :grpc_gen | Mix.compilers() ], deps: deps(), test_coverage: [tool: ExCoveralls], docs: docs(), package: package(), aliases: aliases(), dialyzer: [ # Cache the PLTs in a stable, gitignored dir so CI can cache them. plt_local_path: "priv/plts", plt_core_path: "priv/plts", # `:mix` is needed so the Mix tasks under `lib/mix/tasks` (which call # `Mix.raise/1`, `Mix.shell/0`, and implement the `Mix.Task` behaviour) # resolve instead of tripping `unknown_function`. plt_add_apps: [:mix], # Verify @specs against actual returns, and flag ignored return values. flags: [:unmatched_returns, :extra_return, :missing_return] ] ] end # Run the coverage tasks in :test so test-only deps (excoveralls) load and the # Repo-backed tests see the test database. Mirrors how `mix test` selects :test. def cli do [ preferred_envs: [ coveralls: :test, "coveralls.json": :test, "coveralls.html": :test ] ] end # Run "mix help compile.app" to learn about applications. def application do [ extra_applications: [:logger], mod: {Hyper.Application, []}, # ecto_repos lives here (not config.exs) since it's well-known and # compile-time fixed. Mix's ecto.* tasks read it from the app env. env: [ecto_repos: [Hyper.Img.Db.Repo]] ] end # `test/support` holds test-only helpers (e.g. the Redist HTTP test server); # compile it only in :test so it never ships in dev/prod builds. defp elixirc_paths(:test), do: ["lib", "test/support"] defp elixirc_paths(_), do: ["lib"] # Run "mix help deps" to learn about dependencies. defp deps do [ {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:stream_data, "~> 1.0", only: [:dev, :test], runtime: false}, {:excoveralls, "~> 0.18", only: :test, runtime: false}, {:junit_formatter, "~> 3.4", only: :test, runtime: false}, {:dialyxir, "~> 1.4", only: [:dev], runtime: false}, {:ex_doc, "~> 0.34", only: :dev, runtime: false}, # Syntect-backed Makeup lexer: covers the doc languages that have no # dedicated Makeup lexer (markdown, toml, bash, sh, python). Elixir/erlang # still use their native lexers; this fills the rest in one dep. {:makeup_syntect, "~> 0.1", only: :dev, runtime: false}, {:ecto_sql, "~> 3.13"}, {:grpc, "~> 1.0"}, {:gun, "~> 2.0"}, {:grpc_server, "~> 1.0"}, {:horde, "~> 0.9"}, {:jason, "~> 1.4"}, {:libcluster, "~> 3.3"}, {:muontrap, "~> 1.5"}, {:open_telemetry_decorator, "~> 1.5"}, {:opentelemetry_api, "~> 1.4"}, {:opentelemetry, "~> 1.5"}, {:opentelemetry_ecto, "~> 1.2"}, {:opentelemetry_exporter, "~> 1.8"}, {:postgrex, "~> 0.20"}, {:protobuf, "~> 0.17"}, {:req, "~> 0.5"}, {:toml, "~> 0.7"}, {:uuidv4, "~> 1.0"}, # Not `only: :dev`: the generated Firecracker bindings are gitignored and # produced by the `:firecracker_gen` Mix compiler, which runs wherever hyper # is compiled -- including as a dependency of another app, where Mix won't # load hyper's `config/` or aliases. So the generator must be available in # every env that compiles hyper (deps included). `runtime: false` keeps it # compile-only and out of releases. {:oapi_generator, "~> 0.4.0", runtime: false} ] end # ExDoc config - drives `mix docs` and what HexDocs renders. defp docs do [ # Landing page of the docs site. main: "readme", # Inject Mermaid so ```mermaid fences in docs render as diagrams. before_closing_body_tag: &before_closing_body_tag/1, # Narrative/guide pages rendered alongside the API reference. extras: [ "README.md", "docs/cookbook/quickstart.md", "docs/cookbook/intro.md", "docs/cookbook/install.md", "docs/cookbook/config.md", "docs/cookbook/architecture.md", "docs/grpc.md" ], groups_for_extras: [ Cookbook: ~r/docs\/cookbook\/.*/ ], # Group modules in the sidebar by namespace. Each value is a regex matched # against the module name, so new modules join their group automatically -- # no per-module edits here. The patterns are mutually exclusive, so the # listing order is purely cosmetic. (`Sys.Mon.*` -> Monitoring; every other # `Sys.Posix`/`Sys.Linux.*`, including the /proc parsers, -> System.) groups_for_modules: [ VM: ~r/^Hyper\.Vm(\.|$)/, Node: ~r/^Hyper\.Node(\.|$)/, Images: ~r/^Hyper\.(Img|Layer)(\.|$)/, Controls: ~r/^Controls\./, Monitoring: ~r/^Sys\.Mon(\.|$)/, System: ~r/^(Sys\.|Hyper\.SuidHelper$)/, Units: ~r/^Unit\./ ] ] end # Load Mermaid in the HTML docs and render any ```mermaid code fences as # diagrams. ExDoc tags Mermaid blocks with the `mermaid` class. defp before_closing_body_tag(:html) do """ """ end defp before_closing_body_tag(_), do: "" # Hex package metadata. Required for `mix hex.publish`. defp package do [ description: "A distributed orchestrator for Firecracker microVMs.", # The OTP app is `:hyper`, but `hyper` is already taken on Hex, so the # package publishes as `hypervm`. Package name and app name are independent. name: "hypervm", licenses: ["MIT"], # priv/firecracker ships the OpenAPI spec so the `:firecracker_gen` compiler # can regenerate the bindings in a consumer's build (they are gitignored, so # not in `lib`). # proto/ ships the gRPC contract so the `:grpc_gen` compiler can regenerate # the bindings in a consumer's build (they are gitignored, not in `lib`). # priv/repo ships the migrations so a consumer can `mix ecto.migrate`. # native/ ships the Rust crate *sources* because the `:suidhelper_stamp` and # `:guest_agent_build` compilers build them wherever hyper compiles -- the # suidhelper must be built locally anyway (`Hyper.SuidHelper.verify_version/0` # checks the deployed helper's BLAKE3 against this build's stamp, so a # prebuilt binary can never match). Crate subpaths are listed explicitly to # keep local `target/` build dirs out of the tarball; `tests/` must ship # because cargo resolves the declared `[[test]]` target paths at manifest # load even for plain `cargo build`. files: ~w( lib priv/firecracker priv/repo priv/vmlinux proto config mix.exs README.md LICENSE native/guest-agent/src native/guest-agent/tests native/guest-agent/build.rs native/guest-agent/Cargo.toml native/guest-agent/Cargo.lock native/guest-agent/.cargo native/suidhelper/src native/suidhelper/tests native/suidhelper/meta native/suidhelper/xtask native/suidhelper/build.rs native/suidhelper/Cargo.toml native/suidhelper/Cargo.lock native/suidhelper/rust-toolchain.toml native/suidhelper/README.md native/suidhelper/.cargo native/suidhelper/.config ), # `lib` is included wholesale, so on a dev box the gitignored generated # outputs (bindings, expected.ex) exist on disk and would leak into the # tarball -- with a build identity from the packaging machine. Keep the # 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/agent/v1/}, ~r{^lib/hyper/suid_helper/expected\.ex$} ], links: %{"GitHub" => "https://github.com/harmont-dev/hyper"} ] end # `mix check` - the strict gate. Runs fast checks first, slow ones (dialyzer) last. # Give ```bash / ```sh real syntax highlighting in the docs. # # Two obstacles, both about *who registers the lexer last*: # 1. makeup_syntect registers the shell grammar only under its raw syntect # name "Shell-Unix-Generic" (ExDoc resolves fences by lexer name, not # file extension), so ```bash / ```sh never reach it. # 2. ExDoc itself registers a minimal `ExDoc.ShellLexer` for sh/bash/shell/ # zsh (it only de-selects the `$ ` prompt; everything else is plain text) # from `ExDoc.Application.start`, which runs during the `docs` task. # # So we start :ex_doc and :makeup_syntect here first (idempotent -- the later # `docs` task won't re-run their `start/2`), then register our shell aliases # LAST so they win. Dev-only; runs as the `docs` alias's first step. defp register_doc_lexers(_args) do {:ok, _} = Application.ensure_all_started(:makeup_syntect) {:ok, _} = Application.ensure_all_started(:ex_doc) Makeup.Registry.register_lexer(MakeupSyntect.Lexer, options: [language: "Shell-Unix-Generic"], names: ["bash", "sh", "shell", "zsh"], extensions: [] ) :ok end defp aliases do [ check: [ "format --check-formatted", "compile --warnings-as-errors --force", "credo --strict", "test --warnings-as-errors", "dialyzer" ], # makeup_syntect registers the shell grammar only under its raw syntect # name "Shell-Unix-Generic", and ExDoc resolves fences by lexer *name* # (not file extension), so ```bash / ```sh would fall back to plain text. # Alias them to the shell grammar before ExDoc runs (same VM, so the # registration is visible to the highlighter). docs: ["loadpaths", ®ister_doc_lexers/1, "docs"], # Force a regeneration of the Firecracker bindings (ignores staleness). "firecracker.gen": ["compile.firecracker_gen --force"], # Force a regeneration of the gRPC bindings (ignores staleness). "grpc.gen": ["compile.grpc_gen --force"], # Rebuild + stamp the suidhelper and re-capture its expected identity. "suidhelper.stamp": ["compile.suidhelper_stamp --force"] ] end end defmodule Mix.Tasks.Compile.GrpcGen do @moduledoc """ Mix compiler that generates the gRPC bindings from all `proto/**/*.proto` files, just before the Elixir compiler. Like the Firecracker bindings, the outputs are gitignored and regenerated rather than committed. Defined in `mix.exs` (not under `lib/`) so it is loaded before any compilation. Unlike the Firecracker generator (pure-Elixir `oapi_generator`), this shells out to `protoc` with the `protoc-gen-elixir` plugin, so both must be installed in any environment that compiles hyper from a fresh tree: sudo apt-get install -y protobuf-compiler # or: brew install protobuf mix escript.install hex protobuf 0.17.0 # provides protoc-gen-elixir The plugin escript lives in `~/.mix/escripts`, which this compiler prepends to `PATH` for the `protoc` invocation. Generated files are `mix format`-ed so they pass the formatting gate. Adding a new proto is automatic: drop a `.proto` file anywhere under `proto/` and the compiler picks it up. The convention is that the directory structure mirrors the proto package (e.g. `proto/hyper/agent/v1/agent.proto` declares `package hyper.agent.v1`), which is how protoc-gen-elixir derives the output path under `lib/`. ## How protoc-gen-elixir places output files protoc-gen-elixir writes each output at: `OUT_DIR / package_as_path / FileDescriptorProto.name_minus_proto_ext .pb.ex` `FileDescriptorProto.name` is the proto file's path *relative to `--proto_path`*. To keep the name as a bare filename (e.g. `hyper.proto`) we pass each proto file's own directory as the first `--proto_path`. A second `--proto_path=proto` entry lets protos import siblings from the same tree. """ use Mix.Task.Compiler @proto_root "proto" @impl Mix.Task.Compiler def run(argv) do if "--force" in argv or stale?() do generate() end {:ok, []} end defp generate do escripts = Path.expand("~/.mix/escripts") env = [{"PATH", escripts <> ":" <> System.get_env("PATH", "")}] outputs = Enum.map(proto_files(), fn proto -> out = proto_to_out(proto) File.mkdir_p!(Path.dirname(out)) args = [ "--proto_path=#{Path.dirname(proto)}", "--proto_path=#{@proto_root}", "--elixir_out=plugins=grpc:lib", Path.basename(proto) ] case System.cmd("protoc", args, env: env, stderr_to_stdout: true) do {_, 0} -> out {output, code} -> Mix.raise(""" protoc failed (exit #{code}) generating #{out}: #{output} Ensure `protoc` and the `protoc-gen-elixir` escript are installed: sudo apt-get install -y protobuf-compiler mix escript.install hex protobuf 0.17.0 """) end end) # Format each generated file directly rather than via Mix.Task.run("format", # outputs): a Mix task runs once per session, so when mix check runs # "format --check-formatted" before "compile --force", the format task is # already consumed and the Mix.Task.run call would be a no-op. Enum.each(outputs, fn path -> File.write!(path, [Code.format_string!(File.read!(path)), "\n"]) end) end defp stale? do Enum.any?(proto_files(), fn proto -> out = proto_to_out(proto) not File.exists?(out) or File.stat!(proto).mtime > File.stat!(out).mtime end) end defp proto_files do Path.wildcard("#{@proto_root}/**/*.proto") end # Derives the protoc-gen-elixir output path from the proto source path. # Convention: directory structure mirrors the package declaration, so # proto/hyper/agent/v1/agent.proto -> lib/hyper/agent/v1/agent.pb.ex. defp proto_to_out(proto_path) do proto_path |> String.replace_prefix(@proto_root <> "/", "lib/") |> String.replace_suffix(".proto", ".pb.ex") end end defmodule Mix.Tasks.Compile.FirecrackerGen do @moduledoc """ Mix compiler that generates the Firecracker API bindings into `lib/hyper/firecracker/api/{operations,schemas}` from the committed OpenAPI spec, just before the Elixir compiler. Defined in `mix.exs` (not under `lib/`) so it is loaded before any compilation and is available even when hyper is built as a dependency -- Mix honors a dependency's `:compilers` but neither its `config/` nor its aliases, so the generator config is supplied here via `Application.put_env/3` rather than `config/config.exs`. The committed spec is OpenAPI 3, converted from Firecracker's upstream Swagger 2.0 (not vendored). To bump the version, fetch the new tag's spec and convert, then point `@spec_path` at it and run `mix firecracker.gen`: curl -fsSL https://raw.githubusercontent.com/firecracker-microvm/firecracker/vX.Y.Z/src/firecracker/swagger/firecracker.yaml \\ | curl -fsS -X POST https://converter.swagger.io/api/convert \\ -H 'Content-Type: application/yaml' -H 'Accept: application/json' --data-binary @- \\ -o priv/firecracker/firecracker-vX.Y.Z.openapi.json """ use Mix.Task.Compiler @spec_path "priv/firecracker/firecracker-v1.16.0.openapi.json" @out "lib/hyper/firecracker/api/operations/operations.ex" @config [ output: [ base_module: Hyper.Firecracker.Api, location: "lib/hyper/firecracker/api", default_client: Hyper.Firecracker.Api.Transport, operation_subdirectory: "operations", schema_subdirectory: "schemas", schema_use: Hyper.Firecracker.Api.Codec, extra_fields: [__info__: :any], field_casing: :snake, types: [specs: :spec] ], naming: [ default_operation_module: Operations, operation_use_tags: false ] ] @impl Mix.Task.Compiler def run(argv) do if "--force" in argv or stale?() do Mix.Task.run("loadpaths") Application.put_env(:oapi_generator, :default, @config) OpenAPI.run("default", [@spec_path]) end {:ok, []} end defp stale? do not File.exists?(@out) or File.stat!(@spec_path).mtime > File.stat!(@out).mtime end end defmodule Mix.Tasks.Compile.SuidhelperStamp do @moduledoc """ Mix compiler that builds and stamps the Rust setuid helper, then captures the build identity it will report at runtime into a generated module, `Hyper.SuidHelper.Expected` (gitignored, like the other generated bindings). Steps, run before the Elixir compiler so the generated module compiles: 1. `cargo xtask stamp` (in `native/suidhelper`) builds the release binary and writes its BLAKE3 self-checksum into the ELF `.note.sum` section. 2. The stamped binary's `version` subcommand is invoked; its JSON (`{"version":..,"checksum_blake3":..}`) is the helper's self-reported build identity. 3. Its version + checksum are baked into `lib/hyper/suid_helper/expected.ex` so the BEAM can compare a deployed helper against the one this build made. Always runs (no staleness gate): `cargo` is incremental, so a no-op rebuild is cheap, and this keeps the embedded identity in lockstep with the binary. Like the protoc compiler, a missing toolchain is a hard failure -- `cargo` and the helper's nightly toolchain must be present wherever hyper is compiled. Note: the checksum is *self-reported* by the binary, so the generated module is a build-provenance / version-skew check, not an adversarial tamper proof (a malicious binary could print any value). Real tamper detection would re-hash the on-disk ELF with `.note.sum` zeroed and compare -- the embedded checksum is the reference value that check would use. """ use Mix.Task.Compiler @helper_dir "native/suidhelper" @binary "native/suidhelper/target/release/hyper-suidhelper" @out "lib/hyper/suid_helper/expected.ex" @impl Mix.Task.Compiler def run(_argv) do stamp!() json = capture_version!() generate(json) {:ok, []} end defp stamp! do case System.cmd("cargo", ["xtask", "stamp"], cd: @helper_dir, stderr_to_stdout: true) do {_, 0} -> :ok {output, code} -> Mix.raise(""" `cargo xtask stamp` failed (exit #{code}) building the suidhelper: #{output} Ensure `cargo` and the helper's toolchain (see #{@helper_dir}/rust-toolchain.toml) are installed. """) end end defp capture_version! do case System.cmd(Path.expand(@binary), ["version"], stderr_to_stdout: true) do {out, 0} -> String.trim(out) {out, code} -> Mix.raise("`hyper-suidhelper version` failed (exit #{code}): #{out}") end end defp generate(json) do # Jason and the app's deps are available once loadpaths runs. Mix.Task.run("loadpaths") %{"version" => version, "checksum_blake3" => checksum} = Jason.decode!(json) File.mkdir_p!(Path.dirname(@out)) source = """ defmodule Hyper.SuidHelper.Expected do @moduledoc false # GENERATED by Mix.Tasks.Compile.SuidhelperStamp from the stamped # `hyper-suidhelper version` output. Do not edit; gitignored. @version #{inspect(version)} @checksum_blake3 #{inspect(checksum)} @doc "Expected helper version." @spec version() :: String.t() def version, do: @version @doc "Expected BLAKE3 checksum (hex) of the stamped helper." @spec checksum_blake3() :: String.t() def checksum_blake3, do: @checksum_blake3 end """ # Format the generated source directly rather than via `Mix.Task.run("format", # ...)`: a Mix task runs once per session, so invoking it here would consume # the single run and leave the later `:grpc_gen` compiler's format a no-op. File.write!(@out, [Code.format_string!(source), "\n"]) end end defmodule Mix.Tasks.Compile.GuestAgentBuild do @moduledoc """ Mix compiler that builds the static `hyper-guest-agent` musl binaries into `priv/guest-agent/`, so the guest agent ships *inside* the app (and its release) with no separate install step -- it is redistributed like the generated bindings, not installed like the privileged suidhelper. For each supported arch it runs `cargo build --release --target ` in `native/guest-agent` and copies the binary to `priv/guest-agent/hyper-guest-agent-`. The arch matching the build host is *required* -- a failure there is a hard error (missing `cargo` / musl target). Any *cross* arch is best-effort: it is attempted only when its `rustup` target is installed, and a build/link failure there (e.g. no cross-linker on a dev box) is a warning + skip rather than a compile failure. A node only ever runs its own arch's guests (KVM is same-arch), so the host binary is the sole runtime requirement; the cross copy is redistribution surplus, built wherever the toolchain allows. Always runs (cargo is incremental, so a no-op rebuild is cheap). """ use Mix.Task.Compiler @agent_dir "native/guest-agent" @out_dir "priv/guest-agent" @arches [x86_64: "x86_64-unknown-linux-musl", aarch64: "aarch64-unknown-linux-musl"] @impl Mix.Task.Compiler def run(_argv) do File.mkdir_p!(@out_dir) host = host_arch() installed = installed_targets() Enum.each(@arches, fn {arch, triple} -> cond do arch == host -> build!(arch, triple, required: true) triple in installed -> build!(arch, triple, required: false) # Cross target not installed: nothing to build, and nothing to warn about # -- this host simply does not redistribute that arch. true -> :ok end end) {:ok, []} end defp build!(arch, triple, required: required?) do case System.cmd("cargo", ["build", "--release", "--target", triple], cd: @agent_dir, stderr_to_stdout: true ) do {_, 0} -> install(arch, triple) {output, code} when required? -> Mix.raise(""" Building the guest-agent for the host arch #{arch} (#{triple}) failed (exit #{code}): #{output} Ensure `cargo` and the musl target are installed: `rustup target add #{triple}`. """) {_output, _code} -> Mix.shell().error( "guest-agent: skipping cross arch #{arch} (#{triple}) -- build failed " <> "(missing cross-linker?). The host arch was built; install the cross " <> "toolchain to also redistribute #{arch}." ) end end defp install(arch, triple) do src = Path.join(@agent_dir, "target/#{triple}/release/hyper-guest-agent") dest = Path.join(@out_dir, "hyper-guest-agent-#{arch}") File.cp!(src, dest) File.chmod!(dest, 0o755) end # Inlined rather than calling `Sys.Arch` because this compiler can run before # the Elixir compiler has built the app modules. defp host_arch do sys = to_string(:erlang.system_info(:system_architecture)) cond do String.contains?(sys, "x86_64") or String.contains?(sys, "amd64") -> :x86_64 String.contains?(sys, "aarch64") or String.contains?(sys, "arm64") -> :aarch64 # Fall back to x86_64 so `build!(required: true)` surfaces a clear cargo # error for the real target rather than this compiler guessing. true -> :x86_64 end end defp installed_targets do case System.cmd("rustup", ["target", "list", "--installed"], stderr_to_stdout: true) do {out, 0} -> out |> String.split("\n", trim: true) |> Enum.map(&String.trim/1) # No rustup / unknown: build only the host arch (never attempt a cross build). _ -> [] end end end