Skip to content

Commit d409d23

Browse files
committed
Expand lifecycle APIs and unit validation
1 parent 1ca0bdf commit d409d23

15 files changed

Lines changed: 479 additions & 13 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ The package depends on [`rebus`](https://hex.pm/packages/rebus) for the D-Bus wi
3636

3737
APIs return idiomatic `{:ok, value}` / `{:error, %Systemd.Error{}}` tuples. Permission and polkit failures are classified with `category: :permission` and can be checked with `Systemd.Error.permission?/1`.
3838

39-
See `examples/` for service, timer, and user-bus snippets, and `guides/xamal-style-deployment.md` for a deployment-oriented template unit example.
39+
See `examples/` for service, timer, and user-bus snippets, `guides/dbus-manager.md` for D-Bus manager operations, and `guides/xamal-style-deployment.md` for a deployment-oriented template unit example.
4040

4141
## Permissions
4242

guides/dbus-manager.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# D-Bus manager operations
2+
3+
`systemdkit` talks to `org.freedesktop.systemd1` over D-Bus and returns
4+
idiomatic `{:ok, value}` / `{:error, %Systemd.Error{}}` tuples. It does not
5+
retry through `sudo` or shell out to `systemctl`.
6+
7+
## Short-lived connections
8+
9+
Use the top-level `Systemd` module for one-off operations:
10+
11+
```elixir
12+
{:ok, units} = Systemd.list_units()
13+
{:ok, jobs} = Systemd.list_jobs()
14+
{:ok, unit_files} = Systemd.list_unit_files()
15+
{:ok, state} = Systemd.unit_file_state("sshd.service")
16+
17+
:ok = Systemd.reload()
18+
:ok = Systemd.start_unit("my_app@4000.service")
19+
:ok = Systemd.reload_or_restart_unit("my_app@4000.service")
20+
:ok = Systemd.reset_failed_unit("my_app@4000.service")
21+
```
22+
23+
Mutating operations may fail with a policy or polkit error:
24+
25+
```elixir
26+
case Systemd.start_unit("my_app@4000.service") do
27+
:ok -> :ok
28+
{:error, error} ->
29+
if Systemd.Error.permission?(error) do
30+
# Ask the operator to run with suitable policy/root privileges.
31+
{:error, :permission_denied}
32+
else
33+
{:error, error}
34+
end
35+
end
36+
```
37+
38+
## Reusing a connection
39+
40+
For multiple calls, keep a D-Bus connection open:
41+
42+
```elixir
43+
Systemd.with_connection([], fn conn ->
44+
with {:ok, unit} <- Systemd.Manager.get_unit(conn, "dbus.service"),
45+
{:ok, state} <- Systemd.UnitObject.state(conn, unit),
46+
{:ok, jobs} <- Systemd.Manager.list_jobs(conn) do
47+
{:ok, {state, jobs}}
48+
end
49+
end)
50+
```
51+
52+
## Job tracking
53+
54+
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:
56+
57+
```elixir
58+
{:ok, conn} = Systemd.Manager.connect()
59+
{:ok, job} = Systemd.Manager.restart_unit(conn, "my_app@4000.service")
60+
{:ok, :running} = Systemd.Job.state(conn, job)
61+
:ok = Systemd.Job.await(conn, job, timeout: 10_000)
62+
```
63+
64+
Jobs can be cancelled through the job object when systemd still exposes it:
65+
66+
```elixir
67+
Systemd.Job.cancel(conn, job)
68+
```
69+
70+
## User bus
71+
72+
Pass `bus: :session` for user units when a systemd user session bus is available:
73+
74+
```elixir
75+
Systemd.list_units(bus: :session)
76+
```

lib/systemd.ex

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ defmodule Systemd do
6161
with_connection(opts, &Manager.list_units/1)
6262
end
6363

64+
@doc """
65+
Lists queued jobs using a short-lived connection.
66+
"""
67+
@spec list_jobs(keyword()) :: {:ok, [Systemd.JobStatus.t()]} | {:error, Error.t()}
68+
def list_jobs(opts \\ []) do
69+
with_connection(opts, &Manager.list_jobs/1)
70+
end
71+
6472
@doc """
6573
Reads common state for a unit using a short-lived connection.
6674
"""
@@ -121,6 +129,49 @@ defmodule Systemd do
121129
run_unit_operation(:reload_unit, name, opts)
122130
end
123131

132+
@doc """
133+
Tries to restart a unit only if it is already active.
134+
"""
135+
@spec try_restart_unit(String.t(), keyword()) ::
136+
:ok | {:ok, Systemd.Job.t()} | {:error, Error.t()}
137+
def try_restart_unit(name, opts \\ []) do
138+
run_unit_operation(:try_restart_unit, name, opts)
139+
end
140+
141+
@doc """
142+
Reloads a unit if supported, otherwise restarts it.
143+
"""
144+
@spec reload_or_restart_unit(String.t(), keyword()) ::
145+
:ok | {:ok, Systemd.Job.t()} | {:error, Error.t()}
146+
def reload_or_restart_unit(name, opts \\ []) do
147+
run_unit_operation(:reload_or_restart_unit, name, opts)
148+
end
149+
150+
@doc """
151+
Reloads a unit if supported, otherwise tries to restart it only if active.
152+
"""
153+
@spec reload_or_try_restart_unit(String.t(), keyword()) ::
154+
:ok | {:ok, Systemd.Job.t()} | {:error, Error.t()}
155+
def reload_or_try_restart_unit(name, opts \\ []) do
156+
run_unit_operation(:reload_or_try_restart_unit, name, opts)
157+
end
158+
159+
@doc """
160+
Resets failed state for a unit using a short-lived connection.
161+
"""
162+
@spec reset_failed_unit(String.t(), keyword()) :: :ok | {:error, Error.t()}
163+
def reset_failed_unit(name, opts \\ []) do
164+
with_connection(opts, &Manager.reset_failed_unit(&1, name))
165+
end
166+
167+
@doc """
168+
Sends a Unix signal to processes belonging to a unit using a short-lived connection.
169+
"""
170+
@spec kill_unit(String.t(), String.t(), integer(), keyword()) :: :ok | {:error, Error.t()}
171+
def kill_unit(name, who \\ "all", signal \\ 15, opts \\ []) do
172+
with_connection(opts, &Manager.kill_unit(&1, name, who, signal))
173+
end
174+
124175
@doc """
125176
Enables unit files using a short-lived connection.
126177
"""

lib/systemd/dbus/signature.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ defmodule Systemd.DBus.Signature do
44
@supported_complex_signatures MapSet.new([
55
"a(ssssssouso)",
66
"a(ss)",
7+
"a(usssoo)",
78
"a{sv}",
89
"a(sv)",
910
"a(sa(sv))",

lib/systemd/job.ex

Lines changed: 27 additions & 7 deletions
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.{Error, Properties}
6+
alias Systemd.{DBus, Error, Properties}
77

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

@@ -25,6 +25,32 @@ defmodule Systemd.Job do
2525
Properties.get(conn, path, @interface, property)
2626
end
2727

28+
@doc """
29+
Cancels this job through its D-Bus object.
30+
"""
31+
@spec cancel(pid(), t()) :: :ok | {:error, Error.t()}
32+
def cancel(conn, %__MODULE__{object_path: path}) do
33+
with {:ok, []} <-
34+
DBus.call_body(conn,
35+
destination: "org.freedesktop.systemd1",
36+
path: path,
37+
interface: @interface,
38+
member: "Cancel"
39+
) do
40+
:ok
41+
end
42+
end
43+
44+
@doc """
45+
Reads and normalizes the job state.
46+
"""
47+
@spec state(pid(), t()) :: {:ok, state()} | {:error, Error.t()}
48+
def state(conn, job) do
49+
with {:ok, value} <- property(conn, job, "State") do
50+
{:ok, normalize_state(value)}
51+
end
52+
end
53+
2854
@doc """
2955
Polls until a job leaves `waiting`/`running` or disappears from the bus.
3056
"""
@@ -58,12 +84,6 @@ defmodule Systemd.Job do
5884
end
5985
end
6086

61-
defp state(conn, job) do
62-
with {:ok, value} <- property(conn, job, "State") do
63-
{:ok, normalize_state(value)}
64-
end
65-
end
66-
6787
defp normalize_state("waiting"), do: :waiting
6888
defp normalize_state("running"), do: :running
6989
defp normalize_state("done"), do: :done

lib/systemd/job_status.ex

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
defmodule Systemd.JobStatus do
2+
@moduledoc """
3+
Runtime job information returned by systemd's `ListJobs` D-Bus method.
4+
"""
5+
6+
@enforce_keys [:id, :unit, :type, :state, :job_path, :unit_path]
7+
@type t :: %__MODULE__{
8+
id: non_neg_integer(),
9+
unit: String.t(),
10+
type: String.t(),
11+
state: String.t(),
12+
job_path: String.t(),
13+
unit_path: String.t()
14+
}
15+
16+
defstruct [:id, :unit, :type, :state, :job_path, :unit_path]
17+
18+
@doc false
19+
@spec from_dbus(tuple() | list()) :: t()
20+
def from_dbus({id, unit, type, state, job_path, unit_path}) do
21+
from_dbus([id, unit, type, state, job_path, unit_path])
22+
end
23+
24+
def from_dbus([id, unit, type, state, job_path, unit_path]) do
25+
%__MODULE__{
26+
id: id,
27+
unit: unit,
28+
type: type,
29+
state: state,
30+
job_path: job_path,
31+
unit_path: unit_path
32+
}
33+
end
34+
end

lib/systemd/manager.ex

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ defmodule Systemd.Manager do
33
Client for `org.freedesktop.systemd1.Manager`.
44
"""
55

6-
alias Systemd.{DBus, Error, Job, Unit, UnitFileOperation, UnitFileStatus, UnitObject}
6+
alias Systemd.{DBus, Error, Job, JobStatus, Unit, UnitFileOperation, UnitFileStatus, UnitObject}
77
alias Systemd.Manager.Options
88
alias Systemd.TransientUnit.{AuxUnit, Property}
99

@@ -41,6 +41,16 @@ defmodule Systemd.Manager do
4141
end
4242
end
4343

44+
@doc """
45+
Lists currently queued jobs.
46+
"""
47+
@spec list_jobs(pid()) :: {:ok, [JobStatus.t()]} | {:error, Error.t()}
48+
def list_jobs(conn) when is_pid(conn) do
49+
with {:ok, [jobs]} <- call(conn, "ListJobs") do
50+
{:ok, Enum.map(jobs, &JobStatus.from_dbus/1)}
51+
end
52+
end
53+
4454
@doc """
4555
Gets the D-Bus object path for a loaded unit.
4656
"""
@@ -113,6 +123,51 @@ defmodule Systemd.Manager do
113123
unit_operation(conn, "ReloadUnit", unit_name, opts)
114124
end
115125

126+
@doc """
127+
Tries to restart a unit only if it is already active.
128+
"""
129+
@spec try_restart_unit(pid(), String.t(), keyword()) :: {:ok, Job.t()} | {:error, Error.t()}
130+
def try_restart_unit(conn, unit_name, opts \\ []) do
131+
unit_operation(conn, "TryRestartUnit", unit_name, opts)
132+
end
133+
134+
@doc """
135+
Reloads a unit if supported, otherwise restarts it.
136+
"""
137+
@spec reload_or_restart_unit(pid(), String.t(), keyword()) ::
138+
{:ok, Job.t()} | {:error, Error.t()}
139+
def reload_or_restart_unit(conn, unit_name, opts \\ []) do
140+
unit_operation(conn, "ReloadOrRestartUnit", unit_name, opts)
141+
end
142+
143+
@doc """
144+
Reloads a unit if supported, otherwise tries to restart it only if active.
145+
"""
146+
@spec reload_or_try_restart_unit(pid(), String.t(), keyword()) ::
147+
{:ok, Job.t()} | {:error, Error.t()}
148+
def reload_or_try_restart_unit(conn, unit_name, opts \\ []) do
149+
unit_operation(conn, "ReloadOrTryRestartUnit", unit_name, opts)
150+
end
151+
152+
@doc """
153+
Resets failed state for a unit.
154+
"""
155+
@spec reset_failed_unit(pid(), String.t()) :: :ok | {:error, Error.t()}
156+
def reset_failed_unit(conn, unit_name) when is_pid(conn) and is_binary(unit_name) do
157+
with {:ok, []} <- call(conn, "ResetFailedUnit", [unit_name], "s"), do: :ok
158+
end
159+
160+
@doc """
161+
Sends a Unix signal to processes belonging to a unit.
162+
"""
163+
@spec kill_unit(pid(), String.t(), String.t(), integer()) :: :ok | {:error, Error.t()}
164+
def kill_unit(conn, unit_name, who \\ "all", signal \\ 15)
165+
166+
def kill_unit(conn, unit_name, who, signal)
167+
when is_pid(conn) and is_binary(unit_name) and is_binary(who) and is_integer(signal) do
168+
with {:ok, []} <- call(conn, "KillUnit", [unit_name, who, signal], "ssi"), do: :ok
169+
end
170+
116171
@doc """
117172
Starts a transient unit and returns the queued systemd job.
118173
"""

lib/systemd/unit_file.ex

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,24 @@ defmodule Systemd.UnitFile do
3232
@spec timer(keyword()) :: t()
3333
defdelegate timer(opts), to: Builder
3434

35+
@doc """
36+
Builds a mount unit file from common `Unit`, `Mount`, and `Install` sections.
37+
"""
38+
@spec mount(keyword()) :: t()
39+
defdelegate mount(opts), to: Builder
40+
41+
@doc """
42+
Builds a path unit file from common `Unit`, `Path`, and `Install` sections.
43+
"""
44+
@spec path(keyword()) :: t()
45+
defdelegate path(opts), to: Builder
46+
47+
@doc """
48+
Builds a target unit file from common `Unit`, `Target`, and `Install` sections.
49+
"""
50+
@spec target(keyword()) :: t()
51+
defdelegate target(opts), to: Builder
52+
3553
@doc """
3654
Parses unit file text.
3755
"""

lib/systemd/unit_file/builder.ex

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,39 @@ defmodule Systemd.UnitFile.Builder do
7070
|> maybe_append_section("Install", Keyword.get(opts, :install, []))
7171
end
7272

73+
@doc """
74+
Builds a mount unit file from common `Unit`, `Mount`, and `Install` sections.
75+
"""
76+
@spec mount(keyword()) :: UnitFile.t()
77+
def mount(opts) when is_list(opts) do
78+
UnitFile.parse!("")
79+
|> maybe_append_section("Unit", Keyword.get(opts, :unit, []))
80+
|> maybe_append_section("Mount", Keyword.get(opts, :mount, []))
81+
|> maybe_append_section("Install", Keyword.get(opts, :install, []))
82+
end
83+
84+
@doc """
85+
Builds a path unit file from common `Unit`, `Path`, and `Install` sections.
86+
"""
87+
@spec path(keyword()) :: UnitFile.t()
88+
def path(opts) when is_list(opts) do
89+
UnitFile.parse!("")
90+
|> maybe_append_section("Unit", Keyword.get(opts, :unit, []))
91+
|> maybe_append_section("Path", Keyword.get(opts, :path, []))
92+
|> maybe_append_section("Install", Keyword.get(opts, :install, []))
93+
end
94+
95+
@doc """
96+
Builds a target unit file from common `Unit`, `Target`, and `Install` sections.
97+
"""
98+
@spec target(keyword()) :: UnitFile.t()
99+
def target(opts) when is_list(opts) do
100+
UnitFile.parse!("")
101+
|> maybe_append_section("Unit", Keyword.get(opts, :unit, []))
102+
|> maybe_append_section("Target", Keyword.get(opts, :target, []))
103+
|> maybe_append_section("Install", Keyword.get(opts, :install, []))
104+
end
105+
73106
@doc """
74107
Builds a unit file with one section.
75108
"""

0 commit comments

Comments
 (0)