Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions lib/sys/linux/cgroup.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,19 @@ defmodule Sys.Linux.Cgroup do
@spec versions :: {:ok, MapSet.t(:cgroup | :cgroup2)} | {:error, atom()}
def versions do
case Mounts.list() do
{:ok, mounts} ->
versions =
for %{fs_type: fs} <- mounts, fs in ~w(cgroup cgroup2), into: MapSet.new() do
String.to_existing_atom(fs)
end

{:ok, versions}
{:ok, mounts} -> {:ok, versions_from_mounts(mounts)}
{:error, reason} -> {:error, reason}
end
end

{:error, reason} ->
{:error, reason}
@doc """
Reduce a list of mounts to the set of cgroup versions present, keyed by
filesystem type. Pure; `versions/0` is this applied to `/proc/mounts`.
"""
@spec versions_from_mounts([Sys.Linux.Fstab.Spec.t()]) :: MapSet.t(:cgroup | :cgroup2)
def versions_from_mounts(mounts) do
for %{fs_type: fs} <- mounts, fs in ~w(cgroup cgroup2), into: MapSet.new() do
String.to_existing_atom(fs)
end
end
end
44 changes: 28 additions & 16 deletions lib/sys/linux/nss.ex
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,23 @@ defmodule Sys.Linux.Nss do
{:getent_failed, non_neg_integer()} | :getent_unavailable | :invalid_format}
def entries do
with {:ok, output} <- Sys.Linux.Nss.getent(@getent_db) do
output
|> String.split("\n", trim: true)
|> Enum.reduce_while({:ok, []}, fn line, {:ok, acc} ->
case parse(line) do
{:ok, spec} -> {:cont, {:ok, [spec | acc]}}
{:error, _} = error -> {:halt, error}
end
end)
from_output(output)
end
end

@doc "Parse raw `getent passwd` output into specs, halting on the first bad line."
@spec from_output(binary()) :: {:ok, [Spec.t()]} | {:error, :invalid_format}
def from_output(output) do
output
|> String.split("\n", trim: true)
|> Enum.reduce_while({:ok, []}, fn line, {:ok, acc} ->
case parse(line) do
{:ok, spec} -> {:cont, {:ok, [spec | acc]}}
{:error, _} = error -> {:halt, error}
end
end)
end

# passwd line: name:password:uid:gid:gecos:home_dir:shell
@spec parse(String.t()) :: {:ok, Spec.t()} | {:error, :invalid_format}
defp parse(line) do
Expand Down Expand Up @@ -83,17 +89,23 @@ defmodule Sys.Linux.Nss do
{:getent_failed, non_neg_integer()} | :getent_unavailable | :invalid_format}
def entries do
with {:ok, output} <- Sys.Linux.Nss.getent(@getent_db) do
output
|> String.split("\n", trim: true)
|> Enum.reduce_while({:ok, []}, fn line, {:ok, acc} ->
case parse(line) do
{:ok, spec} -> {:cont, {:ok, [spec | acc]}}
{:error, _} = error -> {:halt, error}
end
end)
from_output(output)
end
end

@doc "Parse raw `getent group` output into specs, halting on the first bad line."
@spec from_output(binary()) :: {:ok, [Spec.t()]} | {:error, :invalid_format}
def from_output(output) do
output
|> String.split("\n", trim: true)
|> Enum.reduce_while({:ok, []}, fn line, {:ok, acc} ->
case parse(line) do
{:ok, spec} -> {:cont, {:ok, [spec | acc]}}
{:error, _} = error -> {:halt, error}
end
end)
end

# group line: name:password:gid:member1,member2,...
@spec parse(String.t()) :: {:ok, Spec.t()} | {:error, :invalid_format}
defp parse(line) do
Expand Down
67 changes: 67 additions & 0 deletions test/hyper/node/budget/hard_state_properties_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
defmodule Hyper.Node.Budget.HardStatePropertiesTest do
@moduledoc """
Algebraic laws of the pure `Hyper.Node.Budget.Hard.State` accumulator: `cut`
is the inverse of `bump` on every spec, bumps accumulate additively (so the
running total is order-independent), and `track`/`untrack` round-trip a
reservation by reference. The example tests spot-check single values; these
pin the laws across the domain.
"""
use ExUnit.Case, async: true
use ExUnitProperties

alias Hyper.Node.Budget.Hard.State
alias Hyper.Vm.Instance.Spec
alias Unit.{Bandwidth, Information}

# Only `mem`/`disk` matter to bump/cut; the other fields are along for the ride.
defp spec do
gen all(mem_mib <- integer(0..1_000_000), disk_mib <- integer(0..1_000_000)) do
%Spec{
vcpus: 1,
mem: Information.mib(mem_mib),
disk: Information.mib(disk_mib),
disk_bw: Bandwidth.zero(),
net_bw: Bandwidth.zero()
}
end
end

property "cut undoes bump for any spec" do
check all(s <- spec()) do
state = State.zero() |> State.bump(s) |> State.cut(s)
assert state.mem_allocated == Information.zero()
assert state.disk_allocated == Information.zero()
end
end

property "the running total is the sum of bumped specs (hence order-independent)" do
check all(specs <- list_of(spec(), max_length: 20)) do
state = Enum.reduce(specs, State.zero(), fn s, acc -> State.bump(acc, s) end)

total_mem = specs |> Enum.map(&Information.as_bytes(&1.mem)) |> Enum.sum()
total_disk = specs |> Enum.map(&Information.as_bytes(&1.disk)) |> Enum.sum()

assert Information.as_bytes(state.mem_allocated) == total_mem
assert Information.as_bytes(state.disk_allocated) == total_disk
end
end

property "untrack returns exactly the spec track stored, leaving the rest unchanged" do
check all(s <- spec()) do
base = State.zero()
ref = make_ref()
tracked = State.track(base, ref, s)
assert {^s, rest} = State.untrack(tracked, ref)
assert rest.reservations == base.reservations
end
end

property "untrack of a ref that was never tracked yields nil and an unchanged state" do
check all(s <- spec()) do
ref = make_ref()
other = make_ref()
state = State.track(State.zero(), ref, s)
assert {nil, ^state} = State.untrack(state, other)
end
end
end
89 changes: 89 additions & 0 deletions test/hyper/node/budget/node_state_properties_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
defmodule Hyper.Node.Budget.NodeStatePropertiesTest do
@moduledoc """
Monotonicity laws of the pure `NodeState.fits?/2` predicate, complementing the
exact-`<=`-boundary example tests. A spec strictly within every ceiling always
fits, exceeding free memory always fails, and a spec that fits a node still
fits a node with strictly more headroom.
"""
use ExUnit.Case, async: true
use ExUnitProperties
use Unit.Operators

alias Hyper.Node.Budget.NodeState
alias Hyper.Vm.Instance.Spec
alias Unit.{Bandwidth, Information}

# A node, idle on every soft metric with `cpu_max_load` at 1.0, so the only
# binding limits are the generated hard headrooms and cpu capacity.
defp state do
gen all(
mem_gib <- integer(1..64),
disk_gib <- integer(1..1000),
cpu_cap <- integer(1..128)
) do
%NodeState{
node: :n@h,
mem_free: Information.gib(mem_gib),
disk_free: Information.gib(disk_gib),
cpu_load: 0.0,
cpu_capacity: cpu_cap,
cpu_max_load: 1.0,
disk_bw_load: Bandwidth.zero(),
disk_bw_ceiling: Bandwidth.gibps(10),
net_bw_load: Bandwidth.zero(),
net_bw_ceiling: Bandwidth.gibps(10),
layers: []
}
end
end

# A spec whose demand sits within every ceiling of `st`.
defp fitting_spec(st) do
gen all(
mem_gib <- integer(0..Information.as_gib(st.mem_free)),
disk_gib <- integer(0..Information.as_gib(st.disk_free)),
vcpus <- integer(0..st.cpu_capacity)
) do
%Spec{
vcpus: vcpus,
mem: Information.gib(mem_gib),
disk: Information.gib(disk_gib),
disk_bw: Bandwidth.zero(),
net_bw: Bandwidth.zero()
}
end
end

property "a spec within every ceiling always fits" do
check all(st <- state(), spec <- fitting_spec(st)) do
assert NodeState.fits?(st, spec)
end
end

property "exceeding free memory always fails" do
check all(st <- state(), over <- integer(1..1000)) do
spec = %Spec{
vcpus: 0,
mem: st.mem_free + Information.gib(over),
disk: Information.zero(),
disk_bw: Bandwidth.zero(),
net_bw: Bandwidth.zero()
}

refute NodeState.fits?(st, spec)
end
end

property "a fitting spec still fits when the node gains headroom" do
check all(st <- state(), spec <- fitting_spec(st), extra <- integer(0..100)) do
roomier = %{
st
| mem_free: st.mem_free + Information.gib(extra),
disk_free: st.disk_free + Information.gib(extra)
}

# Precondition the law on the spec actually fitting `st`.
if NodeState.fits?(st, spec), do: assert(NodeState.fits?(roomier, spec))
end
end
end
81 changes: 81 additions & 0 deletions test/hyper/node/budget/node_state_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
defmodule Hyper.Node.Budget.NodeStateTest do
use ExUnit.Case, async: true

alias Hyper.Node.Budget.NodeState
alias Hyper.Vm.Instance.Spec
alias Unit.{Bandwidth, Information}

# A node with generous headroom, idle on every metric. Override per case.
defp roomy_state(overrides \\ %{}) do
struct!(
%NodeState{
node: :node@host,
mem_free: Information.gib(8),
disk_free: Information.gib(100),
cpu_load: 0.0,
cpu_capacity: 8,
cpu_max_load: 0.8,
disk_bw_load: Bandwidth.zero(),
disk_bw_ceiling: Bandwidth.gibps(1),
net_bw_load: Bandwidth.zero(),
net_bw_ceiling: Bandwidth.gibps(1),
layers: []
},
overrides
)
end

defp spec(overrides \\ %{}) do
struct!(
%Spec{
vcpus: 1,
mem: Information.gib(1),
disk: Information.gib(10),
disk_bw: Bandwidth.mibps(10),
net_bw: Bandwidth.mibps(10)
},
overrides
)
end

test "a spec that fits every metric is admitted" do
assert NodeState.fits?(roomy_state(), spec())
end

test "memory demand over free headroom is rejected" do
state = roomy_state(%{mem_free: Information.gib(1)})
refute NodeState.fits?(state, spec(%{mem: Information.gib(2)}))
end

test "memory demand exactly at free headroom still fits (<= boundary)" do
state = roomy_state(%{mem_free: Information.gib(2)})
assert NodeState.fits?(state, spec(%{mem: Information.gib(2)}))
end

test "disk demand over free headroom is rejected" do
state = roomy_state(%{disk_free: Information.gib(5)})
refute NodeState.fits?(state, spec(%{disk: Information.gib(6)}))
end

test "cpu demand that crosses the load ceiling is rejected" do
# load 0.5 + 3 vcpus / 8 cores = 0.875 > cpu_max_load 0.8
state = roomy_state(%{cpu_load: 0.5})
refute NodeState.fits?(state, spec(%{vcpus: 3}))
end

test "cpu demand exactly at the load ceiling still fits" do
# load 0.0 + 8 vcpus / 8 cores = 1.0 <= cpu_max_load 1.0
state = roomy_state(%{cpu_max_load: 1.0})
assert NodeState.fits?(state, spec(%{vcpus: 8}))
end

test "disk bandwidth over its ceiling is rejected" do
state = roomy_state(%{disk_bw_ceiling: Bandwidth.mibps(5)})
refute NodeState.fits?(state, spec(%{disk_bw: Bandwidth.mibps(6)}))
end

test "net bandwidth over its ceiling is rejected" do
state = roomy_state(%{net_bw_ceiling: Bandwidth.mibps(5)})
refute NodeState.fits?(state, spec(%{net_bw: Bandwidth.mibps(6)}))
end
end
64 changes: 64 additions & 0 deletions test/sys/linux/cgroup_properties_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
defmodule Sys.Linux.CgroupPropertiesTest do
@moduledoc """
`Cgroup.versions_from_mounts/1` reduces a mount list to the set of cgroup
versions present, keyed by `fs_type`. The result is exactly the cgroup
fs_types that appear: it ignores every other filesystem, is insensitive to
order and duplication, and never contains a version that was not mounted.
"""
use ExUnit.Case, async: true
use ExUnitProperties

alias Sys.Linux.Cgroup
alias Sys.Linux.Fstab.Spec

@cgroup_fs ~w(cgroup cgroup2)
# Filesystem types that must never contribute to the result.
@other_fs ~w(ext4 xfs btrfs proc sysfs tmpfs overlay)

defp mount(fs_type) do
%Spec{device: "none", mount_point: "/x", fs_type: fs_type, mount_opts: "rw"}
end

defp fs_type, do: member_of(@cgroup_fs ++ @other_fs)

property "the result is exactly the set of cgroup fs_types present" do
check all(types <- list_of(fs_type(), max_length: 20)) do
mounts = Enum.map(types, &mount/1)

expected =
types
|> Enum.filter(&(&1 in @cgroup_fs))
|> Enum.map(&String.to_existing_atom/1)
|> MapSet.new()

assert Cgroup.versions_from_mounts(mounts) == expected
end
end

property "non-cgroup mounts never change the result" do
check all(
types <- list_of(member_of(@cgroup_fs), max_length: 10),
noise <- list_of(member_of(@other_fs), max_length: 10)
) do
base = Enum.map(types, &mount/1)
with_noise = Enum.map(types ++ noise, &mount/1)
assert Cgroup.versions_from_mounts(base) == Cgroup.versions_from_mounts(with_noise)
end
end

property "order and duplication do not affect the result" do
check all(types <- list_of(fs_type(), max_length: 20)) do
mounts = Enum.map(types, &mount/1)
result = Cgroup.versions_from_mounts(mounts)
assert Cgroup.versions_from_mounts(Enum.reverse(mounts)) == result
assert Cgroup.versions_from_mounts(mounts ++ mounts) == result
end
end

property "the result is always a subset of {:cgroup, :cgroup2}" do
check all(types <- list_of(fs_type(), max_length: 20)) do
result = Cgroup.versions_from_mounts(Enum.map(types, &mount/1))
assert MapSet.subset?(result, MapSet.new([:cgroup, :cgroup2]))
end
end
end
Loading
Loading