Skip to content

Commit 5cd4b26

Browse files
committed
Add signal waits and sandbox validation
1 parent 9ad1f59 commit 5cd4b26

8 files changed

Lines changed: 312 additions & 14 deletions

File tree

guides/dbus-manager.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,23 @@ end)
5252
## Job tracking
5353

5454
Unit lifecycle methods return jobs through `Systemd.Manager`. Top-level helpers
55-
wait for jobs by default; pass `wait: false` to inspect the job yourself:
55+
wait for jobs by default; pass `wait: false` to inspect the job yourself.
56+
Polling is available through `Systemd.Job.await/3`; signal-driven waiting is
57+
available through `Systemd.Job.await_signal/3` and systemd's `JobRemoved` signal:
5658

5759
```elixir
5860
{:ok, conn} = Systemd.Manager.connect()
5961
{:ok, job} = Systemd.Manager.restart_unit(conn, "my_app@4000.service")
6062
{:ok, :running} = Systemd.Job.state(conn, job)
61-
:ok = Systemd.Job.await(conn, job, timeout: 10_000)
63+
:ok = Systemd.Job.await_signal(conn, job, timeout: 10_000)
64+
```
65+
66+
For lower-level signal handling, subscribe to manager signals directly:
67+
68+
```elixir
69+
{:ok, sub} = Systemd.Signal.subscribe_manager(conn)
70+
{:ok, removed} = Systemd.Signal.await_job_removed(sub, job.object_path)
71+
:ok = Systemd.Signal.unsubscribe(sub)
6272
```
6373

6474
Jobs can be cancelled through the job object when systemd still exposes it:

guides/xamal-style-deployment.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,15 @@ unit_file =
2121
restart: "on-failure",
2222
restart_sec: 5,
2323
timeout_stop_sec: 30,
24-
limit_nofile: 1_048_576
24+
limit_nofile: 1_048_576,
25+
cpu_accounting: true,
26+
cpu_quota: "50%",
27+
memory_accounting: true,
28+
memory_max: "512M",
29+
tasks_max: 512,
30+
no_new_privileges: true,
31+
protect_system: :strict,
32+
protect_home: "read-only"
2533
],
2634
install: [wanted_by: "multi-user.target"]
2735
)

lib/systemd/job.ex

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ defmodule Systemd.Job do
33
A systemd job returned by manager operations such as `StartUnit`.
44
"""
55

6-
alias Systemd.{DBus, Error, Properties}
6+
alias Systemd.{DBus, Error, Properties, Signal}
77

88
@interface "org.freedesktop.systemd1.Job"
99

@@ -51,6 +51,24 @@ defmodule Systemd.Job do
5151
end
5252
end
5353

54+
@doc """
55+
Waits for this job's `JobRemoved` D-Bus signal.
56+
"""
57+
@spec await_signal(pid(), t(), keyword()) :: :ok | {:error, Error.t()}
58+
def await_signal(conn, %__MODULE__{object_path: path}, opts \\ []) do
59+
with {:ok, subscription} <- Signal.subscribe_manager(conn) do
60+
try do
61+
case Signal.await_job_removed(subscription, path, opts) do
62+
{:ok, %{result: "done"}} -> :ok
63+
{:ok, %{result: result}} -> {:error, Error.protocol_error({:job_failed, result})}
64+
{:error, error} -> {:error, error}
65+
end
66+
after
67+
Signal.unsubscribe(subscription)
68+
end
69+
end
70+
end
71+
5472
@doc """
5573
Polls until a job leaves `waiting`/`running` or disappears from the bus.
5674
"""

lib/systemd/signal.ex

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
defmodule Systemd.Signal do
2+
@moduledoc """
3+
Helpers for receiving systemd D-Bus signals.
4+
"""
5+
6+
alias Rebus.Message
7+
alias Systemd.{DBus, Error}
8+
9+
@dbus_destination "org.freedesktop.DBus"
10+
@dbus_path "/org/freedesktop/DBus"
11+
@dbus_interface "org.freedesktop.DBus"
12+
@systemd_destination "org.freedesktop.systemd1"
13+
@manager_path "/org/freedesktop/systemd1"
14+
@manager_interface "org.freedesktop.systemd1.Manager"
15+
16+
@type subscription :: %__MODULE__{conn: pid(), ref: reference(), match_rule: String.t()}
17+
@type job_removed :: %{
18+
id: non_neg_integer(),
19+
job_path: String.t(),
20+
unit: String.t(),
21+
result: String.t()
22+
}
23+
24+
@enforce_keys [:conn, :ref, :match_rule]
25+
defstruct [:conn, :ref, :match_rule]
26+
27+
@doc """
28+
Subscribes the current process to systemd manager signals.
29+
"""
30+
@spec subscribe_manager(pid()) :: {:ok, subscription()} | {:error, Error.t()}
31+
def subscribe_manager(conn) when is_pid(conn) do
32+
match_rule =
33+
"type='signal',sender='#{@systemd_destination}',path='#{@manager_path}',interface='#{@manager_interface}'"
34+
35+
with :ok <- add_match(conn, match_rule),
36+
{:ok, []} <- manager_call(conn, "Subscribe"),
37+
ref when is_reference(ref) <- Rebus.add_signal_handler(conn) do
38+
{:ok, %__MODULE__{conn: conn, ref: ref, match_rule: match_rule}}
39+
else
40+
{:error, %Error{} = error} -> {:error, error}
41+
other -> {:error, Error.protocol_error(other)}
42+
end
43+
end
44+
45+
@doc """
46+
Removes a signal subscription returned by `subscribe_manager/1`.
47+
"""
48+
@spec unsubscribe(subscription()) :: :ok | {:error, Error.t()}
49+
def unsubscribe(%__MODULE__{conn: conn, ref: ref, match_rule: match_rule}) do
50+
:ok = Rebus.delete_signal_handler(conn, ref)
51+
remove_match(conn, match_rule)
52+
end
53+
54+
@doc """
55+
Waits for a `JobRemoved` signal matching a job object path.
56+
"""
57+
@spec await_job_removed(subscription(), String.t(), keyword()) ::
58+
{:ok, job_removed()} | {:error, Error.t()}
59+
def await_job_removed(%__MODULE__{ref: ref}, job_path, opts \\ []) when is_binary(job_path) do
60+
timeout = Keyword.get(opts, :timeout, 30_000)
61+
deadline = System.monotonic_time(:millisecond) + timeout
62+
do_await_job_removed(ref, job_path, deadline)
63+
end
64+
65+
defp do_await_job_removed(ref, job_path, deadline) do
66+
remaining = max(deadline - System.monotonic_time(:millisecond), 0)
67+
68+
receive do
69+
{^ref, %Message{} = message} ->
70+
case job_removed(message) do
71+
{:ok, %{job_path: ^job_path} = removed} -> {:ok, removed}
72+
{:ok, _other_job} -> do_await_job_removed(ref, job_path, deadline)
73+
:ignore -> do_await_job_removed(ref, job_path, deadline)
74+
end
75+
after
76+
remaining -> {:error, Error.connection_error(:timeout)}
77+
end
78+
end
79+
80+
defp job_removed(%Message{
81+
type: :signal,
82+
header_fields: headers,
83+
body: [id, job_path, unit, result]
84+
}) do
85+
if Map.get(headers, :member) == "JobRemoved" and
86+
Map.get(headers, :interface) == @manager_interface do
87+
{:ok, %{id: id, job_path: job_path, unit: unit, result: result}}
88+
else
89+
:ignore
90+
end
91+
end
92+
93+
defp job_removed(_message), do: :ignore
94+
95+
defp add_match(conn, match_rule), do: dbus_void_call(conn, "AddMatch", [match_rule], "s")
96+
defp remove_match(conn, match_rule), do: dbus_void_call(conn, "RemoveMatch", [match_rule], "s")
97+
98+
defp manager_call(conn, member) do
99+
DBus.call_body(conn,
100+
destination: @systemd_destination,
101+
path: @manager_path,
102+
interface: @manager_interface,
103+
member: member
104+
)
105+
end
106+
107+
defp dbus_void_call(conn, member, body, signature) do
108+
with {:ok, []} <-
109+
DBus.call_body(conn,
110+
destination: @dbus_destination,
111+
path: @dbus_path,
112+
interface: @dbus_interface,
113+
member: member,
114+
signature: signature,
115+
body: body
116+
) do
117+
:ok
118+
end
119+
end
120+
end

lib/systemd/unit_file/builder.ex

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ defmodule Systemd.UnitFile.Builder do
99

1010
@directive_names %{
1111
cpu_accounting: "CPUAccounting",
12+
cpu_quota: "CPUQuota",
13+
cpu_weight: "CPUWeight",
1214
io_accounting: "IOAccounting",
15+
io_weight: "IOWeight",
1316
ip_accounting: "IPAccounting",
1417
limit_as: "LimitAS",
1518
limit_core: "LimitCORE",
@@ -26,10 +29,39 @@ defmodule Systemd.UnitFile.Builder do
2629
limit_rtprio: "LimitRTPRIO",
2730
limit_rttime: "LimitRTTIME",
2831
limit_sigpending: "LimitSIGPENDING",
32+
memory_accounting: "MemoryAccounting",
33+
memory_high: "MemoryHigh",
34+
memory_low: "MemoryLow",
35+
memory_max: "MemoryMax",
36+
memory_min: "MemoryMin",
37+
memory_swap_max: "MemorySwapMax",
38+
no_new_privileges: "NoNewPrivileges",
2939
oom_policy: "OOMPolicy",
3040
pid_file: "PIDFile",
41+
private_devices: "PrivateDevices",
42+
private_network: "PrivateNetwork",
43+
private_tmp: "PrivateTmp",
44+
private_users: "PrivateUsers",
45+
protect_clock: "ProtectClock",
46+
protect_control_groups: "ProtectControlGroups",
47+
protect_home: "ProtectHome",
48+
protect_hostname: "ProtectHostname",
49+
protect_kernel_logs: "ProtectKernelLogs",
50+
protect_kernel_modules: "ProtectKernelModules",
51+
protect_kernel_tunables: "ProtectKernelTunables",
52+
protect_proc: "ProtectProc",
53+
protect_system: "ProtectSystem",
54+
restrict_address_families: "RestrictAddressFamilies",
55+
restrict_namespaces: "RestrictNamespaces",
56+
restrict_realtime: "RestrictRealtime",
57+
restrict_suid_sgid: "RestrictSUIDSGID",
3158
runtime_directory_preserve: "RuntimeDirectoryPreserve",
3259
selinux_context: "SELinuxContext",
60+
system_call_architectures: "SystemCallArchitectures",
61+
system_call_error_number: "SystemCallErrorNumber",
62+
system_call_filter: "SystemCallFilter",
63+
tasks_accounting: "TasksAccounting",
64+
tasks_max: "TasksMax",
3365
smack_process_label: "SmackProcessLabel",
3466
syslog_identifier: "SyslogIdentifier",
3567
tty_path: "TTYPath",

lib/systemd/unit_file/validator.ex

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ defmodule Systemd.UnitFile.Validator do
2929
),
3030
"Service" =>
3131
MapSet.new(
32-
~w(Type ExecStart ExecStartPre ExecStartPost ExecCondition ExecReload ExecStop ExecStopPost Restart RestartSec User Group WorkingDirectory RootDirectory Environment EnvironmentFile PassEnvironment UnsetEnvironment TimeoutStartSec TimeoutStopSec TimeoutAbortSec KillSignal KillMode RemainAfterExit GuessMainPID PIDFile RuntimeDirectory RuntimeDirectoryPreserve StateDirectory CacheDirectory LogsDirectory ConfigurationDirectory StandardOutput StandardError SyslogIdentifier Slice Delegate CPUAccounting CPUWeight CPUQuota MemoryAccounting MemoryMin MemoryLow MemoryHigh MemoryMax MemorySwapMax TasksAccounting TasksMax IOAccounting IOWeight IPAccounting LimitNOFILE LimitNPROC LimitMEMLOCK LimitCORE LimitCPU LimitAS LimitFSIZE NoNewPrivileges PrivateTmp PrivateDevices ProtectSystem ProtectHome AmbientCapabilities CapabilityBoundingSet ReadWritePaths ReadOnlyPaths InaccessiblePaths OOMPolicy)
32+
~w(Type ExecStart ExecStartPre ExecStartPost ExecCondition ExecReload ExecStop ExecStopPost Restart RestartSec User Group WorkingDirectory RootDirectory Environment EnvironmentFile PassEnvironment UnsetEnvironment TimeoutStartSec TimeoutStopSec TimeoutAbortSec KillSignal KillMode RemainAfterExit GuessMainPID PIDFile RuntimeDirectory RuntimeDirectoryPreserve StateDirectory CacheDirectory LogsDirectory ConfigurationDirectory StandardOutput StandardError SyslogIdentifier Slice Delegate CPUAccounting CPUWeight CPUQuota MemoryAccounting MemoryMin MemoryLow MemoryHigh MemoryMax MemorySwapMax TasksAccounting TasksMax IOAccounting IOWeight IPAccounting LimitNOFILE LimitNPROC LimitMEMLOCK LimitCORE LimitCPU LimitAS LimitFSIZE NoNewPrivileges PrivateTmp PrivateDevices PrivateNetwork PrivateUsers ProtectSystem ProtectHome ProtectKernelTunables ProtectKernelModules ProtectKernelLogs ProtectControlGroups ProtectClock ProtectHostname ProtectProc RestrictAddressFamilies RestrictNamespaces RestrictRealtime RestrictSUIDSGID SystemCallFilter SystemCallArchitectures SystemCallErrorNumber AmbientCapabilities CapabilityBoundingSet ReadWritePaths ReadOnlyPaths InaccessiblePaths OOMPolicy)
3333
),
3434
"Install" => MapSet.new(~w(WantedBy RequiredBy Also Alias DefaultInstance)),
3535
"Socket" =>
@@ -231,7 +231,18 @@ defmodule Systemd.UnitFile.Validator do
231231
"IPAccounting",
232232
"NoNewPrivileges",
233233
"PrivateTmp",
234-
"PrivateDevices"
234+
"PrivateDevices",
235+
"PrivateNetwork",
236+
"PrivateUsers",
237+
"ProtectKernelTunables",
238+
"ProtectKernelModules",
239+
"ProtectKernelLogs",
240+
"ProtectControlGroups",
241+
"ProtectClock",
242+
"ProtectHostname",
243+
"RestrictNamespaces",
244+
"RestrictRealtime",
245+
"RestrictSUIDSGID"
235246
] do
236247
boolean("Service", directive, value, span)
237248
end
@@ -256,6 +267,45 @@ defmodule Systemd.UnitFile.Validator do
256267
defp value_errors("Service", "CPUQuota", value, span),
257268
do: percentage("Service", "CPUQuota", value, span)
258269

270+
defp value_errors("Service", "ProtectSystem", value, span) do
271+
one_of(
272+
"Service",
273+
"ProtectSystem",
274+
String.downcase(value),
275+
~w(1 yes true on 0 no false off full strict),
276+
span
277+
)
278+
end
279+
280+
defp value_errors("Service", "ProtectHome", value, span) do
281+
one_of(
282+
"Service",
283+
"ProtectHome",
284+
String.downcase(value),
285+
~w(1 yes true on 0 no false off read-only tmpfs),
286+
span
287+
)
288+
end
289+
290+
defp value_errors("Service", "ProtectProc", value, span) do
291+
one_of("Service", "ProtectProc", value, ~w(default invisible ptraceable noaccess), span)
292+
end
293+
294+
defp value_errors("Service", directive, value, span)
295+
when directive in [
296+
"RestrictAddressFamilies",
297+
"SystemCallFilter",
298+
"SystemCallArchitectures",
299+
"SystemCallErrorNumber",
300+
"AmbientCapabilities",
301+
"CapabilityBoundingSet",
302+
"ReadWritePaths",
303+
"ReadOnlyPaths",
304+
"InaccessiblePaths"
305+
] do
306+
non_empty_words("Service", directive, value, span)
307+
end
308+
259309
defp value_errors("Service", "KillMode", value, span) do
260310
one_of("Service", "KillMode", value, ~w(control-group mixed process none), span)
261311
end

test/systemd/manager_integration_test.exs

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,10 @@ defmodule Systemd.ManagerIntegrationTest do
106106
TransientUnit.string("Description", "systemd Elixir resource integration test"),
107107
TransientUnit.string("Type", "oneshot"),
108108
TransientUnit.boolean("RemainAfterExit", true),
109+
TransientUnit.boolean("CPUAccounting", true),
109110
TransientUnit.memory_max(67_108_864),
110111
TransientUnit.tasks_max(64),
112+
TransientUnit.cpu_quota_per_sec_usec(500_000),
111113
TransientUnit.exec_start("/bin/true", ["/bin/true"])
112114
]
113115

@@ -116,13 +118,9 @@ defmodule Systemd.ManagerIntegrationTest do
116118
assert :ok = Job.await(conn, job, timeout: 5_000)
117119
assert {:ok, unit} = Manager.get_unit(conn, name)
118120

119-
assert {:ok, 67_108_864} =
120-
Properties.get(
121-
conn,
122-
unit.object_path,
123-
"org.freedesktop.systemd1.Service",
124-
"MemoryMax"
125-
)
121+
assert {:ok, 67_108_864} = service_property(conn, unit, "MemoryMax")
122+
assert {:ok, 64} = service_property(conn, unit, "TasksMax")
123+
assert {:ok, 500_000} = service_property(conn, unit, "CPUQuotaPerSecUSec")
126124

127125
assert :ok = Manager.stop_unit(conn, name) |> await_or_ok(conn)
128126

@@ -131,6 +129,26 @@ defmodule Systemd.ManagerIntegrationTest do
131129
end
132130
end
133131

132+
test "waits for job completion using JobRemoved signal" do
133+
assert {:ok, conn} = Manager.connect()
134+
135+
name = "systemd-elixir-signal-test-#{System.unique_integer([:positive])}.service"
136+
137+
properties = [
138+
TransientUnit.string("Description", "systemd Elixir signal integration test"),
139+
TransientUnit.string("Type", "oneshot"),
140+
TransientUnit.exec_start("/bin/sleep", ["/bin/sleep", "1"])
141+
]
142+
143+
case Manager.start_transient_unit(conn, name, properties) do
144+
{:ok, %Job{} = job} ->
145+
assert :ok = Job.await_signal(conn, job, timeout: 5_000)
146+
147+
{:error, %Error{} = error} ->
148+
assert Error.permission?(error)
149+
end
150+
end
151+
134152
test "starts and awaits a harmless transient unit" do
135153
assert {:ok, conn} = Manager.connect()
136154

@@ -152,6 +170,10 @@ defmodule Systemd.ManagerIntegrationTest do
152170
end
153171
end
154172

173+
defp service_property(conn, unit, property) do
174+
Properties.get(conn, unit.object_path, "org.freedesktop.systemd1.Service", property)
175+
end
176+
155177
defp await_or_ok({:ok, %Job{} = job}, conn), do: Job.await(conn, job, timeout: 5_000)
156178
defp await_or_ok({:error, %Error{} = error}, _conn), do: {:error, error}
157179

0 commit comments

Comments
 (0)