Skip to content

Commit 9c1849e

Browse files
committed
Add semantic unit file comparison
1 parent c9db6bd commit 9c1849e

2 files changed

Lines changed: 135 additions & 0 deletions

File tree

lib/systemd/unit_file.ex

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,28 @@ defmodule Systemd.UnitFile do
7474
:ok | {:error, [Systemd.UnitFile.ValidationError.t()]}
7575
defdelegate validate(unit_file, type \\ nil), to: Validator
7676

77+
@doc """
78+
Returns a normalized representation suitable for semantic-ish comparison.
79+
80+
This intentionally ignores trivia, directive ordering, and equivalent list
81+
spellings for directives such as `Wants=` and `ReadWritePaths=`.
82+
"""
83+
@spec normalize(t() | String.t()) :: map()
84+
def normalize(text) when is_binary(text), do: text |> parse!() |> normalize()
85+
86+
def normalize(%__MODULE__{entries: entries}) do
87+
entries
88+
|> entries_with_sections()
89+
|> Enum.reduce(%{}, &collect_normalized_entry/2)
90+
|> Map.new(fn {section, directives} -> {section, drop_defaults(directives)} end)
91+
end
92+
93+
@doc """
94+
Compares two unit files after normalization.
95+
"""
96+
@spec equivalent?(t() | String.t(), t() | String.t()) :: boolean()
97+
def equivalent?(left, right), do: normalize(left) == normalize(right)
98+
7799
@doc """
78100
Renders a unit file.
79101
"""
@@ -150,6 +172,66 @@ defmodule Systemd.UnitFile do
150172
defp entry_to_iodata(%Directive{name: name, value: value}), do: [name, "=", value, "\n"]
151173
defp entry_to_iodata(%Raw{content: content}), do: [content, "\n"]
152174

175+
@list_directives MapSet.new([
176+
"after",
177+
"before",
178+
"bindsto",
179+
"conflicts",
180+
"documentation",
181+
"environmentfile",
182+
"readwritepaths",
183+
"requires",
184+
"requiredby",
185+
"wants",
186+
"wantedby"
187+
])
188+
189+
defp collect_normalized_entry({section, %Directive{}}, acc) when is_nil(section), do: acc
190+
191+
defp collect_normalized_entry({section, %Directive{} = directive}, acc) do
192+
section = normalize_name(section)
193+
key = normalize_name(directive.name)
194+
values = normalize_directive_values(key, directive.value)
195+
196+
update_in(acc, [Access.key(section, %{}), Access.key(key, [])], &(values ++ &1))
197+
end
198+
199+
defp collect_normalized_entry(_entry, acc), do: acc
200+
201+
defp normalize_directive_values(key, value) do
202+
value = normalize_value(value)
203+
204+
values =
205+
if MapSet.member?(@list_directives, key) do
206+
String.split(value, ~r/\s+/, trim: true)
207+
else
208+
[value]
209+
end
210+
211+
Enum.sort(values)
212+
end
213+
214+
defp drop_defaults(%{"type" => ["simple"]} = directives),
215+
do: directives |> Map.delete("type") |> sort_values()
216+
217+
defp drop_defaults(directives), do: sort_values(directives)
218+
219+
defp sort_values(directives),
220+
do: Map.new(directives, fn {key, values} -> {key, Enum.sort(values)} end)
221+
222+
defp normalize_name(name) do
223+
name
224+
|> String.trim()
225+
|> String.replace("_", "")
226+
|> String.downcase()
227+
end
228+
229+
defp normalize_value(value) do
230+
value
231+
|> String.trim()
232+
|> String.replace(~r/\s+/, " ")
233+
end
234+
153235
defp entries_with_sections(entries) do
154236
{_section, entries} =
155237
Enum.reduce(entries, {nil, []}, fn

test/systemd/unit_file_test.exs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,59 @@ defmodule Systemd.UnitFileTest do
4545
]
4646
end
4747

48+
test "normalizes equivalent unit files" do
49+
left = """
50+
[Unit]
51+
Description=Exograph public Elixir code search
52+
After=network-online.target
53+
Wants=network-online.target
54+
55+
[Service]
56+
Type=simple
57+
User=toys-exograph
58+
Group=toys-exograph
59+
WorkingDirectory=/opt/toys/src/exograph
60+
EnvironmentFile=/etc/toys/exograph/env
61+
ExecStart=/usr/local/bin/mix exograph.index.hex --mode latest --mirror https://hex.elixir.toys --prefix hex --backend duckdb --duckdb-shards 8 --duckdb-threads 2 --concurrency 8 --shard-dir /srv/toys/exograph/shards --manifest-path /srv/toys/exograph/hex-manifest.json --no-bm25 --web --port 4200
62+
Restart=on-failure
63+
RestartSec=10
64+
NoNewPrivileges=true
65+
PrivateTmp=true
66+
ProtectSystem=full
67+
ProtectHome=true
68+
ReadWritePaths=/srv/toys/exograph /var/lib/toys/exograph /opt/toys/src/exograph
69+
70+
[Install]
71+
WantedBy=multi-user.target
72+
"""
73+
74+
right = """
75+
[Unit]
76+
Wants=network-online.target
77+
After=network-online.target
78+
Description=Exograph public Elixir code search
79+
[Service]
80+
ReadWritePaths=/srv/toys/exograph
81+
ReadWritePaths=/var/lib/toys/exograph
82+
ReadWritePaths=/opt/toys/src/exograph
83+
ProtectHome=true
84+
ProtectSystem=full
85+
PrivateTmp=true
86+
NoNewPrivileges=true
87+
RestartSec=10
88+
Restart=on-failure
89+
ExecStart=/usr/local/bin/mix exograph.index.hex --mode latest --mirror https://hex.elixir.toys --prefix hex --backend duckdb --duckdb-shards 8 --duckdb-threads 2 --concurrency 8 --shard-dir /srv/toys/exograph/shards --manifest-path /srv/toys/exograph/hex-manifest.json --no-bm25 --web --port 4200
90+
EnvironmentFile=/etc/toys/exograph/env
91+
WorkingDirectory=/opt/toys/src/exograph
92+
Group=toys-exograph
93+
User=toys-exograph
94+
[Install]
95+
WantedBy=multi-user.target
96+
"""
97+
98+
assert UnitFile.equivalent?(left, right)
99+
end
100+
48101
test "renders parsed unit files deterministically" do
49102
text = "[Unit]\nDescription=My app\n\n[Install]\nWantedBy=multi-user.target\n"
50103

0 commit comments

Comments
 (0)