From 66959418158aef425d79b59f21fc7d9268b52640 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 9 Jul 2026 03:46:52 +0000 Subject: [PATCH 01/10] =?UTF-8?q?test(firecracker):=20codec=20encode?= =?UTF-8?q?=E2=86=92decode=20round-trip=20property?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip codec.ex in coverage: it is a compile-time macro whose lines run during mix compile of the generated schemas, never at test runtime, so its line coverage is structurally 0% independent of behavioral coverage. --- coveralls.json | 1 + .../firecracker/api/codec_properties_test.exs | 61 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 test/hyper/firecracker/api/codec_properties_test.exs diff --git a/coveralls.json b/coveralls.json index 3657e42c..7008f447 100644 --- a/coveralls.json +++ b/coveralls.json @@ -1,5 +1,6 @@ { "skip_files": [ + "lib/hyper/firecracker/api/codec.ex", "lib/hyper/firecracker/api/operations/", "lib/hyper/firecracker/api/schemas/", "lib/mix/" diff --git a/test/hyper/firecracker/api/codec_properties_test.exs b/test/hyper/firecracker/api/codec_properties_test.exs new file mode 100644 index 00000000..1f0e2dc3 --- /dev/null +++ b/test/hyper/firecracker/api/codec_properties_test.exs @@ -0,0 +1,61 @@ +defmodule Hyper.Firecracker.Api.CodecPropertiesTest do + @moduledoc """ + Law under test: JSON encode → decode is the identity on generated schema + structs — `decode(Jason.decode!(Jason.encode!(x))) == x` — for any struct + whose fields are either set (non-nil) or unset (nil). Encode drops unset + fields; decode leaves absent keys at the struct default (nil): the two + halves must cancel exactly, through nested schemas (Drive → RateLimiter → + TokenBucket) and optional fields at every level. + """ + use ExUnit.Case, async: true + use ExUnitProperties + + alias Hyper.Firecracker.Api.{Drive, RateLimiter, TokenBucket} + + defp optional(gen), do: one_of([constant(nil), gen]) + + defp token_bucket do + gen all( + size <- positive_integer(), + refill_time <- positive_integer(), + one_time_burst <- optional(positive_integer()) + ) do + %TokenBucket{size: size, refill_time: refill_time, one_time_burst: one_time_burst} + end + end + + defp rate_limiter do + gen all( + bandwidth <- optional(token_bucket()), + ops <- optional(token_bucket()) + ) do + %RateLimiter{bandwidth: bandwidth, ops: ops} + end + end + + defp drive do + gen all( + drive_id <- string(:alphanumeric, min_length: 1), + is_root_device <- boolean(), + is_read_only <- optional(boolean()), + path_on_host <- optional(string(:alphanumeric, min_length: 1)), + cache_type <- optional(member_of(["Unsafe", "Writeback"])), + rate_limiter <- optional(rate_limiter()) + ) do + %Drive{ + drive_id: drive_id, + is_root_device: is_root_device, + is_read_only: is_read_only, + path_on_host: path_on_host, + cache_type: cache_type, + rate_limiter: rate_limiter + } + end + end + + property "encode → decode is the identity, two schema levels deep" do + check all(d <- drive()) do + assert d |> Jason.encode!() |> Jason.decode!() |> Drive.decode() == d + end + end +end From 5b01ce6a57a7d18017543686d831d174fe5de379 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 9 Jul 2026 03:51:38 +0000 Subject: [PATCH 02/10] test(firecracker): transport result contract over a real unix-socket server --- test/hyper/firecracker/api/transport_test.exs | 87 +++++++++++++++ test/support/firecracker/unix_http.ex | 100 ++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 test/hyper/firecracker/api/transport_test.exs create mode 100644 test/support/firecracker/unix_http.ex diff --git a/test/hyper/firecracker/api/transport_test.exs b/test/hyper/firecracker/api/transport_test.exs new file mode 100644 index 00000000..3372e656 --- /dev/null +++ b/test/hyper/firecracker/api/transport_test.exs @@ -0,0 +1,87 @@ +defmodule Hyper.Firecracker.Api.TransportTest do + @moduledoc """ + Result contract of the Firecracker transport, exercised over a real + Unix-socket HTTP server: 2xx classification against the operation's + response spec (`:ok` for `:null`, typed decode for `{module, :t}`, raw + passthrough otherwise), non-2xx fault extraction, and connection failures + as `{:error, {:transport, _}}` — never a raise. Also pins the wire shape: + Codec-encoded bodies arrive nil-stripped, query params land in the + request line. + """ + use ExUnit.Case, async: true + + alias Hyper.Firecracker.Api.{Drive, InstanceInfo, Transport} + alias Hyper.Firecracker.Support.UnixHttp + + setup do + {:ok, _} = Application.ensure_all_started(:req) + :ok + end + + defp request(socket_path, extra) do + Map.merge(%{method: :get, url: "/", opts: [socket_path: socket_path]}, Map.new(extra)) + end + + test "204 against a :null response spec is :ok" do + path = UnixHttp.start(fn _req -> {204, ""} end) + + assert Transport.request( + request(path, method: :put, url: "/actions", response: [{204, :null}]) + ) == :ok + end + + test "2xx with a typed response spec decodes into the schema struct" do + body = ~s({"id":"vm-1","state":"Running","vmm_version":"1.9.0","app_name":"Firecracker"}) + path = UnixHttp.start(fn _req -> {200, body} end) + + assert Transport.request(request(path, response: [{200, {InstanceInfo, :t}}])) == + {:ok, + %InstanceInfo{ + id: "vm-1", + state: "Running", + vmm_version: "1.9.0", + app_name: "Firecracker" + }} + end + + test "2xx with no matching spec: raw body passthrough, :ok when empty" do + json = UnixHttp.start(fn _req -> {200, ~s({"free":"form"})} end) + assert Transport.request(request(json, [])) == {:ok, %{"free" => "form"}} + + empty = UnixHttp.start(fn _req -> {200, ""} end) + assert Transport.request(request(empty, [])) == :ok + end + + test "non-2xx surfaces {:api, status, fault_message}; nil when the body has none" do + faulted = UnixHttp.start(fn _req -> {400, ~s({"fault_message":"Invalid drive"})} end) + assert Transport.request(request(faulted, [])) == {:error, {:api, 400, "Invalid drive"}} + + opaque = UnixHttp.start(fn _req -> {500, ~s({"unexpected":"shape"})} end) + assert Transport.request(request(opaque, [])) == {:error, {:api, 500, nil}} + end + + test "a connection failure is {:error, {:transport, _}}, never a raise" do + missing = Path.join(System.tmp_dir!(), "no-such-#{System.unique_integer([:positive])}.sock") + assert {:error, {:transport, _reason}} = Transport.request(request(missing, [])) + end + + test "wire shape: body arrives as nil-stripped JSON, query params in the request line" do + path = UnixHttp.start(fn _req -> {204, ""} end) + drive = %Drive{drive_id: "rootfs", is_root_device: false} + + assert :ok = + Transport.request( + request(path, + method: :put, + url: "/drives/rootfs", + body: drive, + query: [foo: "bar"], + response: [{204, :null}] + ) + ) + + assert_receive {:unix_http, %{method: :PUT, path: req_path, body: wire_body}} + assert req_path == "/drives/rootfs?foo=bar" + assert Jason.decode!(wire_body) == %{"drive_id" => "rootfs", "is_root_device" => false} + end +end diff --git a/test/support/firecracker/unix_http.ex b/test/support/firecracker/unix_http.ex new file mode 100644 index 00000000..7897c85a --- /dev/null +++ b/test/support/firecracker/unix_http.ex @@ -0,0 +1,100 @@ +defmodule Hyper.Firecracker.Support.UnixHttp do + @moduledoc """ + Minimal HTTP/1.1 server on a Unix-domain socket, standing in for a + Firecracker daemon so `Hyper.Firecracker.Api.Transport` is exercised over + the real wire (Req → Finch → Mint over `unix_socket`), not a mock. + + `start/1` takes a responder `fun(request) -> {status, body_iodata}` and + returns the socket path. Every accepted request is also sent to the + starting process as `{:unix_http, request}` with + `%{method: atom, path: String.t(), headers: map, body: binary}`. + Must be called from a test process: it registers `on_exit/1` cleanup. + """ + + @type request :: %{method: atom(), path: String.t(), headers: map(), body: binary()} + + @spec start((request() -> {pos_integer(), iodata()})) :: Path.t() + def start(respond) do + path = + Path.join(System.tmp_dir!(), "fc-fake-#{System.unique_integer([:positive])}.sock") + + {:ok, listen} = + :gen_tcp.listen(0, [ + :binary, + ifaddr: {:local, String.to_charlist(path)}, + packet: :http_bin, + active: false + ]) + + owner = self() + server = spawn_link(fn -> accept_loop(listen, respond, owner) end) + + ExUnit.Callbacks.on_exit(fn -> + Process.exit(server, :kill) + :gen_tcp.close(listen) + File.rm(path) + end) + + path + end + + defp accept_loop(listen, respond, owner) do + case :gen_tcp.accept(listen) do + {:ok, sock} -> + request = read_request(sock) + send(owner, {:unix_http, request}) + {status, body} = respond.(request) + :ok = :gen_tcp.send(sock, response(status, IO.iodata_to_binary(body))) + :gen_tcp.close(sock) + accept_loop(listen, respond, owner) + + {:error, :closed} -> + :ok + end + end + + defp read_request(sock) do + {:ok, {:http_request, method, {:abs_path, path}, _version}} = :gen_tcp.recv(sock, 0) + headers = read_headers(sock, %{}) + length = headers |> Map.get("content-length", "0") |> String.to_integer() + :ok = :inet.setopts(sock, packet: :raw) + body = if length > 0, do: recv_exact(sock, length), else: "" + %{method: method, path: to_string(path), headers: headers, body: body} + end + + defp read_headers(sock, acc) do + case :gen_tcp.recv(sock, 0) do + {:ok, {:http_header, _, name, _, value}} -> + read_headers(sock, Map.put(acc, downcase(name), to_string(value))) + + {:ok, :http_eoh} -> + acc + end + end + + defp downcase(name) when is_atom(name), do: name |> Atom.to_string() |> String.downcase() + defp downcase(name), do: name |> to_string() |> String.downcase() + + defp recv_exact(sock, length) do + {:ok, body} = :gen_tcp.recv(sock, length) + body + end + + # 204 must carry neither body nor content-length; an empty 200 must not + # claim application/json or Req's decode step would choke on "". + defp response(204, _body), do: "HTTP/1.1 204 No Content\r\nconnection: close\r\n\r\n" + + defp response(status, "") do + "HTTP/1.1 #{status} X\r\ncontent-length: 0\r\nconnection: close\r\n\r\n" + end + + defp response(status, body) do + [ + "HTTP/1.1 #{status} X\r\n", + "content-type: application/json\r\n", + "content-length: #{byte_size(body)}\r\n", + "connection: close\r\n\r\n", + body + ] + end +end From 8dab2c4981175ef70ff18a785f506ed873f6f788 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 9 Jul 2026 03:56:09 +0000 Subject: [PATCH 03/10] test(cfg): otel exporter resolution contract --- test/hyper/cfg/otel_test.exs | 94 ++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 test/hyper/cfg/otel_test.exs diff --git a/test/hyper/cfg/otel_test.exs b/test/hyper/cfg/otel_test.exs new file mode 100644 index 00000000..6bba1d5b --- /dev/null +++ b/test/hyper/cfg/otel_test.exs @@ -0,0 +1,94 @@ +defmodule Hyper.Cfg.OtelTest do + @moduledoc """ + Contract of OTLP exporter resolution: no endpoint in any source disables + the exporter (`:none`, empty env var included); source order is + config.exs > [otel] toml > OTEL_EXPORTER_OTLP_ENDPOINT; `proto` coerces to + exactly `:grpc` or `:http_protobuf` (never a third value reaches the + exporter); headers normalize to string pairs from map or keyword form. + """ + use ExUnit.Case, async: false + + alias Hyper.Cfg.Otel + alias Hyper.Cfg.Toml + + @env_var "OTEL_EXPORTER_OTLP_ENDPOINT" + + setup do + original = System.get_env(@env_var) + System.delete_env(@env_var) + Toml.put_cache(%{}) + + on_exit(fn -> + if original, do: System.put_env(@env_var, original), else: System.delete_env(@env_var) + Toml.reload() + end) + + :ok + end + + test "no endpoint in any source disables the exporter, empty env var included" do + assert Otel.exporter_options([]) == :none + + System.put_env(@env_var, "") + assert Otel.exporter_options([]) == :none + end + + test "an endpoint alone gets the documented defaults: http_protobuf, no headers" do + assert Otel.exporter_options(endpoint: "http://collector:4318") == + {:ok, + [ + otlp_protocol: :http_protobuf, + otlp_endpoint: "http://collector:4318", + otlp_headers: [] + ]} + end + + test "source precedence: config.exs > [otel] toml > env var" do + System.put_env(@env_var, "http://env:1") + assert {:ok, opts} = Otel.exporter_options([]) + assert opts[:otlp_endpoint] == "http://env:1" + + Toml.put_cache(%{"otel" => %{"endpoint" => "http://toml:2"}}) + assert {:ok, opts} = Otel.exporter_options([]) + assert opts[:otlp_endpoint] == "http://toml:2" + + assert {:ok, opts} = Otel.exporter_options(endpoint: "http://exs:3") + assert opts[:otlp_endpoint] == "http://exs:3" + end + + test "a full [otel] toml table maps through with proto and headers coerced" do + Toml.put_cache(%{ + "otel" => %{ + "endpoint" => "https://api.honeycomb.io", + "proto" => "grpc", + "headers" => %{"x-honeycomb-team" => "key"} + } + }) + + assert Otel.exporter_options([]) == + {:ok, + [ + otlp_protocol: :grpc, + otlp_endpoint: "https://api.honeycomb.io", + otlp_headers: [{"x-honeycomb-team", "key"}] + ]} + end + + test "proto coercion: only grpc (atom or string) selects grpc, all else http_protobuf" do + for {given, expected} <- [ + {:grpc, :grpc}, + {"grpc", :grpc}, + {:http_protobuf, :http_protobuf}, + {"http/protobuf", :http_protobuf}, + {"bogus", :http_protobuf} + ] do + assert {:ok, opts} = Otel.exporter_options(endpoint: "http://c:4318", proto: given) + assert opts[:otlp_protocol] == expected, "proto #{inspect(given)}" + end + end + + test "headers accept keyword form, keys stringified" do + assert {:ok, opts} = Otel.exporter_options(endpoint: "http://c:4318", headers: ["x-a": "1"]) + assert opts[:otlp_headers] == [{"x-a", "1"}] + end +end From 941c98487965974df1c0ef1e2c78f8bcb488a5b5 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 9 Jul 2026 03:59:45 +0000 Subject: [PATCH 04/10] test(cfg): grpc cred coercion and server_options key-omission contract --- test/hyper/cfg/grpc_test.exs | 72 ++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 test/hyper/cfg/grpc_test.exs diff --git a/test/hyper/cfg/grpc_test.exs b/test/hyper/cfg/grpc_test.exs new file mode 100644 index 00000000..fbc8c083 --- /dev/null +++ b/test/hyper/cfg/grpc_test.exs @@ -0,0 +1,72 @@ +defmodule Hyper.Cfg.GrpcTest do + @moduledoc """ + Contracts on top of plain resolution: a `[grpc]` toml `cred` inline table + (`{ cert = ..., key = ... }`) coerces into a `GRPC.Credential` while a + pre-built credential passes through untouched; `server_options/1` omits + `:cred` and `:adapter_opts` entirely at their plaintext defaults (a + literal `cred: nil` reaching GRPC.Server.Supervisor is a different + configuration than the key being absent) and carries them through when + set. + """ + use ExUnit.Case, async: false + + alias Hyper.Cfg.Grpc + alias Hyper.Cfg.Toml + + setup do + Application.delete_env(:hyper, Grpc) + Toml.put_cache(%{}) + + on_exit(fn -> + Application.delete_env(:hyper, Grpc) + Toml.reload() + end) + + :ok + end + + test "a [grpc] toml table loads, with the cred inline table coerced to a GRPC.Credential" do + Toml.put_cache(%{ + "grpc" => %{ + "enabled" => true, + "port" => 50_061, + "cred" => %{"cert" => "/etc/hyper/cert.pem", "key" => "/etc/hyper/key.pem"} + } + }) + + config = Grpc.load() + + assert config.enabled == true + assert config.port == 50_061 + assert %GRPC.Credential{ssl: ssl} = config.cred + assert ssl[:certfile] == "/etc/hyper/cert.pem" + assert ssl[:keyfile] == "/etc/hyper/key.pem" + end + + test "a pre-built GRPC.Credential from config.exs passes through untouched" do + cred = GRPC.Credential.new(ssl: [certfile: "/c.pem", keyfile: "/k.pem"]) + Application.put_env(:hyper, Grpc, cred: cred) + + assert Grpc.load().cred == cred + end + + test "server_options omits :cred and :adapter_opts at their plaintext defaults" do + opts = Grpc.server_options(%Grpc{}) + + assert opts[:endpoint] == Hyper.Grpc.Endpoint + assert opts[:port] == 50_051 + refute Keyword.has_key?(opts, :cred) + refute Keyword.has_key?(opts, :adapter_opts) + end + + test "server_options carries a set credential and adapter options through" do + cred = GRPC.Credential.new(ssl: [certfile: "/c.pem", keyfile: "/k.pem"]) + config = %Grpc{port: 50_061, cred: cred, adapter_opts: [ip: {0, 0, 0, 0}]} + + opts = Grpc.server_options(config) + + assert opts[:port] == 50_061 + assert opts[:cred] == cred + assert opts[:adapter_opts] == [ip: {0, 0, 0, 0}] + end +end From 320557e50bae3f3ec98e47d92c61bb9ac99124a7 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 9 Jul 2026 04:03:46 +0000 Subject: [PATCH 05/10] test(img): lease upsert-heartbeat, scoped release, reap contracts --- test/hyper/img/db/lease_test.exs | 92 ++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 test/hyper/img/db/lease_test.exs diff --git a/test/hyper/img/db/lease_test.exs b/test/hyper/img/db/lease_test.exs new file mode 100644 index 00000000..692c8582 --- /dev/null +++ b/test/hyper/img/db/lease_test.exs @@ -0,0 +1,92 @@ +defmodule Hyper.Img.Db.LeaseTest do + @moduledoc """ + Contract of the lease lifecycle against the real image DB: + + * `bump/3` is take-and-heartbeat in one call: first call inserts, a + repeat for the same (node, vm) moves `expires_at` forward on the SAME + row — upsert, never a duplicate; + * `release/1` deletes only the calling node's lease for that vm, and is + idempotent; + * `reap_expired/0` removes lapsed leases and leaves live ones; + * a lease can never reference an unknown image (FK refusal, nothing + inserted). + + Hits Postgres: excluded from the default run; CI runs it in the + integration job (`mix test --include external`). + """ + use ExUnit.Case, async: false + + @moduletag :external + + import Ecto.Query + + alias Hyper.Img.Db.{Lease, Repo} + + setup_all do + path = Path.join(System.tmp_dir!(), "hyper-lease-#{System.unique_integer([:positive])}.img") + File.write!(path, :crypto.strong_rand_bytes(1024)) + {:ok, img_id} = Hyper.Img.create(path, label: "lease-test") + %{img_id: img_id} + end + + defp lease_rows(vm_id) do + Repo.all(from l in Lease, where: l.vm_id == ^vm_id and l.node_id == ^to_string(node())) + end + + defp expire!(vm_id) do + past = DateTime.add(DateTime.utc_now(), -3600, :second) + + {1, _} = + Repo.update_all( + from(l in Lease, where: l.vm_id == ^vm_id and l.node_id == ^to_string(node())), + set: [expires_at: past] + ) + end + + test "bump inserts once, then heartbeats: same row, expiry moved forward", %{img_id: img} do + vm_id = Hyper.Vm.Id.generate() + on_exit(fn -> Lease.release(vm_id) end) + + assert {:ok, first} = Lease.bump(img, vm_id, Unit.Time.s(60)) + assert {:ok, _} = Lease.bump(img, vm_id, Unit.Time.s(120)) + + assert [row] = lease_rows(vm_id) + assert row.id == first.id + assert DateTime.compare(row.expires_at, first.expires_at) == :gt + end + + test "release deletes only the caller's (node, vm) lease, idempotently", %{img_id: img} do + vm_a = Hyper.Vm.Id.generate() + vm_b = Hyper.Vm.Id.generate() + on_exit(fn -> Lease.release(vm_b) end) + {:ok, _} = Lease.bump(img, vm_a, Unit.Time.s(60)) + {:ok, _} = Lease.bump(img, vm_b, Unit.Time.s(60)) + + assert :ok = Lease.release(vm_a) + assert lease_rows(vm_a) == [] + assert [_survivor] = lease_rows(vm_b) + + assert :ok = Lease.release(vm_a) + end + + test "reap_expired removes lapsed leases and leaves live ones", %{img_id: img} do + live = Hyper.Vm.Id.generate() + lapsed = Hyper.Vm.Id.generate() + on_exit(fn -> Lease.release(live) end) + {:ok, _} = Lease.bump(img, live, Unit.Time.s(300)) + {:ok, _} = Lease.bump(img, lapsed, Unit.Time.s(300)) + expire!(lapsed) + + assert Lease.reap_expired() >= 1 + assert lease_rows(lapsed) == [] + assert [_] = lease_rows(live) + end + + test "a lease on an unknown image is refused by the FK, nothing inserted" do + vm_id = Hyper.Vm.Id.generate() + + assert {:error, changeset} = Lease.bump(String.duplicate("f", 64), vm_id, Unit.Time.s(60)) + assert {"does not exist", _} = changeset.errors[:image_id] + assert lease_rows(vm_id) == [] + end +end From 54050d5f557b61d5fb8674e5df307db005d20fb1 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 9 Jul 2026 04:11:41 +0000 Subject: [PATCH 06/10] refactor(img): extract Gc.Prune so the GC delete step is testable Pure code motion (state.config -> config). The Gc GenServer cannot be started twice (registered name + Horde singleton), which made its safety-critical delete step untestable; Prune takes the config directly, mirroring the existing Gc.Sweep pure-core split. --- lib/hyper/img/db/gc.ex | 131 +++-------------------------- lib/hyper/img/db/gc/prune.ex | 157 +++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 121 deletions(-) create mode 100644 lib/hyper/img/db/gc/prune.ex diff --git a/lib/hyper/img/db/gc.ex b/lib/hyper/img/db/gc.ex index c414a907..9ed8c84d 100644 --- a/lib/hyper/img/db/gc.ex +++ b/lib/hyper/img/db/gc.ex @@ -24,16 +24,18 @@ defmodule Hyper.Img.Db.Gc do **Publish contract:** write a layer's file to the medium before inserting its `blobs` row (ideally write-temp then atomic rename); the grace period is insurance, not a substitute. + + The delete step itself lives in `Hyper.Img.Db.Gc.Prune`. """ use GenServer use OpenTelemetryDecorator require Logger - import Ecto.Query alias Hyper.Cfg.Gc, as: Config alias Hyper.Cluster.Routing - alias Hyper.Img.Db.{Blob, ImageLayer, Repo} + alias Hyper.Img.Db.Blob + alias Hyper.Img.Db.Gc.Prune alias Hyper.Img.Db.Gc.Sweep alias Hyper.Node.Layer.Repo, as: LayerRepo @@ -136,10 +138,13 @@ defmodule Hyper.Img.Db.Gc do @decorate with_span("Hyper.Img.Db.Gc.scan_one_batch") defp scan_one_batch(%__MODULE__{sweep: sweep} = state) do limit = state.config.batch_size - batch = with_low_priority(state, fn -> Blob.present_after(sweep.cursor, limit) end) - {sweep, missing} = Sweep.absorb(sweep, batch, &presence/1) - {pruned, pruned_bytes, dangling} = maybe_prune(state, missing) + batch = + Prune.with_low_priority(state.config, fn -> Blob.present_after(sweep.cursor, limit) end) + + {sweep, missing} = Sweep.absorb(sweep, batch, &Prune.presence/1) + + {pruned, pruned_bytes, dangling} = Prune.execute(state.config, missing) sweep = Sweep.record_prune(sweep, pruned, pruned_bytes, dangling) if Sweep.continue?(batch, limit) do @@ -156,120 +161,4 @@ defmodule Hyper.Img.Db.Gc do %{state | sweep: nil, last_sweep: sweep} end end - - # Re-check the medium is still mounted before acting on a page's "missing" - # set. If the whole mount vanished mid-page, every probe read as gone - skip - # the deletions rather than wipe a page of live rows. - @spec maybe_prune(t(), [Sweep.blob()]) :: - {non_neg_integer(), non_neg_integer(), non_neg_integer()} - defp maybe_prune(_state, []), do: {0, 0, 0} - - defp maybe_prune(state, missing) do - case LayerRepo.test_system() do - :ok -> - prune_missing(state, missing) - - {:error, reason} -> - Logger.warning( - "layer gc: medium became unavailable before pruning (#{inspect(reason)}); " <> - "skipping #{length(missing)} deletion(s) this page" - ) - - {0, 0, 0} - end - end - - # Prune the missing blobs that no image references; report the rest as dangling. - @spec prune_missing(t(), [Sweep.blob()]) :: - {non_neg_integer(), non_neg_integer(), non_neg_integer()} - defp prune_missing(state, missing) do - ids = Enum.map(missing, fn {id, _size} -> id end) - referenced = referenced_ids(state, ids) - - {dangling, prunable} = - Enum.split_with(missing, fn {id, _size} -> MapSet.member?(referenced, id) end) - - report_dangling(dangling) - - cutoff = - DateTime.add(DateTime.utc_now(), -Unit.Time.as_ms(state.config.grace_period), :millisecond) - - prunable_ids = Enum.map(prunable, fn {id, _size} -> id end) - {pruned, pruned_bytes} = prune_rows(state, prunable_ids, cutoff) - - {pruned, pruned_bytes, length(dangling)} - end - - @spec prune_rows(t(), [String.t()], DateTime.t()) :: {non_neg_integer(), non_neg_integer()} - defp prune_rows(_state, [], _cutoff), do: {0, 0} - - defp prune_rows(state, ids, cutoff) do - # `RETURNING size` gives the exact deleted set's count and bytes. The grace - # cutoff protects freshly-published rows. NOT EXISTS double-guards the FK: - # even if a reference appeared since we snapshotted `referenced`, that row is - # skipped rather than raising. - query = - from b in Blob, - as: :b, - where: - b.id in ^ids and b.state == :present and b.inserted_at < ^cutoff and - not exists(from il in ImageLayer, where: il.blob_id == parent_as(:b).id), - select: b.size - - {count, sizes} = with_low_priority(state, fn -> Repo.delete_all(query) end) - {count, Enum.sum(sizes)} - end - - # Report dangling blobs (file gone, still referenced = data loss) once per page, - # aggregated, so a broken mount cannot flood the logs one line per blob. - @spec report_dangling([Sweep.blob()]) :: :ok - defp report_dangling([]), do: :ok - - defp report_dangling(dangling) do - count = length(dangling) - bytes = dangling |> Enum.map(fn {_id, size} -> size end) |> Enum.sum() - sample = dangling |> Enum.take(10) |> Enum.map(fn {id, _size} -> id end) - - Logger.error( - "layer gc: #{count} blob(s) missing from medium but still referenced by an image " <> - "(#{bytes} bytes total); leaving in place. sample=#{inspect(sample)}" - ) - end - - @spec referenced_ids(t(), [String.t()]) :: MapSet.t(String.t()) - defp referenced_ids(state, ids) do - query = from il in ImageLayer, where: il.blob_id in ^ids, distinct: true, select: il.blob_id - state |> with_low_priority(fn -> Repo.all(query) end) |> MapSet.new() - end - - # Run a DB operation at low priority: in a transaction with a capped per-statement - # timeout, so it can never pin a backend and yields under contention. - @spec with_low_priority(t(), (-> result)) :: result when result: var - defp with_low_priority(state, fun) do - timeout = Unit.Time.as_ms(state.config.timeout) - - {:ok, result} = - Repo.transaction(fn -> - _ = - Repo.query!("SELECT set_config('statement_timeout', $1, true)", [ - Integer.to_string(timeout) - ]) - - fun.() - end) - - result - end - - # Shared-medium presence probe injected into the pure Sweep core. Distinguishes - # a genuine absence (`:enoent` -> prunable) from an I/O error (`:unknown` -> - # never pruned), so a transient NFS hiccup can never drive a delete. - @spec presence(String.t()) :: Sweep.presence() - defp presence(id) do - case LayerRepo.find_layer(id) do - {:ok, _path} -> :present - {:error, :enoent} -> :missing - {:error, _posix} -> :unknown - end - end end diff --git a/lib/hyper/img/db/gc/prune.ex b/lib/hyper/img/db/gc/prune.ex new file mode 100644 index 00000000..161ff8b7 --- /dev/null +++ b/lib/hyper/img/db/gc/prune.ex @@ -0,0 +1,157 @@ +defmodule Hyper.Img.Db.Gc.Prune do + @moduledoc """ + The delete step of the layer GC, factored out of the `Gc` server so its + safety contracts are testable against a real database without the + cluster-singleton GenServer around them: + + * a missing blob still referenced by an image is *dangling* — reported, + never deleted; + * rows younger than `grace_period` are never deleted; + * the `DELETE` re-checks `NOT EXISTS (image_layers)`, so a reference + that appeared after the caller's snapshot is skipped, not violated; + * the shared medium is re-probed before any delete, so a mount that + vanished mid-page skips the page instead of mass-deleting; + * `presence/1` maps only a true `:enoent` to `:missing`; any other I/O + error is `:unknown` and never drives a delete. + + Every query runs at low priority: in a transaction with a capped + per-statement timeout, so the GC can never pin a backend. + """ + + require Logger + import Ecto.Query + + alias Hyper.Cfg.Gc, as: Config + alias Hyper.Img.Db.{Blob, ImageLayer, Repo} + alias Hyper.Img.Db.Gc.Sweep + alias Hyper.Node.Layer.Repo, as: LayerRepo + + @doc """ + Act on one page's missing set: re-check the medium is still mounted, split + dangling (still referenced) from prunable, delete what is out of grace. + Returns `{pruned, pruned_bytes, dangling}`. + """ + @spec execute(Config.t(), [Sweep.blob()]) :: + {non_neg_integer(), non_neg_integer(), non_neg_integer()} + def execute(_config, []), do: {0, 0, 0} + + def execute(config, missing) do + case LayerRepo.test_system() do + :ok -> + prune_missing(config, missing) + + {:error, reason} -> + Logger.warning( + "layer gc: medium became unavailable before pruning (#{inspect(reason)}); " <> + "skipping #{length(missing)} deletion(s) this page" + ) + + {0, 0, 0} + end + end + + @doc """ + Shared-medium presence probe injected into the pure Sweep core. + Distinguishes a genuine absence (`:enoent` -> prunable) from an I/O error + (`:unknown` -> never pruned), so a transient NFS hiccup can never drive a + delete. + """ + @spec presence(String.t()) :: Sweep.presence() + def presence(id) do + case LayerRepo.find_layer(id) do + {:ok, _path} -> :present + {:error, :enoent} -> :missing + {:error, _posix} -> :unknown + end + end + + @doc """ + Run a DB operation at low priority: in a transaction with a capped + per-statement timeout, so it can never pin a backend and yields under + contention. + """ + @spec with_low_priority(Config.t(), (-> result)) :: result when result: var + def with_low_priority(config, fun) do + timeout = Unit.Time.as_ms(config.timeout) + + {:ok, result} = + Repo.transaction(fn -> + _ = + Repo.query!("SELECT set_config('statement_timeout', $1, true)", [ + Integer.to_string(timeout) + ]) + + fun.() + end) + + result + end + + @doc false + # Public only for its safety test: the NOT EXISTS re-check guards a race (a + # reference appearing after the caller snapshotted `referenced_ids`) that + # cannot be reached deterministically through `execute/2`. + @spec prune_rows(Config.t(), [String.t()], DateTime.t()) :: + {non_neg_integer(), non_neg_integer()} + def prune_rows(_config, [], _cutoff), do: {0, 0} + + def prune_rows(config, ids, cutoff) do + # `RETURNING size` gives the exact deleted set's count and bytes. The grace + # cutoff protects freshly-published rows. NOT EXISTS double-guards the FK: + # even if a reference appeared since we snapshotted `referenced`, that row + # is skipped rather than raising. + query = + from b in Blob, + as: :b, + where: + b.id in ^ids and b.state == :present and b.inserted_at < ^cutoff and + not exists(from il in ImageLayer, where: il.blob_id == parent_as(:b).id), + select: b.size + + {count, sizes} = with_low_priority(config, fn -> Repo.delete_all(query) end) + {count, Enum.sum(sizes)} + end + + # Prune the missing blobs that no image references; report the rest as dangling. + @spec prune_missing(Config.t(), [Sweep.blob()]) :: + {non_neg_integer(), non_neg_integer(), non_neg_integer()} + defp prune_missing(config, missing) do + ids = Enum.map(missing, fn {id, _size} -> id end) + referenced = referenced_ids(config, ids) + + {dangling, prunable} = + Enum.split_with(missing, fn {id, _size} -> MapSet.member?(referenced, id) end) + + report_dangling(dangling) + + cutoff = + DateTime.add(DateTime.utc_now(), -Unit.Time.as_ms(config.grace_period), :millisecond) + + prunable_ids = Enum.map(prunable, fn {id, _size} -> id end) + {pruned, pruned_bytes} = prune_rows(config, prunable_ids, cutoff) + + {pruned, pruned_bytes, length(dangling)} + end + + # Report dangling blobs (file gone, still referenced = data loss) once per + # page, aggregated, so a broken mount cannot flood the logs one line per blob. + @spec report_dangling([Sweep.blob()]) :: :ok + defp report_dangling([]), do: :ok + + defp report_dangling(dangling) do + count = length(dangling) + bytes = dangling |> Enum.map(fn {_id, size} -> size end) |> Enum.sum() + sample = dangling |> Enum.take(10) |> Enum.map(fn {id, _size} -> id end) + + Logger.error( + "layer gc: #{count} blob(s) missing from medium but still referenced by an image " <> + "(#{bytes} bytes total); leaving in place. sample=#{inspect(sample)}" + ) + end + + @spec referenced_ids(Config.t(), [String.t()]) :: MapSet.t(String.t()) + defp referenced_ids(config, ids) do + query = from il in ImageLayer, where: il.blob_id in ^ids, distinct: true, select: il.blob_id + config |> with_low_priority(fn -> Repo.all(query) end) |> MapSet.new() + end +end From 580424a33a37f80598cb74c07bf93d6461a8c5d4 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 9 Jul 2026 04:27:14 +0000 Subject: [PATCH 07/10] =?UTF-8?q?test(img):=20gc=20prune=20refusal=20contr?= =?UTF-8?q?acts=20=E2=80=94=20grace,=20dangling,=20race=20guard,=20errno,?= =?UTF-8?q?=20mount=20loss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/hyper/img/db/gc/prune_test.exs | 140 ++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 test/hyper/img/db/gc/prune_test.exs diff --git a/test/hyper/img/db/gc/prune_test.exs b/test/hyper/img/db/gc/prune_test.exs new file mode 100644 index 00000000..5dee5824 --- /dev/null +++ b/test/hyper/img/db/gc/prune_test.exs @@ -0,0 +1,140 @@ +defmodule Hyper.Img.Db.Gc.PruneTest do + @moduledoc """ + Safety contracts of the GC delete step against real Postgres and the real + layer store (the laws are stated in `Prune`'s moduledoc): prune only what + is missing AND unreferenced AND out of grace AND on a healthy medium; the + DELETE re-checks references; `presence/1` maps only a true `:enoent` to + `:missing`. Every test creates uniquely-named rows/files and removes them + on exit. + + Hits Postgres + the configured layer store: excluded from the default + run; CI runs it in the integration job (`mix test --include external`). + """ + use ExUnit.Case, async: false + + @moduletag :external + + import Ecto.Query + + alias Hyper.Cfg + alias Hyper.Cfg.Toml + alias Hyper.Img.Db.{Blob, Image, ImageLayer, Repo} + alias Hyper.Img.Db.Gc.Prune + + defp config(overrides \\ []) do + struct!( + %Cfg.Gc{ + batch_size: 100, + batch_pause: Unit.Time.ms(1), + sweep_interval: Unit.Time.s(60), + acquire_interval: Unit.Time.s(5), + retry: Unit.Time.s(60), + timeout: Unit.Time.s(5), + grace_period: Unit.Time.s(0) + }, + overrides + ) + end + + defp unique_id, do: Base.encode16(:crypto.strong_rand_bytes(16), case: :lower) + + defp insert_blob!(id, opts) do + age_s = Keyword.get(opts, :age_s, 3600) + + Repo.insert!(%Blob{ + id: id, + kind: :delta, + size: Keyword.get(opts, :size, 1000), + inserted_at: DateTime.add(DateTime.utc_now(), -age_s, :second) + }) + end + + defp blob!(opts \\ []) do + id = unique_id() + insert_blob!(id, opts) + on_exit(fn -> Repo.delete_all(from b in Blob, where: b.id == ^id) end) + id + end + + # A blob referenced by an image_layer row (ON DELETE RESTRICT on blob_id). + defp referenced_blob!(opts \\ []) do + blob_id = unique_id() + img_id = unique_id() + insert_blob!(blob_id, opts) + Repo.insert!(%Image{id: img_id, label: "gc-prune-test"}) + Repo.insert!(%ImageLayer{image_id: img_id, blob_id: blob_id, position: 0}) + + on_exit(fn -> + Repo.delete_all(from il in ImageLayer, where: il.image_id == ^img_id) + Repo.delete_all(from i in Image, where: i.id == ^img_id) + Repo.delete_all(from b in Blob, where: b.id == ^blob_id) + end) + + blob_id + end + + test "a missing, unreferenced, out-of-grace blob is pruned and its bytes counted" do + id = blob!(size: 2048, age_s: 3600) + + assert Prune.execute(config(), [{id, 2048}]) == {1, 2048, 0} + assert Repo.get(Blob, id) == nil + end + + test "a missing blob still referenced by an image is dangling: reported, never deleted" do + id = referenced_blob!(size: 512, age_s: 3600) + + assert Prune.execute(config(), [{id, 512}]) == {0, 0, 1} + assert Repo.get(Blob, id) + end + + test "rows inside the grace period are never deleted, even when missing and unreferenced" do + id = blob!(age_s: 0) + + assert Prune.execute(config(grace_period: Unit.Time.s(3600)), [{id, 1000}]) == {0, 0, 0} + assert Repo.get(Blob, id) + end + + test "the DELETE re-checks references: a row referenced after the snapshot is skipped" do + # Simulate the race by handing prune_rows an id that IS referenced: the + # NOT EXISTS clause, not the caller's earlier snapshot, must protect it. + id = referenced_blob!(age_s: 3600) + future_cutoff = DateTime.add(DateTime.utc_now(), 3600, :second) + + assert Prune.prune_rows(config(), [id], future_cutoff) == {0, 0} + assert Repo.get(Blob, id) + end + + test "a medium that vanished between probe and delete skips the page's deletions" do + id = blob!(age_s: 3600) + + # Point the layer store at a nonexistent dir only for the duration of the + # call: test_system must fail and Prune must refuse every delete. Safe to + # do globally-briefly: unknown/system-error never deletes, by design. + result = + try do + Toml.put_cache(%{"img" => %{"store" => "/nonexistent-hyper-gc-prune-test"}}) + Prune.execute(config(), [{id, 1000}]) + after + Toml.reload() + end + + assert result == {0, 0, 0} + assert Repo.get(Blob, id) + end + + test "presence/1: real file :present, true absence :missing, any other errno :unknown" do + store = Hyper.Cfg.Dirs.layer_dir() + id = unique_id() + file = Path.join(store, "layer_#{id}.img") + File.write!(file, "x") + on_exit(fn -> File.rm(file) end) + + assert Prune.presence(id) == :present + assert Prune.presence(unique_id()) == :missing + + # Probe a path that traverses THROUGH the regular file: stat fails with + # :enotdir — not :enoent — and must classify :unknown (never prunable), + # standing in for ESTALE/EIO on a wobbling NFS mount. + assert Prune.presence("#{id}.img/nested") == :unknown + end +end From 176e67e0d360f84fb548bd6e669a8d443f1a1979 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 9 Jul 2026 04:32:19 +0000 Subject: [PATCH 08/10] test(e2e): slow-fork fallback publishes then surfaces cluster verdict Also pins the dead-handle contract: fork/fast_fork of an unknown VM is :not_found and is never misrouted into the capacity fallback. --- test/e2e/fork_test.exs | 62 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/test/e2e/fork_test.exs b/test/e2e/fork_test.exs index 4dd85f99..21d7022b 100644 --- a/test/e2e/fork_test.exs +++ b/test/e2e/fork_test.exs @@ -11,7 +11,13 @@ defmodule Hyper.E2e.ForkTest do - a VM created from the derived image composes that chain (dm-snapshot over the published COW store) and boots with the parent's pre-publish state — including the parent's writes, excluding the child's; - - all forked writable volumes are reclaimed on stop (no dm leak). + - all forked writable volumes are reclaimed on stop (no dm leak); + - `Hyper.Vm.fork/1`'s slow-fork fallback: when a real node-admission refusal + stops `fast_fork/1`, `fork/1` publishes exactly one derived image before + re-placing cluster-wide, and the cluster-wide capacity verdict surfaces + unmasked when no other node can take the child; + - `fast_fork/1` and `fork/1` on a dead VM handle are `{:error, :not_found}`, + never misclassified as a capacity refusal. Runs only under `--only integration` / `--include integration` on a host provisioned per docs/cookbook/install.md (CI: the `integration` job). @@ -27,6 +33,17 @@ defmodule Hyper.E2e.ForkTest do # limits, which shared GHA egress IPs routinely exhaust. @image System.get_env("HYPER_E2E_IMAGE", "public.ecr.aws/docker/library/alpine:3.19") + alias Hyper.Img.Db.{Image, Repo} + + # Boot :micro fillers until cluster placement refuses, so the next + # fast_fork must hit a node-admission refusal. + defp saturate(img_id) do + case Hyper.create_vm(%Hyper.Vm.Spec{img_id: img_id, type: :micro}) do + {:ok, vm} -> [vm | saturate(img_id)] + {:error, reason} when reason in [:no_capacity, :exhausted] -> [] + end + end + test "fast_fork isolates writes; published delta composes and boots" do assert {:ok, img_id} = Hyper.Img.OciLoader.load(@image) @@ -108,4 +125,47 @@ defmodule Hyper.E2e.ForkTest do assert cousin_sees_childfile != 0, "child's writes leaked into the published delta" end + + test "fork/1 falls back to publish-and-reschedule when the node refuses admission" do + assert {:ok, img_id} = Hyper.Img.OciLoader.load(@image) + + assert {:ok, parent} = Hyper.create_vm(%Hyper.Vm.Spec{img_id: img_id, type: :micro}) + on_exit(fn -> Hyper.Node.stop_image_vm(parent) end) + + # Unique disk content so the published delta (and thus the derived image + # id) cannot collide with any image published by an earlier test. + assert {:ok, %{exit_code: 0}} = + await_exec( + parent, + ["/bin/sh", "-c", "head -c 32 /dev/urandom > /marker && sync"], + :timer.minutes(3) + ) + + fillers = saturate(img_id) + on_exit(fn -> Enum.each(fillers, &Hyper.Node.stop_image_vm/1) end) + + images_before = Repo.aggregate(Image, :count) + + # Single-node cluster: fast_fork must refuse with a capacity verdict, + # fork/1 must then publish the parent's disk state (the observable slow- + # fork side effect) and re-place cluster-wide — where the same saturated + # node is the only candidate, so the cluster verdict surfaces unmasked. + # fork/1's doc names both cluster-wide verdicts; either proves the point. + assert {:error, reason} = Hyper.Vm.fork(parent) + + assert reason in [:no_capacity, :exhausted], + "expected the cluster-wide capacity verdict, got #{inspect(reason)}" + + assert Repo.aggregate(Image, :count) == images_before + 1, + "slow fork must publish exactly one derived image before re-placing" + end + + test "forking a dead VM handle is :not_found, with no fallback attempted" do + dead = spawn(fn -> :ok end) + ref = Process.monitor(dead) + assert_receive {:DOWN, ^ref, :process, ^dead, _} + + assert Hyper.Vm.fast_fork(dead) == {:error, :not_found} + assert Hyper.Vm.fork(dead) == {:error, :not_found} + end end From f830121dd4ac0e134f85261f50a074e09b5130fe Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 9 Jul 2026 04:46:46 +0000 Subject: [PATCH 09/10] test(firecracker): round-trip all 9 Drive fields; drop gc Internals banner Final-review polish: extend the codec property generator to exercise io_engine/partuuid/socket (were never generated), and delete the pre-existing '## Internals' banner in gc.ex (no-banner rule) while the file is already open from the Gc.Prune extraction. --- lib/hyper/img/db/gc.ex | 2 -- test/hyper/firecracker/api/codec_properties_test.exs | 6 ++++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/hyper/img/db/gc.ex b/lib/hyper/img/db/gc.ex index 9ed8c84d..387cea43 100644 --- a/lib/hyper/img/db/gc.ex +++ b/lib/hyper/img/db/gc.ex @@ -118,8 +118,6 @@ defmodule Hyper.Img.Db.Gc do # role, monitor noise, etc.) rather than crashing an in-flight sweep. def handle_info(_msg, state), do: {:noreply, state} - ## Internals - @spec acquire(t()) :: t() defp acquire(state) do case Horde.Registry.register(Routing.name(), @singleton_key, nil) do diff --git a/test/hyper/firecracker/api/codec_properties_test.exs b/test/hyper/firecracker/api/codec_properties_test.exs index 1f0e2dc3..638ffb25 100644 --- a/test/hyper/firecracker/api/codec_properties_test.exs +++ b/test/hyper/firecracker/api/codec_properties_test.exs @@ -40,6 +40,9 @@ defmodule Hyper.Firecracker.Api.CodecPropertiesTest do is_read_only <- optional(boolean()), path_on_host <- optional(string(:alphanumeric, min_length: 1)), cache_type <- optional(member_of(["Unsafe", "Writeback"])), + io_engine <- optional(member_of(["Sync", "Async"])), + partuuid <- optional(string(:alphanumeric, min_length: 1)), + socket <- optional(string(:alphanumeric, min_length: 1)), rate_limiter <- optional(rate_limiter()) ) do %Drive{ @@ -48,6 +51,9 @@ defmodule Hyper.Firecracker.Api.CodecPropertiesTest do is_read_only: is_read_only, path_on_host: path_on_host, cache_type: cache_type, + io_engine: io_engine, + partuuid: partuuid, + socket: socket, rate_limiter: rate_limiter } end From e0c6058afc9bf919bc90e2970ad5f48581caf3b9 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 9 Jul 2026 04:53:39 +0000 Subject: [PATCH 10/10] test(img): drop unused default args in prune_test helpers blob!/1 and referenced_blob!/1 declared opts \\ [] but every call site passes an explicit keyword list, so the default is dead code and trips --warnings-as-errors in CI's coveralls run (which compiles test files; local mix compile --warnings-as-errors only covers lib/). --- test/hyper/img/db/gc/prune_test.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/hyper/img/db/gc/prune_test.exs b/test/hyper/img/db/gc/prune_test.exs index 5dee5824..aa530a5f 100644 --- a/test/hyper/img/db/gc/prune_test.exs +++ b/test/hyper/img/db/gc/prune_test.exs @@ -49,7 +49,7 @@ defmodule Hyper.Img.Db.Gc.PruneTest do }) end - defp blob!(opts \\ []) do + defp blob!(opts) do id = unique_id() insert_blob!(id, opts) on_exit(fn -> Repo.delete_all(from b in Blob, where: b.id == ^id) end) @@ -57,7 +57,7 @@ defmodule Hyper.Img.Db.Gc.PruneTest do end # A blob referenced by an image_layer row (ON DELETE RESTRICT on blob_id). - defp referenced_blob!(opts \\ []) do + defp referenced_blob!(opts) do blob_id = unique_id() img_id = unique_id() insert_blob!(blob_id, opts)