Skip to content

Commit ae0131d

Browse files
test: cover pure cores (units, budget data, cgroup/nss parsers) (#22)
1 parent a3225b2 commit ae0131d

8 files changed

Lines changed: 575 additions & 25 deletions

File tree

lib/sys/linux/cgroup.ex

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,19 @@ defmodule Sys.Linux.Cgroup do
1212
@spec versions :: {:ok, MapSet.t(:cgroup | :cgroup2)} | {:error, atom()}
1313
def versions do
1414
case Mounts.list() do
15-
{:ok, mounts} ->
16-
versions =
17-
for %{fs_type: fs} <- mounts, fs in ~w(cgroup cgroup2), into: MapSet.new() do
18-
String.to_existing_atom(fs)
19-
end
20-
21-
{:ok, versions}
15+
{:ok, mounts} -> {:ok, versions_from_mounts(mounts)}
16+
{:error, reason} -> {:error, reason}
17+
end
18+
end
2219

23-
{:error, reason} ->
24-
{:error, reason}
20+
@doc """
21+
Reduce a list of mounts to the set of cgroup versions present, keyed by
22+
filesystem type. Pure; `versions/0` is this applied to `/proc/mounts`.
23+
"""
24+
@spec versions_from_mounts([Sys.Linux.Fstab.Spec.t()]) :: MapSet.t(:cgroup | :cgroup2)
25+
def versions_from_mounts(mounts) do
26+
for %{fs_type: fs} <- mounts, fs in ~w(cgroup cgroup2), into: MapSet.new() do
27+
String.to_existing_atom(fs)
2528
end
2629
end
2730
end

lib/sys/linux/nss.ex

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,23 @@ defmodule Sys.Linux.Nss do
2727
{:getent_failed, non_neg_integer()} | :getent_unavailable | :invalid_format}
2828
def entries do
2929
with {:ok, output} <- Sys.Linux.Nss.getent(@getent_db) do
30-
output
31-
|> String.split("\n", trim: true)
32-
|> Enum.reduce_while({:ok, []}, fn line, {:ok, acc} ->
33-
case parse(line) do
34-
{:ok, spec} -> {:cont, {:ok, [spec | acc]}}
35-
{:error, _} = error -> {:halt, error}
36-
end
37-
end)
30+
from_output(output)
3831
end
3932
end
4033

34+
@doc "Parse raw `getent passwd` output into specs, halting on the first bad line."
35+
@spec from_output(binary()) :: {:ok, [Spec.t()]} | {:error, :invalid_format}
36+
def from_output(output) do
37+
output
38+
|> String.split("\n", trim: true)
39+
|> Enum.reduce_while({:ok, []}, fn line, {:ok, acc} ->
40+
case parse(line) do
41+
{:ok, spec} -> {:cont, {:ok, [spec | acc]}}
42+
{:error, _} = error -> {:halt, error}
43+
end
44+
end)
45+
end
46+
4147
# passwd line: name:password:uid:gid:gecos:home_dir:shell
4248
@spec parse(String.t()) :: {:ok, Spec.t()} | {:error, :invalid_format}
4349
defp parse(line) do
@@ -83,17 +89,23 @@ defmodule Sys.Linux.Nss do
8389
{:getent_failed, non_neg_integer()} | :getent_unavailable | :invalid_format}
8490
def entries do
8591
with {:ok, output} <- Sys.Linux.Nss.getent(@getent_db) do
86-
output
87-
|> String.split("\n", trim: true)
88-
|> Enum.reduce_while({:ok, []}, fn line, {:ok, acc} ->
89-
case parse(line) do
90-
{:ok, spec} -> {:cont, {:ok, [spec | acc]}}
91-
{:error, _} = error -> {:halt, error}
92-
end
93-
end)
92+
from_output(output)
9493
end
9594
end
9695

96+
@doc "Parse raw `getent group` output into specs, halting on the first bad line."
97+
@spec from_output(binary()) :: {:ok, [Spec.t()]} | {:error, :invalid_format}
98+
def from_output(output) do
99+
output
100+
|> String.split("\n", trim: true)
101+
|> Enum.reduce_while({:ok, []}, fn line, {:ok, acc} ->
102+
case parse(line) do
103+
{:ok, spec} -> {:cont, {:ok, [spec | acc]}}
104+
{:error, _} = error -> {:halt, error}
105+
end
106+
end)
107+
end
108+
97109
# group line: name:password:gid:member1,member2,...
98110
@spec parse(String.t()) :: {:ok, Spec.t()} | {:error, :invalid_format}
99111
defp parse(line) do
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
defmodule Hyper.Node.Budget.HardStatePropertiesTest do
2+
@moduledoc """
3+
Algebraic laws of the pure `Hyper.Node.Budget.Hard.State` accumulator: `cut`
4+
is the inverse of `bump` on every spec, bumps accumulate additively (so the
5+
running total is order-independent), and `track`/`untrack` round-trip a
6+
reservation by reference. The example tests spot-check single values; these
7+
pin the laws across the domain.
8+
"""
9+
use ExUnit.Case, async: true
10+
use ExUnitProperties
11+
12+
alias Hyper.Node.Budget.Hard.State
13+
alias Hyper.Vm.Instance.Spec
14+
alias Unit.{Bandwidth, Information}
15+
16+
# Only `mem`/`disk` matter to bump/cut; the other fields are along for the ride.
17+
defp spec do
18+
gen all(mem_mib <- integer(0..1_000_000), disk_mib <- integer(0..1_000_000)) do
19+
%Spec{
20+
vcpus: 1,
21+
mem: Information.mib(mem_mib),
22+
disk: Information.mib(disk_mib),
23+
disk_bw: Bandwidth.zero(),
24+
net_bw: Bandwidth.zero()
25+
}
26+
end
27+
end
28+
29+
property "cut undoes bump for any spec" do
30+
check all(s <- spec()) do
31+
state = State.zero() |> State.bump(s) |> State.cut(s)
32+
assert state.mem_allocated == Information.zero()
33+
assert state.disk_allocated == Information.zero()
34+
end
35+
end
36+
37+
property "the running total is the sum of bumped specs (hence order-independent)" do
38+
check all(specs <- list_of(spec(), max_length: 20)) do
39+
state = Enum.reduce(specs, State.zero(), fn s, acc -> State.bump(acc, s) end)
40+
41+
total_mem = specs |> Enum.map(&Information.as_bytes(&1.mem)) |> Enum.sum()
42+
total_disk = specs |> Enum.map(&Information.as_bytes(&1.disk)) |> Enum.sum()
43+
44+
assert Information.as_bytes(state.mem_allocated) == total_mem
45+
assert Information.as_bytes(state.disk_allocated) == total_disk
46+
end
47+
end
48+
49+
property "untrack returns exactly the spec track stored, leaving the rest unchanged" do
50+
check all(s <- spec()) do
51+
base = State.zero()
52+
ref = make_ref()
53+
tracked = State.track(base, ref, s)
54+
assert {^s, rest} = State.untrack(tracked, ref)
55+
assert rest.reservations == base.reservations
56+
end
57+
end
58+
59+
property "untrack of a ref that was never tracked yields nil and an unchanged state" do
60+
check all(s <- spec()) do
61+
ref = make_ref()
62+
other = make_ref()
63+
state = State.track(State.zero(), ref, s)
64+
assert {nil, ^state} = State.untrack(state, other)
65+
end
66+
end
67+
end
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
defmodule Hyper.Node.Budget.NodeStatePropertiesTest do
2+
@moduledoc """
3+
Monotonicity laws of the pure `NodeState.fits?/2` predicate, complementing the
4+
exact-`<=`-boundary example tests. A spec strictly within every ceiling always
5+
fits, exceeding free memory always fails, and a spec that fits a node still
6+
fits a node with strictly more headroom.
7+
"""
8+
use ExUnit.Case, async: true
9+
use ExUnitProperties
10+
use Unit.Operators
11+
12+
alias Hyper.Node.Budget.NodeState
13+
alias Hyper.Vm.Instance.Spec
14+
alias Unit.{Bandwidth, Information}
15+
16+
# A node, idle on every soft metric with `cpu_max_load` at 1.0, so the only
17+
# binding limits are the generated hard headrooms and cpu capacity.
18+
defp state do
19+
gen all(
20+
mem_gib <- integer(1..64),
21+
disk_gib <- integer(1..1000),
22+
cpu_cap <- integer(1..128)
23+
) do
24+
%NodeState{
25+
node: :n@h,
26+
mem_free: Information.gib(mem_gib),
27+
disk_free: Information.gib(disk_gib),
28+
cpu_load: 0.0,
29+
cpu_capacity: cpu_cap,
30+
cpu_max_load: 1.0,
31+
disk_bw_load: Bandwidth.zero(),
32+
disk_bw_ceiling: Bandwidth.gibps(10),
33+
net_bw_load: Bandwidth.zero(),
34+
net_bw_ceiling: Bandwidth.gibps(10),
35+
layers: []
36+
}
37+
end
38+
end
39+
40+
# A spec whose demand sits within every ceiling of `st`.
41+
defp fitting_spec(st) do
42+
gen all(
43+
mem_gib <- integer(0..Information.as_gib(st.mem_free)),
44+
disk_gib <- integer(0..Information.as_gib(st.disk_free)),
45+
vcpus <- integer(0..st.cpu_capacity)
46+
) do
47+
%Spec{
48+
vcpus: vcpus,
49+
mem: Information.gib(mem_gib),
50+
disk: Information.gib(disk_gib),
51+
disk_bw: Bandwidth.zero(),
52+
net_bw: Bandwidth.zero()
53+
}
54+
end
55+
end
56+
57+
property "a spec within every ceiling always fits" do
58+
check all(st <- state(), spec <- fitting_spec(st)) do
59+
assert NodeState.fits?(st, spec)
60+
end
61+
end
62+
63+
property "exceeding free memory always fails" do
64+
check all(st <- state(), over <- integer(1..1000)) do
65+
spec = %Spec{
66+
vcpus: 0,
67+
mem: st.mem_free + Information.gib(over),
68+
disk: Information.zero(),
69+
disk_bw: Bandwidth.zero(),
70+
net_bw: Bandwidth.zero()
71+
}
72+
73+
refute NodeState.fits?(st, spec)
74+
end
75+
end
76+
77+
property "a fitting spec still fits when the node gains headroom" do
78+
check all(st <- state(), spec <- fitting_spec(st), extra <- integer(0..100)) do
79+
roomier = %{
80+
st
81+
| mem_free: st.mem_free + Information.gib(extra),
82+
disk_free: st.disk_free + Information.gib(extra)
83+
}
84+
85+
# Precondition the law on the spec actually fitting `st`.
86+
if NodeState.fits?(st, spec), do: assert(NodeState.fits?(roomier, spec))
87+
end
88+
end
89+
end
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
defmodule Hyper.Node.Budget.NodeStateTest do
2+
use ExUnit.Case, async: true
3+
4+
alias Hyper.Node.Budget.NodeState
5+
alias Hyper.Vm.Instance.Spec
6+
alias Unit.{Bandwidth, Information}
7+
8+
# A node with generous headroom, idle on every metric. Override per case.
9+
defp roomy_state(overrides \\ %{}) do
10+
struct!(
11+
%NodeState{
12+
node: :node@host,
13+
mem_free: Information.gib(8),
14+
disk_free: Information.gib(100),
15+
cpu_load: 0.0,
16+
cpu_capacity: 8,
17+
cpu_max_load: 0.8,
18+
disk_bw_load: Bandwidth.zero(),
19+
disk_bw_ceiling: Bandwidth.gibps(1),
20+
net_bw_load: Bandwidth.zero(),
21+
net_bw_ceiling: Bandwidth.gibps(1),
22+
layers: []
23+
},
24+
overrides
25+
)
26+
end
27+
28+
defp spec(overrides \\ %{}) do
29+
struct!(
30+
%Spec{
31+
vcpus: 1,
32+
mem: Information.gib(1),
33+
disk: Information.gib(10),
34+
disk_bw: Bandwidth.mibps(10),
35+
net_bw: Bandwidth.mibps(10)
36+
},
37+
overrides
38+
)
39+
end
40+
41+
test "a spec that fits every metric is admitted" do
42+
assert NodeState.fits?(roomy_state(), spec())
43+
end
44+
45+
test "memory demand over free headroom is rejected" do
46+
state = roomy_state(%{mem_free: Information.gib(1)})
47+
refute NodeState.fits?(state, spec(%{mem: Information.gib(2)}))
48+
end
49+
50+
test "memory demand exactly at free headroom still fits (<= boundary)" do
51+
state = roomy_state(%{mem_free: Information.gib(2)})
52+
assert NodeState.fits?(state, spec(%{mem: Information.gib(2)}))
53+
end
54+
55+
test "disk demand over free headroom is rejected" do
56+
state = roomy_state(%{disk_free: Information.gib(5)})
57+
refute NodeState.fits?(state, spec(%{disk: Information.gib(6)}))
58+
end
59+
60+
test "cpu demand that crosses the load ceiling is rejected" do
61+
# load 0.5 + 3 vcpus / 8 cores = 0.875 > cpu_max_load 0.8
62+
state = roomy_state(%{cpu_load: 0.5})
63+
refute NodeState.fits?(state, spec(%{vcpus: 3}))
64+
end
65+
66+
test "cpu demand exactly at the load ceiling still fits" do
67+
# load 0.0 + 8 vcpus / 8 cores = 1.0 <= cpu_max_load 1.0
68+
state = roomy_state(%{cpu_max_load: 1.0})
69+
assert NodeState.fits?(state, spec(%{vcpus: 8}))
70+
end
71+
72+
test "disk bandwidth over its ceiling is rejected" do
73+
state = roomy_state(%{disk_bw_ceiling: Bandwidth.mibps(5)})
74+
refute NodeState.fits?(state, spec(%{disk_bw: Bandwidth.mibps(6)}))
75+
end
76+
77+
test "net bandwidth over its ceiling is rejected" do
78+
state = roomy_state(%{net_bw_ceiling: Bandwidth.mibps(5)})
79+
refute NodeState.fits?(state, spec(%{net_bw: Bandwidth.mibps(6)}))
80+
end
81+
end
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
defmodule Sys.Linux.CgroupPropertiesTest do
2+
@moduledoc """
3+
`Cgroup.versions_from_mounts/1` reduces a mount list to the set of cgroup
4+
versions present, keyed by `fs_type`. The result is exactly the cgroup
5+
fs_types that appear: it ignores every other filesystem, is insensitive to
6+
order and duplication, and never contains a version that was not mounted.
7+
"""
8+
use ExUnit.Case, async: true
9+
use ExUnitProperties
10+
11+
alias Sys.Linux.Cgroup
12+
alias Sys.Linux.Fstab.Spec
13+
14+
@cgroup_fs ~w(cgroup cgroup2)
15+
# Filesystem types that must never contribute to the result.
16+
@other_fs ~w(ext4 xfs btrfs proc sysfs tmpfs overlay)
17+
18+
defp mount(fs_type) do
19+
%Spec{device: "none", mount_point: "/x", fs_type: fs_type, mount_opts: "rw"}
20+
end
21+
22+
defp fs_type, do: member_of(@cgroup_fs ++ @other_fs)
23+
24+
property "the result is exactly the set of cgroup fs_types present" do
25+
check all(types <- list_of(fs_type(), max_length: 20)) do
26+
mounts = Enum.map(types, &mount/1)
27+
28+
expected =
29+
types
30+
|> Enum.filter(&(&1 in @cgroup_fs))
31+
|> Enum.map(&String.to_existing_atom/1)
32+
|> MapSet.new()
33+
34+
assert Cgroup.versions_from_mounts(mounts) == expected
35+
end
36+
end
37+
38+
property "non-cgroup mounts never change the result" do
39+
check all(
40+
types <- list_of(member_of(@cgroup_fs), max_length: 10),
41+
noise <- list_of(member_of(@other_fs), max_length: 10)
42+
) do
43+
base = Enum.map(types, &mount/1)
44+
with_noise = Enum.map(types ++ noise, &mount/1)
45+
assert Cgroup.versions_from_mounts(base) == Cgroup.versions_from_mounts(with_noise)
46+
end
47+
end
48+
49+
property "order and duplication do not affect the result" do
50+
check all(types <- list_of(fs_type(), max_length: 20)) do
51+
mounts = Enum.map(types, &mount/1)
52+
result = Cgroup.versions_from_mounts(mounts)
53+
assert Cgroup.versions_from_mounts(Enum.reverse(mounts)) == result
54+
assert Cgroup.versions_from_mounts(mounts ++ mounts) == result
55+
end
56+
end
57+
58+
property "the result is always a subset of {:cgroup, :cgroup2}" do
59+
check all(types <- list_of(fs_type(), max_length: 20)) do
60+
result = Cgroup.versions_from_mounts(Enum.map(types, &mount/1))
61+
assert MapSet.subset?(result, MapSet.new([:cgroup, :cgroup2]))
62+
end
63+
end
64+
end

0 commit comments

Comments
 (0)